Compare commits
8 Commits
fix_local_
...
v0.13.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
197dee2aea | ||
|
|
045d8da8b2 | ||
|
|
c9bd4b7395 | ||
|
|
41a5026331 | ||
|
|
d1a27ac31b | ||
|
|
37b3f85e61 | ||
|
|
55a6479c0e | ||
|
|
f88064af06 |
@@ -22,7 +22,7 @@
|
|||||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
|
|||||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
.vite/
|
||||||
|
electron/resources/server/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { requestMicrophoneAccess } from "./permissions"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"media:requestMicrophoneAccess",
|
||||||
|
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { dirname, join } from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupCliIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
|
import { configureMediaPermissionHandlers } from "./permissions"
|
||||||
import { CliProcessManager } from "./process-manager"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
@@ -489,6 +490,7 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
|
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
})
|
})
|
||||||
|
|||||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
58
packages/electron-app/electron/main/permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { session, systemPreferences } from "electron"
|
||||||
|
|
||||||
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
||||||
|
if (!origin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = new URL(origin).origin
|
||||||
|
return allowedOrigins.includes(normalized)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||||
|
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||||
|
if (permission !== "media") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
||||||
|
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
||||||
|
})
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
||||||
|
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestMicrophoneAccess(): Promise<boolean> {
|
||||||
|
if (!isMac) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = systemPreferences.getMediaAccessStatus("microphone")
|
||||||
|
if (status === "granted") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemPreferences.askForMediaAccess("microphone")
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app } from "electron"
|
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ interface CliEntryResolution {
|
|||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedChild = ChildProcess | UtilityProcess
|
||||||
|
type ChildLaunchMode = "spawn" | "utility"
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
@@ -117,7 +123,8 @@ export declare interface CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ChildProcess
|
private child?: ManagedChild
|
||||||
|
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
@@ -135,33 +142,63 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
|
||||||
console.info(
|
let child: ManagedChild
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
const runtimePath = this.resolveShellNodeCommand()
|
||||||
|
const entryPath = this.resolveBundledProdEntry()
|
||||||
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
|
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
|
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||||
|
const supervisorPayload = JSON.stringify({
|
||||||
|
command: shellCommand.command,
|
||||||
|
args: shellCommand.args,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
console.info(
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
)
|
||||||
|
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||||
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
env: shellEnv,
|
||||||
cwd: process.cwd(),
|
stdio: "pipe",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
env,
|
})
|
||||||
shell: false,
|
this.childLaunchMode = "utility"
|
||||||
detached,
|
} else {
|
||||||
})
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
console.info(
|
||||||
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
|
)
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
if (!child.pid) {
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
|
|
||||||
|
const spawnDetails = supportsUserShell()
|
||||||
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
|
const detached = process.platform !== "win32"
|
||||||
|
child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
detached,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
|
this.childLaunchMode = "spawn"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "spawn" && !child.pid) {
|
||||||
console.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,23 +213,48 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("error", (error) => {
|
if (this.childLaunchMode === "utility") {
|
||||||
console.error("[cli] failed to start CLI:", error)
|
const utilityChild = child as UtilityProcess
|
||||||
this.updateStatus({ state: "error", error: error.message })
|
|
||||||
this.emit("error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
utilityChild.on("error", (error) => {
|
||||||
const failed = this.status.state !== "ready"
|
const message = this.describeUtilityProcessError(error)
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
console.error("[cli] utility supervisor failed:", error)
|
||||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
this.updateStatus({ state: "error", error: message })
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
this.emit("error", new Error(message))
|
||||||
if (failed && error) {
|
})
|
||||||
this.emit("error", new Error(error))
|
|
||||||
}
|
utilityChild.on("exit", (code) => {
|
||||||
this.emit("exit", this.status)
|
const failed = this.status.state !== "ready"
|
||||||
this.child = undefined
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
||||||
})
|
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
|
spawnedChild.on("error", (error) => {
|
||||||
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
spawnedChild.on("exit", (code, signal) => {
|
||||||
|
const failed = this.status.state !== "ready"
|
||||||
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<CliStatus>((resolve, reject) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -219,16 +281,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "utility") {
|
||||||
|
return this.stopUtilityChild(child as UtilityProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
const pid = child.pid
|
const pid = spawnedChild.pid
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
try {
|
try {
|
||||||
@@ -304,7 +372,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
sendStopSignal("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
spawnedChild.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
console.info("[cli] CLI process exited")
|
console.info("[cli] CLI process exited")
|
||||||
@@ -324,6 +392,46 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
||||||
|
this.requestedStop = true
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
console.info("[cli] CLI process exited")
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (child.pid === undefined) {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getStatus(): CliStatus {
|
getStatus(): CliStatus {
|
||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
@@ -335,14 +443,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
const pid = this.child.pid
|
const pid = this.child.pid
|
||||||
if (pid && process.platform !== "win32") {
|
if (this.childLaunchMode === "utility") {
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pid && process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, "SIGKILL")
|
process.kill(-pid, "SIGKILL")
|
||||||
} catch {
|
} catch {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
@@ -449,6 +565,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildExecutableCommand(command: string, args: string[]): string {
|
||||||
|
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
@@ -519,4 +639,58 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||||
|
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCliSupervisorPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||||
|
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveShellNodeCommand(): string {
|
||||||
|
const configured = process.env.NODE_BINARY?.trim()
|
||||||
|
return configured && configured.length > 0 ? configured : "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBundledProdEntry(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||||
|
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const typed = error as { type?: unknown; location?: unknown }
|
||||||
|
if (typeof typed.type === "string") {
|
||||||
|
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const electronAPI = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn } = require("child_process")
|
||||||
|
|
||||||
|
const SHUTDOWN_GRACE_MS = 30_000
|
||||||
|
|
||||||
|
let child = null
|
||||||
|
let shutdownTimer = null
|
||||||
|
|
||||||
|
function log(message, error) {
|
||||||
|
if (error) {
|
||||||
|
console.error(`[cli-supervisor] ${message}`, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[cli-supervisor] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShutdownTimer() {
|
||||||
|
if (shutdownTimer) {
|
||||||
|
clearTimeout(shutdownTimer)
|
||||||
|
shutdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardStream(stream, target) {
|
||||||
|
if (!stream) return
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
target.write(chunk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminateChild(force) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill(force ? "SIGKILL" : "SIGTERM")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestShutdown(force = false) {
|
||||||
|
if (!child) {
|
||||||
|
process.exit(force ? 1 : 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminateChild(force)
|
||||||
|
if (force) {
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearShutdownTimer()
|
||||||
|
shutdownTimer = setTimeout(() => {
|
||||||
|
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
||||||
|
terminateChild(true)
|
||||||
|
}, SHUTDOWN_GRACE_MS)
|
||||||
|
shutdownTimer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShutdownHandlers() {
|
||||||
|
process.on("SIGTERM", () => requestShutdown(false))
|
||||||
|
process.on("SIGINT", () => requestShutdown(false))
|
||||||
|
process.on("disconnect", () => requestShutdown(false))
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
log("uncaught exception", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
process.on("unhandledRejection", (error) => {
|
||||||
|
log("unhandled rejection", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload() {
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Supervisor payload is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Supervisor payload must be an object")
|
||||||
|
}
|
||||||
|
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
||||||
|
throw new Error("Supervisor payload command is required")
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
||||||
|
throw new Error("Supervisor payload args must be a string array")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: parsed.command,
|
||||||
|
args: parsed.args,
|
||||||
|
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
installShutdownHandlers()
|
||||||
|
|
||||||
|
const payload = parsePayload()
|
||||||
|
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
||||||
|
|
||||||
|
child = spawn(payload.command, payload.args, {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
|
||||||
|
forwardStream(child.stdout, process.stdout)
|
||||||
|
forwardStream(child.stderr, process.stderr)
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
log("failed to spawn shell command", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearShutdownTimer()
|
||||||
|
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
||||||
|
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
||||||
|
process.exit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
|
"prepare:resources": "node scripts/prepare-resources.js",
|
||||||
|
"prebuild": "npm run prepare:resources",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
@@ -33,8 +35,11 @@
|
|||||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||||
"build:all": "node scripts/build.js all",
|
"build:all": "node scripts/build.js all",
|
||||||
|
"prepackage:mac": "npm run prepare:resources",
|
||||||
"package:mac": "electron-builder --mac",
|
"package:mac": "electron-builder --mac",
|
||||||
|
"prepackage:win": "npm run prepare:resources",
|
||||||
"package:win": "electron-builder --win",
|
"package:win": "electron-builder --win",
|
||||||
|
"prepackage:linux": "npm run prepare:resources",
|
||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -82,6 +87,12 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"entitlements": "electron/resources/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
||||||
|
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
||||||
|
},
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
|||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||||
|
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
|
})
|
||||||
|
|
||||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
|
|||||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path, { join } from "path"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
|
const appDir = join(__dirname, "..")
|
||||||
|
const workspaceRoot = join(appDir, "..", "..")
|
||||||
|
const serverRoot = join(appDir, "..", "server")
|
||||||
|
const resourcesRoot = join(appDir, "electron", "resources")
|
||||||
|
const serverDest = join(resourcesRoot, "server")
|
||||||
|
const npmExecPath = process.env.npm_execpath
|
||||||
|
const npmNodeExecPath = process.env.npm_node_execpath
|
||||||
|
|
||||||
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
|
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[prepare-resources] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerBuild() {
|
||||||
|
const distPath = join(serverRoot, "dist")
|
||||||
|
const publicPath = join(serverRoot, "public")
|
||||||
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||||
|
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDependencies() {
|
||||||
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("installing production server dependencies")
|
||||||
|
const npmArgs = [
|
||||||
|
"install",
|
||||||
|
"--omit=dev",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--workspaces=false",
|
||||||
|
"--package-lock=false",
|
||||||
|
"--install-strategy=shallow",
|
||||||
|
"--fund=false",
|
||||||
|
"--audit=false",
|
||||||
|
]
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
npm_config_workspaces: "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||||
|
const result = npmCli
|
||||||
|
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||||
|
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyServerArtifacts() {
|
||||||
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
|
|
||||||
|
for (const name of serverSources) {
|
||||||
|
const from = join(serverRoot, name)
|
||||||
|
const to = join(serverDest, name)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
throw new Error(`Missing required server artifact: ${from}`)
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||||
|
log(`copied ${name} to Electron resources`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = join(serverDest, "node_modules")
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
fs.rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
log(`removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[prepare-resources] failed:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||||
|
|
||||||
|
let voiceModeEnabled = false
|
||||||
|
|
||||||
export async function CodeNomadPlugin(input: PluginInput) {
|
export async function CodeNomadPlugin(input: PluginInput) {
|
||||||
const config = getCodeNomadConfig()
|
const config = getCodeNomadConfig()
|
||||||
const client = createCodeNomadClient(config)
|
const client = createCodeNomadClient(config)
|
||||||
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
pingTs: (event.properties as any)?.ts,
|
pingTs: (event.properties as any)?.ts,
|
||||||
},
|
},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "codenomad.voiceMode") {
|
||||||
|
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
tool: {
|
tool: {
|
||||||
...backgroundProcessTools,
|
...backgroundProcessTools,
|
||||||
},
|
},
|
||||||
|
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
|
||||||
|
if (!voiceModeEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
|
||||||
|
},
|
||||||
async event(input: { event: any }) {
|
async event(input: { event: any }) {
|
||||||
const opencodeEvent = input?.event
|
const opencodeEvent = input?.event
|
||||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||||
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVoiceModePrompt(): string {
|
||||||
|
return [
|
||||||
|
"Voice conversation mode is enabled.",
|
||||||
|
"Prepend your reply with a fenced code block using language `spoken`.",
|
||||||
|
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
|
||||||
|
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
|
||||||
|
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
|
||||||
|
"Do not add generic phrases about whether the user should read more.",
|
||||||
|
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
|
||||||
|
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
|
||||||
|
"After the `spoken` block, continue with your normal detailed response.",
|
||||||
|
"Example:",
|
||||||
|
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ export interface SpeechSynthesisResponse {
|
|||||||
mimeType: string
|
mimeType: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VoiceModeStateResponse {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export class FileSystemBrowser {
|
|||||||
return { path: relativePath, absolutePath }
|
return { path: relativePath, absolutePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeFile(relativePath: string, contents: string): void {
|
||||||
|
if (this.unrestricted) {
|
||||||
|
throw new Error("writeFile is not available in unrestricted mode")
|
||||||
|
}
|
||||||
|
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||||
|
fs.writeFileSync(resolved, contents, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
readFile(relativePath: string): string {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { AuthManager } from "../auth/manager"
|
|||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
import type { SpeechService } from "../speech/service"
|
import type { SpeechService } from "../speech/service"
|
||||||
|
import { PluginChannelManager } from "../plugins/channel"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -173,6 +174,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
|
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||||
|
|
||||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
@@ -256,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
registerPluginRoutes(app, {
|
||||||
|
workspaceManager: deps.workspaceManager,
|
||||||
|
eventBus: deps.eventBus,
|
||||||
|
logger: proxyLogger,
|
||||||
|
channel: pluginChannel,
|
||||||
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
import type { VoiceModeStateResponse } from "../../api-types"
|
||||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||||
import type { EventBus } from "../../events/bus"
|
import type { EventBus } from "../../events/bus"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
@@ -10,6 +11,7 @@ interface RouteDeps {
|
|||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
|
channel: PluginChannelManager
|
||||||
}
|
}
|
||||||
|
|
||||||
const PluginEventSchema = z.object({
|
const PluginEventSchema = z.object({
|
||||||
@@ -17,9 +19,11 @@ const PluginEventSchema = z.object({
|
|||||||
properties: z.record(z.unknown()).optional(),
|
properties: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
const VoiceModeStateSchema = z.object({
|
||||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||||
const workspace = deps.workspaceManager.get(request.params.id)
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -33,10 +37,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
reply.raw.flushHeaders?.()
|
reply.raw.flushHeaders?.()
|
||||||
reply.hijack()
|
reply.hijack()
|
||||||
|
|
||||||
const registration = channel.register(request.params.id, reply)
|
const registration = deps.channel.register(request.params.id, reply)
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
channel.send(request.params.id, buildPingEvent())
|
deps.channel.send(request.params.id, buildPingEvent())
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -49,6 +53,24 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
request.raw.on("error", close)
|
request.raw.on("error", close)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
|
||||||
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
|
if (!workspace) {
|
||||||
|
reply.code(404).send({ error: "Workspace not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||||
|
deps.channel.send(request.params.id, {
|
||||||
|
type: "codenomad.voiceMode",
|
||||||
|
properties: {
|
||||||
|
enabled: payload.enabled,
|
||||||
|
formatVersion: "v1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { enabled: payload.enabled }
|
||||||
|
})
|
||||||
|
|
||||||
const handleWildcard = async (request: any, reply: any) => {
|
const handleWildcard = async (request: any, reply: any) => {
|
||||||
const workspaceId = request.params.id as string
|
const workspaceId = request.params.id as string
|
||||||
const workspace = deps.workspaceManager.get(workspaceId)
|
const workspace = deps.workspaceManager.get(workspaceId)
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
|
|||||||
path: z.string(),
|
path: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WorkspaceFileContentBodySchema = z.object({
|
||||||
|
contents: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
const WorkspaceFileSearchQuerySchema = z.object({
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, "Query is required"),
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return handleWorkspaceError(error, reply)
|
return handleWorkspaceError(error, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string }
|
||||||
|
Querystring: { path?: string }
|
||||||
|
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
||||||
|
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
||||||
|
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
||||||
|
reply.code(204)
|
||||||
|
} catch (error) {
|
||||||
|
return handleWorkspaceError(error, reply)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,19 +147,49 @@ export class OpenAICompatibleSpeechProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||||
const response = await fetch(endpoint, {
|
let response: Response
|
||||||
method: "POST",
|
try {
|
||||||
headers: {
|
response = await fetch(endpoint, {
|
||||||
Authorization: `Bearer ${settings.apiKey}`,
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${settings.apiKey}`,
|
||||||
body: JSON.stringify({
|
"Content-Type": "application/json",
|
||||||
model: settings.ttsModel,
|
},
|
||||||
voice: settings.ttsVoice,
|
body: JSON.stringify({
|
||||||
input: text,
|
model: settings.ttsModel,
|
||||||
response_format: format,
|
voice: settings.ttsVoice,
|
||||||
}),
|
input: text,
|
||||||
})
|
response_format: format,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const detailedError = error as Error & {
|
||||||
|
cause?: unknown
|
||||||
|
code?: string
|
||||||
|
errno?: number | string
|
||||||
|
syscall?: string
|
||||||
|
address?: string
|
||||||
|
port?: number
|
||||||
|
}
|
||||||
|
this.options.logger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
endpoint: endpoint.toString(),
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
model: settings.ttsModel,
|
||||||
|
voice: settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
cause: detailedError.cause,
|
||||||
|
code: detailedError.code,
|
||||||
|
errno: detailedError.errno,
|
||||||
|
syscall: detailedError.syscall,
|
||||||
|
address: detailedError.address,
|
||||||
|
port: detailedError.port,
|
||||||
|
},
|
||||||
|
"speech.synthesize fetch failed",
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text()
|
const detail = await response.text()
|
||||||
|
|||||||
@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
|
|||||||
assert.equal(result.uiStaticDir, bundledDir)
|
assert.equal(result.uiStaticDir, bundledDir)
|
||||||
assert.equal(result.uiVersion, "0.8.1")
|
assert.equal(result.uiVersion, "0.8.1")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("prefers bundled when bundled and downloaded versions are equal", async () => {
|
||||||
|
const bundledDir = path.join(tempRoot, "bundled")
|
||||||
|
const configDir = path.join(tempRoot, "config")
|
||||||
|
const currentDir = path.join(configDir, "ui", "current")
|
||||||
|
|
||||||
|
await mkdir(bundledDir, { recursive: true })
|
||||||
|
await mkdir(currentDir, { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||||
|
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||||
|
|
||||||
|
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||||
|
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||||
|
|
||||||
|
const result = await resolveUi({
|
||||||
|
serverVersion: "0.8.1",
|
||||||
|
bundledUiDir: bundledDir,
|
||||||
|
autoUpdate: false,
|
||||||
|
configDir,
|
||||||
|
logger: noopLogger,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.source, "bundled")
|
||||||
|
assert.equal(result.uiStaticDir, bundledDir)
|
||||||
|
assert.equal(result.uiVersion, "0.8.1")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
|||||||
uiStaticDir: currentResolved,
|
uiStaticDir: currentResolved,
|
||||||
source: "downloaded",
|
source: "downloaded",
|
||||||
uiVersion: await readUiVersion(currentResolved),
|
uiVersion: await readUiVersion(currentResolved),
|
||||||
priority: 2,
|
priority: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
|||||||
uiStaticDir: bundledResolved,
|
uiStaticDir: bundledResolved,
|
||||||
source: "bundled",
|
source: "bundled",
|
||||||
uiVersion: await readUiVersion(bundledResolved),
|
uiVersion: await readUiVersion(bundledResolved),
|
||||||
priority: 1,
|
priority: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
||||||
|
const workspace = this.requireWorkspace(workspaceId)
|
||||||
|
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||||
|
browser.writeFile(relativePath, contents)
|
||||||
|
}
|
||||||
|
|
||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
"dev:prep": "node ./scripts/dev-prep.js",
|
"dev:prep": "node ./scripts/dev-prep.js",
|
||||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||||
|
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||||
"prebuild": "node ./scripts/prebuild.js",
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
"bundle:server": "npm run prebuild",
|
"bundle:server": "npm run prebuild",
|
||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
|
|||||||
@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
|
|||||||
function ensureServerBuild() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist")
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public")
|
const publicPath = path.join(serverRoot, "public")
|
||||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[prebuild] server build missing; running workspace build...")
|
|
||||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
|
|||||||
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
102
packages/tauri-app/scripts/sync-tauri-version.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const packageJsonPath = path.join(root, "package.json")
|
||||||
|
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
|
||||||
|
const cargoLockPath = path.join(root, "Cargo.lock")
|
||||||
|
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
|
||||||
|
|
||||||
|
function readPackageVersion() {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
|
||||||
|
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||||
|
throw new Error("Missing version in packages/tauri-app/package.json")
|
||||||
|
}
|
||||||
|
return packageJson.version
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCargoToml(version) {
|
||||||
|
const current = fs.readFileSync(cargoTomlPath, "utf8")
|
||||||
|
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
|
||||||
|
const match = current.match(packageVersionPattern)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[2] === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||||
|
fs.writeFileSync(cargoTomlPath, updated)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCargoLock(version) {
|
||||||
|
if (!fs.existsSync(cargoLockPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = fs.readFileSync(cargoLockPath, "utf8")
|
||||||
|
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
|
||||||
|
const match = current.match(packageVersionPattern)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[2] === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
|
||||||
|
fs.writeFileSync(cargoLockPath, updated)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTauriConfig(version) {
|
||||||
|
const current = fs.readFileSync(tauriConfigPath, "utf8")
|
||||||
|
const config = JSON.parse(current)
|
||||||
|
if (config.version === version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
config.version = version
|
||||||
|
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const version = readPackageVersion()
|
||||||
|
const changed = []
|
||||||
|
|
||||||
|
if (syncCargoToml(version)) {
|
||||||
|
changed.push(path.relative(root, cargoTomlPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncCargoLock(version)) {
|
||||||
|
changed.push(path.relative(root, cargoLockPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncTauriConfig(version)) {
|
||||||
|
changed.push(path.relative(root, tauriConfigPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed.length === 0) {
|
||||||
|
console.log(`[sync-tauri-version] already aligned to ${version}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error(`[sync-tauri-version] failed: ${message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
|
|||||||
open
|
open
|
||||||
modal
|
modal
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
// Only handle dismiss if dialog is dismissible (default: true)
|
||||||
|
if (!open && payload.dismissible !== false) {
|
||||||
dismiss(false, payload)
|
dismiss(false, payload)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay z-[60]" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||||
@@ -140,10 +140,11 @@ const AlertDialog: Component = () => {
|
|||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="text-sm font-medium text-secondary">
|
<label for="prompt-input" class="text-sm font-medium text-secondary">
|
||||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="prompt-input"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
promptInputRef = el
|
promptInputRef = el
|
||||||
}}
|
}}
|
||||||
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
|
|||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</div>
|
</Dialog.Portal>
|
||||||
</Dialog.Portal>
|
</Dialog>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
|
|||||||
scopeKey: string
|
scopeKey: string
|
||||||
path: string
|
path: string
|
||||||
content: string
|
content: string
|
||||||
|
onSave?: (content: string) => void
|
||||||
|
onContentChange?: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||||
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||||||
editor = null
|
editor = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveContent = () => {
|
||||||
|
if (!editor || !props.onSave) return
|
||||||
|
props.onSave(editor.getValue())
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||||||
editor = monaco.editor.create(host, {
|
editor = monaco.editor.create(host, {
|
||||||
value: "",
|
value: "",
|
||||||
language: "plaintext",
|
language: "plaintext",
|
||||||
readOnly: true,
|
readOnly: false,
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
lineNumbers: "on",
|
lineNumbers: "on",
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
if (props.onContentChange) {
|
||||||
|
props.onContentChange(editor.getValue())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onClose={closeLeftDrawer}
|
onClose={closeLeftDrawer}
|
||||||
ModalProps={modalProps}
|
ModalProps={modalProps}
|
||||||
sx={{
|
sx={{
|
||||||
|
zIndex: 60,
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
@@ -530,6 +531,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
onClose={closeRightDrawer}
|
onClose={closeRightDrawer}
|
||||||
ModalProps={modalProps}
|
ModalProps={modalProps}
|
||||||
sx={{
|
sx={{
|
||||||
|
zIndex: 60,
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
|
|||||||
|
|
||||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||||
import { requestData } from "../../../../lib/opencode-api"
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
|
import { serverApi } from "../../../../lib/api-client"
|
||||||
|
import { showConfirmDialog } from "../../../../stores/alerts"
|
||||||
|
import { showToastNotification } from "../../../../lib/notifications"
|
||||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +105,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
|
||||||
|
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
|
||||||
|
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||||
@@ -539,6 +545,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedLoading(true)
|
setBrowserSelectedLoading(true)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
|
setBrowserSelectedDirty(false)
|
||||||
|
setBrowserSelectedOriginalContent(null)
|
||||||
|
|
||||||
// Phone: treat file selection as a commit action and close the overlay.
|
// Phone: treat file selection as a commit action and close the overlay.
|
||||||
if (props.isPhoneLayout()) {
|
if (props.isPhoneLayout()) {
|
||||||
@@ -559,6 +567,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
throw new Error("Unsupported file type")
|
throw new Error("Unsupported file type")
|
||||||
}
|
}
|
||||||
setBrowserSelectedContent(text)
|
setBrowserSelectedContent(text)
|
||||||
|
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -566,6 +575,95 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveBrowserFile = async (content: string): Promise<boolean> => {
|
||||||
|
const path = browserSelectedPath()
|
||||||
|
if (!path) return false
|
||||||
|
|
||||||
|
// Check for conflict: agent edited file while user was editing
|
||||||
|
const originalContent = browserSelectedOriginalContent()
|
||||||
|
if (originalContent !== null) {
|
||||||
|
try {
|
||||||
|
const currentDiskContent = await requestData<FileContent>(
|
||||||
|
browserClient().file.read({ path }),
|
||||||
|
"file.read",
|
||||||
|
)
|
||||||
|
const diskContent = (currentDiskContent as any)?.content
|
||||||
|
|
||||||
|
// If disk content differs from what we originally loaded (agent edit)
|
||||||
|
// AND differs from user's current edits, we have a conflict
|
||||||
|
if (diskContent !== originalContent && diskContent !== content) {
|
||||||
|
const confirmed = await showConfirmDialog(
|
||||||
|
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
|
||||||
|
{
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
||||||
|
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!confirmed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// User chose to overwrite, proceed with save
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't check for conflict, proceed with save
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrowserSelectedSaving(true)
|
||||||
|
try {
|
||||||
|
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
|
||||||
|
setBrowserSelectedContent(content)
|
||||||
|
setBrowserSelectedOriginalContent(content) // Update original to match saved
|
||||||
|
setBrowserSelectedDirty(false)
|
||||||
|
showToastNotification({
|
||||||
|
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
|
||||||
|
variant: "success",
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
|
||||||
|
showToastNotification({
|
||||||
|
message: props.t("instanceShell.rightPanel.toast.saveError"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setBrowserSelectedSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrowserFileChange = (content: string) => {
|
||||||
|
setBrowserSelectedContent(content)
|
||||||
|
setBrowserSelectedDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenBrowserFileRequest = async (path: string) => {
|
||||||
|
if (browserSelectedDirty()) {
|
||||||
|
const confirmed = await showConfirmDialog(
|
||||||
|
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
|
||||||
|
{
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
|
||||||
|
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (confirmed) {
|
||||||
|
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
|
||||||
|
if (!saveSuccess) {
|
||||||
|
// Save failed - stay on current file, error toast already shown
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User chose not to save - clear dirty state and discard edits
|
||||||
|
setBrowserSelectedDirty(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await openBrowserFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (rightPanelTab() !== "files") return
|
if (rightPanelTab() !== "files") return
|
||||||
if (browserLoading()) return
|
if (browserLoading()) return
|
||||||
@@ -578,6 +676,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
setBrowserSelectedLoading(false)
|
setBrowserSelectedLoading(false)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
|
setBrowserSelectedDirty(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -630,6 +729,22 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshFilesTab = async () => {
|
const refreshFilesTab = async () => {
|
||||||
|
// Prompt for confirmation if file has unsaved changes
|
||||||
|
if (browserSelectedDirty()) {
|
||||||
|
const confirmed = await showConfirmDialog(
|
||||||
|
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
|
||||||
|
{
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
|
||||||
|
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void loadBrowserEntries(browserPath())
|
void loadBrowserEntries(browserPath())
|
||||||
const selected = browserSelectedPath()
|
const selected = browserSelectedPath()
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -651,6 +766,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
throw new Error("Unsupported file type")
|
throw new Error("Unsupported file type")
|
||||||
}
|
}
|
||||||
setBrowserSelectedContent(text)
|
setBrowserSelectedContent(text)
|
||||||
|
setBrowserSelectedOriginalContent(text) // Update original content after refresh
|
||||||
|
setBrowserSelectedDirty(false) // Clear dirty after refresh
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -830,11 +947,15 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
browserSelectedContent={browserSelectedContent}
|
browserSelectedContent={browserSelectedContent}
|
||||||
browserSelectedLoading={browserSelectedLoading}
|
browserSelectedLoading={browserSelectedLoading}
|
||||||
browserSelectedError={browserSelectedError}
|
browserSelectedError={browserSelectedError}
|
||||||
|
browserSelectedDirty={browserSelectedDirty}
|
||||||
|
browserSelectedSaving={browserSelectedSaving}
|
||||||
parentPath={browserParentPath}
|
parentPath={browserParentPath}
|
||||||
scopeKey={browserScopeKey}
|
scopeKey={browserScopeKey}
|
||||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||||
onOpenFile={(path: string) => void openBrowserFile(path)}
|
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
||||||
onRefresh={() => void refreshFilesTab()}
|
onRefresh={() => void refreshFilesTab()}
|
||||||
|
onSave={(content: string) => void saveBrowserFile(content)}
|
||||||
|
onContentChange={(content: string) => handleBrowserFileChange(content)}
|
||||||
listOpen={filesListOpen}
|
listOpen={filesListOpen}
|
||||||
onToggleList={toggleFilesList}
|
onToggleList={toggleFilesList}
|
||||||
splitWidth={filesSplitWidth}
|
splitWidth={filesSplitWidth}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw, Save } from "lucide-solid"
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
|
||||||
@@ -21,13 +21,17 @@ interface FilesTabProps {
|
|||||||
browserSelectedContent: Accessor<string | null>
|
browserSelectedContent: Accessor<string | null>
|
||||||
browserSelectedLoading: Accessor<boolean>
|
browserSelectedLoading: Accessor<boolean>
|
||||||
browserSelectedError: Accessor<string | null>
|
browserSelectedError: Accessor<string | null>
|
||||||
|
browserSelectedDirty: Accessor<boolean>
|
||||||
|
browserSelectedSaving: Accessor<boolean>
|
||||||
|
|
||||||
parentPath: Accessor<string | null>
|
parentPath: Accessor<string | null>
|
||||||
scopeKey: Accessor<string>
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
onLoadEntries: (path: string) => void
|
onLoadEntries: (path: string) => void
|
||||||
onOpenFile: (path: string) => void
|
onRequestOpenFile: (path: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
onSave: (content: string) => void
|
||||||
|
onContentChange: (content: string) => void
|
||||||
|
|
||||||
listOpen: Accessor<boolean>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -38,6 +42,13 @@ interface FilesTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||||
|
const handleSave = () => {
|
||||||
|
const content = props.browserSelectedContent()
|
||||||
|
if (content !== undefined && content !== null) {
|
||||||
|
props.onSave(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
const entriesValue = props.browserEntries()
|
const entriesValue = props.browserEntries()
|
||||||
const entries = entriesValue || []
|
const entries = entriesValue || []
|
||||||
@@ -86,7 +97,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
<LazyMonacoFileViewer
|
||||||
|
scopeKey={props.scopeKey()}
|
||||||
|
path={payload().path}
|
||||||
|
content={payload().content}
|
||||||
|
onSave={props.onSave}
|
||||||
|
onContentChange={props.onContentChange}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -135,7 +152,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
props.onLoadEntries(item.path)
|
props.onLoadEntries(item.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
props.onOpenFile(item.path)
|
props.onRequestOpenFile(item.path)
|
||||||
}}
|
}}
|
||||||
title={item.path}
|
title={item.path}
|
||||||
>
|
>
|
||||||
@@ -168,14 +185,25 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
|
||||||
|
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
|
||||||
|
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
|
||||||
|
style={{ "margin-inline-start": "auto" }}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
|
||||||
|
<RefreshCw class="h-4 w-4 animate-spin" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="files-header-icon-button"
|
class="files-header-icon-button"
|
||||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
disabled={props.browserLoading()}
|
disabled={props.browserLoading()}
|
||||||
style={{ "margin-inline-start": "auto" }}
|
|
||||||
onClick={() => props.onRefresh()}
|
onClick={() => props.onRefresh()}
|
||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||||
@@ -198,4 +226,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
return <>{renderContent()}</>
|
return <>{renderContent()}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilesTab
|
export default FilesTab
|
||||||
@@ -83,6 +83,7 @@ interface MarkdownProps {
|
|||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
size?: "base" | "sm" | "tight"
|
size?: "base" | "sm" | "tight"
|
||||||
disableHighlight?: boolean
|
disableHighlight?: boolean
|
||||||
|
escapeRawHtml?: boolean
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const text = decodeHtmlEntitiesLocally(rawText)
|
const text = decodeHtmlEntitiesLocally(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
|
const escapeRawHtml = Boolean(props.escapeRawHtml)
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||||
const cacheId = resolvePartCacheId(part, text)
|
const cacheId = resolvePartCacheId(part, text)
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
|
||||||
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
text: snapshot.text,
|
text: snapshot.text,
|
||||||
html: renderedHtml,
|
html: renderedHtml,
|
||||||
theme: snapshot.themeKey,
|
theme: snapshot.themeKey,
|
||||||
mode: snapshot.version,
|
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
||||||
}
|
}
|
||||||
setHtml(renderedHtml)
|
setHtml(renderedHtml)
|
||||||
cacheHandle.set(cacheEntry)
|
cacheHandle.set(cacheEntry)
|
||||||
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||||
suppressHighlight: !snapshot.highlightEnabled,
|
suppressHighlight: !snapshot.highlightEnabled,
|
||||||
|
escapeRawHtml: snapshot.escapeRawHtml,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const snapshot = resolved()
|
const snapshot = resolved()
|
||||||
latestRequestKey = snapshot.requestKey
|
latestRequestKey = snapshot.requestKey
|
||||||
|
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
return cache.theme === snapshot.themeKey && cache.mode === cacheMode
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = snapshot.part.renderCache
|
const localCache = snapshot.part.renderCache
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isDark={isDark()}
|
isDark={isDark()}
|
||||||
size={isAssistantMessage() ? "tight" : "base"}
|
size={isAssistantMessage() ? "tight" : "base"}
|
||||||
|
escapeRawHtml={props.messageType === "user"}
|
||||||
onRendered={props.onRendered}
|
onRendered={props.onRendered}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { showAlertDialog } from "../../stores/alerts"
|
|||||||
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { isElectronHost } from "../../lib/runtime-env"
|
||||||
|
|
||||||
interface UsePromptVoiceInputOptions {
|
interface UsePromptVoiceInputOptions {
|
||||||
prompt: Accessor<string>
|
prompt: Accessor<string>
|
||||||
@@ -88,6 +89,14 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
|||||||
try {
|
try {
|
||||||
recordedChunks = []
|
recordedChunks = []
|
||||||
shouldTranscribe = true
|
shouldTranscribe = true
|
||||||
|
|
||||||
|
if (isElectronHost()) {
|
||||||
|
const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.()
|
||||||
|
if (granted && !granted.granted) {
|
||||||
|
throw new Error(t("promptInput.voiceInput.error.permissionDenied"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
mediaRecorder = createRecorder(mediaStream)
|
mediaRecorder = createRecorder(mediaStream)
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -285,6 +286,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SpeechSynthesisResponse,
|
SpeechSynthesisResponse,
|
||||||
SpeechTranscriptionResponse,
|
SpeechTranscriptionResponse,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
|
VoiceModeStateResponse,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
WorkspaceFileResponse,
|
WorkspaceFileResponse,
|
||||||
@@ -234,6 +235,16 @@ export const serverApi = {
|
|||||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
|
||||||
|
const params = new URLSearchParams({ path: relativePath })
|
||||||
|
return request(
|
||||||
|
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ contents }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||||
@@ -338,6 +349,12 @@ export const serverApi = {
|
|||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
|
||||||
|
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
},
|
||||||
fetchBackgroundProcessOutput(
|
fetchBackgroundProcessOutput(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
processId: string,
|
processId: string,
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.status": "Status",
|
"instanceShell.rightPanel.tabs.status": "Status",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||||
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||||
|
"instanceShell.rightPanel.actions.save": "Save (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Save",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Discard Changes",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "File was modified by the agent. Overwrite agent's changes?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Overwrite",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancel",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "File has unsaved changes. Refresh will discard your edits. Continue?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Refresh",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
"promptInput.voiceInput.error.title": "Voice input failed",
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.",
|
||||||
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "Actualizar",
|
||||||
|
"instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Guardar",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Descartar cambios",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "El archivo fue modificado por el agente. ¿Sobrescribir los cambios del agente?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Sobrescribir",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancelar",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "El archivo tiene cambios sin guardar. Actualizar discardará tus ediciones. ¿Continuar?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualizar",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
|
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
|
||||||
"promptInput.voiceInput.error.title": "La entrada de voz falló",
|
"promptInput.voiceInput.error.title": "La entrada de voz falló",
|
||||||
"promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.",
|
"promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS denegó el acceso al micrófono.",
|
||||||
"promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.",
|
"promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.",
|
||||||
"promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.",
|
"promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "Actualiser",
|
||||||
|
"instanceShell.rightPanel.actions.save": "Enregistrer (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "Voulez-vous enregistrer les modifications de \"{path}\" avant de changer ?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Enregistrer",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Annuler les modifications",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "Le fichier a été modifié par l'agent. Écraser les modifications de l'agent ?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Écraser",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Annuler",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "Le fichier a des modifications non enregistrées. Actualiser supprimera vos modifications. Continuer ?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualiser",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
|
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
|
||||||
"promptInput.voiceInput.error.title": "Échec de la saisie vocale",
|
"promptInput.voiceInput.error.title": "Échec de la saisie vocale",
|
||||||
"promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.",
|
"promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS a refusé l'accès au microphone.",
|
||||||
"promptInput.voiceInput.error.unsupported": "La saisie vocale n'est pas prise en charge dans ce navigateur.",
|
"promptInput.voiceInput.error.unsupported": "La saisie vocale n'est pas prise en charge dans ce navigateur.",
|
||||||
"promptInput.voiceInput.error.transcribe": "Impossible de transcrire l'audio enregistré.",
|
"promptInput.voiceInput.error.transcribe": "Impossible de transcrire l'audio enregistré.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
||||||
"instanceShell.rightPanel.actions.refresh": "רענן",
|
"instanceShell.rightPanel.actions.refresh": "רענן",
|
||||||
|
"instanceShell.rightPanel.actions.save": "שמור (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "האם ברצונך לשמור את השינויים לפני המעבר?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "שמור",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "בטל שינויים",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "הקובץ שונה על ידי הסוכן. לדרוס את שינויי הסוכן?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "דרוס",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "בטל",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "לקובץ יש שינויים שלא נשמרו. רענון יבטל את העריכות שלך. להמשיך?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "רענן",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||||
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
|
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
|
||||||
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
|
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
|
||||||
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
|
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "הגישה למיקרופון נדחתה על ידי macOS.",
|
||||||
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
|
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
|
||||||
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
|
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "更新",
|
||||||
|
"instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "「{path}」への変更を切り替え前に保存しますか?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "保存",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "変更を破棄",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "ファイルはエージェントによって変更されました。上書きしますか?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "上書き",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "キャンセル",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "ファイルには未保存の変更があります。更新すると編集が破棄されます。続行しますか?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "更新",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
|
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
|
||||||
"instanceShell.rightPanel.sections.plan": "計画",
|
"instanceShell.rightPanel.sections.plan": "計画",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "音声を文字起こし中",
|
"promptInput.voiceInput.transcribing.title": "音声を文字起こし中",
|
||||||
"promptInput.voiceInput.error.title": "音声入力に失敗しました",
|
"promptInput.voiceInput.error.title": "音声入力に失敗しました",
|
||||||
"promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。",
|
"promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS によりマイクへのアクセスが拒否されました。",
|
||||||
"promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。",
|
"promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。",
|
||||||
"promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。",
|
"promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "Обновить",
|
||||||
|
"instanceShell.rightPanel.actions.save": "Сохранить (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "Сохранить изменения в \"{path}\" перед переключением?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Сохранить",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Отменить изменения",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "Файл был изменён агентом. Перезаписать изменения агента?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Перезаписать",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Отмена",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "Файл имеет несохранённые изменения. Обновление отменит ваши правки. Продолжить?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Обновить",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
|
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
|
||||||
"instanceShell.rightPanel.sections.plan": "План",
|
"instanceShell.rightPanel.sections.plan": "План",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио",
|
"promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио",
|
||||||
"promptInput.voiceInput.error.title": "Сбой голосового ввода",
|
"promptInput.voiceInput.error.title": "Сбой голосового ввода",
|
||||||
"promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.",
|
"promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS запретила доступ к микрофону.",
|
||||||
"promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.",
|
"promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.",
|
||||||
"promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.",
|
"promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "文件",
|
"instanceShell.rightPanel.tabs.files": "文件",
|
||||||
"instanceShell.rightPanel.tabs.status": "状态",
|
"instanceShell.rightPanel.tabs.status": "状态",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "刷新",
|
||||||
|
"instanceShell.rightPanel.actions.save": "保存 (Ctrl+S)",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.message": "切换前是否保存对 \"{path}\" 的更改?",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "保存",
|
||||||
|
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "放弃更改",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.message": "文件已被代理修改。是否覆盖代理的更改?",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.confirmLabel": "覆盖",
|
||||||
|
"instanceShell.rightPanel.actions.conflict.cancelLabel": "取消",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.message": "文件有未保存的更改。刷新将放弃您的编辑。继续?",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "刷新",
|
||||||
|
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消",
|
||||||
|
"instanceShell.rightPanel.toast.saveSuccess": "文件保存成功",
|
||||||
|
"instanceShell.rightPanel.toast.saveError": "保存文件失败",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "会话更改",
|
"instanceShell.rightPanel.sections.sessionChanges": "会话更改",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
|
||||||
"instanceShell.rightPanel.sections.plan": "计划",
|
"instanceShell.rightPanel.sections.plan": "计划",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "正在转写音频",
|
"promptInput.voiceInput.transcribing.title": "正在转写音频",
|
||||||
"promptInput.voiceInput.error.title": "语音输入失败",
|
"promptInput.voiceInput.error.title": "语音输入失败",
|
||||||
"promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。",
|
"promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS 已拒绝麦克风访问。",
|
||||||
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
|
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
|
||||||
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
|
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
|
|||||||
let currentTheme: "light" | "dark" = "light"
|
let currentTheme: "light" | "dark" = "light"
|
||||||
let isInitialized = false
|
let isInitialized = false
|
||||||
let highlightSuppressed = false
|
let highlightSuppressed = false
|
||||||
|
let escapeRawHtmlEnabled = false
|
||||||
let rendererSetup = false
|
let rendererSetup = false
|
||||||
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
|
||||||
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
|
||||||
@@ -285,6 +286,14 @@ function setupRenderer(isDark: boolean) {
|
|||||||
return `<code class="inline-code">${escapeHtml(decoded)}</code>`
|
return `<code class="inline-code">${escapeHtml(decoded)}</code>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderer.html = (html: string) => {
|
||||||
|
if (!escapeRawHtmlEnabled) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapeHtml(decodeHtmlEntities(html))
|
||||||
|
}
|
||||||
|
|
||||||
marked.use({ renderer })
|
marked.use({ renderer })
|
||||||
rendererSetup = true
|
rendererSetup = true
|
||||||
}
|
}
|
||||||
@@ -308,6 +317,7 @@ export async function renderMarkdown(
|
|||||||
content: string,
|
content: string,
|
||||||
options?: {
|
options?: {
|
||||||
suppressHighlight?: boolean
|
suppressHighlight?: boolean
|
||||||
|
escapeRawHtml?: boolean
|
||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
@@ -316,6 +326,7 @@ export async function renderMarkdown(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suppressHighlight = options?.suppressHighlight ?? false
|
const suppressHighlight = options?.suppressHighlight ?? false
|
||||||
|
const escapeRawHtml = options?.escapeRawHtml ?? false
|
||||||
const decoded = decodeHtmlEntities(content)
|
const decoded = decodeHtmlEntities(content)
|
||||||
|
|
||||||
if (!suppressHighlight) {
|
if (!suppressHighlight) {
|
||||||
@@ -324,13 +335,16 @@ export async function renderMarkdown(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousSuppressed = highlightSuppressed
|
const previousSuppressed = highlightSuppressed
|
||||||
|
const previousEscapeRawHtml = escapeRawHtmlEnabled
|
||||||
highlightSuppressed = suppressHighlight
|
highlightSuppressed = suppressHighlight
|
||||||
|
escapeRawHtmlEnabled = escapeRawHtml
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Proceed to parse immediately - highlighting will be available on next render
|
// Proceed to parse immediately - highlighting will be available on next render
|
||||||
return marked.parse(decoded) as Promise<string>
|
return marked.parse(decoded) as Promise<string>
|
||||||
} finally {
|
} finally {
|
||||||
highlightSuppressed = previousSuppressed
|
highlightSuppressed = previousSuppressed
|
||||||
|
escapeRawHtmlEnabled = previousEscapeRawHtml
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type AlertDialogState = {
|
|||||||
variant?: AlertVariant
|
variant?: AlertVariant
|
||||||
confirmLabel?: string
|
confirmLabel?: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
|
/** When false, prevents dismissal via Escape key or backdrop click. Default: true */
|
||||||
|
dismissible?: boolean
|
||||||
onConfirm?: () => void
|
onConfirm?: () => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface PlaybackHandle {
|
|||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
|
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
|
||||||
|
const LEADING_SPOKEN_BLOCK_REGEX = /^\s*```spoken[ \t]*\r?\n([\s\S]*?)\r?\n```(?:\r?\n|$)/i
|
||||||
|
|
||||||
const queuedKeys = new Set<string>()
|
const queuedKeys = new Set<string>()
|
||||||
const spokenKeysBySession = new Map<string, Set<string>>()
|
const spokenKeysBySession = new Map<string, Set<string>>()
|
||||||
@@ -107,6 +108,9 @@ export function canUseConversationMode(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
|
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
|
||||||
|
const previous = isConversationModeEnabled(instanceId)
|
||||||
|
if (previous === enabled) return
|
||||||
|
|
||||||
setConversationModeInstances((prev) => {
|
setConversationModeInstances((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -120,6 +124,23 @@ export function setConversationModeEnabled(instanceId: string, enabled: boolean)
|
|||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
clearConversationPlaybackForInstance(instanceId)
|
clearConversationPlaybackForInstance(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void serverApi.updateVoiceMode(instanceId, enabled).catch((error) => {
|
||||||
|
log.error("Failed to update conversation mode", error)
|
||||||
|
setConversationModeInstances((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (previous) {
|
||||||
|
next.set(instanceId, true)
|
||||||
|
} else {
|
||||||
|
next.delete(instanceId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
clearConversationPlaybackForInstance(instanceId)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleConversationMode(instanceId: string): void {
|
export function toggleConversationMode(instanceId: string): void {
|
||||||
@@ -188,7 +209,7 @@ export function handleConversationAssistantPartUpdated(instanceId: string, part:
|
|||||||
if (!isConversationModeEnabled(instanceId)) return
|
if (!isConversationModeEnabled(instanceId)) return
|
||||||
if (!isSpeakableSession(instanceId, sessionId)) return
|
if (!isSpeakableSession(instanceId, sessionId)) return
|
||||||
|
|
||||||
const text = resolveTextPartContent(part).trim()
|
const text = extractLeadingSpokenBlock(resolveTextPartContent(part))
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
const key = getEntryKey(instanceId, sessionId, messageId, partId)
|
const key = getEntryKey(instanceId, sessionId, messageId, partId)
|
||||||
@@ -505,3 +526,9 @@ function createObjectUrlFromBase64(audioBase64: string, mimeType: string): strin
|
|||||||
}
|
}
|
||||||
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
|
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractLeadingSpokenBlock(text: string): string {
|
||||||
|
const match = text.match(LEADING_SPOKEN_BLOCK_REGEX)
|
||||||
|
if (!match?.[1]) return ""
|
||||||
|
return match[1].trim()
|
||||||
|
}
|
||||||
|
|||||||
@@ -673,6 +673,7 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
|
|||||||
detail: tGlobal("sessionState.cleanup.deepConfirm.detail"),
|
detail: tGlobal("sessionState.cleanup.deepConfirm.detail"),
|
||||||
confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"),
|
confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"),
|
||||||
cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"),
|
cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"),
|
||||||
|
dismissible: false,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|||||||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
@@ -29,6 +29,7 @@ declare global {
|
|||||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||||
getPathForFile?: (file: File) => string | null
|
getPathForFile?: (file: File) => string | null
|
||||||
|
requestMicrophoneAccess?: () => Promise<{ granted: boolean }>
|
||||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
|
|
||||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
|||||||
Reference in New Issue
Block a user