Compare commits

...

18 Commits

Author SHA1 Message Date
Shantur
197dee2aea Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 00:22:32 +01:00
Shantur
045d8da8b2 feat(voice): add spoken summary mode for conversation replies 2026-03-31 00:20:26 +01:00
Pascal André
c9bd4b7395 fix(tauri): stop stale UI assets from shadowing desktop builds (#258)
## Summary
- prefer the bundled desktop UI over the downloaded cache when both
report the same version, so rebuilt installers do not keep serving stale
frontend assets
- rebuild the server workspace during the Tauri prebuild step on every
desktop package build, matching Electron's correctness boundary for
fresh UI/server assets
- add a regression test covering the equal-version bundled-vs-downloaded
UI selection path

## Why
- local desktop rebuilds should reflect the latest server and UI code
without requiring users to manually clear cached assets
- packaged updates should keep favoring the freshly bundled frontend
when the cached copy is not actually newer

## Testing
- node --import tsx --test
packages/server/src/ui/__tests__/remote-ui.test.ts
- npm run build:tauri
2026-03-30 20:54:29 +01:00
Pascal André
41a5026331 fix(tauri): sync native app version with package releases (#257)
## Summary
- sync the Tauri native version metadata from
`packages/tauri-app/package.json` so release builds pick up workspace
version bumps like `0.13.1`
- update the checked-in Tauri `Cargo.toml` and `tauri.conf.json`
versions from `0.12.3` to `0.13.1`
- document the prebuild sync behavior in `BUILD.md`

## Testing
- `node packages/tauri-app/scripts/sync-tauri-version.js`
2026-03-30 20:52:37 +01:00
codenomadbot[bot]
d1a27ac31b fix(ui): escape raw HTML in user prompt messages (#260)
## Summary
- escape raw HTML when rendering user message markdown so prompt input
is shown as text instead of injected HTML
- keep assistant and tool markdown behavior unchanged by scoping the
escape behavior to user messages
- update markdown cache keys so escaped and non-escaped render output do
not collide

## Verification
- `npm run typecheck --workspace @codenomad/ui` *(fails in this
workspace because frontend dependencies are not installed)*
- `npm run build --workspace @codenomad/ui` *(fails in this workspace
because `vite` is not installed)*

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur <shantur@Mac.home>
2026-03-30 08:48:52 +01:00
Jess Chadwick
37b3f85e61 feat: Enable file editing and saving (#252)
## Summary
- Adds file writing capability to Monaco editor in the file viewer
- Implements writeFile API on the server for workspace files
- Integrates save functionality into the file viewer UI with proper
state management

## Bug Fixes (Review Feedback)
- Fixed failed save discarding edits when switching files - now checks
save result and only proceeds if successful
- Fixed refresh overwriting dirty editor state - now prompts for
confirmation before discarding edits
- Fixed save button unable to save empty files - changed check from `if
(content)` to `if (content !== undefined && content !== null)`
- Added agent edit conflict detection - when agent edits file while user
has unsaved changes, shows conflict dialog with Overwrite/Cancel options
- Fixed dialog appearing behind unpinned sidebar - increased alert
dialog z-index to z-100

## Related Issues
- Closes #251

---------

Co-authored-by: Jess Chadwick <jchadwick@gmail.com>
2026-03-29 22:41:11 +01:00
Shantur
55a6479c0e fix(electron): use safe npm invocation on windows 2026-03-29 09:47:58 +01:00
Shantur Rathore
f88064af06 fix(desktop): bundle CLI resources and request mic access 2026-03-28 15:30:14 +00:00
Shantur Rathore
1b4eff9419 Min version 0.13.1 2026-03-27 19:46:54 +00:00
Shantur Rathore
6c1febf50e Bump to v0.13.1 2026-03-27 19:46:12 +00:00
Shantur Rathore
75622ef366 refactor(ui): simplify prompt recording indicator 2026-03-27 19:45:56 +00:00
Shantur Rathore
864f913e3e feat(ui): add assistant conversation playback mode 2026-03-27 19:17:25 +00:00
Shantur Rathore
b7d4f8f869 feat(ui): add clear action to prompt input 2026-03-26 23:10:02 +00:00
Shantur Rathore
0dc5867fb3 fix(speech): surface streaming playback compatibility 2026-03-26 22:59:30 +00:00
Shantur Rathore
d13ecba322 feat(speech): add configurable TTS playback modes 2026-03-26 20:46:49 +00:00
Shantur Rathore
740f37db86 refactor(ui): use stop-square icon for speech playback 2026-03-26 19:39:37 +00:00
Shantur Rathore
d447b05821 feat(ui): add message text-to-speech controls 2026-03-26 18:29:45 +00:00
Shantur Rathore
1233121a13 feat(speech): add prompt voice input (#249)
## Summary
- add server-backed speech capabilities and transcription endpoints plus
UI settings for speech configuration
- add push-to-talk prompt voice input with microphone controls,
transcription insertion, and browser capability gating
- keep prompt controls aligned by restoring right-side nav placement and
moving the mic beside the expand control
2026-03-25 14:08:11 +00:00
100 changed files with 4473 additions and 159 deletions

35
package-lock.json generated
View File

@@ -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",
@@ -8240,6 +8240,27 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -12019,6 +12040,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -12033,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",
@@ -12070,7 +12092,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.13.1",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12080,6 +12102,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -12111,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"
@@ -12119,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",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.12.3",
"version": "0.13.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --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",
"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": {
"7zip-bin": "^5.2.0",
@@ -31,4 +31,4 @@
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}
}

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.12.3",
"minServerVersion": "0.13.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -2,3 +2,4 @@ node_modules/
dist/
release/
.vite/
electron/resources/server/

View File

@@ -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 }> => {

View File

@@ -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)
})

View 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")
}

View File

@@ -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)
}
}

View File

@@ -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),
}

View 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()

View File

@@ -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>

View File

@@ -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": [
{

View File

@@ -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"])

View 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)
})

View File

@@ -14,5 +14,5 @@
"noEmit": true
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "electron/resources/server"]
}

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.24"
"@opencode-ai/plugin": "1.3.2"
}
}

View File

@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts,
},
}).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: {
...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 }) {
const opencodeEvent = input?.event
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")
}

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.13.1",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
@@ -32,6 +32,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -46,4 +47,4 @@
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}
}

View File

@@ -207,6 +207,43 @@ export interface BinaryValidationResult {
error?: string
}
export interface SpeechSegment {
startMs: number
endMs: number
text: string
}
export interface SpeechCapabilitiesResponse {
available: boolean
configured: boolean
provider: string
supportsStt: boolean
supportsTts: boolean
supportsStreamingTts: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormats: string[]
streamingTtsFormats: string[]
}
export interface SpeechTranscriptionResponse {
text: string
language?: string
durationMs?: number
segments?: SpeechSegment[]
}
export interface SpeechSynthesisResponse {
audioBase64: string
mimeType: string
}
export interface VoiceModeStateResponse {
enabled: boolean
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"

View File

@@ -81,6 +81,14 @@ export class FileSystemBrowser {
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 {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")

View File

@@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
const require = createRequire(import.meta.url)
@@ -304,6 +305,7 @@ async function main() {
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@@ -388,6 +390,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -408,6 +411,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,

View File

@@ -21,12 +21,15 @@ import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service"
import { PluginChannelManager } from "../plugins/channel"
interface HttpServerDeps {
bindHost: string
@@ -41,6 +44,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
@@ -170,6 +174,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
registerAuthRoutes(app, { authManager: deps.authManager })
@@ -252,7 +257,13 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: proxyLogger,
channel: pluginChannel,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
@@ -10,6 +11,7 @@ interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
channel: PluginChannelManager
}
const PluginEventSchema = z.object({
@@ -17,9 +19,11 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const VoiceModeStateSchema = z.object({
enabled: z.boolean(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
@@ -33,10 +37,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const registration = deps.channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
deps.channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
@@ -49,6 +53,24 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
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 workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -3,6 +3,7 @@ import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
interface RouteDeps {
settings: SettingsService
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
@@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
})
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
return sanitizeConfigOwner(
request.params.owner,
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }

View File

@@ -0,0 +1,74 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SpeechService } from "../../speech/service"
interface RouteDeps {
speechService: SpeechService
}
const TranscribeBodySchema = z.object({
audioBase64: z.string().min(1, "Audio payload is required"),
mimeType: z.string().min(1, "Audio MIME type is required"),
filename: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional(),
})
const SynthesizeBodySchema = z.object({
text: z.string().trim().min(1, "Text is required"),
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
function getSpeechErrorStatus(error: unknown): number {
if (error instanceof z.ZodError) {
return 400
}
if (error instanceof Error && /not configured/i.test(error.message)) {
return 503
}
return 502
}
function getSpeechErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback
}
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
app.post("/api/speech/transcribe", async (request, reply) => {
try {
const body = TranscribeBodySchema.parse(request.body ?? {})
return await deps.speechService.transcribe(body)
} catch (error) {
request.log.error({ err: error }, "Failed to transcribe audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
}
})
app.post("/api/speech/synthesize", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
return await deps.speechService.synthesize(body)
} catch (error) {
request.log.error({ err: error }, "Failed to synthesize audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
}
})
app.post("/api/speech/synthesize/stream", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
const result = await deps.speechService.synthesizeStream(body)
reply.header("Content-Type", result.mimeType)
reply.header("Cache-Control", "no-store")
return reply.send(result.stream)
} catch (error) {
request.log.error({ err: error }, "Failed to stream synthesized audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
}
})
}

View File

@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
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)
}
})
}

View File

@@ -0,0 +1,40 @@
import type { SettingsDoc } from "./yaml-doc-store"
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
if (!speech) {
return next
}
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
if (rawApiKey) {
delete speech.apiKey
speech.hasApiKey = true
} else if (!("hasApiKey" in speech)) {
speech.hasApiKey = false
}
next.speech = speech
return next
}
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
if (owner !== "server") {
return value
}
return sanitizeServerOwner(value)
}
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
if (isPlainObject(next.server)) {
next.server = sanitizeServerOwner(next.server)
}
return next
}

View File

@@ -4,6 +4,7 @@ import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state"
@@ -45,10 +46,11 @@ export class SettingsService {
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const nextValue = value ?? this.getOwner(kind, owner)
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
} as any
this.eventBus.publish(payload)
}

View File

