Compare commits
7 Commits
v0.12.3-de
...
v0.13.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a6479c0e | ||
|
|
f88064af06 | ||
|
|
1b4eff9419 | ||
|
|
6c1febf50e | ||
|
|
75622ef366 | ||
|
|
864f913e3e | ||
|
|
b7d4f8f869 |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -12055,7 +12055,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12092,7 +12092,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12134,7 +12134,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12142,7 +12142,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -31,4 +31,4 @@
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.12.3",
|
||||
"minServerVersion": "0.13.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
electron/resources/server/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import { requestMicrophoneAccess } from "./permissions"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { enabled: false }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"media:requestMicrophoneAccess",
|
||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"notifications:show",
|
||||
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 { createApplicationMenu } from "./menu"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { configureMediaPermissionHandlers } from "./permissions"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
@@ -489,6 +490,7 @@ app.whenReady().then(() => {
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
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 { app } from "electron"
|
||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { parse as parseYaml } from "yaml"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = path.dirname(mainFilename)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
|
||||
@@ -38,6 +41,9 @@ interface CliEntryResolution {
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
type ManagedChild = ChildProcess | UtilityProcess
|
||||
type ChildLaunchMode = "spawn" | "utility"
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function isYamlPath(filePath: string): boolean {
|
||||
@@ -117,7 +123,8 @@ export declare interface CliProcessManager {
|
||||
}
|
||||
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private child?: ManagedChild
|
||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
@@ -135,33 +142,63 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
let child: ManagedChild
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||
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()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
detached,
|
||||
})
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: shellEnv,
|
||||
stdio: "pipe",
|
||||
serviceName: "CodeNomad CLI Supervisor",
|
||||
})
|
||||
this.childLaunchMode = "utility"
|
||||
} 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(" ")}`)
|
||||
if (!child.pid) {
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -176,23 +213,48 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
if (this.childLaunchMode === "utility") {
|
||||
const utilityChild = child as UtilityProcess
|
||||
|
||||
child.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
|
||||
})
|
||||
utilityChild.on("error", (error) => {
|
||||
const message = this.describeUtilityProcessError(error)
|
||||
console.error("[cli] utility supervisor failed:", error)
|
||||
this.updateStatus({ state: "error", error: message })
|
||||
this.emit("error", new Error(message))
|
||||
})
|
||||
|
||||
utilityChild.on("exit", (code) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
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) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -219,16 +281,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.childLaunchMode === "utility") {
|
||||
return this.stopUtilityChild(child as UtilityProcess)
|
||||
}
|
||||
|
||||
const spawnedChild = child as ChildProcess
|
||||
|
||||
this.requestedStop = true
|
||||
|
||||
const pid = child.pid
|
||||
const pid = spawnedChild.pid
|
||||
if (!pid) {
|
||||
this.child = undefined
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
@@ -304,7 +372,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
spawnedChild.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.child = undefined
|
||||
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 {
|
||||
return { ...this.status }
|
||||
}
|
||||
@@ -335,14 +443,22 @@ export class CliProcessManager extends EventEmitter {
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
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 {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
} catch {
|
||||
this.child.kill("SIGKILL")
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
} else {
|
||||
this.child.kill("SIGKILL")
|
||||
;(this.child as ChildProcess).kill("SIGKILL")
|
||||
}
|
||||
this.child = undefined
|
||||
}
|
||||
@@ -449,6 +565,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
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[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
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.")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -20,6 +20,8 @@
|
||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug 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",
|
||||
"prepare:resources": "node scripts/prepare-resources.js",
|
||||
"prebuild": "npm run prepare:resources",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
@@ -33,8 +35,11 @@
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"prepackage:mac": "npm run prepare:resources",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"prepackage:win": "npm run prepare:resources",
|
||||
"package:win": "electron-builder --win",
|
||||
"prepackage:linux": "npm run prepare:resources",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -82,6 +87,12 @@
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"target": [
|
||||
{
|
||||
|
||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
||||
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")
|
||||
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
|
||||
},
|
||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.14"
|
||||
"@opencode-ai/plugin": "1.3.2"
|
||||
}
|
||||
}
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -47,4 +47,4 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,19 +147,49 @@ export class OpenAICompatibleSpeechProvider {
|
||||
}
|
||||
|
||||
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.ttsModel,
|
||||
voice: settings.ttsVoice,
|
||||
input: text,
|
||||
response_format: format,
|
||||
}),
|
||||
})
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.ttsModel,
|
||||
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) {
|
||||
const detail = await response.text()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -45,4 +45,4 @@
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
|
||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
@@ -19,6 +19,7 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
|
||||
const log = getLogger("actions")
|
||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||
|
||||
@@ -351,6 +352,19 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function handleClearPrompt() {
|
||||
clearPrompt()
|
||||
clearHistoryDraft()
|
||||
resetHistoryNavigation()
|
||||
setShowPicker(false)
|
||||
setPickerMode("mention")
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
syncAttachmentCounters("")
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function insertBlockContent(block: string) {
|
||||
const textarea = textareaRef
|
||||
const current = prompt()
|
||||
@@ -422,6 +436,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const canClearPrompt = () => prompt().length > 0
|
||||
|
||||
const shellHint = () =>
|
||||
mode() === "shell"
|
||||
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
||||
@@ -461,6 +477,13 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const showVoiceInput = () =>
|
||||
preferences().showPromptVoiceInput &&
|
||||
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
|
||||
const conversationModeEnabled = () => isConversationModeEnabled(props.instanceId)
|
||||
const showConversationToggle = () => showVoiceInput() || conversationModeEnabled()
|
||||
const canToggleConversationMode = () => canUseConversationMode()
|
||||
const conversationModeButtonTitle = () =>
|
||||
conversationModeEnabled()
|
||||
? t("promptInput.conversationMode.disable.title")
|
||||
: t("promptInput.conversationMode.enable.title")
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
|
||||
@@ -543,7 +566,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-nav-buttons">
|
||||
<div class="prompt-nav-top-row">
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -582,47 +605,72 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
@@ -712,10 +760,3 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatVoiceTimer(elapsedMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { showAlertDialog } from "../../stores/alerts"
|
||||
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { isElectronHost } from "../../lib/runtime-env"
|
||||
|
||||
interface UsePromptVoiceInputOptions {
|
||||
prompt: Accessor<string>
|
||||
@@ -88,6 +89,14 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
||||
try {
|
||||
recordedChunks = []
|
||||
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 })
|
||||
mediaRecorder = createRecorder(mediaStream)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
||||
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -88,6 +89,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
on(
|
||||
() => props.isActive,
|
||||
(isActive) => {
|
||||
if (!isActive) {
|
||||
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
|
||||
return
|
||||
}
|
||||
if (!isActive) return
|
||||
|
||||
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
|
||||
|
||||
@@ -142,14 +142,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "again to abort session",
|
||||
"promptInput.stopSession.ariaLabel": "Stop session",
|
||||
"promptInput.stopSession.title": "Stop session",
|
||||
"promptInput.clear.ariaLabel": "Clear prompt text",
|
||||
"promptInput.clear.title": "Clear prompt text",
|
||||
"promptInput.send.ariaLabel": "Send message",
|
||||
"promptInput.send.errorFallback": "Failed to send message",
|
||||
"promptInput.send.errorTitle": "Send failed",
|
||||
"promptInput.conversationMode.enable.title": "Enable conversation mode",
|
||||
"promptInput.conversationMode.disable.title": "Disable conversation mode",
|
||||
"promptInput.conversationMode.error.title": "Conversation playback failed",
|
||||
"promptInput.conversationMode.error.message": "Unable to continue speaking assistant replies.",
|
||||
"promptInput.voiceInput.start.title": "Start voice input",
|
||||
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||
"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.transcribe": "Unable to transcribe the recorded audio.",
|
||||
} as const
|
||||
|
||||
@@ -144,14 +144,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "otra vez para abortar la sesión",
|
||||
"promptInput.stopSession.ariaLabel": "Detener sesión",
|
||||
"promptInput.stopSession.title": "Detener sesión",
|
||||
"promptInput.clear.ariaLabel": "Borrar el texto del prompt",
|
||||
"promptInput.clear.title": "Borrar el texto del prompt",
|
||||
"promptInput.send.ariaLabel": "Enviar mensaje",
|
||||
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
||||
"promptInput.send.errorTitle": "Error al enviar",
|
||||
"promptInput.conversationMode.enable.title": "Activar modo conversacion",
|
||||
"promptInput.conversationMode.disable.title": "Desactivar modo conversacion",
|
||||
"promptInput.conversationMode.error.title": "Fallo la reproduccion de la conversacion",
|
||||
"promptInput.conversationMode.error.message": "No se pudieron seguir reproduciendo las respuestas del asistente.",
|
||||
"promptInput.voiceInput.start.title": "Iniciar entrada de voz",
|
||||
"promptInput.voiceInput.stop.title": "Detener grabación y transcribir",
|
||||
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
|
||||
"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.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.transcribe": "No se pudo transcribir el audio grabado.",
|
||||
} as const
|
||||
|
||||
@@ -144,14 +144,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "à nouveau pour interrompre la session",
|
||||
"promptInput.stopSession.ariaLabel": "Arrêter la session",
|
||||
"promptInput.stopSession.title": "Arrêter la session",
|
||||
"promptInput.clear.ariaLabel": "Effacer le texte du prompt",
|
||||
"promptInput.clear.title": "Effacer le texte du prompt",
|
||||
"promptInput.send.ariaLabel": "Envoyer le message",
|
||||
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
||||
"promptInput.send.errorTitle": "Échec de l'envoi",
|
||||
"promptInput.conversationMode.enable.title": "Activer le mode conversation",
|
||||
"promptInput.conversationMode.disable.title": "Desactiver le mode conversation",
|
||||
"promptInput.conversationMode.error.title": "La lecture de la conversation a echoue",
|
||||
"promptInput.conversationMode.error.message": "Impossible de continuer a lire les reponses de l'assistant.",
|
||||
"promptInput.voiceInput.start.title": "Démarrer la saisie vocale",
|
||||
"promptInput.voiceInput.stop.title": "Arrêter l'enregistrement et transcrire",
|
||||
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
|
||||
"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.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.transcribe": "Impossible de transcrire l'audio enregistré.",
|
||||
} as const
|
||||
|
||||
@@ -142,14 +142,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "שוב כדי לבטל את הסשן",
|
||||
"promptInput.stopSession.ariaLabel": "עצור סשן",
|
||||
"promptInput.stopSession.title": "עצור סשן",
|
||||
"promptInput.clear.ariaLabel": "נקה את טקסט הפרומפט",
|
||||
"promptInput.clear.title": "נקה את טקסט הפרומפט",
|
||||
"promptInput.send.ariaLabel": "שלח הודעה",
|
||||
"promptInput.send.errorFallback": "שליחת ההודעה נכשלה",
|
||||
"promptInput.send.errorTitle": "השליחה נכשלה",
|
||||
"promptInput.conversationMode.enable.title": "הפעל מצב שיחה",
|
||||
"promptInput.conversationMode.disable.title": "כבה מצב שיחה",
|
||||
"promptInput.conversationMode.error.title": "ניגון השיחה נכשל",
|
||||
"promptInput.conversationMode.error.message": "לא ניתן היה להמשיך להקריא את תגובות העוזר.",
|
||||
"promptInput.voiceInput.start.title": "התחל קלט קולי",
|
||||
"promptInput.voiceInput.stop.title": "עצור הקלטה ותמלל",
|
||||
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
|
||||
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
|
||||
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
|
||||
"promptInput.voiceInput.error.permissionDenied": "הגישה למיקרופון נדחתה על ידי macOS.",
|
||||
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
|
||||
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
|
||||
} as const
|
||||
|
||||
@@ -144,14 +144,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "もう一度押すとセッションを中断",
|
||||
"promptInput.stopSession.ariaLabel": "セッションを停止",
|
||||
"promptInput.stopSession.title": "セッションを停止",
|
||||
"promptInput.clear.ariaLabel": "プロンプトのテキストをクリア",
|
||||
"promptInput.clear.title": "プロンプトのテキストをクリア",
|
||||
"promptInput.send.ariaLabel": "メッセージを送信",
|
||||
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
|
||||
"promptInput.send.errorTitle": "送信に失敗",
|
||||
"promptInput.conversationMode.enable.title": "会話モードを有効化",
|
||||
"promptInput.conversationMode.disable.title": "会話モードを無効化",
|
||||
"promptInput.conversationMode.error.title": "会話の読み上げに失敗しました",
|
||||
"promptInput.conversationMode.error.message": "アシスタントの返信の読み上げを続行できませんでした。",
|
||||
"promptInput.voiceInput.start.title": "音声入力を開始",
|
||||
"promptInput.voiceInput.stop.title": "録音を停止して文字起こし",
|
||||
"promptInput.voiceInput.transcribing.title": "音声を文字起こし中",
|
||||
"promptInput.voiceInput.error.title": "音声入力に失敗しました",
|
||||
"promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。",
|
||||
"promptInput.voiceInput.error.permissionDenied": "macOS によりマイクへのアクセスが拒否されました。",
|
||||
"promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。",
|
||||
"promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。",
|
||||
} as const
|
||||
|
||||
@@ -144,14 +144,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "еще раз, чтобы прервать сессию",
|
||||
"promptInput.stopSession.ariaLabel": "Остановить сессию",
|
||||
"promptInput.stopSession.title": "Остановить сессию",
|
||||
"promptInput.clear.ariaLabel": "Очистить текст prompt",
|
||||
"promptInput.clear.title": "Очистить текст prompt",
|
||||
"promptInput.send.ariaLabel": "Отправить сообщение",
|
||||
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
|
||||
"promptInput.send.errorTitle": "Не удалось отправить",
|
||||
"promptInput.conversationMode.enable.title": "Включить режим разговора",
|
||||
"promptInput.conversationMode.disable.title": "Выключить режим разговора",
|
||||
"promptInput.conversationMode.error.title": "Сбой озвучивания разговора",
|
||||
"promptInput.conversationMode.error.message": "Не удалось продолжить озвучивание ответов ассистента.",
|
||||
"promptInput.voiceInput.start.title": "Начать голосовой ввод",
|
||||
"promptInput.voiceInput.stop.title": "Остановить запись и расшифровать",
|
||||
"promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио",
|
||||
"promptInput.voiceInput.error.title": "Сбой голосового ввода",
|
||||
"promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.",
|
||||
"promptInput.voiceInput.error.permissionDenied": "macOS запретила доступ к микрофону.",
|
||||
"promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.",
|
||||
"promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.",
|
||||
} as const
|
||||
|
||||
@@ -144,14 +144,21 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "再次按下以中止会话",
|
||||
"promptInput.stopSession.ariaLabel": "停止会话",
|
||||
"promptInput.stopSession.title": "停止会话",
|
||||
"promptInput.clear.ariaLabel": "清除输入框文本",
|
||||
"promptInput.clear.title": "清除输入框文本",
|
||||
"promptInput.send.ariaLabel": "发送消息",
|
||||
"promptInput.send.errorFallback": "发送消息失败",
|
||||
"promptInput.send.errorTitle": "发送失败",
|
||||
"promptInput.conversationMode.enable.title": "开启对话模式",
|
||||
"promptInput.conversationMode.disable.title": "关闭对话模式",
|
||||
"promptInput.conversationMode.error.title": "对话播报失败",
|
||||
"promptInput.conversationMode.error.message": "无法继续播报助手回复。",
|
||||
"promptInput.voiceInput.start.title": "开始语音输入",
|
||||
"promptInput.voiceInput.stop.title": "停止录音并转写",
|
||||
"promptInput.voiceInput.transcribing.title": "正在转写音频",
|
||||
"promptInput.voiceInput.error.title": "语音输入失败",
|
||||
"promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。",
|
||||
"promptInput.voiceInput.error.permissionDenied": "macOS 已拒绝麦克风访问。",
|
||||
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
|
||||
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
|
||||
} as const
|
||||
|
||||
507
packages/ui/src/stores/conversation-speech.ts
Normal file
507
packages/ui/src/stores/conversation-speech.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { loadSpeechCapabilities, speechCapabilities } from "./speech"
|
||||
import { getActiveSession, sessions } from "./session-state"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { activeInstanceId } from "./instances"
|
||||
|
||||
type SpeechPlaybackMode = "streaming" | "buffered"
|
||||
type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac"
|
||||
|
||||
interface ConversationQueueEntry {
|
||||
key: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PlaybackHandle {
|
||||
stop: () => void
|
||||
done: Promise<void>
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
const queuedKeys = new Set<string>()
|
||||
const spokenKeysBySession = new Map<string, Set<string>>()
|
||||
let queue: ConversationQueueEntry[] = []
|
||||
let currentPlayback:
|
||||
| {
|
||||
entry: ConversationQueueEntry
|
||||
handle: PlaybackHandle
|
||||
}
|
||||
| null = null
|
||||
let queueRunner: Promise<void> | null = null
|
||||
let playbackErrorShown = false
|
||||
|
||||
function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string {
|
||||
return `${instanceId}:${sessionId}:${messageId}:${partId}`
|
||||
}
|
||||
|
||||
function getSpokenKeySet(instanceId: string, sessionId: string): Set<string> {
|
||||
const sessionKey = `${instanceId}:${sessionId}`
|
||||
const existing = spokenKeysBySession.get(sessionKey)
|
||||
if (existing) return existing
|
||||
const next = new Set<string>()
|
||||
spokenKeysBySession.set(sessionKey, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function resolveTextPartContent(part: ClientPart): string {
|
||||
if (part.type !== "text") return ""
|
||||
if (typeof part.text === "string") {
|
||||
return part.text
|
||||
}
|
||||
|
||||
if (part.text && typeof part.text === "object") {
|
||||
const value = part.text as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const segments: string[] = []
|
||||
if (typeof value.text === "string") {
|
||||
segments.push(value.text)
|
||||
}
|
||||
if (typeof value.value === "string") {
|
||||
segments.push(value.value)
|
||||
}
|
||||
if (Array.isArray(value.content)) {
|
||||
for (const segment of value.content) {
|
||||
if (typeof segment === "string") {
|
||||
segments.push(segment)
|
||||
} else if (segment && typeof segment === "object") {
|
||||
const typedSegment = segment as { text?: unknown; value?: unknown }
|
||||
if (typeof typedSegment.text === "string") segments.push(typedSegment.text)
|
||||
if (typeof typedSegment.value === "string") segments.push(typedSegment.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments.join("\n")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function isConversationModeEnabled(instanceId: string): boolean {
|
||||
return conversationModeInstances().get(instanceId) === true
|
||||
}
|
||||
|
||||
export function canUseConversationMode(): boolean {
|
||||
const capabilities = speechCapabilities()
|
||||
if (!capabilities?.available || !capabilities.configured || !capabilities.supportsTts) {
|
||||
return false
|
||||
}
|
||||
|
||||
const settings = serverSettings().speech
|
||||
return getSpeechPlaybackSupport({
|
||||
playbackMode: settings.playbackMode,
|
||||
ttsFormat: settings.ttsFormat,
|
||||
capabilities,
|
||||
}).available
|
||||
}
|
||||
|
||||
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
|
||||
setConversationModeInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (enabled) {
|
||||
next.set(instanceId, true)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
if (!enabled) {
|
||||
clearConversationPlaybackForInstance(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleConversationMode(instanceId: string): void {
|
||||
setConversationModeEnabled(instanceId, !isConversationModeEnabled(instanceId))
|
||||
}
|
||||
|
||||
export function clearConversationPlaybackForSession(instanceId: string, sessionId: string): void {
|
||||
const sessionKey = `${instanceId}:${sessionId}`
|
||||
queue = queue.filter((entry) => {
|
||||
if (`${entry.instanceId}:${entry.sessionId}` === sessionKey) {
|
||||
queuedKeys.delete(entry.key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (currentPlayback && `${currentPlayback.entry.instanceId}:${currentPlayback.entry.sessionId}` === sessionKey) {
|
||||
currentPlayback.handle.stop()
|
||||
currentPlayback = null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearConversationPlaybackForInstance(instanceId: string): void {
|
||||
queue = queue.filter((entry) => {
|
||||
if (entry.instanceId === instanceId) {
|
||||
queuedKeys.delete(entry.key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (currentPlayback?.entry.instanceId === instanceId) {
|
||||
currentPlayback.handle.stop()
|
||||
currentPlayback = null
|
||||
}
|
||||
}
|
||||
|
||||
function isSpeakableSession(instanceId: string, sessionId: string): boolean {
|
||||
if (activeInstanceId() !== instanceId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activeSession = getActiveSession(instanceId)
|
||||
if (!activeSession || activeSession.id !== sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId) ?? activeSession
|
||||
return !session?.parentId
|
||||
}
|
||||
|
||||
export function handleConversationAssistantPartUpdated(instanceId: string, part: ClientPart, messageInfo?: MessageInfo): void {
|
||||
if (part.type !== "text") return
|
||||
|
||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : messageInfo?.sessionID
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : messageInfo?.id
|
||||
const partId = typeof part.id === "string" ? part.id : undefined
|
||||
if (!sessionId || !messageId || !partId) return
|
||||
|
||||
const messageRole =
|
||||
messageInfo?.role ??
|
||||
messageStoreBus.getOrCreate(instanceId).getMessage(messageId)?.role ??
|
||||
null
|
||||
if (messageRole !== "assistant") return
|
||||
|
||||
if (!isConversationModeEnabled(instanceId)) return
|
||||
if (!isSpeakableSession(instanceId, sessionId)) return
|
||||
|
||||
const text = resolveTextPartContent(part).trim()
|
||||
if (!text) return
|
||||
|
||||
const key = getEntryKey(instanceId, sessionId, messageId, partId)
|
||||
const spokenKeys = getSpokenKeySet(instanceId, sessionId)
|
||||
if (spokenKeys.has(key) || queuedKeys.has(key) || currentPlayback?.entry.key === key) {
|
||||
return
|
||||
}
|
||||
|
||||
queuedKeys.add(key)
|
||||
queue.push({ key, instanceId, sessionId, messageId, partId, text })
|
||||
void runConversationQueue()
|
||||
}
|
||||
|
||||
async function runConversationQueue(): Promise<void> {
|
||||
if (queueRunner) {
|
||||
await queueRunner
|
||||
return
|
||||
}
|
||||
|
||||
queueRunner = (async () => {
|
||||
while (queue.length > 0) {
|
||||
const entry = queue.shift()!
|
||||
queuedKeys.delete(entry.key)
|
||||
|
||||
if (!isConversationModeEnabled(entry.instanceId)) {
|
||||
continue
|
||||
}
|
||||
if (!isSpeakableSession(entry.instanceId, entry.sessionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const spokenKeys = getSpokenKeySet(entry.instanceId, entry.sessionId)
|
||||
spokenKeys.add(entry.key)
|
||||
|
||||
try {
|
||||
const handle = await createPlaybackHandle(entry.text)
|
||||
currentPlayback = { entry, handle }
|
||||
await handle.done
|
||||
} catch (error) {
|
||||
spokenKeys.delete(entry.key)
|
||||
clearConversationPlaybackForInstance(entry.instanceId)
|
||||
if (!playbackErrorShown) {
|
||||
playbackErrorShown = true
|
||||
showToastNotification({
|
||||
title: tGlobal("promptInput.conversationMode.error.title"),
|
||||
message:
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: tGlobal("promptInput.conversationMode.error.message"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
log.error("Conversation playback failed", error)
|
||||
break
|
||||
} finally {
|
||||
if (currentPlayback?.entry.key === entry.key) {
|
||||
currentPlayback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await queueRunner
|
||||
} finally {
|
||||
queueRunner = null
|
||||
if (queue.length === 0) {
|
||||
playbackErrorShown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlaybackHandle(text: string): Promise<PlaybackHandle> {
|
||||
const capabilities = (await loadSpeechCapabilities()) ?? speechCapabilities()
|
||||
const settings = serverSettings().speech
|
||||
|
||||
if (!capabilities?.available || !capabilities.configured || !capabilities.supportsTts) {
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.unavailable"))
|
||||
}
|
||||
|
||||
const support = getSpeechPlaybackSupport({
|
||||
playbackMode: settings.playbackMode,
|
||||
ttsFormat: settings.ttsFormat,
|
||||
capabilities,
|
||||
})
|
||||
if (!support.available) {
|
||||
if (support.reason === "provider-streaming-unavailable") {
|
||||
throw new Error(tGlobal("settings.speech.compatibility.streamingUnavailable"))
|
||||
}
|
||||
if (support.reason === "browser-streaming-unavailable") {
|
||||
throw new Error(tGlobal("settings.speech.compatibility.browserStreamingUnavailable"))
|
||||
}
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.unsupported"))
|
||||
}
|
||||
|
||||
return settings.playbackMode === "streaming"
|
||||
? createStreamingPlaybackHandle(text, settings.ttsFormat)
|
||||
: createBufferedPlaybackHandle(text, settings.ttsFormat)
|
||||
}
|
||||
|
||||
async function createBufferedPlaybackHandle(text: string, format: SpeechTtsFormat): Promise<PlaybackHandle> {
|
||||
const response = await serverApi.synthesizeSpeech({ text, format })
|
||||
const objectUrl = createObjectUrlFromBase64(response.audioBase64, response.mimeType)
|
||||
const audio = new Audio(objectUrl)
|
||||
|
||||
let settled = false
|
||||
let resolveDone!: () => void
|
||||
let rejectDone!: (error: unknown) => void
|
||||
|
||||
const cleanup = () => {
|
||||
audio.pause()
|
||||
audio.src = ""
|
||||
audio.load()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
resolveDone = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
rejectDone = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener("ended", () => resolveDone(), { once: true })
|
||||
audio.addEventListener("error", () => rejectDone(new Error(tGlobal("messageItem.actions.speak.error.generate"))), {
|
||||
once: true,
|
||||
})
|
||||
|
||||
await audio.play()
|
||||
|
||||
return {
|
||||
stop: () => resolveDone(),
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
async function createStreamingPlaybackHandle(text: string, format: SpeechTtsFormat): Promise<PlaybackHandle> {
|
||||
if (typeof MediaSource === "undefined") {
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.unsupported"))
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
const response = await serverApi.synthesizeSpeechStream({ text, format }, abortController.signal)
|
||||
const mimeType = response.headers.get("content-type") || formatToMimeType(format)
|
||||
const stream = response.body
|
||||
if (!stream) {
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.generate"))
|
||||
}
|
||||
|
||||
if (!MediaSource.isTypeSupported(mimeType)) {
|
||||
throw new Error(tGlobal("settings.speech.compatibility.browserStreamingUnavailable"))
|
||||
}
|
||||
|
||||
const mediaSource = new MediaSource()
|
||||
const objectUrl = URL.createObjectURL(mediaSource)
|
||||
const audio = new Audio(objectUrl)
|
||||
|
||||
let settled = false
|
||||
let startedPlayback = false
|
||||
let resolveDone!: () => void
|
||||
let rejectDone!: (error: unknown) => void
|
||||
|
||||
const cleanup = () => {
|
||||
abortController.abort()
|
||||
audio.pause()
|
||||
audio.src = ""
|
||||
audio.load()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
resolveDone = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
rejectDone = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener("ended", () => resolveDone(), { once: true })
|
||||
audio.addEventListener("error", () => rejectDone(new Error(tGlobal("messageItem.actions.speak.error.generate"))), {
|
||||
once: true,
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mediaSource.addEventListener(
|
||||
"sourceopen",
|
||||
() => {
|
||||
void streamToMediaSource({
|
||||
mediaSource,
|
||||
stream,
|
||||
mimeType,
|
||||
onPlayable: async () => {
|
||||
if (startedPlayback) return
|
||||
startedPlayback = true
|
||||
try {
|
||||
await audio.play()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
onError: reject,
|
||||
})
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
stop: () => resolveDone(),
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
async function streamToMediaSource(options: {
|
||||
mediaSource: MediaSource
|
||||
stream: ReadableStream<Uint8Array>
|
||||
mimeType: string
|
||||
onPlayable: () => Promise<void>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
try {
|
||||
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
|
||||
const reader = options.stream.getReader()
|
||||
const queue: Uint8Array[] = []
|
||||
let processing = false
|
||||
let playbackStarted = false
|
||||
|
||||
const flushQueue = async () => {
|
||||
if (processing || sourceBuffer.updating || queue.length === 0) return
|
||||
processing = true
|
||||
const chunk = queue.shift()!
|
||||
await appendChunk(sourceBuffer, chunk)
|
||||
if (!playbackStarted) {
|
||||
playbackStarted = true
|
||||
await options.onPlayable()
|
||||
}
|
||||
processing = false
|
||||
await flushQueue()
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value && value.byteLength > 0) {
|
||||
queue.push(value)
|
||||
await flushQueue()
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0 || sourceBuffer.updating) {
|
||||
if (queue.length > 0) {
|
||||
await flushQueue()
|
||||
} else {
|
||||
await waitForUpdateEnd(sourceBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.mediaSource.readyState === "open") {
|
||||
options.mediaSource.endOfStream()
|
||||
}
|
||||
} catch (error) {
|
||||
options.onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleUpdateEnd = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const handleError = () => {
|
||||
cleanup()
|
||||
reject(new Error(tGlobal("messageItem.actions.speak.error.generate")))
|
||||
}
|
||||
const cleanup = () => {
|
||||
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
|
||||
sourceBuffer.removeEventListener("error", handleError)
|
||||
}
|
||||
|
||||
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
|
||||
sourceBuffer.addEventListener("error", handleError, { once: true })
|
||||
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
|
||||
})
|
||||
}
|
||||
|
||||
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
|
||||
const binary = atob(audioBase64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { messageStoreBus } from "./message-v2/bus"
|
||||
import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { clearConversationPlaybackForSession } from "./conversation-speech"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -165,6 +166,8 @@ async function sendMessage(
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const createdAt = Date.now()
|
||||
|
||||
clearConversationPlaybackForSession(instanceId, sessionId)
|
||||
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
import { handleConversationAssistantPartUpdated } from "./conversation-speech"
|
||||
|
||||
const log = getLogger("sse")
|
||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||
@@ -330,8 +331,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
if (messageInfo) {
|
||||
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
||||
}
|
||||
|
||||
|
||||
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||
handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo)
|
||||
|
||||
if (part.type === "tool" && part.tool === "question") {
|
||||
// Questions can arrive before their tool part exists; re-link now.
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
.prompt-input {
|
||||
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 5.5rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
@@ -90,22 +90,32 @@
|
||||
inset-inline-end: 0.25rem;
|
||||
bottom: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 0.125rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.prompt-nav-top-row {
|
||||
.prompt-nav-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.prompt-nav-column-left {
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.prompt-nav-column-right {
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.prompt-expand-button,
|
||||
.prompt-history-button {
|
||||
.prompt-history-button,
|
||||
.prompt-clear-button {
|
||||
@apply w-7 h-7 flex items-center justify-center rounded-md;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--control-ghost-bg);
|
||||
@@ -115,7 +125,8 @@
|
||||
}
|
||||
|
||||
.prompt-expand-button:hover:not(:disabled),
|
||||
.prompt-history-button:hover:not(:disabled) {
|
||||
.prompt-history-button:hover:not(:disabled),
|
||||
.prompt-clear-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -127,7 +138,8 @@
|
||||
}
|
||||
|
||||
.prompt-expand-button:disabled,
|
||||
.prompt-history-button:disabled {
|
||||
.prompt-history-button:disabled,
|
||||
.prompt-clear-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -208,6 +220,16 @@
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-voice-button.is-recording:hover:not(:disabled) {
|
||||
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-voice-button.is-recording:active:not(:disabled) {
|
||||
background-color: var(--button-danger-active-bg, rgba(239, 68, 68, 1));
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-nav-voice-button {
|
||||
min-width: 1.75rem;
|
||||
width: 1.75rem;
|
||||
@@ -216,14 +238,24 @@
|
||||
}
|
||||
|
||||
.prompt-nav-voice-button.is-recording {
|
||||
min-width: 3.5rem;
|
||||
width: auto;
|
||||
min-width: 1.75rem;
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.prompt-voice-button:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.prompt-conversation-button.is-active {
|
||||
background-color: color-mix(in oklab, var(--accent-primary) 76%, var(--surface-secondary));
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.prompt-conversation-button.is-active:hover:not(:disabled) {
|
||||
background-color: color-mix(in oklab, var(--accent-primary) 88%, var(--surface-secondary));
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.prompt-voice-timer {
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -397,7 +429,7 @@
|
||||
.prompt-input {
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-inline-end: 5.5rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||
getPathForFile?: (file: File) => string | null
|
||||
requestMicrophoneAccess?: () => Promise<{ granted: boolean }>
|
||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||
|
||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||
|
||||
Reference in New Issue
Block a user