@@ -0,0 +1,234 @@
import { Readable } from "node:stream"
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
supportsStreamingTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
ttsFormats: ["mp3", "wav", "opus", "aac"],
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await this.requestSpeechAudio(input.text, format)
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType,
}
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize.stream",
)
const response = await this.requestSpeechAudio(input.text, format)
if (!response.body) {
throw new Error("Speech provider did not return a stream.")
}
return {
stream: Readable.fromWeb(response.body as any),
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
}
}
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
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()
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
}
return response
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return 'audio/ogg; codecs="opus"'
if (format === "aac") return "audio/aac"
return "audio/mpeg"
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`
}

View File

@@ -0,0 +1,106 @@
import { z } from "zod"
import type { Readable } from "node:stream"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
const ServerSpeechSettingsSchema = z.object({
speech: z
.object({
provider: z.string().optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
sttModel: z.string().optional(),
ttsModel: z.string().optional(),
ttsVoice: z.string().optional(),
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
.optional(),
})
export interface TranscribeAudioInput {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}
export interface SynthesizeSpeechInput {
text: string
format?: "mp3" | "wav" | "opus" | "aac"
}
export interface SpeechSynthesisStreamResponse {
stream: Readable
mimeType: string
}
export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
}
export interface NormalizedSpeechSettings {
provider: string
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormat: "mp3" | "wav" | "opus" | "aac"
}
const DEFAULT_PROVIDER = "openai-compatible"
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
const DEFAULT_TTS_VOICE = "alloy"
const DEFAULT_TTS_FORMAT = "mp3"
export class SpeechService {
constructor(
private readonly settings: SettingsService,
private readonly logger: Logger,
) {}
getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities()
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input)
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input)
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
return this.createProvider().synthesizeStream(input)
}
private createProvider(): SpeechProvider {
const settings = this.resolveSettings()
return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider }),
})
}
private resolveSettings(): NormalizedSpeechSettings {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
const speech = parsed.speech ?? {}
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
}
}
}

View File

@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir)
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")
})
})

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 2,
priority: 1,
})
}
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 1,
priority: 2,
})
}

View File

@@ -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> {
const id = `${Date.now().toString(36)}`

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.12.3",
"version": "0.13.1",
"private": true,
"license": "MIT",
"scripts": {
@@ -8,6 +8,7 @@
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "tauri build"

View File

@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",

View 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)
}

View 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>

View File

@@ -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"
}
}
}

View File

@@ -68,6 +68,7 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -353,6 +354,7 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,

View File

@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
open
modal
onOpenChange={(open) => {
if (!open) {
// Only handle dismiss if dialog is dismissible (default: true)
if (!open && payload.dismissible !== false) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<Dialog.Overlay class="modal-overlay z-[60]" />
<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}>
<div class="flex items-start gap-3">
<div
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}>
<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")}
</label>
<input
id="prompt-input"
ref={(el) => {
promptInputRef = el
}}
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}}
</Show>

View File

@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
onSave?: (content: string) => void
onContentChange?: (content: string) => void
}
export function MonacoFileViewer(props: MonacoFileViewerProps) {
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = null
}
const saveContent = () => {
if (!editor || !props.onSave) return
props.onSave(editor.getValue())
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = monaco.editor.create(host, {
value: "",
language: "plaintext",
readOnly: true,
readOnly: false,
automaticLayout: true,
lineNumbers: "on",
minimap: { enabled: false },
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
fontSize: 13,
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
editor.onDidChangeModelContent(() => {
if (props.onContentChange) {
props.onContentChange(editor.getValue())
}
})
setReady(true)
})()

View File

@@ -44,6 +44,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) return

View File

@@ -420,6 +420,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onClose={closeLeftDrawer}
ModalProps={modalProps}
sx={{
zIndex: 60,
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
boxSizing: "border-box",
@@ -530,6 +531,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onClose={closeRightDrawer}
ModalProps={modalProps}
sx={{
zIndex: 60,
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
boxSizing: "border-box",

View File

@@ -24,6 +24,9 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
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 { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import {
@@ -102,6 +105,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
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>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
@@ -539,6 +545,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
setBrowserSelectedDirty(false)
setBrowserSelectedOriginalContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
@@ -559,6 +567,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} 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(() => {
if (rightPanelTab() !== "files") return
if (browserLoading()) return
@@ -578,6 +676,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
setBrowserSelectedDirty(false)
})
createEffect(() => {
@@ -630,6 +729,22 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}
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())
const selected = browserSelectedPath()
if (selected) {
@@ -651,6 +766,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Update original content after refresh
setBrowserSelectedDirty(false) // Clear dirty after refresh
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
@@ -830,11 +947,15 @@ const RightPanel: Component<RightPanelProps> = (props) => {
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
onOpenFile={(path: string) => void openBrowserFile(path)}
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}

View File

@@ -1,7 +1,7 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
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"
@@ -21,13 +21,17 @@ interface FilesTabProps {
browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null>
browserSelectedDirty: Accessor<boolean>
browserSelectedSaving: Accessor<boolean>
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
onLoadEntries: (path: string) => void
onOpenFile: (path: string) => void
onRequestOpenFile: (path: string) => void
onRefresh: () => void
onSave: (content: string) => void
onContentChange: (content: string) => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -38,6 +42,13 @@ interface FilesTabProps {
}
const FilesTab: Component<FilesTabProps> = (props) => {
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
props.onSave(content)
}
}
const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries()
const entries = entriesValue || []
@@ -86,7 +97,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</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>
)}
</Show>
@@ -135,7 +152,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
props.onLoadEntries(item.path)
return
}
props.onOpenFile(item.path)
props.onRequestOpenFile(item.path)
}}
title={item.path}
>
@@ -168,14 +185,25 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</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
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-inline-start": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
@@ -198,4 +226,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
return <>{renderContent()}</>
}
export default FilesTab
export default FilesTab

View File

@@ -83,6 +83,7 @@ interface MarkdownProps {
isDark?: boolean
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void
}
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
})
const cacheHandle = useGlobalCache({
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown",
cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
},
version: () => resolved().version,
})
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: snapshot.version,
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
})
if (latestRequestKey === snapshot.requestKey) {
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => {
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

View File

@@ -14,6 +14,8 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
@@ -1384,6 +1386,13 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
text: reasoningText,
})
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
@@ -1462,6 +1471,20 @@ function ReasoningCard(props: ReasoningCardProps) {
</button>
<div class="message-reasoning-actions">
<Show when={canSpeakReasoning()}>
<SpeechActionButton
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void speech.toggle()
}}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<button
type="button"
class="message-action-button"

View File

@@ -11,6 +11,8 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
@@ -294,6 +296,13 @@ export default function MessageItem(props: MessageItemProps) {
.join("\n\n")
}
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
text: getRawContent,
})
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
const handleCopy = async () => {
const content = getRawContent()
if (!content) return
@@ -443,6 +452,16 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
@@ -503,6 +522,16 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"

View File

@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
</Show>

View File

@@ -1,5 +1,5 @@
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
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"
@@ -18,6 +18,8 @@ import { usePromptState } from "./prompt-input/usePromptState"
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"))
@@ -350,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()
@@ -421,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") }
@@ -450,9 +467,52 @@ export default function PromptInput(props: PromptInputProps) {
})
const shouldShowOverlay = () => prompt().length === 0
const voiceInput = usePromptVoiceInput({
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
enabled: () => preferences().showPromptVoiceInput,
disabled: () => Boolean(props.disabled),
})
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()
let voiceButtonPressed = false
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true
if (event instanceof PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return (
<div class="prompt-input-container">
<div
@@ -506,42 +566,111 @@ export default function PromptInput(props: PromptInputProps) {
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<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-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
<X class="h-4 w-4" 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>
<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>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>

View File

@@ -0,0 +1,253 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
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>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
enabled: Accessor<boolean>
disabled: Accessor<boolean>
}
type VoiceInputState = "idle" | "recording" | "transcribing"
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const { t } = useI18n()
const [state, setState] = createSignal<VoiceInputState>("idle")
const [elapsedMs, setElapsedMs] = createSignal(0)
let mediaRecorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let timerId: number | undefined
let shouldTranscribe = true
let recordedChunks: Blob[] = []
let recordingStartedAt = 0
createEffect(() => {
void loadSpeechCapabilities()
})
onCleanup(() => {
cleanupMedia(false)
})
const isSupported = () => {
if (typeof window === "undefined") return false
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
}
const canUseVoiceInput = () => {
const capabilities = speechCapabilities()
return Boolean(
options.enabled() &&
isSupported() &&
capabilities?.available &&
capabilities?.configured &&
capabilities?.supportsStt,
)
}
async function toggleRecording(): Promise<void> {
if (state() === "recording") {
stopRecording()
return
}
await startRecording()
}
function stopRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = true
mediaRecorder.stop()
setState("transcribing")
stopTimer()
}
function cancelRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = false
mediaRecorder.stop()
cleanupMedia(false)
}
async function startRecording() {
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
if (!isSupported()) {
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
title: t("promptInput.voiceInput.error.title"),
variant: "error",
})
return
}
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)
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data)
}
})
mediaRecorder.addEventListener("stop", () => {
void finalizeRecording()
})
recordingStartedAt = Date.now()
setElapsedMs(0)
setState("recording")
startTimer()
mediaRecorder.start()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function finalizeRecording() {
const recorder = mediaRecorder
const stream = mediaStream
mediaRecorder = null
mediaStream = null
if (!shouldTranscribe || recordedChunks.length === 0) {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
return
}
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
try {
const audioBlob = new Blob(recordedChunks, { type: mimeType })
const transcription = await serverApi.transcribeAudio({
audioBase64: await blobToBase64(audioBlob),
mimeType,
})
if (transcription.text.trim()) {
insertTranscript(transcription.text.trim())
}
} catch (error) {
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
}
}
function insertTranscript(text: string) {
const current = options.prompt()
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
}, 0)
}
}
function cleanupMedia(resetState = true) {
stopTimer()
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop()
}
mediaRecorder = null
stopTracks(mediaStream)
mediaStream = null
recordedChunks = []
if (resetState) {
setState("idle")
setElapsedMs(0)
}
}
function startTimer() {
stopTimer()
timerId = window.setInterval(() => {
setElapsedMs(Date.now() - recordingStartedAt)
}, 250)
}
function stopTimer() {
if (timerId !== undefined) {
window.clearInterval(timerId)
timerId = undefined
}
}
return {
state,
elapsedMs,
canUseVoiceInput,
startRecording,
stopRecording,
toggleRecording,
cancelRecording,
isRecording: () => state() === "recording",
isTranscribing: () => state() === "transcribing",
buttonTitle: () => {
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
return t("promptInput.voiceInput.start.title")
},
}
}
function createRecorder(stream: MediaStream): MediaRecorder {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
}
function stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}

View File

@@ -98,6 +98,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) {

View File

@@ -157,6 +157,7 @@ const SessionList: Component<SessionListProps> = (props) => {
variant: "warning",
confirmLabel: t("sessionList.delete.confirmLabel"),
cancelLabel: t("sessionList.delete.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) return
@@ -285,6 +286,7 @@ const SessionList: Component<SessionListProps> = (props) => {
variant: "warning",
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
dismissible: false,
},
)

View File

@@ -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).

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
@@ -13,6 +13,7 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
@@ -21,6 +22,7 @@ export const SettingsScreen: Component = () => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
@@ -30,6 +32,8 @@ export const SettingsScreen: Component = () => {
return <NotificationsSettingsSection />
case "remote":
return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":

View File

@@ -24,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,

View File

@@ -86,6 +86,7 @@ export const RemoteAccessSettingsSection: Component = () => {
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) return

View File

@@ -0,0 +1,373 @@
import { For, Show, createEffect, createMemo, createSignal, type Component } from "solid-js"
import { Loader2, Mic, Square, Volume2 } from "lucide-solid"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
import { getLogger } from "../../lib/logger"
import { useSpeech } from "../../lib/hooks/use-speech"
import { getSpeechPlaybackSupport } from "../../lib/speech-playback-support"
const log = getLogger("actions")
type DraftFields = {
apiKey: string
baseUrl: string
sttModel: string
ttsModel: string
ttsVoice: string
playbackMode: SpeechSettings["playbackMode"]
ttsFormat: SpeechSettings["ttsFormat"]
}
function createDraftFields(speech: SpeechSettings): DraftFields {
return {
apiKey: "",
baseUrl: speech.baseUrl ?? "",
sttModel: speech.sttModel,
ttsModel: speech.ttsModel,
ttsVoice: speech.ttsVoice,
playbackMode: speech.playbackMode,
ttsFormat: speech.ttsFormat,
}
}
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
return (
a.apiKey === b.apiKey &&
a.baseUrl === b.baseUrl &&
a.sttModel === b.sttModel &&
a.ttsModel === b.ttsModel &&
a.ttsVoice === b.ttsVoice &&
a.playbackMode === b.playbackMode &&
a.ttsFormat === b.ttsFormat
)
}
export const SpeechSettingsCard: Component = () => {
const { t } = useI18n()
const { serverSettings, updateSpeechSettings } = useConfig()
const initialDrafts = createDraftFields(serverSettings().speech)
const [isSaving, setIsSaving] = createSignal(false)
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
const testSpeech = useSpeech({
id: () => "settings-speech-test",
text: () => t("settings.speech.testPlayback.sample"),
settingsOverride: () => ({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
}),
})
createEffect(() => {
const speech = serverSettings().speech
const nextDrafts = createDraftFields(speech)
if (!isSaving() && !isDirty()) {
if (!isDraftEqual(drafts(), nextDrafts)) {
setDrafts(nextDrafts)
}
if (apiKeyTouched()) {
setApiKeyTouched(false)
}
if (clearStoredApiKey()) {
setClearStoredApiKey(false)
}
}
})
createEffect(() => {
void loadSpeechCapabilities()
})
const capabilityLabel = () => {
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
if (speechCapabilitiesError()) return t("settings.speech.status.error")
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
}
const updateDraft = (key: keyof DraftFields, value: string) => {
setSaveStatus("idle")
if (key === "apiKey") {
setApiKeyTouched(true)
setClearStoredApiKey(false)
}
setDrafts((current) => ({ ...current, [key]: value }))
}
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
const playbackSupport = createMemo(() =>
getSpeechPlaybackSupport({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
capabilities: speechCapabilities(),
}),
)
const compatibilityMessage = createMemo(() => {
const capabilities = speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return null
}
if (drafts().playbackMode === "streaming" && !capabilities.supportsStreamingTts) {
return t("settings.speech.compatibility.streamingUnavailable")
}
if (drafts().playbackMode === "streaming" && !playbackSupport().available) {
return t("settings.speech.compatibility.browserStreamingUnavailable")
}
return t("settings.speech.compatibility.runtimeNote")
})
const isDirty = createMemo(() => {
const speech = serverSettings().speech
const current = drafts()
return (
apiKeyDirty() ||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
current.sttModel !== speech.sttModel ||
current.ttsModel !== speech.ttsModel ||
current.ttsVoice !== speech.ttsVoice ||
current.playbackMode !== speech.playbackMode ||
current.ttsFormat !== speech.ttsFormat
)
})
const saveStatusLabel = () => {
if (isSaving()) return t("settings.speech.save.saving")
if (saveStatus() === "saved") return t("settings.speech.save.saved")
if (saveStatus() === "error") return t("settings.speech.save.error")
return t("settings.speech.save.unsaved")
}
async function handleSave() {
if (!isDirty() || isSaving()) return
const current = drafts()
setIsSaving(true)
setSaveStatus("idle")
try {
const trimmedApiKey = current.apiKey.trim()
await updateSpeechSettings({
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
baseUrl: current.baseUrl.trim() || undefined,
sttModel: current.sttModel.trim() || undefined,
ttsModel: current.ttsModel.trim() || undefined,
ttsVoice: current.ttsVoice.trim() || undefined,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
await loadSpeechCapabilities(true)
setDrafts({
apiKey: "",
baseUrl: current.baseUrl.trim(),
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
setApiKeyTouched(false)
setClearStoredApiKey(false)
setSaveStatus("saved")
} catch (error) {
log.error("Failed to save speech settings", error)
setSaveStatus("error")
} finally {
setIsSaving(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Volume2 class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
<span class="settings-inline-note">{capabilityLabel()}</span>
<span class="settings-inline-note">{saveStatusLabel()}</span>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap inline-flex items-center gap-2"
onClick={() => void testSpeech.toggle()}
disabled={isSaving()}
title={testSpeech.buttonTitle()}
aria-label={testSpeech.buttonTitle()}
>
<Show
when={testSpeech.isLoading()}
fallback={
<Show when={testSpeech.isPlaying()} fallback={<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />}>
<Square class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
}
>
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
</Show>
<span>
{testSpeech.isPlaying()
? t("settings.speech.testPlayback.stop")
: testSpeech.isLoading()
? t("settings.speech.testPlayback.generating")
: t("settings.speech.testPlayback.action")}
</span>
</button>
<button
type="button"
class="selector-button selector-button-primary w-auto whitespace-nowrap"
onClick={() => void handleSave()}
disabled={!isDirty() || isSaving()}
>
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
</button>
</div>
</div>
<Field
label={t("settings.speech.apiKey.title")}
caption={t("settings.speech.apiKey.subtitle")}
value={drafts().apiKey}
onInput={(value) => updateDraft("apiKey", value)}
type="password"
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
/>
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
<div class="settings-inline-note">
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
<Show when={!clearStoredApiKey()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => {
setClearStoredApiKey(true)
setSaveStatus("idle")
}}
>
{t("settings.speech.apiKey.clearAction")}
</button>
</Show>
</div>
</Show>
<Field
label={t("settings.speech.baseUrl.title")}
caption={t("settings.speech.baseUrl.subtitle")}
value={drafts().baseUrl}
onInput={(value) => updateDraft("baseUrl", value)}
placeholder={t("settings.speech.baseUrl.placeholder")}
/>
<Field
label={t("settings.speech.sttModel.title")}
caption={t("settings.speech.sttModel.subtitle")}
value={drafts().sttModel}
onInput={(value) => updateDraft("sttModel", value)}
/>
<Field
label={t("settings.speech.ttsModel.title")}
caption={t("settings.speech.ttsModel.subtitle")}
value={drafts().ttsModel}
onInput={(value) => updateDraft("ttsModel", value)}
/>
<Field
label={t("settings.speech.ttsVoice.title")}
caption={t("settings.speech.ttsVoice.subtitle")}
value={drafts().ttsVoice}
onInput={(value) => updateDraft("ttsVoice", value)}
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
/>
<SelectField
label={t("settings.speech.playbackMode.title")}
caption={t("settings.speech.playbackMode.subtitle")}
value={drafts().playbackMode}
onInput={(value) => updateDraft("playbackMode", value as DraftFields["playbackMode"])}
options={[
{ value: "streaming", label: t("settings.speech.playbackMode.streaming") },
{ value: "buffered", label: t("settings.speech.playbackMode.buffered") },
]}
/>
<SelectField
label={t("settings.speech.ttsFormat.title")}
caption={t("settings.speech.ttsFormat.subtitle")}
value={drafts().ttsFormat}
onInput={(value) => updateDraft("ttsFormat", value as DraftFields["ttsFormat"])}
options={[
{ value: "mp3", label: "MP3" },
{ value: "wav", label: "WAV" },
{ value: "opus", label: "Opus" },
{ value: "aac", label: "AAC" },
]}
/>
<div class="settings-inline-note">{t("settings.speech.help")}</div>
<Show when={compatibilityMessage()}>{(message) => <div class="settings-inline-note">{message()}</div>}</Show>
<div class="settings-inline-note">{t("settings.speech.testPlayback.note")}</div>
</div>
</div>
)
}
const Field: Component<{
label: string
caption: string
value: string
type?: string
placeholder?: string
onInput: (value: string) => void
icon?: any
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
{props.icon}
<input
type={props.type ?? "text"}
value={props.value}
onInput={(event) => props.onInput(event.currentTarget.value)}
class="selector-input w-full"
placeholder={props.placeholder}
/>
</div>
</div>
)
}
const SelectField: Component<{
label: string
caption: string
value: string
onInput: (value: string) => void
options: Array<{ value: string; label: string }>
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="min-w-[18rem] max-w-[24rem] w-full">
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</div>
</div>
)
}
export default SpeechSettingsCard

View File

@@ -0,0 +1,10 @@
import type { Component } from "solid-js"
import SpeechSettingsCard from "./speech-settings-card"
export const SpeechSettingsSection: Component = () => {
return (
<div class="settings-section-stack">
<SpeechSettingsCard />
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Loader2, Volume2 } from "lucide-solid"
import type { JSX } from "solid-js"
interface SpeechActionButtonProps {
class?: string
title: string
isLoading: boolean
isPlaying: boolean
onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
type?: "button" | "submit" | "reset"
}
export default function SpeechActionButton(props: SpeechActionButtonProps) {
return (
<button
type={props.type ?? "button"}
class={props.class}
onClick={props.onClick}
aria-label={props.title}
title={props.title}
>
{props.isLoading ? (
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
) : props.isPlaying ? (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" stroke="none" />
</svg>
) : (
<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />
)}
</button>
)
}

View File

@@ -29,6 +29,7 @@ import type {
ToolScrollHelpers,
} from "./tool-call/types"
import {
buildToolSpeechText,
ensureMarkdownContent,
getRelativePath,
getToolIcon,
@@ -41,6 +42,8 @@ import {
} from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
const log = getLogger("session")
@@ -960,6 +963,21 @@ export default function ToolCall(props: ToolCallProps) {
return renderToolTitle()
})
const speechText = createMemo(() =>
buildToolSpeechText({
title: headerText(),
state: toolState(),
t,
}),
)
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId ?? "message"}:${toolCallIdentifier()}`,
text: speechText,
})
const canSpeakToolCall = () => speechText().trim().length > 0 && speech.canUseSpeech()
const handleCopyHeader = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -1023,6 +1041,16 @@ export default function ToolCall(props: ToolCallProps) {
<Copy class="w-3.5 h-3.5" />
</button>
<Show when={canSpeakToolCall()}>
<SpeechActionButton
class="tool-call-header-copy"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<span class="tool-call-header-status" aria-hidden="true">
{statusIcon()}
</span>

View File

@@ -231,3 +231,37 @@ export function getDefaultToolAction(toolName: string) {
return tGlobal("toolCall.renderer.action.working")
}
}
export function buildToolSpeechText(options: {
title: string
state?: ToolState
t: (key: string, params?: Record<string, unknown>) => string
}): string {
const sections: string[] = []
if (options.title.trim()) {
sections.push(options.title.trim())
}
const { input, output } = readToolStatePayload(options.state)
const formattedInput = formatUnknown(input)
const formattedOutput = formatUnknown(output)
if (formattedInput?.text?.trim()) {
sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`)
}
if (formattedOutput?.text?.trim()) {
sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`)
}
if (options.state?.status === "error" && options.state.error?.trim()) {
sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`)
}
if (sections.length === 1 && options.state?.status === "pending") {
sections.push(options.t("toolCall.pending.waitingToRun"))
}
return sections.join("\n\n").trim()
}

View File

@@ -7,7 +7,11 @@ import type {
FileSystemCreateFolderResponse,
FileSystemListResponse,
InstanceData,
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
ServerMeta,
VoiceModeStateResponse,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
@@ -120,6 +124,28 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
}
async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json"
}
const method = (init?.method ?? "GET").toUpperCase()
const startedAt = Date.now()
logHttp(`${method} ${path}`)
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt })
return response
}
export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
@@ -209,6 +235,16 @@ export const serverApi = {
`/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> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
@@ -235,6 +271,37 @@ export const serverApi = {
body: JSON.stringify({ path }),
})
},
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
},
transcribeAudio(payload: {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}): Promise<SpeechTranscriptionResponse> {
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" }): Promise<SpeechSynthesisResponse> {
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeechStream(
payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" },
signal?: AbortSignal,
): Promise<Response> {
return requestRaw("/api/speech/synthesize/stream", {
method: "POST",
body: JSON.stringify(payload),
signal,
})
},
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams()
if (path && path !== ".") {
@@ -282,6 +349,12 @@ export const serverApi = {
{ 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(
instanceId: string,
processId: string,

View File

@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -435,6 +436,7 @@ export function useCommands(options: UseCommandsOptions) {
toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion,

View File

@@ -0,0 +1,416 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { serverApi } from "../api-client"
import { useI18n } from "../i18n"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { formatToMimeType, getSpeechPlaybackSupport } from "../speech-playback-support"
type SpeechPlaybackState = "idle" | "loading" | "playing"
interface UseSpeechOptions {
id: Accessor<string>
text: Accessor<string>
settingsOverride?: Accessor<Partial<Pick<SpeechSettings, "playbackMode" | "ttsFormat">>>
}
interface ActivePlaybackEntry {
ownerId: string
stop: () => void
}
const stateResetters = new Map<string, () => void>()
let activePlayback: ActivePlaybackEntry | null = null
function resetOwnerState(ownerId: string) {
stateResetters.get(ownerId)?.()
}
function stopActivePlayback(ownerId?: string) {
if (!activePlayback) return
if (ownerId && activePlayback.ownerId !== ownerId) return
const current = activePlayback
activePlayback = null
current.stop()
}
function setActivePlayback(ownerId: string, stop: () => void) {
if (activePlayback?.ownerId === ownerId) {
activePlayback = { ownerId, stop }
return
}
stopActivePlayback()
activePlayback = { ownerId, stop }
}
export function useSpeech(options: UseSpeechOptions) {
const { t } = useI18n()
const { serverSettings } = useConfig()
const [state, setState] = createSignal<SpeechPlaybackState>("idle")
let requestVersion = 0
let audio: HTMLAudioElement | null = null
let objectUrl: string | null = null
let mediaSource: MediaSource | null = null
let abortController: AbortController | null = null
createEffect(() => {
void loadSpeechCapabilities()
})
const cleanupAudio = () => {
if (abortController) {
abortController.abort()
abortController = null
}
if (audio) {
audio.pause()
audio.currentTime = 0
audio.src = ""
audio.load()
audio = null
}
mediaSource = null
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
}
}
const resetState = () => {
requestVersion += 1
cleanupAudio()
setState("idle")
}
stateResetters.set(options.id(), resetState)
onCleanup(() => {
stateResetters.delete(options.id())
stopActivePlayback(options.id())
resetState()
})
const isSupported = () => typeof window !== "undefined" && typeof window.Audio !== "undefined"
const resolvedSettings = () => ({
...serverSettings().speech,
...(options.settingsOverride?.() ?? {}),
})
const canUseSpeech = () => {
const capabilities = speechCapabilities()
if (!isSupported() || !capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return false
}
return getSpeechPlaybackSupport({
playbackMode: resolvedSettings().playbackMode,
ttsFormat: resolvedSettings().ttsFormat,
capabilities,
}).available
}
const stop = () => {
if (activePlayback?.ownerId === options.id()) {
activePlayback = null
}
resetState()
}
const start = async () => {
const ownerId = options.id()
const text = options.text().trim()
if (!text || state() === "loading" || state() === "playing") return
if (!isSupported()) {
showAlertDialog(t("messageItem.actions.speak.error.unsupported"), {
title: t("messageItem.actions.speak.error.title"),
variant: "error",
})
return
}
const capabilities = (await loadSpeechCapabilities()) ?? speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
title: t("messageItem.actions.speak.error.title"),
variant: "error",
})
return
}
const support = getSpeechPlaybackSupport({
playbackMode: resolvedSettings().playbackMode,
ttsFormat: resolvedSettings().ttsFormat,
capabilities,
})
if (!support.available) {
const detailKey =
support.reason === "provider-streaming-unavailable"
? "settings.speech.compatibility.streamingUnavailable"
: support.reason === "browser-streaming-unavailable"
? "settings.speech.compatibility.browserStreamingUnavailable"
: "messageItem.actions.speak.error.unsupported"
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
title: t("messageItem.actions.speak.error.title"),
detail: t(detailKey),
variant: "error",
})
return
}
requestVersion += 1
const currentRequest = requestVersion
stopActivePlayback()
cleanupAudio()
setState("loading")
const settings = resolvedSettings()
const format = settings.ttsFormat
try {
if (settings.playbackMode === "streaming") {
await startStreamingPlayback(ownerId, currentRequest, text, format)
} else {
await startBufferedPlayback(ownerId, currentRequest, text, format)
}
} catch (error) {
if (currentRequest !== requestVersion) {
return
}
resetState()
showAlertDialog(t("messageItem.actions.speak.error.generate"), {
title: t("messageItem.actions.speak.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function startBufferedPlayback(
ownerId: string,
currentRequest: number,
text: string,
format: "mp3" | "wav" | "opus" | "aac",
) {
const response = await serverApi.synthesizeSpeech({ text, format })
if (currentRequest !== requestVersion) {
return
}
const nextUrl = createObjectUrlFromBase64(response.audioBase64, response.mimeType)
const nextAudio = new Audio(nextUrl)
objectUrl = nextUrl
audio = nextAudio
attachPlaybackLifecycle(ownerId, nextAudio)
setActivePlayback(ownerId, () => {
cleanupAudio()
setState("idle")
})
setState("playing")
await nextAudio.play()
}
async function startStreamingPlayback(
ownerId: string,
currentRequest: number,
text: string,
format: "mp3" | "wav" | "opus" | "aac",
) {
if (typeof MediaSource === "undefined") {
throw new Error("MediaSource is not available in this browser.")
}
const controller = new AbortController()
abortController = controller
const response = await serverApi.synthesizeSpeechStream({ text, format }, controller.signal)
const mimeType = response.headers.get("content-type") || formatToMimeType(format)
if (!MediaSource.isTypeSupported(mimeType)) {
throw new Error(`Streaming playback is not supported for ${mimeType}.`)
}
const stream = response.body
if (!stream) {
throw new Error("Speech stream did not include a response body.")
}
const nextMediaSource = new MediaSource()
const nextObjectUrl = URL.createObjectURL(nextMediaSource)
const nextAudio = new Audio(nextObjectUrl)
mediaSource = nextMediaSource
objectUrl = nextObjectUrl
audio = nextAudio
attachPlaybackLifecycle(ownerId, nextAudio)
setActivePlayback(ownerId, () => {
cleanupAudio()
setState("idle")
})
await new Promise<void>((resolve, reject) => {
const handleSourceOpen = () => {
nextMediaSource.removeEventListener("sourceopen", handleSourceOpen)
void streamToMediaSource({
mediaSource: nextMediaSource,
stream,
mimeType,
audioElement: nextAudio,
onPlayable: async () => {
if (currentRequest !== requestVersion) return
if (state() !== "playing") {
setState("playing")
}
try {
await nextAudio.play()
} catch (error) {
reject(error)
}
},
onComplete: resolve,
onError: reject,
})
}
nextMediaSource.addEventListener("sourceopen", handleSourceOpen, { once: true })
nextAudio.addEventListener(
"error",
() => reject(new Error("Unable to play streamed speech.")),
{ once: true },
)
})
}
const toggle = async () => {
if (state() === "idle") {
await start()
return
}
stop()
}
return {
state,
canUseSpeech,
isLoading: () => state() === "loading",
isPlaying: () => state() === "playing",
toggle,
stop,
buttonTitle: () => {
if (state() === "loading") return t("messageItem.actions.generatingSpeech")
if (state() === "playing") return t("messageItem.actions.stopSpeech")
return t("messageItem.actions.speak")
},
}
}
function attachPlaybackLifecycle(ownerId: string, audio: HTMLAudioElement) {
const finish = () => {
if (activePlayback?.ownerId === ownerId) {
activePlayback = null
}
resetOwnerState(ownerId)
}
audio.addEventListener("ended", finish, { once: true })
audio.addEventListener("error", finish, { once: true })
}
async function streamToMediaSource(options: {
mediaSource: MediaSource
stream: ReadableStream<Uint8Array>
mimeType: string
audioElement: HTMLAudioElement
onPlayable: () => Promise<void>
onComplete: () => void
onError: (error: unknown) => void
}) {
try {
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
const reader = options.stream.getReader()
let startedPlayback = false
let queue: Uint8Array[] = []
let processing = false
const flushQueue = async () => {
if (processing || sourceBuffer.updating || queue.length === 0) return
processing = true
const chunk = queue.shift()!
await appendChunk(sourceBuffer, chunk)
if (!startedPlayback) {
startedPlayback = 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()
}
options.onComplete()
} 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("Failed to append audio stream chunk."))
}
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" }))
}

View File

@@ -95,6 +95,18 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"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.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan",

View File

@@ -75,6 +75,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!",
"messageItem.actions.speak": "Speak message",
"messageItem.actions.generatingSpeech": "Generating speech",
"messageItem.actions.stopSpeech": "Stop playback",
"messageItem.actions.speak.error.title": "Speech playback failed",
"messageItem.actions.speak.error.unsupported": "Speech playback is not supported in this browser.",
"messageItem.actions.speak.error.unavailable": "Speech playback is unavailable until speech settings are configured.",
"messageItem.actions.speak.error.generate": "Unable to generate speech for this message.",
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
"messageItem.actions.deletingMessage": "Deleting...",
@@ -135,7 +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

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.apiKey.placeholder": "Enter a new API key",
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
"settings.speech.apiKey.clearAction": "Clear saved key",
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.playbackMode.title": "Playback mode",
"settings.speech.playbackMode.subtitle": "Choose whether TTS starts playing as audio streams in or after the full file is generated.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Output format",
"settings.speech.ttsFormat.subtitle": "Choose the audio format for synthesized speech. Streaming support depends on your provider and browser.",
"settings.speech.help": "Prompt voice input appears when speech transcription is configured and supported. Message playback uses the TTS mode and format selected here.",
"settings.speech.compatibility.streamingUnavailable": "Your current speech provider configuration does not advertise streaming TTS. Switch playback mode to buffered if you want playback to work now.",
"settings.speech.compatibility.browserStreamingUnavailable": "Your current browser cannot stream the selected TTS format. Choose buffered playback or switch to a different format.",
"settings.speech.compatibility.runtimeNote": "All formats stay selectable in streaming mode. Some browser and provider combinations may still fail at playback time.",
"settings.speech.testPlayback.action": "Test playback",
"settings.speech.testPlayback.generating": "Generating sample",
"settings.speech.testPlayback.stop": "Stop sample",
"settings.speech.testPlayback.sample": "Thank you for using CodeNomad, your speech settings are working fine.",
"settings.speech.testPlayback.note": "The test uses your current playback mode and format immediately. Save API key, base URL, model, or voice changes first if you want those reflected too.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado",
"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.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copiar",
"messageItem.actions.copyTitle": "Copiar mensaje",
"messageItem.actions.copied": "¡Copiado!",
"messageItem.actions.speak": "Reproducir mensaje",
"messageItem.actions.generatingSpeech": "Generando audio",
"messageItem.actions.stopSpeech": "Detener reproduccion",
"messageItem.actions.speak.error.title": "La reproduccion de voz fallo",
"messageItem.actions.speak.error.unsupported": "La reproduccion de voz no es compatible con este navegador.",
"messageItem.actions.speak.error.unavailable": "La reproduccion de voz no estara disponible hasta que la configuracion de voz este lista.",
"messageItem.actions.speak.error.generate": "No se pudo generar audio para este mensaje.",
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
"messageItem.actions.deletingMessage": "Eliminando...",
@@ -137,7 +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

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
"settings.speech.title": "Voz",
"settings.speech.subtitle": "Configura ahora el reconocimiento de voz y prepara la base de texto a voz para funciones futuras.",
"settings.speech.provider.title": "Proveedor",
"settings.speech.provider.subtitle": "Las solicitudes de voz usan el adaptador de voz del servidor.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Comprobando configuración...",
"settings.speech.status.configured": "Configurado",
"settings.speech.status.missing": "Falta la clave API",
"settings.speech.status.error": "Servicio de voz no disponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Se usa para las solicitudes de voz gestionadas por CodeNomad.",
"settings.speech.apiKey.placeholder": "Introduce una nueva clave API",
"settings.speech.apiKey.storedNote": "Hay una clave API guardada y oculta. Introduce un nuevo valor para reemplazarla o deja el campo vacío para conservarla.",
"settings.speech.apiKey.clearAction": "Borrar clave guardada",
"settings.speech.apiKey.clearPending": "La clave API guardada se eliminará al guardar.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Anulación opcional para endpoints de voz compatibles con OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modelo de transcripción",
"settings.speech.sttModel.subtitle": "Modelo usado para las solicitudes de voz a texto en el prompt.",
"settings.speech.ttsModel.title": "Modelo de voz",
"settings.speech.ttsModel.subtitle": "Modelo predeterminado de texto a voz reservado para futuras funciones de reproducción.",
"settings.speech.ttsVoice.title": "Voz predeterminada",
"settings.speech.ttsVoice.subtitle": "Voz predeterminada de texto a voz reservada para futuras funciones de reproducción.",
"settings.speech.playbackMode.title": "Modo de reproduccion",
"settings.speech.playbackMode.subtitle": "Elige si TTS empieza a reproducirse mientras llega el audio o despues de generar el archivo completo.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Formato de salida",
"settings.speech.ttsFormat.subtitle": "Elige el formato de audio para la voz sintetizada. La compatibilidad de streaming depende de tu proveedor y navegador.",
"settings.speech.help": "La entrada de voz del prompt aparece cuando la transcripcion de voz esta configurada y es compatible. La reproduccion de mensajes usa el modo y formato TTS seleccionados aqui.",
"settings.speech.compatibility.streamingUnavailable": "Tu configuracion actual del proveedor de voz no anuncia TTS por streaming. Cambia el modo de reproduccion a buffered si quieres que la reproduccion funcione ahora.",
"settings.speech.compatibility.browserStreamingUnavailable": "Tu navegador actual no puede reproducir por streaming el formato TTS seleccionado. Elige reproduccion buffered o cambia a otro formato.",
"settings.speech.compatibility.runtimeNote": "Todos los formatos siguen disponibles en modo streaming. Algunas combinaciones de navegador y proveedor aun pueden fallar al reproducir.",
"settings.speech.testPlayback.action": "Probar reproduccion",
"settings.speech.testPlayback.generating": "Generando muestra",
"settings.speech.testPlayback.stop": "Detener muestra",
"settings.speech.testPlayback.sample": "Gracias por usar CodeNomad, tu configuracion de voz funciona correctamente.",
"settings.speech.testPlayback.note": "La prueba usa de inmediato el modo y formato actuales. Guarda primero los cambios de API key, base URL, modelo o voz si tambien quieres probarlos.",
"settings.speech.save.action": "Guardar",
"settings.speech.save.saving": "Guardando...",
"settings.speech.save.saved": "Guardado",
"settings.speech.save.unsaved": "Cambios sin guardar",
"settings.speech.save.error": "Error al guardar",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut",
"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.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copier",
"messageItem.actions.copyTitle": "Copier le message",
"messageItem.actions.copied": "Copié !",
"messageItem.actions.speak": "Lire le message",
"messageItem.actions.generatingSpeech": "Generation de l'audio",
"messageItem.actions.stopSpeech": "Arreter la lecture",
"messageItem.actions.speak.error.title": "La lecture vocale a echoue",
"messageItem.actions.speak.error.unsupported": "La lecture vocale n'est pas prise en charge dans ce navigateur.",
"messageItem.actions.speak.error.unavailable": "La lecture vocale n'est pas disponible tant que les parametres vocaux ne sont pas configures.",
"messageItem.actions.speak.error.generate": "Impossible de generer l'audio pour ce message.",
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
"messageItem.actions.deletingMessage": "Suppression...",
@@ -137,7 +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

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
"settings.speech.title": "Voix",
"settings.speech.subtitle": "Configurez dès maintenant la reconnaissance vocale et préparez la synthèse vocale pour de futures fonctionnalités.",
"settings.speech.provider.title": "Fournisseur",
"settings.speech.provider.subtitle": "Les requêtes vocales utilisent l'adaptateur vocal côté serveur.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Vérification de la configuration...",
"settings.speech.status.configured": "Configuré",
"settings.speech.status.missing": "Clé API manquante",
"settings.speech.status.error": "Service vocal indisponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Utilisée pour les requêtes vocales gérées par CodeNomad.",
"settings.speech.apiKey.placeholder": "Saisissez une nouvelle clé API",
"settings.speech.apiKey.storedNote": "Une clé API enregistrée est masquée. Saisissez une nouvelle valeur pour la remplacer ou laissez le champ vide pour la conserver.",
"settings.speech.apiKey.clearAction": "Effacer la clé enregistrée",
"settings.speech.apiKey.clearPending": "La clé API enregistrée sera supprimée lors de l'enregistrement.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Remplacement facultatif des points d'accès vocaux compatibles OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modèle de transcription",
"settings.speech.sttModel.subtitle": "Modèle utilisé pour les requêtes vocales vers texte du prompt.",
"settings.speech.ttsModel.title": "Modèle vocal",
"settings.speech.ttsModel.subtitle": "Modèle de synthèse vocale par défaut réservé aux futures fonctions de lecture.",
"settings.speech.ttsVoice.title": "Voix par défaut",
"settings.speech.ttsVoice.subtitle": "Voix de synthèse vocale par défaut réservée aux futures fonctions de lecture.",
"settings.speech.playbackMode.title": "Mode de lecture",
"settings.speech.playbackMode.subtitle": "Choisissez si le TTS commence a jouer pendant le flux audio ou apres la generation complete du fichier.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Format de sortie",
"settings.speech.ttsFormat.subtitle": "Choisissez le format audio pour la voix synthetisee. La prise en charge du streaming depend du fournisseur et du navigateur.",
"settings.speech.help": "La saisie vocale du prompt apparait lorsque la transcription vocale est configuree et prise en charge. La lecture des messages utilise le mode et le format TTS selectionnes ici.",
"settings.speech.compatibility.streamingUnavailable": "Votre configuration actuelle du fournisseur vocal n'annonce pas le TTS en streaming. Passez le mode de lecture sur buffered si vous voulez que la lecture fonctionne maintenant.",
"settings.speech.compatibility.browserStreamingUnavailable": "Votre navigateur actuel ne peut pas lire en streaming le format TTS selectionne. Choisissez la lecture buffered ou passez a un autre format.",
"settings.speech.compatibility.runtimeNote": "Tous les formats restent selectionnables en mode streaming. Certaines combinaisons navigateur/fournisseur peuvent quand meme echouer au moment de la lecture.",
"settings.speech.testPlayback.action": "Tester la lecture",
"settings.speech.testPlayback.generating": "Generation de l'extrait",
"settings.speech.testPlayback.stop": "Arreter l'extrait",
"settings.speech.testPlayback.sample": "Merci d'utiliser CodeNomad, vos parametres vocaux fonctionnent correctement.",
"settings.speech.testPlayback.note": "Le test utilise immediatement le mode et le format actuels. Enregistrez d'abord les changements d'API key, d'URL de base, de modele ou de voix si vous voulez aussi les tester.",
"settings.speech.save.action": "Enregistrer",
"settings.speech.save.saving": "Enregistrement...",
"settings.speech.save.saved": "Enregistré",
"settings.speech.save.unsaved": "Modifications non enregistrées",
"settings.speech.save.error": "Échec de l'enregistrement",
} as const

View File

@@ -95,6 +95,18 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "סטטוס",
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
"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.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
"instanceShell.rightPanel.sections.plan": "תוכנית",

View File

@@ -75,6 +75,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "העתק",
"messageItem.actions.copyTitle": "העתק הודעה",
"messageItem.actions.copied": "הועתק!",
"messageItem.actions.speak": "השמע הודעה",
"messageItem.actions.generatingSpeech": "יוצר אודיו",
"messageItem.actions.stopSpeech": "עצור ניגון",
"messageItem.actions.speak.error.title": "ניגון הקול נכשל",
"messageItem.actions.speak.error.unsupported": "ניגון קול אינו נתמך בדפדפן הזה.",
"messageItem.actions.speak.error.unavailable": "ניגון קול לא זמין עד שהגדרות הקול יוגדרו.",
"messageItem.actions.speak.error.generate": "לא ניתן היה ליצור אודיו עבור ההודעה הזו.",
"messageItem.actions.deleteMessage": "מחק הודעה (לא מבטל שינויים)",
"messageItem.actions.deleteMessagesUpTo": "מחק הודעות עד כאן (לא מבטל שינויים)",
"messageItem.actions.deletingMessage": "מוחק...",
@@ -135,7 +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

View File

@@ -137,6 +137,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
"settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט",
"settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.",
"settings.behavior.promptSubmit.title": "Enter לשליחה",
"settings.behavior.promptSubmit.subtitle": "השתמש ב-Enter לשליחת פקודות; Cmd/Ctrl+Enter מוסיף שורה חדשה.",
"settings.speech.title": "קול",
"settings.speech.subtitle": "הגדר כעת דיבור-לטקסט והכן תשתית לטקסט-לדיבור עבור יכולות עתידיות.",
"settings.speech.provider.title": "ספק",
"settings.speech.provider.subtitle": "בקשות קול משתמשות במתאם הקול שבצד השרת.",
"settings.speech.provider.openaiCompatible": "תואם OpenAI",
"settings.speech.status.loading": "בודק את ההגדרות...",
"settings.speech.status.configured": "מוגדר",
"settings.speech.status.missing": "חסר מפתח API",
"settings.speech.status.error": "שירות הקול אינו זמין",
"settings.speech.apiKey.title": "מפתח API",
"settings.speech.apiKey.subtitle": "משמש עבור בקשות קול המנוהלות על ידי CodeNomad.",
"settings.speech.apiKey.placeholder": "הזן מפתח API חדש",
"settings.speech.apiKey.storedNote": "מפתח API שמור מוסתר. הזן ערך חדש כדי להחליף אותו, או השאר את השדה ריק כדי לשמור עליו.",
"settings.speech.apiKey.clearAction": "נקה מפתח שמור",
"settings.speech.apiKey.clearPending": "מפתח ה-API השמור יוסר בעת השמירה.",
"settings.speech.baseUrl.title": "כתובת בסיס",
"settings.speech.baseUrl.subtitle": "עקיפה אופציונלית עבור נקודות קצה קוליות התואמות ל-OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "מודל תמלול",
"settings.speech.sttModel.subtitle": "המודל המשמש לבקשות דיבור-לטקסט בפרומפט.",
"settings.speech.ttsModel.title": "מודל קול",
"settings.speech.ttsModel.subtitle": "מודל ברירת מחדל לטקסט-לדיבור השמור ליכולות ניגון עתידיות.",
"settings.speech.ttsVoice.title": "קול ברירת מחדל",
"settings.speech.ttsVoice.subtitle": "קול ברירת מחדל לטקסט-לדיבור השמור ליכולות ניגון עתידיות.",
"settings.speech.playbackMode.title": "מצב ניגון",
"settings.speech.playbackMode.subtitle": "בחר אם TTS יתחיל לנגן בזמן שהאודיו מוזרם או רק אחרי שהקובץ כולו נוצר.",
"settings.speech.playbackMode.streaming": "סטרימינג",
"settings.speech.playbackMode.buffered": "באפר מלא",
"settings.speech.ttsFormat.title": "פורמט פלט",
"settings.speech.ttsFormat.subtitle": "בחר את פורמט האודיו לדיבור מסונתז. תמיכת סטרימינג תלויה בספק ובדפדפן.",
"settings.speech.help": "קלט קולי לפרומפט מופיע כאשר תמלול קול מוגדר ונתמך. השמעת הודעות משתמשת במצב ובפורמט ה-TTS שנבחרו כאן.",
"settings.speech.compatibility.streamingUnavailable": "תצורת ספק הקול הנוכחית שלך לא מצהירה על TTS בסטרימינג. עבור למצב buffered אם אתה רוצה שהניגון יעבוד כבר עכשיו.",
"settings.speech.compatibility.browserStreamingUnavailable": "הדפדפן הנוכחי שלך לא יכול לנגן בסטרימינג את פורמט ה-TTS שנבחר. בחר בניגון buffered או עבור לפורמט אחר.",
"settings.speech.compatibility.runtimeNote": "כל הפורמטים נשארים זמינים במצב סטרימינג. חלק מהשילובים של דפדפן וספק עדיין עלולים להיכשל בזמן הניגון.",
"settings.speech.testPlayback.action": "בדוק ניגון",
"settings.speech.testPlayback.generating": "יוצר דוגמה",
"settings.speech.testPlayback.stop": "עצור דוגמה",
"settings.speech.testPlayback.sample": "תודה שאתה משתמש ב-CodeNomad, הגדרות הקול שלך פועלות כראוי.",
"settings.speech.testPlayback.note": "המבחן משתמש מיד במצב ובפורמט הנוכחיים. שמור תחילה שינויים ב-API key, ב-Base URL, במודל או בקול אם גם אותם תרצה לבדוק.",
"settings.speech.save.action": "שמור",
"settings.speech.save.saving": "שומר...",
"settings.speech.save.saved": "נשמר",
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
"settings.speech.save.error": "השמירה נכשלה",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス",
"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.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
"instanceShell.rightPanel.sections.plan": "計画",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "コピー",
"messageItem.actions.copyTitle": "メッセージをコピー",
"messageItem.actions.copied": "コピーしました!",
"messageItem.actions.speak": "メッセージを読み上げ",
"messageItem.actions.generatingSpeech": "音声を生成中",
"messageItem.actions.stopSpeech": "再生を停止",
"messageItem.actions.speak.error.title": "音声再生に失敗しました",
"messageItem.actions.speak.error.unsupported": "このブラウザでは音声再生に対応していません。",
"messageItem.actions.speak.error.unavailable": "音声設定が完了するまで音声再生は利用できません。",
"messageItem.actions.speak.error.generate": "このメッセージの音声を生成できませんでした。",
"messageItem.actions.deleteMessage": "メッセージを削除(変更は元に戻さない)",
"messageItem.actions.deleteMessagesUpTo": "ここまでのメッセージを削除(変更は元に戻さない)",
"messageItem.actions.deletingMessage": "削除中...",
@@ -137,7 +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

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enterで送信",
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
"settings.speech.title": "音声",
"settings.speech.subtitle": "今すぐ音声入力を設定し、今後の機能のために音声合成の基盤も準備します。",
"settings.speech.provider.title": "プロバイダー",
"settings.speech.provider.subtitle": "音声リクエストはサーバー側の音声アダプターを使用します。",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "設定を確認しています...",
"settings.speech.status.configured": "設定済み",
"settings.speech.status.missing": "APIキーがありません",
"settings.speech.status.error": "音声サービスを利用できません",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "CodeNomadが管理する音声リクエストに使用されます。",
"settings.speech.apiKey.placeholder": "新しいAPIキーを入力",
"settings.speech.apiKey.storedNote": "保存済みのAPIキーは非表示になっています。置き換えるには新しい値を入力し、そのまま使うには空欄のままにしてください。",
"settings.speech.apiKey.clearAction": "保存済みキーを削除",
"settings.speech.apiKey.clearPending": "保存すると、保存済みのAPIキーは削除されます。",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "OpenAI互換の音声エンドポイント用の任意の上書き設定です。",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "文字起こしモデル",
"settings.speech.sttModel.subtitle": "プロンプトの音声入力を文字起こしする際に使用するモデルです。",
"settings.speech.ttsModel.title": "音声モデル",
"settings.speech.ttsModel.subtitle": "将来の再生機能のために予約されている既定の音声合成モデルです。",
"settings.speech.ttsVoice.title": "既定の音声",
"settings.speech.ttsVoice.subtitle": "将来の再生機能のために予約されている既定の音声合成ボイスです。",
"settings.speech.playbackMode.title": "再生モード",
"settings.speech.playbackMode.subtitle": "音声が届き次第再生を始めるか、ファイル全体の生成後に再生するかを選択します。",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "出力形式",
"settings.speech.ttsFormat.subtitle": "音声合成の出力形式を選択します。ストリーミング対応はプロバイダーとブラウザーに依存します。",
"settings.speech.help": "プロンプト音声入力は音声文字起こしが設定され対応している場合に表示されます。メッセージ再生にはここで選んだTTSモードと形式が使われます。",
"settings.speech.compatibility.streamingUnavailable": "現在の音声プロバイダー設定ではストリーミングTTSが利用可能として公開されていません。今すぐ再生を使いたい場合は再生モードを buffered に切り替えてください。",
"settings.speech.compatibility.browserStreamingUnavailable": "現在のブラウザーでは、選択したTTS形式をストリーミング再生できません。buffered 再生に切り替えるか、別の形式を選んでください。",
"settings.speech.compatibility.runtimeNote": "ストリーミングモードでも全ての形式を選択できますが、ブラウザーとプロバイダーの組み合わせによっては再生時に失敗することがあります。",
"settings.speech.testPlayback.action": "再生をテスト",
"settings.speech.testPlayback.generating": "サンプルを生成中",
"settings.speech.testPlayback.stop": "サンプルを停止",
"settings.speech.testPlayback.sample": "CodeNomad をご利用いただきありがとうございます。音声設定は正常に動作しています。",
"settings.speech.testPlayback.note": "このテストは現在の再生モードと形式をすぐに使います。APIキー、Base URL、モデル、音声の変更も試したい場合は先に保存してください。",
"settings.speech.save.action": "保存",
"settings.speech.save.saving": "保存中...",
"settings.speech.save.saved": "保存済み",
"settings.speech.save.unsaved": "未保存の変更",
"settings.speech.save.error": "保存に失敗しました",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус",
"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.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
"instanceShell.rightPanel.sections.plan": "План",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Копировать",
"messageItem.actions.copyTitle": "Копировать сообщение",
"messageItem.actions.copied": "Скопировано!",
"messageItem.actions.speak": "Озвучить сообщение",
"messageItem.actions.generatingSpeech": "Генерация аудио",
"messageItem.actions.stopSpeech": "Остановить воспроизведение",
"messageItem.actions.speak.error.title": "Не удалось воспроизвести речь",
"messageItem.actions.speak.error.unsupported": "В этом браузере воспроизведение речи не поддерживается.",
"messageItem.actions.speak.error.unavailable": "Воспроизведение речи недоступно, пока не настроены голосовые параметры.",
"messageItem.actions.speak.error.generate": "Не удалось сгенерировать аудио для этого сообщения.",
"messageItem.actions.deleteMessage": "Удалить сообщение (без отката изменений)",
"messageItem.actions.deleteMessagesUpTo": "Удалить сообщения до этого места (без отката изменений)",
"messageItem.actions.deletingMessage": "Удаление...",
@@ -137,7 +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

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter для отправки",
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
"settings.speech.title": "Речь",
"settings.speech.subtitle": "Настройте преобразование речи в текст сейчас и подготовьте основу для синтеза речи в будущих функциях.",
"settings.speech.provider.title": "Провайдер",
"settings.speech.provider.subtitle": "Речевые запросы используют серверный речевой адаптер.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Проверка конфигурации...",
"settings.speech.status.configured": "Настроено",
"settings.speech.status.missing": "Отсутствует API-ключ",
"settings.speech.status.error": "Речевой сервис недоступен",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Используется для речевых запросов, управляемых CodeNomad.",
"settings.speech.apiKey.placeholder": "Введите новый API-ключ",
"settings.speech.apiKey.storedNote": "Сохранённый API-ключ скрыт. Введите новое значение, чтобы заменить его, или оставьте поле пустым, чтобы сохранить текущий ключ.",
"settings.speech.apiKey.clearAction": "Удалить сохранённый ключ",
"settings.speech.apiKey.clearPending": "Сохранённый API-ключ будет удалён после сохранения.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Необязательная переопределяющая ссылка для речевых endpoint'ов, совместимых с OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Модель распознавания",
"settings.speech.sttModel.subtitle": "Модель, используемая для преобразования голосового ввода в тексте запроса.",
"settings.speech.ttsModel.title": "Речевая модель",
"settings.speech.ttsModel.subtitle": "Модель синтеза речи по умолчанию, зарезервированная для будущих функций воспроизведения.",
"settings.speech.ttsVoice.title": "Голос по умолчанию",
"settings.speech.ttsVoice.subtitle": "Голос синтеза речи по умолчанию, зарезервированный для будущих функций воспроизведения.",
"settings.speech.playbackMode.title": "Режим воспроизведения",
"settings.speech.playbackMode.subtitle": "Выберите, начинать ли воспроизведение TTS во время поступления аудио или только после полной генерации файла.",
"settings.speech.playbackMode.streaming": "Потоковый",
"settings.speech.playbackMode.buffered": "Буферизованный",
"settings.speech.ttsFormat.title": "Формат вывода",
"settings.speech.ttsFormat.subtitle": "Выберите аудиоформат для синтезированной речи. Поддержка потокового режима зависит от провайдера и браузера.",
"settings.speech.help": "Голосовой ввод появляется, когда распознавание речи настроено и поддерживается. Для воспроизведения сообщений используются выбранные здесь режим и формат TTS.",
"settings.speech.compatibility.streamingUnavailable": "Текущая конфигурация голосового провайдера не заявляет поддержку потокового TTS. Переключите режим воспроизведения на buffered, если хотите, чтобы воспроизведение работало уже сейчас.",
"settings.speech.compatibility.browserStreamingUnavailable": "Ваш текущий браузер не может воспроизводить потоково выбранный формат TTS. Выберите buffered-воспроизведение или переключитесь на другой формат.",
"settings.speech.compatibility.runtimeNote": "В режиме streaming по-прежнему доступны все форматы. Некоторые сочетания браузера и провайдера все равно могут завершаться ошибкой во время воспроизведения.",
"settings.speech.testPlayback.action": "Проверить воспроизведение",
"settings.speech.testPlayback.generating": "Генерация примера",
"settings.speech.testPlayback.stop": "Остановить пример",
"settings.speech.testPlayback.sample": "Спасибо, что используете CodeNomad, ваши настройки речи работают нормально.",
"settings.speech.testPlayback.note": "Тест сразу использует текущие режим и формат. Сначала сохраните изменения API key, Base URL, модели или голоса, если хотите проверить и их.",
"settings.speech.save.action": "Сохранить",
"settings.speech.save.saving": "Сохранение...",
"settings.speech.save.saved": "Сохранено",
"settings.speech.save.unsaved": "Есть несохранённые изменения",
"settings.speech.save.error": "Не удалось сохранить",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态",
"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.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
"instanceShell.rightPanel.sections.plan": "计划",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "复制",
"messageItem.actions.copyTitle": "复制消息",
"messageItem.actions.copied": "已复制!",
"messageItem.actions.speak": "朗读消息",
"messageItem.actions.generatingSpeech": "正在生成语音",
"messageItem.actions.stopSpeech": "停止播放",
"messageItem.actions.speak.error.title": "语音播放失败",
"messageItem.actions.speak.error.unsupported": "此浏览器不支持语音播放。",
"messageItem.actions.speak.error.unavailable": "语音设置完成前,语音播放不可用。",
"messageItem.actions.speak.error.generate": "无法为这条消息生成语音。",
"messageItem.actions.deleteMessage": "删除消息(不会撤销更改)",
"messageItem.actions.deleteMessagesUpTo": "删除到此处的消息(不会撤销更改)",
"messageItem.actions.deletingMessage": "正在删除...",
@@ -137,7 +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

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
"settings.behavior.autoCleanup.title": "自动清理空会话",
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "回车发送",
"settings.behavior.promptSubmit.subtitle": "使用回车发送Cmd/Ctrl+回车插入新行。",
"settings.speech.title": "语音",
"settings.speech.subtitle": "立即配置语音转文字,并为后续功能预留文字转语音基础。",
"settings.speech.provider.title": "提供商",
"settings.speech.provider.subtitle": "语音请求使用服务器端语音适配器。",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "正在检查配置...",
"settings.speech.status.configured": "已配置",
"settings.speech.status.missing": "缺少 API 密钥",
"settings.speech.status.error": "语音服务不可用",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "用于 CodeNomad 管理的语音请求。",
"settings.speech.apiKey.placeholder": "输入新的 API 密钥",
"settings.speech.apiKey.storedNote": "已保存的 API 密钥会被隐藏。输入新值可替换它,留空则保留当前密钥。",
"settings.speech.apiKey.clearAction": "清除已保存的密钥",
"settings.speech.apiKey.clearPending": "保存后将删除已保存的 API 密钥。",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "可选,用于覆盖 OpenAI 兼容语音端点的基础地址。",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "转写模型",
"settings.speech.sttModel.subtitle": "用于提示框语音转文字请求的模型。",
"settings.speech.ttsModel.title": "语音模型",
"settings.speech.ttsModel.subtitle": "为未来播放功能预留的默认文字转语音模型。",
"settings.speech.ttsVoice.title": "默认语音",
"settings.speech.ttsVoice.subtitle": "为未来播放功能预留的默认文字转语音音色。",
"settings.speech.playbackMode.title": "播放模式",
"settings.speech.playbackMode.subtitle": "选择在音频流入时开始播放,还是在整个文件生成完成后再播放。",
"settings.speech.playbackMode.streaming": "流式",
"settings.speech.playbackMode.buffered": "缓冲后播放",
"settings.speech.ttsFormat.title": "输出格式",
"settings.speech.ttsFormat.subtitle": "选择语音合成的音频格式。流式支持取决于你的提供商和浏览器。",
"settings.speech.help": "当语音转写已配置且受支持时,提示框语音输入会显示。消息播放会使用这里选择的 TTS 模式和格式。",
"settings.speech.compatibility.streamingUnavailable": "你当前的语音提供商配置没有声明支持流式 TTS。如果你现在就想让播放可用请把播放模式切换为 buffered。",
"settings.speech.compatibility.browserStreamingUnavailable": "你当前的浏览器无法流式播放所选的 TTS 格式。请选择 buffered 播放,或切换到其他格式。",
"settings.speech.compatibility.runtimeNote": "在流式模式下仍然可以选择所有格式,但某些浏览器与提供商的组合在播放时仍可能失败。",
"settings.speech.testPlayback.action": "测试播放",
"settings.speech.testPlayback.generating": "正在生成示例",
"settings.speech.testPlayback.stop": "停止示例",
"settings.speech.testPlayback.sample": "感谢你使用 CodeNomad你的语音设置工作正常。",
"settings.speech.testPlayback.note": "测试会立即使用当前播放模式和格式。如果你也想测试 API key、Base URL、模型或音色的更改请先保存。",
"settings.speech.save.action": "保存",
"settings.speech.save.saving": "保存中...",
"settings.speech.save.saved": "已保存",
"settings.speech.save.unsaved": "有未保存的更改",
"settings.speech.save.error": "保存失败",
} as const

View File

@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light"
let isInitialized = false
let highlightSuppressed = false
let escapeRawHtmlEnabled = false
let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | 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>`
}
renderer.html = (html: string) => {
if (!escapeRawHtmlEnabled) {
return html
}
return escapeHtml(decodeHtmlEntities(html))
}
marked.use({ renderer })
rendererSetup = true
}
@@ -308,6 +317,7 @@ export async function renderMarkdown(
content: string,
options?: {
suppressHighlight?: boolean
escapeRawHtml?: boolean
},
): Promise<string> {
if (!isInitialized) {
@@ -316,6 +326,7 @@ export async function renderMarkdown(
}
const suppressHighlight = options?.suppressHighlight ?? false
const escapeRawHtml = options?.escapeRawHtml ?? false
const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) {
@@ -324,13 +335,16 @@ export async function renderMarkdown(
}
const previousSuppressed = highlightSuppressed
const previousEscapeRawHtml = escapeRawHtmlEnabled
highlightSuppressed = suppressHighlight
escapeRawHtmlEnabled = escapeRawHtml
try {
// Proceed to parse immediately - highlighting will be available on next render
return marked.parse(decoded) as Promise<string>
} finally {
highlightSuppressed = previousSuppressed
escapeRawHtmlEnabled = previousEscapeRawHtml
}
}

View File

@@ -42,6 +42,7 @@ export type BehaviorRegistryActions = {
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -248,6 +249,24 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
)
},
},
{
kind: "toggle",
id: "behavior.promptVoiceInput",
titleKey: "settings.behavior.promptVoiceInput.title",
subtitleKey: "settings.behavior.promptVoiceInput.subtitle",
get: (p) => Boolean(p.showPromptVoiceInput ?? true),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showPromptVoiceInput: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showPromptVoiceInput ?? true),
actions.toggleShowPromptVoiceInput,
next,
)
},
},
{
kind: "toggle",
id: "behavior.promptSubmitOnEnter",

View File

@@ -0,0 +1,58 @@
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
import type { SpeechPlaybackMode, SpeechTtsFormat } from "../stores/preferences"
export interface SpeechPlaybackSupportResult {
available: boolean
reason?: "unsupported-environment" | "provider-streaming-unavailable" | "browser-streaming-unavailable"
}
export function formatToMimeType(format: SpeechTtsFormat): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return getSupportedMimeType(format)
if (format === "aac") return "audio/aac"
return "audio/mpeg"
}
export function getCandidateMimeTypes(format: SpeechTtsFormat): string[] {
if (format === "wav") return ["audio/wav"]
if (format === "opus") {
return ['audio/ogg; codecs="opus"', 'audio/webm; codecs="opus"', "audio/opus"]
}
if (format === "aac") return ["audio/aac", "audio/mp4", 'audio/mp4; codecs="mp4a.40.2"']
return ["audio/mpeg"]
}
export function getSupportedMimeType(format: SpeechTtsFormat): string {
const candidates = getCandidateMimeTypes(format)
if (typeof MediaSource === "undefined") {
return candidates[0]
}
return candidates.find((candidate) => MediaSource.isTypeSupported(candidate)) ?? candidates[0]
}
export function getSpeechPlaybackSupport(options: {
playbackMode: SpeechPlaybackMode
ttsFormat: SpeechTtsFormat
capabilities?: SpeechCapabilitiesResponse | null
}): SpeechPlaybackSupportResult {
if (typeof window === "undefined" || typeof window.Audio === "undefined") {
return { available: false, reason: "unsupported-environment" }
}
if (options.playbackMode !== "streaming") {
return { available: true }
}
if (!options.capabilities?.supportsStreamingTts) {
return { available: false, reason: "provider-streaming-unavailable" }
}
if (
typeof MediaSource === "undefined" ||
!getCandidateMimeTypes(options.ttsFormat).some((candidate) => MediaSource.isTypeSupported(candidate))
) {
return { available: false, reason: "browser-streaming-unavailable" }
}
return { available: true }
}

View File

@@ -10,6 +10,8 @@ export type AlertDialogState = {
variant?: AlertVariant
confirmLabel?: string
cancelLabel?: string
/** When false, prevents dismissal via Escape key or backdrop click. Default: true */
dismissible?: boolean
onConfirm?: () => void
onCancel?: () => void

View File

@@ -0,0 +1,534 @@
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 LEADING_SPOKEN_BLOCK_REGEX = /^\s*```spoken[ \t]*\r?\n([\s\S]*?)\r?\n```(?:\r?\n|$)/i
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 {
const previous = isConversationModeEnabled(instanceId)
if (previous === enabled) return
setConversationModeInstances((prev) => {
const next = new Map(prev)
if (enabled) {
next.set(instanceId, true)
} else {
next.delete(instanceId)
}
return next
})
if (!enabled) {
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 {
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 = extractLeadingSpokenBlock(resolveTextPartContent(part))
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" }))
}
function extractLeadingSpokenBlock(text: string): string {
const match = text.match(LEADING_SPOKEN_BLOCK_REGEX)
if (!match?.[1]) return ""
return match[1].trim()
}

View File

@@ -7,6 +7,7 @@ import {
updateInstanceConfig as updateInstanceData,
} from "./instance-config"
import { getLogger } from "../lib/logger"
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
const log = getLogger("actions")
@@ -27,6 +28,25 @@ export type DiffViewMode = "split" | "unified"
export type ExpansionPreference = "expanded" | "collapsed"
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
export type ListeningMode = "local" | "all"
export type SpeechProviderPreference = "openai-compatible"
export type SpeechPlaybackMode = "streaming" | "buffered"
export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac"
export interface SpeechSettings {
provider: SpeechProviderPreference
apiKey?: string
hasApiKey: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
playbackMode: SpeechPlaybackMode
ttsFormat: SpeechTtsFormat
}
export type SpeechSettingsUpdate = Partial<Omit<SpeechSettings, "apiKey">> & {
apiKey?: string | null
}
export interface UiSettings {
showThinkingBlocks: boolean
@@ -34,6 +54,7 @@ export interface UiSettings {
thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean
promptSubmitOnEnter: boolean
showPromptVoiceInput: boolean
locale?: string
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
@@ -75,6 +96,7 @@ interface ServerConfigBucket {
listeningMode?: ListeningMode
environmentVariables?: Record<string, string>
opencodeBinary?: string
speech?: Partial<SpeechSettings>
}
interface UiStateBucket {
@@ -107,6 +129,7 @@ const defaultUiSettings: UiSettings = {
thinkingBlocksExpansion: "expanded",
showTimelineTools: true,
promptSubmitOnEnter: false,
showPromptVoiceInput: true,
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
@@ -120,6 +143,16 @@ const defaultUiSettings: UiSettings = {
notifyOnIdle: true,
}
const defaultSpeechSettings: SpeechSettings = {
provider: "openai-compatible",
hasApiKey: false,
sttModel: "gpt-4o-mini-transcribe",
ttsModel: "gpt-4o-mini-tts",
ttsVoice: "alloy",
playbackMode: "streaming",
ttsFormat: "mp3",
}
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
const sanitized = input ?? {}
return {
@@ -129,6 +162,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
locale: sanitized.locale ?? defaultUiSettings.locale,
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
@@ -156,6 +190,36 @@ function normalizeRecord(value: unknown): Record<string, string> {
return out
}
function normalizeSpeechSettings(input?: Partial<SpeechSettings> | null): SpeechSettings {
const sanitized = input ?? {}
return {
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined,
hasApiKey: sanitized.hasApiKey === true || (typeof sanitized.apiKey === "string" && sanitized.apiKey.trim().length > 0),
baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined,
sttModel:
typeof sanitized.sttModel === "string" && sanitized.sttModel.trim()
? sanitized.sttModel.trim()
: defaultSpeechSettings.sttModel,
ttsModel:
typeof sanitized.ttsModel === "string" && sanitized.ttsModel.trim()
? sanitized.ttsModel.trim()
: defaultSpeechSettings.ttsModel,
ttsVoice:
typeof sanitized.ttsVoice === "string" && sanitized.ttsVoice.trim()
? sanitized.ttsVoice.trim()
: defaultSpeechSettings.ttsVoice,
playbackMode:
sanitized.playbackMode === "buffered" || sanitized.playbackMode === "streaming"
? sanitized.playbackMode
: defaultSpeechSettings.playbackMode,
ttsFormat:
sanitized.ttsFormat === "wav" || sanitized.ttsFormat === "opus" || sanitized.ttsFormat === "aac" || sanitized.ttsFormat === "mp3"
? sanitized.ttsFormat
: defaultSpeechSettings.ttsFormat,
}
}
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
if (!Array.isArray(value)) return []
const out: T[] = []
@@ -206,12 +270,15 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
}
}
function normalizeServerConfig(input?: ServerConfigBucket | null): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> {
function normalizeServerConfig(
input?: ServerConfigBucket | null,
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
const source = input ?? {}
const listeningMode = source.listeningMode === "all" ? "all" : "local"
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
const environmentVariables = normalizeRecord(source.environmentVariables)
return { listeningMode, opencodeBinary, environmentVariables }
const speech = normalizeSpeechSettings(source.speech)
return { listeningMode, opencodeBinary, environmentVariables, speech }
}
function getModelKey(model: { providerId: string; modelId: string }): string {
@@ -342,6 +409,27 @@ function updateLastUsedBinary(path: string): void {
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
}
async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise<void> {
const apiKeyPatch = updates.apiKey
const { apiKey: _apiKey, ...restUpdates } = updates
const next = normalizeSpeechSettings({
...serverSettings().speech,
...restUpdates,
...(apiKeyPatch === null ? {} : { apiKey: apiKeyPatch }),
})
const { hasApiKey: _hasApiKey, ...persistedSpeech } = next
const patch = {
...persistedSpeech,
...(apiKeyPatch === null ? { apiKey: null } : {}),
}
try {
await patchConfigOwner("server", { speech: patch })
} catch (error) {
log.error("Failed to update speech settings", error)
throw error
}
}
function addOpenCodeBinary(path: string, version?: string): void {
const nextList = buildBinaryList(path, version, opencodeBinaries())
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
@@ -476,6 +564,10 @@ function togglePromptSubmitOnEnter(): void {
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
}
function toggleShowPromptVoiceInput(): void {
updateUiSettings({ showPromptVoiceInput: !preferences().showPromptVoiceInput })
}
function toggleAutoCleanupBlankSessions(): void {
const nextValue = !preferences().autoCleanupBlankSessions
log.info("toggle auto cleanup", { value: nextValue })
@@ -521,6 +613,7 @@ interface ConfigContextValue {
addEnvironmentVariable: typeof addEnvironmentVariable
removeEnvironmentVariable: typeof removeEnvironmentVariable
updateLastUsedBinary: typeof updateLastUsedBinary
updateSpeechSettings: typeof updateSpeechSettings
// ui-owned state
recentFolders: typeof recentFolders
@@ -544,6 +637,7 @@ interface ConfigContextValue {
toggleUsageMetrics: typeof toggleUsageMetrics
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
toggleShowPromptVoiceInput: typeof toggleShowPromptVoiceInput
setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
@@ -569,6 +663,7 @@ const configContextValue: ConfigContextValue = {
addEnvironmentVariable,
removeEnvironmentVariable,
updateLastUsedBinary,
updateSpeechSettings,
recentFolders,
opencodeBinaries,
uiState,
@@ -588,6 +683,7 @@ const configContextValue: ConfigContextValue = {
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -610,6 +706,8 @@ export const ConfigProvider: ParentComponent = (props) => {
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
setServerConfigBucket(bucket as any)
setIsLoaded(true)
resetSpeechCapabilities()
void loadSpeechCapabilities(true)
})
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
setUiStateBucket(bucket as any)
@@ -648,6 +746,7 @@ export {
addEnvironmentVariable,
removeEnvironmentVariable,
updateLastUsedBinary,
updateSpeechSettings,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
@@ -664,6 +763,7 @@ export {
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,

View File

@@ -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,

View File

@@ -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.

View File

@@ -673,6 +673,7 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
detail: tGlobal("sessionState.cleanup.deepConfirm.detail"),
confirmLabel: tGlobal("sessionState.cleanup.deepConfirm.confirmLabel"),
cancelLabel: tGlobal("sessionState.cleanup.deepConfirm.cancelLabel"),
dismissible: false,
}
)
if (!confirmed) return

View File

@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode"
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
const [settingsOpen, setSettingsOpen] = createSignal(false)
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")

View File

@@ -0,0 +1,46 @@
import { createSignal } from "solid-js"
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
const log = getLogger("api")
const [speechCapabilities, setSpeechCapabilities] = createSignal<SpeechCapabilitiesResponse | null>(null)
const [speechCapabilitiesLoading, setSpeechCapabilitiesLoading] = createSignal(false)
const [speechCapabilitiesError, setSpeechCapabilitiesError] = createSignal<string | null>(null)
let speechCapabilitiesPromise: Promise<SpeechCapabilitiesResponse | null> | null = null
async function loadSpeechCapabilities(force = false): Promise<SpeechCapabilitiesResponse | null> {
if (!force && speechCapabilities()) return speechCapabilities()
if (speechCapabilitiesPromise) return speechCapabilitiesPromise
setSpeechCapabilitiesLoading(true)
setSpeechCapabilitiesError(null)
speechCapabilitiesPromise = serverApi
.fetchSpeechCapabilities()
.then((result) => {
setSpeechCapabilities(result)
setSpeechCapabilitiesError(null)
return result
})
.catch((error) => {
log.error("Failed to load speech capabilities", error)
setSpeechCapabilities(null)
setSpeechCapabilitiesError(error instanceof Error ? error.message : String(error))
return null
})
.finally(() => {
setSpeechCapabilitiesLoading(false)
speechCapabilitiesPromise = null
})
return speechCapabilitiesPromise
}
function resetSpeechCapabilities(): void {
setSpeechCapabilities(null)
setSpeechCapabilitiesError(null)
}
export { speechCapabilities, speechCapabilitiesLoading, speechCapabilitiesError, loadSpeechCapabilities, resetSpeechCapabilities }

View File

@@ -36,8 +36,8 @@
.prompt-input {
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
padding-inline-start: 2.5rem;
padding-inline-end: 0.75rem;
padding-inline-start: 0.75rem;
padding-inline-end: 7.5rem;
font-family: inherit;
background-color: var(--surface-base);
color: var(--text-primary);
@@ -83,23 +83,39 @@
color: var(--text-primary);
}
/* Navigation buttons container (expand, prev, next).
Intentionally at inline-start (left in LTR, right in RTL) so buttons never overlap
the scrollbar, which browsers always place at inline-end. */
/* Navigation buttons container (expand, prev, next). */
.prompt-nav-buttons {
position: absolute;
top: 0.25rem;
inset-inline-start: 0.25rem;
inset-inline-end: 0.25rem;
bottom: 0.25rem;
display: flex;
flex-direction: column;
justify-content: flex-start;
flex-direction: row;
align-items: flex-start;
justify-content: flex-end;
gap: 0.125rem;
z-index: 2;
}
.prompt-nav-column {
display: flex;
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);
@@ -109,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);
}
@@ -121,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;
}
@@ -179,6 +197,73 @@
color: var(--button-danger-text, var(--text-inverted, #ffffff));
}
.prompt-voice-button {
@apply h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
min-width: 2.5rem;
background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base));
color: var(--text-secondary);
}
.prompt-voice-button:hover:not(:disabled) {
color: var(--text-primary);
background-color: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-secondary));
@apply scale-105;
}
.prompt-voice-button:active:not(:disabled) {
@apply scale-95;
}
.prompt-voice-button.is-recording {
min-width: 3.5rem;
background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%);
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;
height: 1.75rem;
border-radius: 0.375rem;
}
.prompt-nav-voice-button.is-recording {
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;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.stop-button:hover:not(:disabled) {
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
@apply opacity-95 scale-105;
@@ -344,7 +429,7 @@
.prompt-input {
min-height: 0;
padding: 0.5rem 0.75rem;
padding-inline-start: 2.5rem; /* preserve space for nav buttons */
padding-inline-end: 7.5rem;
padding-bottom: 0.75rem;
}

View File

@@ -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 }>