Compare commits
8 Commits
v0.13.1-de
...
ready/ui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f59e66065 | ||
|
|
51ac7f152d | ||
|
|
df74c06ba2 | ||
|
|
5f144ca24d | ||
|
|
de66b1349a | ||
|
|
3d888fee64 | ||
|
|
1abcc8ee3c | ||
|
|
d0d5c309e6 |
35
package-lock.json
generated
35
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -8240,27 +8240,6 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"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": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -12040,7 +12019,6 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -12055,7 +12033,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12092,7 +12070,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12102,7 +12080,6 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
@@ -12134,7 +12111,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12142,7 +12119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
|
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -31,4 +31,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.13.1",
|
"minServerVersion": "0.12.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,4 +2,3 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
.vite/
|
||||||
electron/resources/server/
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { requestMicrophoneAccess } from "./permissions"
|
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -112,11 +111,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"media:requestMicrophoneAccess",
|
|
||||||
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { dirname, join } from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupCliIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
import { configureMediaPermissionHandlers } from "./permissions"
|
|
||||||
import { CliProcessManager } from "./process-manager"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
@@ -490,7 +489,6 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { session, systemPreferences } from "electron"
|
|
||||||
|
|
||||||
const isMac = process.platform === "darwin"
|
|
||||||
|
|
||||||
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
|
||||||
if (!origin) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const normalized = new URL(origin).origin
|
|
||||||
return allowedOrigins.includes(normalized)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
|
||||||
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
|
||||||
if (permission !== "media") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
|
||||||
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
|
||||||
}
|
|
||||||
|
|
||||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
|
||||||
if (!isAudioMediaRequest(permission, details)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
|
||||||
})
|
|
||||||
|
|
||||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
|
||||||
if (!isAudioMediaRequest(permission, details)) {
|
|
||||||
callback(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
|
||||||
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestMicrophoneAccess(): Promise<boolean> {
|
|
||||||
if (!isMac) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = systemPreferences.getMediaAccessStatus("microphone")
|
|
||||||
if (status === "granted") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return systemPreferences.askForMediaAccess("microphone")
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app, utilityProcess, type UtilityProcess } from "electron"
|
import { app } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
|
||||||
const mainDirname = path.dirname(mainFilename)
|
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
@@ -41,9 +38,6 @@ interface CliEntryResolution {
|
|||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedChild = ChildProcess | UtilityProcess
|
|
||||||
type ChildLaunchMode = "spawn" | "utility"
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
@@ -123,8 +117,7 @@ export declare interface CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ManagedChild
|
private child?: ChildProcess
|
||||||
private childLaunchMode: ChildLaunchMode = "spawn"
|
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
@@ -142,63 +135,33 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
|
||||||
let child: ManagedChild
|
console.info(
|
||||||
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
|
)
|
||||||
|
|
||||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
const runtimePath = this.resolveShellNodeCommand()
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info(
|
const spawnDetails = supportsUserShell()
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
|
||||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
|
||||||
|
|
||||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
const detached = process.platform !== "win32"
|
||||||
env: shellEnv,
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
stdio: "pipe",
|
cwd: process.cwd(),
|
||||||
serviceName: "CodeNomad CLI Supervisor",
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
})
|
env,
|
||||||
this.childLaunchMode = "utility"
|
shell: false,
|
||||||
} else {
|
detached,
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
})
|
||||||
console.info(
|
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
if (!child.pid) {
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
|
||||||
child = spawn(spawnDetails.command, spawnDetails.args, {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
env,
|
|
||||||
shell: false,
|
|
||||||
detached,
|
|
||||||
})
|
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
|
||||||
this.childLaunchMode = "spawn"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.childLaunchMode === "spawn" && !child.pid) {
|
|
||||||
console.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,48 +176,23 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.childLaunchMode === "utility") {
|
child.on("error", (error) => {
|
||||||
const utilityChild = child as UtilityProcess
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
utilityChild.on("error", (error) => {
|
child.on("exit", (code, signal) => {
|
||||||
const message = this.describeUtilityProcessError(error)
|
const failed = this.status.state !== "ready"
|
||||||
console.error("[cli] utility supervisor failed:", error)
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
this.updateStatus({ state: "error", error: message })
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
this.emit("error", new Error(message))
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
})
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
utilityChild.on("exit", (code) => {
|
}
|
||||||
const failed = this.status.state !== "ready"
|
this.emit("exit", this.status)
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
this.child = undefined
|
||||||
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
})
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
|
||||||
if (failed && error) {
|
|
||||||
this.emit("error", new Error(error))
|
|
||||||
}
|
|
||||||
this.emit("exit", this.status)
|
|
||||||
this.child = undefined
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const spawnedChild = child as ChildProcess
|
|
||||||
|
|
||||||
spawnedChild.on("error", (error) => {
|
|
||||||
console.error("[cli] failed to start CLI:", error)
|
|
||||||
this.updateStatus({ state: "error", error: error.message })
|
|
||||||
this.emit("error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
spawnedChild.on("exit", (code, signal) => {
|
|
||||||
const failed = this.status.state !== "ready"
|
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
|
||||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
|
||||||
if (failed && error) {
|
|
||||||
this.emit("error", new Error(error))
|
|
||||||
}
|
|
||||||
this.emit("exit", this.status)
|
|
||||||
this.child = undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<CliStatus>((resolve, reject) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -281,22 +219,16 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.childLaunchMode === "utility") {
|
|
||||||
return this.stopUtilityChild(child as UtilityProcess)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spawnedChild = child as ChildProcess
|
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
const pid = spawnedChild.pid
|
const pid = child.pid
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
try {
|
try {
|
||||||
@@ -372,7 +304,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
sendStopSignal("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
spawnedChild.on("exit", () => {
|
child.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
console.info("[cli] CLI process exited")
|
console.info("[cli] CLI process exited")
|
||||||
@@ -392,46 +324,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
|
||||||
this.requestedStop = true
|
|
||||||
|
|
||||||
const pid = child.pid
|
|
||||||
if (!pid) {
|
|
||||||
this.child = undefined
|
|
||||||
this.updateStatus({ state: "stopped" })
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const killTimeout = setTimeout(() => {
|
|
||||||
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL")
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}, 30000)
|
|
||||||
|
|
||||||
child.once("exit", () => {
|
|
||||||
clearTimeout(killTimeout)
|
|
||||||
this.child = undefined
|
|
||||||
console.info("[cli] CLI process exited")
|
|
||||||
this.updateStatus({ state: "stopped" })
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (child.pid === undefined) {
|
|
||||||
clearTimeout(killTimeout)
|
|
||||||
this.child = undefined
|
|
||||||
this.updateStatus({ state: "stopped" })
|
|
||||||
resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
child.kill()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus(): CliStatus {
|
getStatus(): CliStatus {
|
||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
@@ -443,22 +335,14 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
const pid = this.child.pid
|
const pid = this.child.pid
|
||||||
if (this.childLaunchMode === "utility") {
|
if (pid && process.platform !== "win32") {
|
||||||
if (pid) {
|
|
||||||
try {
|
|
||||||
process.kill(pid, "SIGKILL")
|
|
||||||
} catch {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (pid && process.platform !== "win32") {
|
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, "SIGKILL")
|
process.kill(-pid, "SIGKILL")
|
||||||
} catch {
|
} catch {
|
||||||
;(this.child as ChildProcess).kill("SIGKILL")
|
this.child.kill("SIGKILL")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
;(this.child as ChildProcess).kill("SIGKILL")
|
this.child.kill("SIGKILL")
|
||||||
}
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
@@ -565,10 +449,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildExecutableCommand(command: string, args: string[]): string {
|
|
||||||
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
@@ -639,58 +519,4 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
|
||||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveCliSupervisorPath(): string {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
|
||||||
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveShellNodeCommand(): string {
|
|
||||||
const configured = process.env.NODE_BINARY?.trim()
|
|
||||||
return configured && configured.length > 0 ? configured : "node"
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveBundledProdEntry(): string {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
|
||||||
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private describeUtilityProcessError(error: unknown): string {
|
|
||||||
if (error instanceof Error && error.message) {
|
|
||||||
return error.message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && typeof error === "object") {
|
|
||||||
const typed = error as { type?: unknown; location?: unknown }
|
|
||||||
if (typeof typed.type === "string") {
|
|
||||||
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const electronAPI = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -20,8 +20,6 @@
|
|||||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
"prepare:resources": "node scripts/prepare-resources.js",
|
|
||||||
"prebuild": "npm run prepare:resources",
|
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
@@ -35,11 +33,8 @@
|
|||||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||||
"build:all": "node scripts/build.js all",
|
"build:all": "node scripts/build.js all",
|
||||||
"prepackage:mac": "npm run prepare:resources",
|
|
||||||
"package:mac": "electron-builder --mac",
|
"package:mac": "electron-builder --mac",
|
||||||
"prepackage:win": "npm run prepare:resources",
|
|
||||||
"package:win": "electron-builder --win",
|
"package:win": "electron-builder --win",
|
||||||
"prepackage:linux": "npm run prepare:resources",
|
|
||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -87,12 +82,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"entitlements": "electron/resources/entitlements.mac.plist",
|
|
||||||
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
|
||||||
"extendInfo": {
|
|
||||||
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
|
||||||
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
|
||||||
},
|
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,12 +111,6 @@ async function build(platform) {
|
|||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
|
||||||
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
|
||||||
cwd: workspaceRoot,
|
|
||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
import path, { join } from "path"
|
|
||||||
import { spawnSync } from "child_process"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
|
||||||
const appDir = join(__dirname, "..")
|
|
||||||
const workspaceRoot = join(appDir, "..", "..")
|
|
||||||
const serverRoot = join(appDir, "..", "server")
|
|
||||||
const resourcesRoot = join(appDir, "electron", "resources")
|
|
||||||
const serverDest = join(resourcesRoot, "server")
|
|
||||||
const npmExecPath = process.env.npm_execpath
|
|
||||||
const npmNodeExecPath = process.env.npm_node_execpath
|
|
||||||
|
|
||||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
|
||||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
|
||||||
|
|
||||||
function log(message) {
|
|
||||||
console.log(`[prepare-resources] ${message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerBuild() {
|
|
||||||
const distPath = join(serverRoot, "dist")
|
|
||||||
const publicPath = join(serverRoot, "public")
|
|
||||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
|
||||||
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureServerDependencies() {
|
|
||||||
if (fs.existsSync(serverDepsMarker)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log("installing production server dependencies")
|
|
||||||
const npmArgs = [
|
|
||||||
"install",
|
|
||||||
"--omit=dev",
|
|
||||||
"--ignore-scripts",
|
|
||||||
"--workspaces=false",
|
|
||||||
"--package-lock=false",
|
|
||||||
"--install-strategy=shallow",
|
|
||||||
"--fund=false",
|
|
||||||
"--audit=false",
|
|
||||||
]
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
||||||
npm_config_workspaces: "false",
|
|
||||||
}
|
|
||||||
|
|
||||||
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
|
||||||
const result = npmCli
|
|
||||||
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
|
||||||
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error
|
|
||||||
}
|
|
||||||
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyServerArtifacts() {
|
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
|
||||||
|
|
||||||
for (const name of serverSources) {
|
|
||||||
const from = join(serverRoot, name)
|
|
||||||
const to = join(serverDest, name)
|
|
||||||
if (!fs.existsSync(from)) {
|
|
||||||
throw new Error(`Missing required server artifact: ${from}`)
|
|
||||||
}
|
|
||||||
fs.cpSync(from, to, { recursive: true, dereference: true })
|
|
||||||
log(`copied ${name} to Electron resources`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripNodeModuleBins() {
|
|
||||||
const root = join(serverDest, "node_modules")
|
|
||||||
if (!fs.existsSync(root)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stack = [root]
|
|
||||||
let removed = 0
|
|
||||||
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const current = stack.pop()
|
|
||||||
if (!current) break
|
|
||||||
|
|
||||||
let entries
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const full = join(current, entry.name)
|
|
||||||
if (entry.name === ".bin") {
|
|
||||||
fs.rmSync(full, { recursive: true, force: true })
|
|
||||||
removed += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
stack.push(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removed > 0) {
|
|
||||||
log(`removed ${removed} node_modules/.bin directories`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
ensureServerBuild()
|
|
||||||
ensureServerDependencies()
|
|
||||||
copyServerArtifacts()
|
|
||||||
stripNodeModuleBins()
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error("[prepare-resources] failed:", error)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||||
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.3.2"
|
"@opencode-ai/plugin": "1.2.24"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,6 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||||
|
|
||||||
let voiceModeEnabled = false
|
|
||||||
|
|
||||||
export async function CodeNomadPlugin(input: PluginInput) {
|
export async function CodeNomadPlugin(input: PluginInput) {
|
||||||
const config = getCodeNomadConfig()
|
const config = getCodeNomadConfig()
|
||||||
const client = createCodeNomadClient(config)
|
const client = createCodeNomadClient(config)
|
||||||
@@ -18,11 +16,6 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
pingTs: (event.properties as any)?.ts,
|
pingTs: (event.properties as any)?.ts,
|
||||||
},
|
},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "codenomad.voiceMode") {
|
|
||||||
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,13 +23,6 @@ export async function CodeNomadPlugin(input: PluginInput) {
|
|||||||
tool: {
|
tool: {
|
||||||
...backgroundProcessTools,
|
...backgroundProcessTools,
|
||||||
},
|
},
|
||||||
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
|
|
||||||
if (!voiceModeEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
|
|
||||||
},
|
|
||||||
async event(input: { event: any }) {
|
async event(input: { event: any }) {
|
||||||
const opencodeEvent = input?.event
|
const opencodeEvent = input?.event
|
||||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||||
@@ -44,19 +30,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
@@ -47,4 +46,4 @@
|
|||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,43 +207,6 @@ export interface BinaryValidationResult {
|
|||||||
error?: string
|
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 =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -81,14 +81,6 @@ export class FileSystemBrowser {
|
|||||||
return { path: relativePath, absolutePath }
|
return { path: relativePath, absolutePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFile(relativePath: string, contents: string): void {
|
|
||||||
if (this.unrestricted) {
|
|
||||||
throw new Error("writeFile is not available in unrestricted mode")
|
|
||||||
}
|
|
||||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
|
||||||
fs.writeFileSync(resolved, contents, "utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
readFile(relativePath: string): string {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
|
|||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -305,7 +304,6 @@ async function main() {
|
|||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -390,7 +388,6 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
@@ -411,7 +408,6 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
speechService,
|
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
|
|||||||
@@ -21,15 +21,12 @@ import { registerStorageRoutes } from "./routes/storage"
|
|||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
import { registerSpeechRoutes } from "./routes/speech"
|
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
import type { AuthManager } from "../auth/manager"
|
import type { AuthManager } from "../auth/manager"
|
||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
import type { SpeechService } from "../speech/service"
|
|
||||||
import { PluginChannelManager } from "../plugins/channel"
|
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -44,7 +41,6 @@ interface HttpServerDeps {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
speechService: SpeechService
|
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
@@ -174,7 +170,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
|
||||||
|
|
||||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
@@ -257,13 +252,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||||
registerPluginRoutes(app, {
|
|
||||||
workspaceManager: deps.workspaceManager,
|
|
||||||
eventBus: deps.eventBus,
|
|
||||||
logger: proxyLogger,
|
|
||||||
channel: pluginChannel,
|
|
||||||
})
|
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { VoiceModeStateResponse } from "../../api-types"
|
|
||||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||||
import type { EventBus } from "../../events/bus"
|
import type { EventBus } from "../../events/bus"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
@@ -11,7 +10,6 @@ interface RouteDeps {
|
|||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
channel: PluginChannelManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PluginEventSchema = z.object({
|
const PluginEventSchema = z.object({
|
||||||
@@ -19,11 +17,9 @@ const PluginEventSchema = z.object({
|
|||||||
properties: z.record(z.unknown()).optional(),
|
properties: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const VoiceModeStateSchema = z.object({
|
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||||
const workspace = deps.workspaceManager.get(request.params.id)
|
const workspace = deps.workspaceManager.get(request.params.id)
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -37,10 +33,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
reply.raw.flushHeaders?.()
|
reply.raw.flushHeaders?.()
|
||||||
reply.hijack()
|
reply.hijack()
|
||||||
|
|
||||||
const registration = deps.channel.register(request.params.id, reply)
|
const registration = channel.register(request.params.id, reply)
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
deps.channel.send(request.params.id, buildPingEvent())
|
channel.send(request.params.id, buildPingEvent())
|
||||||
}, 15000)
|
}, 15000)
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -53,24 +49,6 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
request.raw.on("error", close)
|
request.raw.on("error", close)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
|
|
||||||
const workspace = deps.workspaceManager.get(request.params.id)
|
|
||||||
if (!workspace) {
|
|
||||||
reply.code(404).send({ error: "Workspace not found" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
|
||||||
deps.channel.send(request.params.id, {
|
|
||||||
type: "codenomad.voiceMode",
|
|
||||||
properties: {
|
|
||||||
enabled: payload.enabled,
|
|
||||||
formatVersion: "v1",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { enabled: payload.enabled }
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleWildcard = async (request: any, reply: any) => {
|
const handleWildcard = async (request: any, reply: any) => {
|
||||||
const workspaceId = request.params.id as string
|
const workspaceId = request.params.id as string
|
||||||
const workspace = deps.workspaceManager.get(workspaceId)
|
const workspace = deps.workspaceManager.get(workspaceId)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { z } from "zod"
|
|||||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||||
import type { SettingsService } from "../../settings/service"
|
import type { SettingsService } from "../../settings/service"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
settings: SettingsService
|
settings: SettingsService
|
||||||
@@ -21,10 +20,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
|
|||||||
|
|
||||||
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
// Full-document access
|
// Full-document access
|
||||||
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
|
||||||
app.patch("/api/storage/config", async (request, reply) => {
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
@@ -32,15 +31,12 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||||
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
return deps.settings.getOwner("config", request.params.owner)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return sanitizeConfigOwner(
|
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
|
||||||
request.params.owner,
|
|
||||||
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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") }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -19,10 +19,6 @@ const WorkspaceFileContentQuerySchema = z.object({
|
|||||||
path: z.string(),
|
path: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const WorkspaceFileContentBodySchema = z.object({
|
|
||||||
contents: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const WorkspaceFileSearchQuerySchema = z.object({
|
const WorkspaceFileSearchQuerySchema = z.object({
|
||||||
q: z.string().trim().min(1, "Query is required"),
|
q: z.string().trim().min(1, "Query is required"),
|
||||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||||
@@ -104,20 +100,6 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return handleWorkspaceError(error, reply)
|
return handleWorkspaceError(error, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put<{
|
|
||||||
Params: { id: string }
|
|
||||||
Querystring: { path?: string }
|
|
||||||
}>("/api/workspaces/:id/files/content", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
|
|
||||||
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
|
|
||||||
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
|
|
||||||
reply.code(204)
|
|
||||||
} catch (error) {
|
|
||||||
return handleWorkspaceError(error, reply)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import type { ConfigLocation } from "../config/location"
|
|||||||
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
import { migrateSettingsLayout } from "./migrate"
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
import type { WorkspaceEventPayload } from "../api-types"
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
import { sanitizeConfigOwner } from "./public-config"
|
|
||||||
|
|
||||||
export type DocKind = "config" | "state"
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
@@ -46,11 +45,10 @@ export class SettingsService {
|
|||||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||||
if (!this.eventBus) return
|
if (!this.eventBus) return
|
||||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||||
const nextValue = value ?? this.getOwner(kind, owner)
|
|
||||||
const payload: WorkspaceEventPayload = {
|
const payload: WorkspaceEventPayload = {
|
||||||
type,
|
type,
|
||||||
owner,
|
owner,
|
||||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
value: value ?? this.getOwner(kind, owner),
|
||||||
} as any
|
} as any
|
||||||
this.eventBus.publish(payload)
|
this.eventBus.publish(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
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}/`
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -55,31 +55,4 @@ describe("resolveUi local version preference", () => {
|
|||||||
assert.equal(result.uiStaticDir, bundledDir)
|
assert.equal(result.uiStaticDir, bundledDir)
|
||||||
assert.equal(result.uiVersion, "0.8.1")
|
assert.equal(result.uiVersion, "0.8.1")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("prefers bundled when bundled and downloaded versions are equal", async () => {
|
|
||||||
const bundledDir = path.join(tempRoot, "bundled")
|
|
||||||
const configDir = path.join(tempRoot, "config")
|
|
||||||
const currentDir = path.join(configDir, "ui", "current")
|
|
||||||
|
|
||||||
await mkdir(bundledDir, { recursive: true })
|
|
||||||
await mkdir(currentDir, { recursive: true })
|
|
||||||
|
|
||||||
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
|
||||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
|
||||||
|
|
||||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
|
||||||
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
|
||||||
|
|
||||||
const result = await resolveUi({
|
|
||||||
serverVersion: "0.8.1",
|
|
||||||
bundledUiDir: bundledDir,
|
|
||||||
autoUpdate: false,
|
|
||||||
configDir,
|
|
||||||
logger: noopLogger,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(result.source, "bundled")
|
|
||||||
assert.equal(result.uiStaticDir, bundledDir)
|
|
||||||
assert.equal(result.uiVersion, "0.8.1")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
|
|||||||
uiStaticDir: currentResolved,
|
uiStaticDir: currentResolved,
|
||||||
source: "downloaded",
|
source: "downloaded",
|
||||||
uiVersion: await readUiVersion(currentResolved),
|
uiVersion: await readUiVersion(currentResolved),
|
||||||
priority: 1,
|
priority: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
|
|||||||
uiStaticDir: bundledResolved,
|
uiStaticDir: bundledResolved,
|
||||||
source: "bundled",
|
source: "bundled",
|
||||||
uiVersion: await readUiVersion(bundledResolved),
|
uiVersion: await readUiVersion(bundledResolved),
|
||||||
priority: 2,
|
priority: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,6 @@ export class WorkspaceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFile(workspaceId: string, relativePath: string, contents: string): void {
|
|
||||||
const workspace = this.requireWorkspace(workspaceId)
|
|
||||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
|
||||||
browser.writeFile(relativePath, contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
|
|||||||
67
packages/tauri-app/Cargo.lock
generated
67
packages/tauri-app/Cargo.lock
generated
@@ -473,7 +473,6 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-global-shortcut",
|
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1351,16 +1350,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gethostname"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
|
||||||
dependencies = [
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.16"
|
version = "0.1.16"
|
||||||
@@ -1493,24 +1482,6 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "global-hotkey"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-channel",
|
|
||||||
"keyboard-types",
|
|
||||||
"objc2",
|
|
||||||
"objc2-app-kit",
|
|
||||||
"once_cell",
|
|
||||||
"serde",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"windows-sys 0.59.0",
|
|
||||||
"x11rb",
|
|
||||||
"xkeysym",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gobject-sys"
|
name = "gobject-sys"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
@@ -4084,21 +4055,6 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tauri-plugin-global-shortcut"
|
|
||||||
version = "2.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
|
|
||||||
dependencies = [
|
|
||||||
"global-hotkey",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-plugin",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-notification"
|
name = "tauri-plugin-notification"
|
||||||
version = "2.3.3"
|
version = "2.3.3"
|
||||||
@@ -5779,29 +5735,6 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
|
||||||
dependencies = [
|
|
||||||
"gethostname",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"x11rb-protocol",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb-protocol"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "xkeysym"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
"dev:prep": "node ./scripts/dev-prep.js",
|
"dev:prep": "node ./scripts/dev-prep.js",
|
||||||
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
|
||||||
"prebuild": "node ./scripts/prebuild.js",
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
"bundle:server": "npm run prebuild",
|
"bundle:server": "npm run prebuild",
|
||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ async function ensureMonacoAssets() {
|
|||||||
function ensureServerBuild() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist")
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public")
|
const publicPath = path.join(serverRoot, "public")
|
||||||
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] server build missing; running workspace build...")
|
||||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,6 @@ keepawake = "0.6"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-global-shortcut = "2"
|
|
||||||
url = "2"
|
url = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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>
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -2378,72 +2378,6 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:default",
|
|
||||||
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the is_registered command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:allow-is-registered",
|
|
||||||
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the register command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:allow-register",
|
|
||||||
"markdownDescription": "Enables the register command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the register_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:allow-register-all",
|
|
||||||
"markdownDescription": "Enables the register_all command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the unregister command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:allow-unregister",
|
|
||||||
"markdownDescription": "Enables the unregister command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the unregister_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:allow-unregister-all",
|
|
||||||
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the is_registered command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-is-registered",
|
|
||||||
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-register",
|
|
||||||
"markdownDescription": "Denies the register command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the register_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-register-all",
|
|
||||||
"markdownDescription": "Denies the register_all command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the unregister command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-unregister",
|
|
||||||
"markdownDescription": "Denies the unregister command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the unregister_all command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "global-shortcut:deny-unregister-all",
|
|
||||||
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
#[cfg(windows)]
|
|
||||||
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn configure_posix_process_group(command: &mut Command) {
|
fn configure_posix_process_group(command: &mut Command) {
|
||||||
@@ -404,8 +402,6 @@ impl CliProcessManager {
|
|||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(windows)]
|
|
||||||
let mut forced_tree_shutdown = false;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
let pid = child.id() as i32;
|
||||||
@@ -418,7 +414,9 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = kill_process_tree_windows(child.id(), false);
|
if !kill_process_tree_windows(child.id(), false) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -426,21 +424,6 @@ impl CliProcessManager {
|
|||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(windows)]
|
|
||||||
if !forced_tree_shutdown
|
|
||||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
|
||||||
{
|
|
||||||
log_line(&format!(
|
|
||||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
|
||||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
|
||||||
child.id()
|
|
||||||
));
|
|
||||||
forced_tree_shutdown = true;
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
log_line(&format!(
|
log_line(&format!(
|
||||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||||
@@ -457,11 +440,7 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !forced_tree_shutdown
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
&& !kill_process_tree_windows(child.id(), true)
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
} else if forced_tree_shutdown {
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||||
use tauri_plugin_global_shortcut::{
|
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
|
||||||
};
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -29,10 +25,6 @@ use std::os::windows::ffi::OsStrExt;
|
|||||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
|
||||||
const ZOOM_STEP: f64 = 0.2;
|
|
||||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||||
@@ -40,7 +32,6 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -166,83 +157,6 @@ fn emit_folder_drop_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clamp_zoom_level(value: f64) -> f64 {
|
|
||||||
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let normalized = clamp_zoom_level(next_zoom);
|
|
||||||
if window.set_zoom(normalized).is_ok() {
|
|
||||||
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
|
||||||
*zoom_level = normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reload_main_window(app_handle: &AppHandle) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let _ = window.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn force_reload_main_window(app_handle: &AppHandle) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
if let Ok(mut url) = window.url() {
|
|
||||||
if should_allow_internal(&url) {
|
|
||||||
let reload_token = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let existing_pairs: Vec<(String, String)> = url
|
|
||||||
.query_pairs()
|
|
||||||
.into_owned()
|
|
||||||
.filter(|(key, _)| key != "__codenomad_force_reload")
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut pairs = url.query_pairs_mut();
|
|
||||||
pairs.clear();
|
|
||||||
for (key, value) in existing_pairs {
|
|
||||||
pairs.append_pair(&key, &value);
|
|
||||||
}
|
|
||||||
pairs.append_pair("__codenomad_force_reload", &reload_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = window.navigate(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = window.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_fullscreen_window(app_handle: &AppHandle) {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
|
|
||||||
let _ = window.set_fullscreen(next_fullscreen);
|
|
||||||
if cfg!(not(target_os = "macos")) {
|
|
||||||
if next_fullscreen {
|
|
||||||
let _ = window.hide_menu();
|
|
||||||
} else {
|
|
||||||
let _ = window.show_menu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fullscreen_shortcut() -> Option<Shortcut> {
|
|
||||||
if cfg!(target_os = "macos") {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(Shortcut::new(None, ShortcutCode::F11))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn set_windows_app_user_model_id() {
|
fn set_windows_app_user_model_id() {
|
||||||
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||||
@@ -267,48 +181,15 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(
|
|
||||||
tauri_plugin_global_shortcut::Builder::new()
|
|
||||||
.with_handler(|app, shortcut, event| {
|
|
||||||
if event.state() != ShortcutState::Pressed {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if fullscreen_shortcut().as_ref() == Some(shortcut) {
|
|
||||||
toggle_fullscreen_window(app);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
set_windows_app_user_model_id();
|
set_windows_app_user_model_id();
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
if let Some(shortcut) = fullscreen_shortcut() {
|
|
||||||
let shortcut_manager = app.handle().global_shortcut();
|
|
||||||
let _ = shortcut_manager.register(shortcut.clone());
|
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
let app_handle = app.handle().clone();
|
|
||||||
window.on_window_event(move |event| {
|
|
||||||
if let WindowEvent::Focused(focused) = event {
|
|
||||||
let shortcut_manager = app_handle.global_shortcut();
|
|
||||||
if *focused {
|
|
||||||
let _ = shortcut_manager.register(shortcut.clone());
|
|
||||||
} else {
|
|
||||||
let _ = shortcut_manager.unregister(shortcut.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dev_mode = is_dev_mode();
|
let dev_mode = is_dev_mode();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
let manager = app.state::<AppState>().manager.clone();
|
let manager = app.state::<AppState>().manager.clone();
|
||||||
@@ -333,42 +214,36 @@ fn main() {
|
|||||||
let _ = window.emit("menu:newInstance", ());
|
let _ = window.emit("menu:newInstance", ());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"close" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
app_handle.exit(0);
|
app_handle.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
"reload" => {
|
"reload" => {
|
||||||
reload_main_window(app_handle);
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval("window.location.reload()");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"force_reload" => {
|
"force_reload" => {
|
||||||
force_reload_main_window(app_handle);
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval("window.location.reload(true)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"toggle_devtools" => {
|
"toggle_devtools" => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
if window.is_devtools_open() {
|
window.open_devtools();
|
||||||
window.close_devtools();
|
|
||||||
} else {
|
|
||||||
window.open_devtools();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"reset_zoom" => {
|
|
||||||
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
|
|
||||||
}
|
|
||||||
"zoom_in" => {
|
|
||||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
|
||||||
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"zoom_out" => {
|
|
||||||
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
|
|
||||||
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"toggle_fullscreen" => {
|
"toggle_fullscreen" => {
|
||||||
toggle_fullscreen_window(app_handle);
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
@@ -382,11 +257,6 @@ fn main() {
|
|||||||
let _ = window.maximize();
|
let _ = window.maximize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"close_window" => {
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|
||||||
let _ = window.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// App menu (macOS)
|
// App menu (macOS)
|
||||||
"about" => {
|
"about" => {
|
||||||
@@ -474,7 +344,6 @@ fn main() {
|
|||||||
|
|
||||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||||
let is_mac = cfg!(target_os = "macos");
|
let is_mac = cfg!(target_os = "macos");
|
||||||
let is_linux = cfg!(target_os = "linux");
|
|
||||||
|
|
||||||
// Create submenus
|
// Create submenus
|
||||||
let mut submenus = Vec::new();
|
let mut submenus = Vec::new();
|
||||||
@@ -502,74 +371,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
Some("CmdOrCtrl+N"),
|
Some("CmdOrCtrl+N"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = if is_mac {
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
SubmenuBuilder::new(app, "File")
|
.item(&new_instance_item)
|
||||||
.item(&new_instance_item)
|
.separator()
|
||||||
.separator()
|
.text(
|
||||||
.close_window()
|
if is_mac { "close" } else { "quit" },
|
||||||
.build()?
|
if is_mac { "Close" } else { "Quit" },
|
||||||
} else {
|
)
|
||||||
SubmenuBuilder::new(app, "File")
|
.build()?;
|
||||||
.item(&new_instance_item)
|
|
||||||
.separator()
|
|
||||||
.text("quit", "Quit")
|
|
||||||
.build()?
|
|
||||||
};
|
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
|
|
||||||
let force_reload_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"force_reload",
|
|
||||||
"Force Reload",
|
|
||||||
true,
|
|
||||||
Some("CmdOrCtrl+Shift+R"),
|
|
||||||
)?;
|
|
||||||
let toggle_devtools_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"toggle_devtools",
|
|
||||||
"Toggle Developer Tools",
|
|
||||||
true,
|
|
||||||
Some("Alt+CmdOrCtrl+I"),
|
|
||||||
)?;
|
|
||||||
let reset_zoom_item =
|
|
||||||
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
|
|
||||||
let zoom_in_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"zoom_in",
|
|
||||||
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
|
|
||||||
true,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
let zoom_out_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"zoom_out",
|
|
||||||
if is_mac {
|
|
||||||
"Zoom Out"
|
|
||||||
} else {
|
|
||||||
"Zoom Out\tCtrl+-"
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
None::<&str>,
|
|
||||||
)?;
|
|
||||||
let toggle_fullscreen_item = MenuItem::with_id(
|
|
||||||
app,
|
|
||||||
"toggle_fullscreen",
|
|
||||||
if is_mac {
|
|
||||||
"Toggle Full Screen"
|
|
||||||
} else {
|
|
||||||
"Toggle Full Screen\tF11"
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
if is_mac {
|
|
||||||
Some("Ctrl+Cmd+F")
|
|
||||||
} else {
|
|
||||||
None::<&str>
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let close_window_item =
|
|
||||||
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
|
|
||||||
|
|
||||||
// Edit menu with predefined items for standard functionality
|
// Edit menu with predefined items for standard functionality
|
||||||
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||||
.undo()
|
.undo()
|
||||||
@@ -585,39 +396,20 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
|
|
||||||
// View menu
|
// View menu
|
||||||
let view_menu = SubmenuBuilder::new(app, "View")
|
let view_menu = SubmenuBuilder::new(app, "View")
|
||||||
.item(&reload_item)
|
.text("reload", "Reload")
|
||||||
.item(&force_reload_item)
|
.text("force_reload", "Force Reload")
|
||||||
.item(&toggle_devtools_item)
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
.item(&reset_zoom_item)
|
|
||||||
.item(&zoom_in_item)
|
|
||||||
.item(&zoom_out_item)
|
|
||||||
.separator()
|
.separator()
|
||||||
.item(&toggle_fullscreen_item)
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(view_menu);
|
submenus.push(view_menu);
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
let window_menu = if is_linux {
|
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||||
SubmenuBuilder::new(app, "Window")
|
.text("minimize", "Minimize")
|
||||||
.text("minimize", "Minimize")
|
.text("zoom", "Zoom")
|
||||||
.text("zoom", "Zoom")
|
.build()?;
|
||||||
.separator()
|
|
||||||
.item(&close_window_item)
|
|
||||||
.build()?
|
|
||||||
} else if is_mac {
|
|
||||||
SubmenuBuilder::new(app, "Window")
|
|
||||||
.minimize()
|
|
||||||
.maximize()
|
|
||||||
.build()?
|
|
||||||
} else {
|
|
||||||
SubmenuBuilder::new(app, "Window")
|
|
||||||
.minimize()
|
|
||||||
.maximize()
|
|
||||||
.separator()
|
|
||||||
.close_window()
|
|
||||||
.build()?
|
|
||||||
};
|
|
||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// Build the main menu with all submenus
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.1",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -45,4 +45,4 @@
|
|||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,6 @@ const App: Component = () => {
|
|||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput,
|
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -354,7 +353,6 @@ const App: Component = () => {
|
|||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput,
|
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
|
|||||||
open
|
open
|
||||||
modal
|
modal
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
// Only handle dismiss if dialog is dismissible (default: true)
|
if (!open) {
|
||||||
if (!open && payload.dismissible !== false) {
|
|
||||||
dismiss(false, payload)
|
dismiss(false, payload)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay z-[60]" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<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="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}>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||||
@@ -140,11 +140,10 @@ const AlertDialog: Component = () => {
|
|||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label for="prompt-input" class="text-sm font-medium text-secondary">
|
<label class="text-sm font-medium text-secondary">
|
||||||
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="prompt-input"
|
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
promptInputRef = el
|
promptInputRef = el
|
||||||
}}
|
}}
|
||||||
@@ -185,10 +184,11 @@ const AlertDialog: Component = () => {
|
|||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</div>
|
||||||
</Dialog>
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ interface MonacoFileViewerProps {
|
|||||||
scopeKey: string
|
scopeKey: string
|
||||||
path: string
|
path: string
|
||||||
content: string
|
content: string
|
||||||
onSave?: (content: string) => void
|
|
||||||
onContentChange?: (content: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||||
@@ -35,11 +33,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||||||
editor = null
|
editor = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveContent = () => {
|
|
||||||
if (!editor || !props.onSave) return
|
|
||||||
props.onSave(editor.getValue())
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -51,7 +44,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||||||
editor = monaco.editor.create(host, {
|
editor = monaco.editor.create(host, {
|
||||||
value: "",
|
value: "",
|
||||||
language: "plaintext",
|
language: "plaintext",
|
||||||
readOnly: false,
|
readOnly: true,
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
lineNumbers: "on",
|
lineNumbers: "on",
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
@@ -61,14 +54,6 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
})
|
})
|
||||||
|
|
||||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
|
|
||||||
|
|
||||||
editor.onDidChangeModelContent(() => {
|
|
||||||
if (props.onContentChange) {
|
|
||||||
props.onContentChange(editor.getValue())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
{ value: "ru", label: "Русский" },
|
{ value: "ru", label: "Русский" },
|
||||||
{ value: "ja", label: "日本語" },
|
{ value: "ja", label: "日本語" },
|
||||||
{ value: "zh-Hans", label: "简体中文" },
|
{ value: "zh-Hans", label: "简体中文" },
|
||||||
{ value: "he", label: "עברית" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||||
@@ -342,7 +341,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
aria-busy={isLoading() ? "true" : "false"}
|
aria-busy={isLoading() ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
<div class="absolute top-4 left-6">
|
||||||
<Select<LanguageOption>
|
<Select<LanguageOption>
|
||||||
value={selectedLanguageOption()}
|
value={selectedLanguageOption()}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -386,7 +385,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||||
dismissible: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -83,7 +82,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="panel-body space-y-3">
|
<div class="panel-body space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||||
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
{currentInstance().folder}
|
{currentInstance().folder}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +94,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
{t("instanceInfo.labels.project")}
|
{t("instanceInfo.labels.project")}
|
||||||
</div>
|
</div>
|
||||||
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||||
{project().id}
|
{project().id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +137,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
{t("instanceInfo.labels.binaryPath")}
|
{t("instanceInfo.labels.binaryPath")}
|
||||||
</div>
|
</div>
|
||||||
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
{currentInstance().binaryPath}
|
{currentInstance().binaryPath}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +151,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={environmentEntries()}>
|
<For each={environmentEntries()}>
|
||||||
{([key, value]) => (
|
{([key, value]) => (
|
||||||
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||||
{key}
|
{key}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -404,7 +404,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||||
dir="auto"
|
|
||||||
classList={{
|
classList={{
|
||||||
"text-accent": isFocused(),
|
"text-accent": isFocused(),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ interface InstanceShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
const { t, locale } = useI18n()
|
const { t } = useI18n()
|
||||||
const isRTL = () => locale() === "he"
|
|
||||||
|
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
||||||
@@ -372,7 +371,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `${sessionSidebarWidth()}px`,
|
width: `${sessionSidebarWidth()}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderInlineEnd: "1px solid var(--border-base)",
|
borderRight: "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -414,17 +413,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const modalProps = container ? { container: container as Element } : undefined
|
const modalProps = container ? { container: container as Element } : undefined
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor={isRTL() ? "right" : "left"}
|
anchor="left"
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
open={leftOpen()}
|
open={leftOpen()}
|
||||||
onClose={closeLeftDrawer}
|
onClose={closeLeftDrawer}
|
||||||
ModalProps={modalProps}
|
ModalProps={modalProps}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: 60,
|
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
backgroundImage: "none",
|
backgroundImage: "none",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
@@ -482,7 +480,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `${rightDrawerWidth()}px`,
|
width: `${rightDrawerWidth()}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderInlineStart: "1px solid var(--border-base)",
|
borderLeft: "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -525,17 +523,16 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const modalProps = container ? { container: container as Element } : undefined
|
const modalProps = container ? { container: container as Element } : undefined
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor={isRTL() ? "left" : "right"}
|
anchor="right"
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
open={rightOpen()}
|
open={rightOpen()}
|
||||||
onClose={closeRightDrawer}
|
onClose={closeRightDrawer}
|
||||||
ModalProps={modalProps}
|
ModalProps={modalProps}
|
||||||
sx={{
|
sx={{
|
||||||
zIndex: 60,
|
|
||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
backgroundImage: "none",
|
backgroundImage: "none",
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
@@ -745,7 +742,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="ms-auto flex items-center gap-3">
|
<div class="ml-auto flex items-center gap-3">
|
||||||
<div class="connection-status-meta flex items-center gap-3">
|
<div class="connection-status-meta flex items-center gap-3">
|
||||||
<Show when={connectionStatus() === "connected"}>
|
<Show when={connectionStatus() === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Show,
|
Show,
|
||||||
Suspense,
|
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
lazy,
|
|
||||||
onCleanup,
|
onCleanup,
|
||||||
type Accessor,
|
type Accessor,
|
||||||
type Component,
|
type Component,
|
||||||
@@ -22,11 +20,13 @@ import type { Session } from "../../../../types/session"
|
|||||||
import type { DrawerViewState } from "../types"
|
import type { DrawerViewState } from "../types"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||||
|
|
||||||
|
import ChangesTab from "./tabs/ChangesTab"
|
||||||
|
import FilesTab from "./tabs/FilesTab"
|
||||||
|
import GitChangesTab from "./tabs/GitChangesTab"
|
||||||
|
import StatusTab from "./tabs/StatusTab"
|
||||||
|
|
||||||
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||||
import { requestData } from "../../../../lib/opencode-api"
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
import { serverApi } from "../../../../lib/api-client"
|
|
||||||
import { showConfirmDialog } from "../../../../stores/alerts"
|
|
||||||
import { showToastNotification } from "../../../../lib/notifications"
|
|
||||||
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||||
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
import {
|
import {
|
||||||
@@ -49,15 +49,6 @@ import {
|
|||||||
readStoredRightPanelTab,
|
readStoredRightPanelTab,
|
||||||
} from "../storage"
|
} from "../storage"
|
||||||
|
|
||||||
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
|
|
||||||
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
|
|
||||||
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
|
|
||||||
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
|
|
||||||
|
|
||||||
function RightPanelTabFallback() {
|
|
||||||
return <div class="flex-1 min-h-0" />
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RightPanelProps {
|
interface RightPanelProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -105,9 +96,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||||
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||||
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||||
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
|
|
||||||
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
|
|
||||||
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||||
@@ -255,8 +243,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const mode = activeSplitResize()
|
const mode = activeSplitResize()
|
||||||
if (!mode) return
|
if (!mode) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
const delta = event.clientX - splitResizeStartX()
|
||||||
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
|
||||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
if (mode === "changes") setChangesSplitWidth(next)
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
@@ -279,8 +266,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const touch = event.touches[0]
|
const touch = event.touches[0]
|
||||||
if (!touch) return
|
if (!touch) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
const delta = touch.clientX - splitResizeStartX()
|
||||||
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
|
||||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
if (mode === "changes") setChangesSplitWidth(next)
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
@@ -545,8 +531,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
setBrowserSelectedLoading(true)
|
setBrowserSelectedLoading(true)
|
||||||
setBrowserSelectedError(null)
|
setBrowserSelectedError(null)
|
||||||
setBrowserSelectedContent(null)
|
setBrowserSelectedContent(null)
|
||||||
setBrowserSelectedDirty(false)
|
|
||||||
setBrowserSelectedOriginalContent(null)
|
|
||||||
|
|
||||||
// Phone: treat file selection as a commit action and close the overlay.
|
// Phone: treat file selection as a commit action and close the overlay.
|
||||||
if (props.isPhoneLayout()) {
|
if (props.isPhoneLayout()) {
|
||||||
@@ -567,7 +551,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
throw new Error("Unsupported file type")
|
throw new Error("Unsupported file type")
|
||||||
}
|
}
|
||||||
setBrowserSelectedContent(text)
|
setBrowserSelectedContent(text)
|
||||||
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -575,95 +558,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveBrowserFile = async (content: string): Promise<boolean> => {
|
|
||||||
const path = browserSelectedPath()
|
|
||||||
if (!path) return false
|
|
||||||
|
|
||||||
// Check for conflict: agent edited file while user was editing
|
|
||||||
const originalContent = browserSelectedOriginalContent()
|
|
||||||
if (originalContent !== null) {
|
|
||||||
try {
|
|
||||||
const currentDiskContent = await requestData<FileContent>(
|
|
||||||
browserClient().file.read({ path }),
|
|
||||||
"file.read",
|
|
||||||
)
|
|
||||||
const diskContent = (currentDiskContent as any)?.content
|
|
||||||
|
|
||||||
// If disk content differs from what we originally loaded (agent edit)
|
|
||||||
// AND differs from user's current edits, we have a conflict
|
|
||||||
if (diskContent !== originalContent && diskContent !== content) {
|
|
||||||
const confirmed = await showConfirmDialog(
|
|
||||||
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
|
|
||||||
{
|
|
||||||
variant: "warning",
|
|
||||||
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
|
|
||||||
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
|
|
||||||
dismissible: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!confirmed) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// User chose to overwrite, proceed with save
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If we can't check for conflict, proceed with save
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setBrowserSelectedSaving(true)
|
|
||||||
try {
|
|
||||||
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
|
|
||||||
setBrowserSelectedContent(content)
|
|
||||||
setBrowserSelectedOriginalContent(content) // Update original to match saved
|
|
||||||
setBrowserSelectedDirty(false)
|
|
||||||
showToastNotification({
|
|
||||||
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
|
|
||||||
variant: "success",
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
|
|
||||||
showToastNotification({
|
|
||||||
message: props.t("instanceShell.rightPanel.toast.saveError"),
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
setBrowserSelectedSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBrowserFileChange = (content: string) => {
|
|
||||||
setBrowserSelectedContent(content)
|
|
||||||
setBrowserSelectedDirty(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenBrowserFileRequest = async (path: string) => {
|
|
||||||
if (browserSelectedDirty()) {
|
|
||||||
const confirmed = await showConfirmDialog(
|
|
||||||
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
|
|
||||||
{
|
|
||||||
variant: "warning",
|
|
||||||
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
|
|
||||||
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
|
|
||||||
dismissible: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (confirmed) {
|
|
||||||
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
|
|
||||||
if (!saveSuccess) {
|
|
||||||
// Save failed - stay on current file, error toast already shown
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User chose not to save - clear dirty state and discard edits
|
|
||||||
setBrowserSelectedDirty(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await openBrowserFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (rightPanelTab() !== "files") return
|
if (rightPanelTab() !== "files") return
|
||||||
if (browserLoading()) return
|
if (browserLoading()) return
|
||||||
@@ -671,14 +565,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
void loadBrowserEntries(browserPath())
|
void loadBrowserEntries(browserPath())
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (rightPanelTab() === "files") return
|
|
||||||
setBrowserSelectedContent(null)
|
|
||||||
setBrowserSelectedLoading(false)
|
|
||||||
setBrowserSelectedError(null)
|
|
||||||
setBrowserSelectedDirty(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (rightPanelTab() !== "git-changes") return
|
if (rightPanelTab() !== "git-changes") return
|
||||||
if (gitStatusLoading()) return
|
if (gitStatusLoading()) return
|
||||||
@@ -686,14 +572,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
void loadGitStatus()
|
void loadGitStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (rightPanelTab() === "git-changes") return
|
|
||||||
setGitSelectedBefore(null)
|
|
||||||
setGitSelectedAfter(null)
|
|
||||||
setGitSelectedLoading(false)
|
|
||||||
setGitSelectedError(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
setSelectedFile(file)
|
setSelectedFile(file)
|
||||||
if (closeList) {
|
if (closeList) {
|
||||||
@@ -729,22 +607,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshFilesTab = async () => {
|
const refreshFilesTab = async () => {
|
||||||
// Prompt for confirmation if file has unsaved changes
|
|
||||||
if (browserSelectedDirty()) {
|
|
||||||
const confirmed = await showConfirmDialog(
|
|
||||||
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
|
|
||||||
{
|
|
||||||
variant: "warning",
|
|
||||||
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
|
|
||||||
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
|
|
||||||
dismissible: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadBrowserEntries(browserPath())
|
void loadBrowserEntries(browserPath())
|
||||||
const selected = browserSelectedPath()
|
const selected = browserSelectedPath()
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -766,8 +628,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
throw new Error("Unsupported file type")
|
throw new Error("Unsupported file type")
|
||||||
}
|
}
|
||||||
setBrowserSelectedContent(text)
|
setBrowserSelectedContent(text)
|
||||||
setBrowserSelectedOriginalContent(text) // Update original content after refresh
|
|
||||||
setBrowserSelectedDirty(false) // Clear dirty after refresh
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -878,113 +738,101 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<Show when={rightPanelTab() === "changes"}>
|
<Show when={rightPanelTab() === "changes"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<ChangesTab
|
||||||
<LazyChangesTab
|
t={props.t}
|
||||||
t={props.t}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionId={props.activeSessionId}
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
activeSessionDiffs={props.activeSessionDiffs}
|
selectedFile={selectedFile}
|
||||||
selectedFile={selectedFile}
|
onSelectFile={handleSelectChangesFile}
|
||||||
onSelectFile={handleSelectChangesFile}
|
diffViewMode={diffViewMode}
|
||||||
diffViewMode={diffViewMode}
|
diffContextMode={diffContextMode}
|
||||||
diffContextMode={diffContextMode}
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
diffWordWrapMode={diffWordWrapMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
listOpen={changesListOpen}
|
||||||
listOpen={changesListOpen}
|
onToggleList={toggleChangesList}
|
||||||
onToggleList={toggleChangesList}
|
splitWidth={changesSplitWidth}
|
||||||
splitWidth={changesSplitWidth}
|
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "git-changes"}>
|
<Show when={rightPanelTab() === "git-changes"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<GitChangesTab
|
||||||
<LazyGitChangesTab
|
t={props.t}
|
||||||
t={props.t}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionId={props.activeSessionId}
|
entries={gitStatusEntries}
|
||||||
entries={gitStatusEntries}
|
statusLoading={gitStatusLoading}
|
||||||
statusLoading={gitStatusLoading}
|
statusError={gitStatusError}
|
||||||
statusError={gitStatusError}
|
selectedPath={gitSelectedPath}
|
||||||
selectedPath={gitSelectedPath}
|
selectedLoading={gitSelectedLoading}
|
||||||
selectedLoading={gitSelectedLoading}
|
selectedError={gitSelectedError}
|
||||||
selectedError={gitSelectedError}
|
selectedBefore={gitSelectedBefore}
|
||||||
selectedBefore={gitSelectedBefore}
|
selectedAfter={gitSelectedAfter}
|
||||||
selectedAfter={gitSelectedAfter}
|
mostChangedPath={gitMostChangedPath}
|
||||||
mostChangedPath={gitMostChangedPath}
|
scopeKey={gitScopeKey}
|
||||||
scopeKey={gitScopeKey}
|
diffViewMode={diffViewMode}
|
||||||
diffViewMode={diffViewMode}
|
diffContextMode={diffContextMode}
|
||||||
diffContextMode={diffContextMode}
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
diffWordWrapMode={diffWordWrapMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
onOpenFile={(path) => void openGitFile(path)}
|
||||||
onOpenFile={(path: string) => void openGitFile(path)}
|
onRefresh={() => void refreshGitStatus()}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
listOpen={gitChangesListOpen}
|
||||||
listOpen={gitChangesListOpen}
|
onToggleList={toggleGitList}
|
||||||
onToggleList={toggleGitList}
|
splitWidth={gitChangesSplitWidth}
|
||||||
splitWidth={gitChangesSplitWidth}
|
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "files"}>
|
<Show when={rightPanelTab() === "files"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<FilesTab
|
||||||
<LazyFilesTab
|
t={props.t}
|
||||||
t={props.t}
|
browserPath={browserPath}
|
||||||
browserPath={browserPath}
|
browserEntries={browserEntries}
|
||||||
browserEntries={browserEntries}
|
browserLoading={browserLoading}
|
||||||
browserLoading={browserLoading}
|
browserError={browserError}
|
||||||
browserError={browserError}
|
browserSelectedPath={browserSelectedPath}
|
||||||
browserSelectedPath={browserSelectedPath}
|
browserSelectedContent={browserSelectedContent}
|
||||||
browserSelectedContent={browserSelectedContent}
|
browserSelectedLoading={browserSelectedLoading}
|
||||||
browserSelectedLoading={browserSelectedLoading}
|
browserSelectedError={browserSelectedError}
|
||||||
browserSelectedError={browserSelectedError}
|
parentPath={browserParentPath}
|
||||||
browserSelectedDirty={browserSelectedDirty}
|
scopeKey={browserScopeKey}
|
||||||
browserSelectedSaving={browserSelectedSaving}
|
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||||
parentPath={browserParentPath}
|
onOpenFile={(path) => void openBrowserFile(path)}
|
||||||
scopeKey={browserScopeKey}
|
onRefresh={() => void refreshFilesTab()}
|
||||||
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
listOpen={filesListOpen}
|
||||||
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
|
onToggleList={toggleFilesList}
|
||||||
onRefresh={() => void refreshFilesTab()}
|
splitWidth={filesSplitWidth}
|
||||||
onSave={(content: string) => void saveBrowserFile(content)}
|
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||||
onContentChange={(content: string) => handleBrowserFileChange(content)}
|
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||||
listOpen={filesListOpen}
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
onToggleList={toggleFilesList}
|
/>
|
||||||
splitWidth={filesSplitWidth}
|
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "status"}>
|
<Show when={rightPanelTab() === "status"}>
|
||||||
<Suspense fallback={<RightPanelTabFallback />}>
|
<StatusTab
|
||||||
<LazyStatusTab
|
t={props.t}
|
||||||
t={props.t}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
instance={props.instance}
|
||||||
instance={props.instance}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionId={props.activeSessionId}
|
activeSession={props.activeSession}
|
||||||
activeSession={props.activeSession}
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
activeSessionDiffs={props.activeSessionDiffs}
|
latestTodoState={props.latestTodoState}
|
||||||
latestTodoState={props.latestTodoState}
|
backgroundProcessList={props.backgroundProcessList}
|
||||||
backgroundProcessList={props.backgroundProcessList}
|
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
expandedItems={rightPanelExpandedItems}
|
||||||
expandedItems={rightPanelExpandedItems}
|
onExpandedItemsChange={handleAccordionChange}
|
||||||
onExpandedItemsChange={handleAccordionChange}
|
onOpenChangesTab={openChangesTabFromStatus}
|
||||||
onOpenChangesTab={openChangesTabFromStatus}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Component } from "solid-js"
|
|||||||
|
|
||||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||||
|
|
||||||
import { useI18n } from "../../../../../lib/i18n"
|
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
interface DiffToolbarProps {
|
interface DiffToolbarProps {
|
||||||
@@ -15,15 +14,14 @@ interface DiffToolbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||||
const { t } = useI18n()
|
|
||||||
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
||||||
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
||||||
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
||||||
|
|
||||||
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
|
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
|
||||||
const contextModeTitle = () =>
|
const contextModeTitle = () =>
|
||||||
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
|
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
||||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
|
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="file-viewer-toolbar">
|
<div class="file-viewer-toolbar">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Show, type Component, type JSX } from "solid-js"
|
import { Show, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
import { useI18n } from "../../../../../lib/i18n"
|
|
||||||
import OverlayList from "./OverlayList"
|
import OverlayList from "./OverlayList"
|
||||||
|
|
||||||
type SplitFilePanelList = {
|
type SplitFilePanelList = {
|
||||||
@@ -25,13 +24,12 @@ interface SplitFilePanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||||
const { t } = useI18n()
|
|
||||||
return (
|
return (
|
||||||
<div class="files-tab-container">
|
<div class="files-tab-container">
|
||||||
<div class="files-tab-header">
|
<div class="files-tab-header">
|
||||||
<div class="files-tab-header-row">
|
<div class="files-tab-header-row">
|
||||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||||
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
{props.listOpen ? "Hide files" : "Show files"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{props.header}
|
{props.header}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw, Save } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
|
||||||
@@ -21,17 +21,13 @@ interface FilesTabProps {
|
|||||||
browserSelectedContent: Accessor<string | null>
|
browserSelectedContent: Accessor<string | null>
|
||||||
browserSelectedLoading: Accessor<boolean>
|
browserSelectedLoading: Accessor<boolean>
|
||||||
browserSelectedError: Accessor<string | null>
|
browserSelectedError: Accessor<string | null>
|
||||||
browserSelectedDirty: Accessor<boolean>
|
|
||||||
browserSelectedSaving: Accessor<boolean>
|
|
||||||
|
|
||||||
parentPath: Accessor<string | null>
|
parentPath: Accessor<string | null>
|
||||||
scopeKey: Accessor<string>
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
onLoadEntries: (path: string) => void
|
onLoadEntries: (path: string) => void
|
||||||
onRequestOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
onSave: (content: string) => void
|
|
||||||
onContentChange: (content: string) => void
|
|
||||||
|
|
||||||
listOpen: Accessor<boolean>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -42,13 +38,6 @@ interface FilesTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FilesTab: Component<FilesTabProps> = (props) => {
|
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||||
const handleSave = () => {
|
|
||||||
const content = props.browserSelectedContent()
|
|
||||||
if (content !== undefined && content !== null) {
|
|
||||||
props.onSave(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
const entriesValue = props.browserEntries()
|
const entriesValue = props.browserEntries()
|
||||||
const entries = entriesValue || []
|
const entries = entriesValue || []
|
||||||
@@ -97,13 +86,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LazyMonacoFileViewer
|
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
scopeKey={props.scopeKey()}
|
|
||||||
path={payload().path}
|
|
||||||
content={payload().content}
|
|
||||||
onSave={props.onSave}
|
|
||||||
onContentChange={props.onContentChange}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -152,7 +135,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
props.onLoadEntries(item.path)
|
props.onLoadEntries(item.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
props.onRequestOpenFile(item.path)
|
props.onOpenFile(item.path)
|
||||||
}}
|
}}
|
||||||
title={item.path}
|
title={item.path}
|
||||||
>
|
>
|
||||||
@@ -185,25 +168,14 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="files-header-icon-button"
|
|
||||||
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
|
|
||||||
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
|
|
||||||
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
|
|
||||||
style={{ "margin-inline-start": "auto" }}
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
|
|
||||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="files-header-icon-button"
|
class="files-header-icon-button"
|
||||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
disabled={props.browserLoading()}
|
disabled={props.browserLoading()}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
onClick={() => props.onRefresh()}
|
onClick={() => props.onRefresh()}
|
||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||||
@@ -226,4 +198,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
return <>{renderContent()}</>
|
return <>{renderContent()}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilesTab
|
export default FilesTab
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
const emptyViewerMessage = createMemo(() => {
|
||||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||||
const currentEntries = entries()
|
const currentEntries = entries()
|
||||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
|||||||
if (!side) return
|
if (!side) return
|
||||||
const startWidth = resizeStartWidth()
|
const startWidth = resizeStartWidth()
|
||||||
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||||
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
|
||||||
const delta = isRtl ? -rawDelta : rawDelta
|
|
||||||
const nextWidth = clamp(startWidth + delta)
|
const nextWidth = clamp(startWidth + delta)
|
||||||
applyDrawerWidth(side, nextWidth)
|
applyDrawerWidth(side, nextWidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ interface MarkdownProps {
|
|||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
size?: "base" | "sm" | "tight"
|
size?: "base" | "sm" | "tight"
|
||||||
disableHighlight?: boolean
|
disableHighlight?: boolean
|
||||||
escapeRawHtml?: boolean
|
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +103,11 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const text = decodeHtmlEntitiesLocally(rawText)
|
const text = decodeHtmlEntitiesLocally(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
const escapeRawHtml = Boolean(props.escapeRawHtml)
|
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||||
const cacheId = resolvePartCacheId(part, text)
|
const cacheId = resolvePartCacheId(part, text)
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
|
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||||
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
|
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -118,7 +116,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { cacheId, themeKey, highlightEnabled } = resolved()
|
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
|
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
@@ -128,7 +126,7 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
text: snapshot.text,
|
text: snapshot.text,
|
||||||
html: renderedHtml,
|
html: renderedHtml,
|
||||||
theme: snapshot.themeKey,
|
theme: snapshot.themeKey,
|
||||||
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
|
mode: snapshot.version,
|
||||||
}
|
}
|
||||||
setHtml(renderedHtml)
|
setHtml(renderedHtml)
|
||||||
cacheHandle.set(cacheEntry)
|
cacheHandle.set(cacheEntry)
|
||||||
@@ -140,7 +138,6 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||||
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||||
suppressHighlight: !snapshot.highlightEnabled,
|
suppressHighlight: !snapshot.highlightEnabled,
|
||||||
escapeRawHtml: snapshot.escapeRawHtml,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (latestRequestKey === snapshot.requestKey) {
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
@@ -151,11 +148,10 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const snapshot = resolved()
|
const snapshot = resolved()
|
||||||
latestRequestKey = snapshot.requestKey
|
latestRequestKey = snapshot.requestKey
|
||||||
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
|
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === snapshot.themeKey && cache.mode === cacheMode
|
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = snapshot.part.renderCache
|
const localCache = snapshot.part.renderCache
|
||||||
@@ -248,7 +244,6 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
class="markdown-body"
|
class="markdown-body"
|
||||||
dir="auto"
|
|
||||||
data-view="markdown"
|
data-view="markdown"
|
||||||
data-part-id={resolved().partId}
|
data-part-id={resolved().partId}
|
||||||
data-markdown-theme={resolved().themeKey}
|
data-markdown-theme={resolved().themeKey}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { ClientPart, MessageInfo } from "../types/message"
|
import type { ClientPart, MessageInfo } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
@@ -14,8 +15,6 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
|
||||||
import SpeechActionButton from "./speech-action-button"
|
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -30,12 +29,6 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
|
|||||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
|
||||||
|
|
||||||
function ToolCallFallback() {
|
|
||||||
return <div class="tool-call tool-call-loading" />
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
|
||||||
@@ -507,18 +500,16 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<ToolCallFallback />}>
|
<ToolCall
|
||||||
<LazyToolCall
|
toolCall={resolvedToolPart()}
|
||||||
toolCall={resolvedToolPart()}
|
toolCallId={props.partId}
|
||||||
toolCallId={props.partId}
|
messageId={props.messageId}
|
||||||
messageId={props.messageId}
|
messageVersion={messageVersion()}
|
||||||
messageVersion={messageVersion()}
|
partVersion={partVersion()}
|
||||||
partVersion={partVersion()}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
sessionId={props.sessionId}
|
||||||
sessionId={props.sessionId}
|
onContentRendered={props.onContentRendered}
|
||||||
onContentRendered={props.onContentRendered}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -911,7 +902,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
onContentRendered={props.onContentRendered}
|
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -1290,7 +1280,6 @@ interface ReasoningCardProps {
|
|||||||
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
selectedMessageIds?: () => Set<string>
|
selectedMessageIds?: () => Set<string>
|
||||||
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
onContentRendered?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
@@ -1299,25 +1288,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
let pendingRenderNotificationFrame: number | null = null
|
|
||||||
|
|
||||||
const notifyContentRendered = () => {
|
|
||||||
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
|
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
|
||||||
}
|
|
||||||
pendingRenderNotificationFrame = requestAnimationFrame(() => {
|
|
||||||
pendingRenderNotificationFrame = null
|
|
||||||
props.onContentRendered?.()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (pendingRenderNotificationFrame !== null) {
|
|
||||||
cancelAnimationFrame(pendingRenderNotificationFrame)
|
|
||||||
pendingRenderNotificationFrame = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
@@ -1386,19 +1356,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const viewHideLabel = () =>
|
const viewHideLabel = () =>
|
||||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
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()
|
|
||||||
notifyContentRendered()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDeleteMessage = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
@@ -1471,20 +1428,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="message-reasoning-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -1554,7 +1497,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { deleteMessage } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { isTauriHost } from "../lib/runtime-env"
|
import { isTauriHost } from "../lib/runtime-env"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
|
||||||
import SpeechActionButton from "./speech-action-button"
|
|
||||||
|
|
||||||
function DeleteUpToIcon() {
|
function DeleteUpToIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -296,13 +294,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
.join("\n\n")
|
.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 handleCopy = async () => {
|
||||||
const content = getRawContent()
|
const content = getRawContent()
|
||||||
if (!content) return
|
if (!content) return
|
||||||
@@ -452,16 +443,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</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}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -522,16 +503,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</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}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -571,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
|
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
@@ -579,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={errorMessage()}>
|
<Show when={errorMessage()}>
|
||||||
<div class="message-error-block" dir="auto">⚠️ {errorMessage()}</div>
|
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={isGenerating()}>
|
<Show when={isGenerating()}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
|
import { Show, Match, Switch } from "solid-js"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
@@ -6,8 +7,6 @@ import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/m
|
|||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
|
||||||
|
|
||||||
interface MessagePartProps {
|
interface MessagePartProps {
|
||||||
part: ClientPart
|
part: ClientPart
|
||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
@@ -134,19 +133,17 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||||
<div
|
<div
|
||||||
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||||
dir="auto"
|
|
||||||
data-role={textContainerRole()}
|
data-role={textContainerRole()}
|
||||||
data-part-type="text"
|
data-part-type="text"
|
||||||
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||||
>
|
>
|
||||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
|
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||||
<Markdown
|
<Markdown
|
||||||
part={createTextPartForMarkdown()}
|
part={createTextPartForMarkdown()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isDark={isDark()}
|
isDark={isDark()}
|
||||||
size={isAssistantMessage() ? "tight" : "base"}
|
size={isAssistantMessage() ? "tight" : "base"}
|
||||||
escapeRawHtml={props.messageType === "user"}
|
|
||||||
onRendered={props.onRendered}
|
onRendered={props.onRendered}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -155,14 +152,12 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
<Match when={partType() === "tool"}>
|
<Match when={partType() === "tool"}>
|
||||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
<ToolCall
|
||||||
<LazyToolCall
|
toolCall={props.part as ToolCallPart}
|
||||||
toolCall={props.part as ToolCallPart}
|
toolCallId={props.part?.id}
|
||||||
toolCallId={props.part?.id}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
sessionId={props.sessionId}
|
||||||
sessionId={props.sessionId}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
|
|||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
|
|
||||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||||
</span>
|
</span>
|
||||||
{currentModelValue() && (
|
{currentModelValue() && (
|
||||||
<span class="selector-trigger-secondary" dir="ltr">
|
<span class="selector-trigger-secondary">
|
||||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js"
|
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
||||||
import type { PermissionRequestLike } from "../types/permission"
|
import type { PermissionRequestLike } from "../types/permission"
|
||||||
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
|
||||||
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
|
||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
} from "../stores/instances"
|
} from "../stores/instances"
|
||||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import ToolCall from "./tool-call"
|
||||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
|
||||||
|
|
||||||
interface PermissionApprovalModalProps {
|
interface PermissionApprovalModalProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -409,17 +408,15 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
<ToolCall
|
||||||
<LazyToolCall
|
toolCall={data().toolPart}
|
||||||
toolCall={data().toolPart}
|
toolCallId={data().toolPart.id}
|
||||||
toolCallId={data().toolPart.id}
|
messageId={data().messageId}
|
||||||
messageId={data().messageId}
|
messageVersion={data().messageVersion}
|
||||||
messageVersion={data().messageVersion}
|
partVersion={data().partVersion}
|
||||||
partVersion={data().partVersion}
|
instanceId={props.instanceId}
|
||||||
instanceId={props.instanceId}
|
sessionId={data().sessionId}
|
||||||
sessionId={data().sessionId}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
|
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||||
|
import UnifiedPicker from "./unified-picker"
|
||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
@@ -18,10 +19,7 @@ import { usePromptState } from "./prompt-input/usePromptState"
|
|||||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
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 log = getLogger("actions")
|
||||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
|
||||||
|
|
||||||
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
||||||
if (!text || attachments.length === 0) return []
|
if (!text || attachments.length === 0) return []
|
||||||
@@ -352,19 +350,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef?.focus()
|
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) {
|
function insertBlockContent(block: string) {
|
||||||
const textarea = textareaRef
|
const textarea = textareaRef
|
||||||
const current = prompt()
|
const current = prompt()
|
||||||
@@ -436,8 +421,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
return hasText || attachments().length > 0
|
return hasText || attachments().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const canClearPrompt = () => prompt().length > 0
|
|
||||||
|
|
||||||
const shellHint = () =>
|
const shellHint = () =>
|
||||||
mode() === "shell"
|
mode() === "shell"
|
||||||
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
||||||
@@ -467,52 +450,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shouldShowOverlay = () => prompt().length === 0
|
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()
|
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 (
|
return (
|
||||||
<div class="prompt-input-container">
|
<div class="prompt-input-container">
|
||||||
<div
|
<div
|
||||||
@@ -527,20 +467,18 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<Show when={showPicker() && instance()}>
|
<Show when={showPicker() && instance()}>
|
||||||
<Suspense fallback={null}>
|
<UnifiedPicker
|
||||||
<LazyUnifiedPicker
|
open={showPicker()}
|
||||||
open={showPicker()}
|
mode={pickerMode()}
|
||||||
mode={pickerMode()}
|
onClose={handlePickerClose}
|
||||||
onClose={handlePickerClose}
|
onSelect={handlePickerSelect}
|
||||||
onSelect={handlePickerSelect}
|
agents={instanceAgents()}
|
||||||
agents={instanceAgents()}
|
commands={getCommands(props.instanceId)}
|
||||||
commands={getCommands(props.instanceId)}
|
instanceClient={instance()!.client}
|
||||||
instanceClient={instance()!.client}
|
searchQuery={searchQuery()}
|
||||||
searchQuery={searchQuery()}
|
textareaRef={textareaRef}
|
||||||
textareaRef={textareaRef}
|
workspaceId={props.instanceId}
|
||||||
workspaceId={props.instanceId}
|
/>
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col">
|
<div class="flex flex-1 flex-col">
|
||||||
@@ -550,7 +488,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
|
||||||
dir="auto"
|
|
||||||
placeholder={getPlaceholder()}
|
placeholder={getPlaceholder()}
|
||||||
value={prompt()}
|
value={prompt()}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
@@ -566,111 +503,42 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<div class="prompt-nav-buttons">
|
<div class="prompt-nav-buttons">
|
||||||
<div class="prompt-nav-column prompt-nav-column-left">
|
<ExpandButton
|
||||||
<Show when={showVoiceInput()}>
|
expandState={expandState}
|
||||||
<button
|
onToggleExpand={handleExpandToggle}
|
||||||
type="button"
|
/>
|
||||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
<Show when={hasHistory()}>
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="prompt-clear-button"
|
class="prompt-history-button"
|
||||||
onClick={handleClearPrompt}
|
onClick={() =>
|
||||||
disabled={!canClearPrompt()}
|
selectPreviousHistory({
|
||||||
aria-label={t("promptInput.clear.ariaLabel")}
|
force: true,
|
||||||
title={t("promptInput.clear.title")}
|
isPickerOpen: showPicker(),
|
||||||
|
getTextarea: () => textareaRef,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canHistoryGoPrevious()}
|
||||||
|
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||||
>
|
>
|
||||||
<X class="h-4 w-4" aria-hidden="true" />
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div class="prompt-nav-column prompt-nav-column-right">
|
type="button"
|
||||||
<ExpandButton
|
class="prompt-history-button"
|
||||||
expandState={expandState}
|
onClick={() =>
|
||||||
onToggleExpand={handleExpandToggle}
|
selectNextHistory({
|
||||||
/>
|
force: true,
|
||||||
<Show when={hasHistory()}>
|
isPickerOpen: showPicker(),
|
||||||
<button
|
getTextarea: () => textareaRef,
|
||||||
type="button"
|
})
|
||||||
class="prompt-history-button"
|
}
|
||||||
onClick={() =>
|
disabled={!canHistoryGoNext()}
|
||||||
selectPreviousHistory({
|
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||||
force: true,
|
>
|
||||||
isPickerOpen: showPicker(),
|
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||||
getTextarea: () => textareaRef,
|
</button>
|
||||||
})
|
</Show>
|
||||||
}
|
|
||||||
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>
|
</div>
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -98,7 +98,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
dismissible: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("sessionList.delete.confirmLabel"),
|
confirmLabel: t("sessionList.delete.confirmLabel"),
|
||||||
cancelLabel: t("sessionList.delete.cancelLabel"),
|
cancelLabel: t("sessionList.delete.cancelLabel"),
|
||||||
dismissible: false,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -286,7 +285,6 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
|
||||||
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
|
||||||
dismissible: false,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -446,7 +444,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
|
||||||
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
|
<span class="session-item-title session-item-title--clamp">{title()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-row session-item-meta">
|
<div class="session-item-row session-item-meta">
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
|||||||
inputRef = element
|
inputRef = element
|
||||||
}}
|
}}
|
||||||
type="text"
|
type="text"
|
||||||
dir="auto"
|
|
||||||
value={title()}
|
value={title()}
|
||||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||||
placeholder={t("sessionRenameDialog.input.placeholder")}
|
placeholder={t("sessionRenameDialog.input.placeholder")}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { getLogger } from "../../lib/logger"
|
|||||||
import { requestData } from "../../lib/opencode-api"
|
import { requestData } from "../../lib/opencode-api"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
||||||
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
|
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -89,10 +88,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
on(
|
on(
|
||||||
() => props.isActive,
|
() => props.isActive,
|
||||||
(isActive) => {
|
(isActive) => {
|
||||||
if (!isActive) {
|
|
||||||
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
|
|
||||||
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
|
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
|
||||||
import { createMemo, For, type Component } from "solid-js"
|
import { createMemo, For, type Component } from "solid-js"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,6 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
|
|||||||
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
||||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
|
||||||
|
|
||||||
export const SettingsScreen: Component = () => {
|
export const SettingsScreen: Component = () => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -22,7 +21,6 @@ export const SettingsScreen: Component = () => {
|
|||||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
{ 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") },
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -32,8 +30,6 @@ export const SettingsScreen: Component = () => {
|
|||||||
return <NotificationsSettingsSection />
|
return <NotificationsSettingsSection />
|
||||||
case "remote":
|
case "remote":
|
||||||
return <RemoteAccessSettingsSection />
|
return <RemoteAccessSettingsSection />
|
||||||
case "speech":
|
|
||||||
return <SpeechSettingsSection />
|
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return <OpenCodeSettingsSection />
|
return <OpenCodeSettingsSection />
|
||||||
case "appearance":
|
case "appearance":
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export const AppearanceSettingsSection: Component = () => {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput,
|
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -39,11 +38,10 @@ export const AppearanceSettingsSection: Component = () => {
|
|||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput,
|
setDiffViewMode,
|
||||||
setDiffViewMode,
|
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
variant: "warning",
|
variant: "warning",
|
||||||
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
dismissible: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|||||||
@@ -1,373 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { Component } from "solid-js"
|
|
||||||
import SpeechSettingsCard from "./speech-settings-card"
|
|
||||||
|
|
||||||
export const SpeechSettingsSection: Component = () => {
|
|
||||||
return (
|
|
||||||
<div class="settings-section-stack">
|
|
||||||
<SpeechSettingsCard />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,6 @@ import type {
|
|||||||
ToolScrollHelpers,
|
ToolScrollHelpers,
|
||||||
} from "./tool-call/types"
|
} from "./tool-call/types"
|
||||||
import {
|
import {
|
||||||
buildToolSpeechText,
|
|
||||||
ensureMarkdownContent,
|
ensureMarkdownContent,
|
||||||
getRelativePath,
|
getRelativePath,
|
||||||
getToolIcon,
|
getToolIcon,
|
||||||
@@ -42,8 +41,6 @@ import {
|
|||||||
} from "./tool-call/utils"
|
} from "./tool-call/utils"
|
||||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useSpeech } from "../lib/hooks/use-speech"
|
|
||||||
import SpeechActionButton from "./speech-action-button"
|
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -517,7 +514,6 @@ function ToolCallDetails(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { renderDiffContent } = createDiffContentRenderer({
|
const { renderDiffContent } = createDiffContentRenderer({
|
||||||
toolState: props.toolState,
|
|
||||||
preferences: props.preferences,
|
preferences: props.preferences,
|
||||||
setDiffViewMode: props.setDiffViewMode,
|
setDiffViewMode: props.setDiffViewMode,
|
||||||
isDark: props.isDark,
|
isDark: props.isDark,
|
||||||
@@ -963,21 +959,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return renderToolTitle()
|
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) => {
|
const handleCopyHeader = async (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -1041,16 +1022,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
<Copy class="w-3.5 h-3.5" />
|
<Copy class="w-3.5 h-3.5" />
|
||||||
</button>
|
</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">
|
<span class="tool-call-header-status" aria-hidden="true">
|
||||||
{statusIcon()}
|
{statusIcon()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -20,14 +20,6 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||||
let runningAnsiSource = ""
|
let runningAnsiSource = ""
|
||||||
|
|
||||||
const registerTracked = (element: HTMLDivElement | null) => {
|
|
||||||
params.scrollHelpers.registerContainer(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
const registerUntracked = (element: HTMLDivElement | null) => {
|
|
||||||
params.scrollHelpers.registerContainer(element, { disableTracking: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMode = () => {
|
const getMode = () => {
|
||||||
const version = params.partVersion?.()
|
const version = params.partVersion?.()
|
||||||
return typeof version === "number" ? String(version) : undefined
|
return typeof version === "number" ? String(version) : undefined
|
||||||
@@ -44,8 +36,6 @@ export function createAnsiContentRenderer(params: {
|
|||||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||||
const mode = getMode()
|
const mode = getMode()
|
||||||
const isRunningVariant = options.variant === "running"
|
const isRunningVariant = options.variant === "running"
|
||||||
const disableScrollTracking = !isRunningVariant
|
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
|
||||||
|
|
||||||
let nextCache: AnsiRenderCache
|
let nextCache: AnsiRenderCache
|
||||||
|
|
||||||
@@ -97,9 +87,9 @@ export function createAnsiContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
|
||||||
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
|
|||||||
{entry.displayPath}
|
{entry.displayPath}
|
||||||
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
|
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import type { DiffViewMode } from "../../stores/preferences"
|
import type { DiffViewMode } from "../../stores/preferences"
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
@@ -32,7 +31,6 @@ type DiffPrefs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDiffContentRenderer(params: {
|
export function createDiffContentRenderer(params: {
|
||||||
toolState: Accessor<ToolState | undefined>
|
|
||||||
preferences: Accessor<DiffPrefs>
|
preferences: Accessor<DiffPrefs>
|
||||||
setDiffViewMode: (mode: DiffViewMode) => void
|
setDiffViewMode: (mode: DiffViewMode) => void
|
||||||
isDark: Accessor<boolean>
|
isDark: Accessor<boolean>
|
||||||
@@ -60,10 +58,7 @@ export function createDiffContentRenderer(params: {
|
|||||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||||
const themeKey = params.isDark() ? "dark" : "light"
|
const themeKey = params.isDark() ? "dark" : "light"
|
||||||
const state = params.toolState()
|
const disableScrollTracking = Boolean(options?.disableScrollTracking)
|
||||||
const disableScrollTracking = Boolean(
|
|
||||||
options?.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending"),
|
|
||||||
)
|
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
|
||||||
const baseEntryParams = cacheHandle.params() as any
|
const baseEntryParams = cacheHandle.params() as any
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
const size = options.size || "default"
|
const size = options.size || "default"
|
||||||
const disableHighlight = options.disableHighlight || false
|
const disableHighlight = options.disableHighlight || false
|
||||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||||
const state = params.toolState()
|
const disableScrollTracking = options.disableScrollTracking || false
|
||||||
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
|
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
|
|
||||||
|
const state = params.toolState()
|
||||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||||
if (shouldDeferMarkdown) {
|
if (shouldDeferMarkdown) {
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +43,7 @@ export function createMarkdownContentRenderer(params: {
|
|||||||
ref={registerRef}
|
ref={registerRef}
|
||||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||||
>
|
>
|
||||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
|
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -231,37 +231,3 @@ export function getDefaultToolAction(toolName: string) {
|
|||||||
return tGlobal("toolCall.renderer.action.working")
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
setWorktreeSlugForParentSession,
|
setWorktreeSlugForParentSession,
|
||||||
} from "../stores/worktrees"
|
} from "../stores/worktrees"
|
||||||
import { sessions } from "../stores/sessions"
|
import { sessions } from "../stores/sessions"
|
||||||
import { useI18n } from "../lib/i18n"
|
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -26,6 +25,8 @@ type WorktreeOption =
|
|||||||
| { kind: "action"; key: "__create__"; label: string }
|
| { kind: "action"; key: "__create__"; label: string }
|
||||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||||
|
|
||||||
|
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
|
||||||
|
|
||||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||||
// Prevent Select.Item from treating this as a selection.
|
// Prevent Select.Item from treating this as a selection.
|
||||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||||
@@ -70,7 +71,6 @@ interface WorktreeSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||||
const { t } = useI18n()
|
|
||||||
const [isOpen, setIsOpen] = createSignal(false)
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
const [createOpen, setCreateOpen] = createSignal(false)
|
const [createOpen, setCreateOpen] = createSignal(false)
|
||||||
const [createSlug, setCreateSlug] = createSignal("")
|
const [createSlug, setCreateSlug] = createSignal("")
|
||||||
@@ -99,8 +99,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
directory: wt.directory,
|
directory: wt.directory,
|
||||||
raw: wt,
|
raw: wt,
|
||||||
}))
|
}))
|
||||||
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
|
return [CREATE_OPTION, ...mapped]
|
||||||
return [createOption, ...mapped]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import type {
|
|||||||
FileSystemCreateFolderResponse,
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
SpeechCapabilitiesResponse,
|
|
||||||
SpeechSynthesisResponse,
|
|
||||||
SpeechTranscriptionResponse,
|
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
VoiceModeStateResponse,
|
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
WorkspaceFileResponse,
|
WorkspaceFileResponse,
|
||||||
@@ -124,28 +120,6 @@ 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 = {
|
export const serverApi = {
|
||||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||||
@@ -235,16 +209,6 @@ export const serverApi = {
|
|||||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
|
|
||||||
const params = new URLSearchParams({ path: relativePath })
|
|
||||||
return request(
|
|
||||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ contents }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||||
@@ -271,37 +235,6 @@ export const serverApi = {
|
|||||||
body: JSON.stringify({ path }),
|
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> {
|
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (path && path !== ".") {
|
if (path && path !== ".") {
|
||||||
@@ -349,12 +282,6 @@ export const serverApi = {
|
|||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
|
|
||||||
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ enabled }),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fetchBackgroundProcessOutput(
|
fetchBackgroundProcessOutput(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
processId: string,
|
processId: string,
|
||||||
|
|||||||
200
packages/ui/src/lib/git-diff-lowlight.ts
Normal file
200
packages/ui/src/lib/git-diff-lowlight.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { createLowlight, common } from "lowlight"
|
||||||
|
|
||||||
|
type AstNode = {
|
||||||
|
type: string
|
||||||
|
value?: string
|
||||||
|
children?: AstNode[]
|
||||||
|
startIndex?: number
|
||||||
|
endIndex?: number
|
||||||
|
lineNumber?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyntaxNodeEntry = {
|
||||||
|
node: AstNode
|
||||||
|
wrapper?: AstNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyntaxFileLine = {
|
||||||
|
value: string
|
||||||
|
lineNumber: number
|
||||||
|
valueLength: number
|
||||||
|
nodeList: SyntaxNodeEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type LowlightApi = ReturnType<typeof createLowlight>
|
||||||
|
|
||||||
|
export function processAST(ast: { children: AstNode[] }) {
|
||||||
|
let lineNumber = 1
|
||||||
|
const syntaxObj: Record<number, SyntaxFileLine> = {}
|
||||||
|
|
||||||
|
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
const textValue = node.value ?? ""
|
||||||
|
if (!textValue.includes("\n")) {
|
||||||
|
const valueLength = textValue.length
|
||||||
|
if (!syntaxObj[lineNumber]) {
|
||||||
|
node.startIndex = 0
|
||||||
|
node.endIndex = valueLength - 1
|
||||||
|
syntaxObj[lineNumber] = {
|
||||||
|
value: textValue,
|
||||||
|
lineNumber,
|
||||||
|
valueLength,
|
||||||
|
nodeList: [{ node, wrapper }],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.startIndex = syntaxObj[lineNumber].valueLength
|
||||||
|
node.endIndex = node.startIndex + valueLength - 1
|
||||||
|
syntaxObj[lineNumber].value += textValue
|
||||||
|
syntaxObj[lineNumber].valueLength += valueLength
|
||||||
|
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
|
||||||
|
}
|
||||||
|
node.lineNumber = lineNumber
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = textValue.split("\n")
|
||||||
|
node.children = node.children || []
|
||||||
|
for (let index = 0; index < lines.length; index++) {
|
||||||
|
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
|
||||||
|
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
|
||||||
|
const valueLength = value.length
|
||||||
|
const childNode: AstNode = {
|
||||||
|
type: "text",
|
||||||
|
value,
|
||||||
|
startIndex: Infinity,
|
||||||
|
endIndex: Infinity,
|
||||||
|
lineNumber: currentLineNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!syntaxObj[currentLineNumber]) {
|
||||||
|
childNode.startIndex = 0
|
||||||
|
childNode.endIndex = valueLength - 1
|
||||||
|
syntaxObj[currentLineNumber] = {
|
||||||
|
value,
|
||||||
|
lineNumber: currentLineNumber,
|
||||||
|
valueLength,
|
||||||
|
nodeList: [{ node: childNode, wrapper }],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
|
||||||
|
childNode.endIndex = childNode.startIndex + valueLength - 1
|
||||||
|
syntaxObj[currentLineNumber].value += value
|
||||||
|
syntaxObj[currentLineNumber].valueLength += valueLength
|
||||||
|
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children.push(childNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.lineNumber = lineNumber
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
loopAST(node.children, node)
|
||||||
|
node.lineNumber = lineNumber
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loopAST(ast.children)
|
||||||
|
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _getAST() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common)
|
||||||
|
|
||||||
|
lowlight.register("vue", function hljsDefineVue(hljs: any) {
|
||||||
|
return {
|
||||||
|
subLanguage: "xml",
|
||||||
|
contains: [
|
||||||
|
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
|
||||||
|
{
|
||||||
|
begin: /^(\s*)(<script>)/gm,
|
||||||
|
end: /^(\s*)(<\/script>)/gm,
|
||||||
|
subLanguage: "javascript",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
|
||||||
|
end: /^(\s*)(<\/script>)/gm,
|
||||||
|
subLanguage: "typescript",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
|
||||||
|
end: /^(\s*)(<\/style>)/gm,
|
||||||
|
subLanguage: "css",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
|
||||||
|
end: /^(\s*)(<\/style>)/gm,
|
||||||
|
subLanguage: "scss",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
|
||||||
|
end: /^(\s*)(<\/style>)/gm,
|
||||||
|
subLanguage: "stylus",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let maxLineToIgnoreSyntax = 2000
|
||||||
|
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
|
||||||
|
|
||||||
|
export const highlighter = {
|
||||||
|
name: "lowlight",
|
||||||
|
get maxLineToIgnoreSyntax() {
|
||||||
|
return maxLineToIgnoreSyntax
|
||||||
|
},
|
||||||
|
setMaxLineToIgnoreSyntax(value: number) {
|
||||||
|
maxLineToIgnoreSyntax = value
|
||||||
|
},
|
||||||
|
get ignoreSyntaxHighlightList() {
|
||||||
|
return ignoreSyntaxHighlightList
|
||||||
|
},
|
||||||
|
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
|
||||||
|
ignoreSyntaxHighlightList.length = 0
|
||||||
|
ignoreSyntaxHighlightList.push(...values)
|
||||||
|
},
|
||||||
|
getAST(raw: string, fileName?: string, lang?: string) {
|
||||||
|
const language = typeof lang === "string" ? lang.trim() : ""
|
||||||
|
if (
|
||||||
|
fileName &&
|
||||||
|
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language && lowlight.registered(language)) {
|
||||||
|
return lowlight.highlight(language, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lowlight.highlightAuto(raw)
|
||||||
|
},
|
||||||
|
processAST(ast: { children: AstNode[] }) {
|
||||||
|
return processAST(ast)
|
||||||
|
},
|
||||||
|
hasRegisteredCurrentLang(lang: string) {
|
||||||
|
return lowlight.registered(lang)
|
||||||
|
},
|
||||||
|
getHighlighterEngine(): LowlightApi {
|
||||||
|
return lowlight
|
||||||
|
},
|
||||||
|
type: "class" as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const versions = "local-common"
|
||||||
@@ -34,7 +34,6 @@ export interface UseCommandsOptions {
|
|||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
togglePromptSubmitOnEnter: () => void
|
togglePromptSubmitOnEnter: () => void
|
||||||
toggleShowPromptVoiceInput: () => void
|
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
@@ -436,7 +435,6 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
toggleUsageMetrics: options.toggleUsageMetrics,
|
toggleUsageMetrics: options.toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
||||||
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
|
|
||||||
setDiffViewMode: options.setDiffViewMode,
|
setDiffViewMode: options.setDiffViewMode,
|
||||||
setToolOutputExpansion: options.setToolOutputExpansion,
|
setToolOutputExpansion: options.setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -1,416 +0,0 @@
|
|||||||
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" }))
|
|
||||||
}
|
|
||||||
@@ -2,32 +2,27 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
|
|||||||
import type { ParentComponent } from "solid-js"
|
import type { ParentComponent } from "solid-js"
|
||||||
import { useConfig } from "../../stores/preferences"
|
import { useConfig } from "../../stores/preferences"
|
||||||
import { enMessages } from "./messages/en"
|
import { enMessages } from "./messages/en"
|
||||||
|
import { esMessages } from "./messages/es"
|
||||||
|
import { frMessages } from "./messages/fr"
|
||||||
|
import { ruMessages } from "./messages/ru"
|
||||||
|
import { jaMessages } from "./messages/ja"
|
||||||
|
import { zhHansMessages } from "./messages/zh-Hans"
|
||||||
|
|
||||||
type Messages = Record<string, string>
|
type Messages = Record<string, string>
|
||||||
|
|
||||||
export type TranslateParams = Record<string, unknown>
|
export type TranslateParams = Record<string, unknown>
|
||||||
|
|
||||||
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" | "he"
|
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
|
||||||
|
|
||||||
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans", "he"] as const
|
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
|
||||||
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
|
||||||
const RTL_LOCALES = new Set<Locale>(["he"])
|
|
||||||
|
|
||||||
const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
|
const messagesByLocale: Record<Locale, Messages> = {
|
||||||
const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
|
en: enMessages,
|
||||||
|
es: esMessages,
|
||||||
const localeLoaders: Record<Locale, () => Promise<Messages>> = {
|
fr: frMessages,
|
||||||
en: async () => enMessages,
|
ru: ruMessages,
|
||||||
es: async () => (await import("./messages/es")).esMessages,
|
ja: jaMessages,
|
||||||
fr: async () => (await import("./messages/fr")).frMessages,
|
"zh-Hans": zhHansMessages,
|
||||||
ru: async () => (await import("./messages/ru")).ruMessages,
|
|
||||||
ja: async () => (await import("./messages/ja")).jaMessages,
|
|
||||||
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
|
|
||||||
he: async () => (await import("./messages/he")).heMessages,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocaleDirection(locale: Locale): "ltr" | "rtl" {
|
|
||||||
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocaleTag(value: string): string {
|
function normalizeLocaleTag(value: string): string {
|
||||||
@@ -39,7 +34,8 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
|||||||
|
|
||||||
const normalized = normalizeLocaleTag(value)
|
const normalized = normalizeLocaleTag(value)
|
||||||
const lower = normalized.toLowerCase()
|
const lower = normalized.toLowerCase()
|
||||||
const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
|
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||||
|
const exact = supportedLower.get(lower)
|
||||||
if (exact) return exact
|
if (exact) return exact
|
||||||
|
|
||||||
const parts = lower.split("-")
|
const parts = lower.split("-")
|
||||||
@@ -47,11 +43,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
|||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
if (base === "zh") {
|
if (base === "zh") {
|
||||||
const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
|
const zhHans = supportedLower.get("zh-hans")
|
||||||
return zhHans ?? null
|
return zhHans ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
|
const baseMatch = supportedLower.get(base)
|
||||||
return baseMatch ?? null
|
return baseMatch ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,54 +84,8 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [globalRevision, setGlobalRevision] = createSignal(0)
|
const [globalRevision, setGlobalRevision] = createSignal(0)
|
||||||
let globalMessages: Messages = enMessages
|
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
|
||||||
let globalLocale: Locale = "en"
|
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
|
||||||
|
|
||||||
function getMessagesForLocale(locale: Locale): Messages {
|
|
||||||
return localeMessagesCache.get(locale) ?? enMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLocaleMessages(locale: Locale): Promise<Messages> {
|
|
||||||
const cached = localeMessagesCache.get(locale)
|
|
||||||
if (cached) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = localeMessagesPromises.get(locale)
|
|
||||||
if (pending) {
|
|
||||||
return pending
|
|
||||||
}
|
|
||||||
|
|
||||||
const loader = localeLoaders[locale]
|
|
||||||
const promise = loader()
|
|
||||||
.then((messages) => {
|
|
||||||
localeMessagesCache.set(locale, messages)
|
|
||||||
localeMessagesPromises.delete(locale)
|
|
||||||
return messages
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
localeMessagesPromises.delete(locale)
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
|
|
||||||
localeMessagesPromises.set(locale, promise)
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function preloadLocaleMessages(preferredLocale?: string | null): Promise<Locale> {
|
|
||||||
const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en"
|
|
||||||
try {
|
|
||||||
globalMessages = await loadLocaleMessages(resolvedLocale)
|
|
||||||
globalLocale = resolvedLocale
|
|
||||||
setGlobalRevision((value) => value + 1)
|
|
||||||
return resolvedLocale
|
|
||||||
} catch {
|
|
||||||
globalMessages = enMessages
|
|
||||||
globalLocale = "en"
|
|
||||||
setGlobalRevision((value) => value + 1)
|
|
||||||
return "en"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tGlobal(key: string, params?: TranslateParams): string {
|
export function tGlobal(key: string, params?: TranslateParams): string {
|
||||||
globalRevision()
|
globalRevision()
|
||||||
@@ -151,12 +101,9 @@ const I18nContext = createContext<I18nContextValue>()
|
|||||||
|
|
||||||
export const I18nProvider: ParentComponent = (props) => {
|
export const I18nProvider: ParentComponent = (props) => {
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
|
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
|
||||||
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
|
|
||||||
const previousGlobalMessages = globalMessages
|
const previousMessages = globalMessages
|
||||||
const previousGlobalLocale = globalLocale
|
|
||||||
const previousDocumentLanguage = typeof document !== "undefined" ? document.documentElement.lang : ""
|
|
||||||
const previousDocumentDirection = typeof document !== "undefined" ? document.documentElement.dir : ""
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const detected = detectNavigatorLocale()
|
const detected = detectNavigatorLocale()
|
||||||
@@ -168,56 +115,20 @@ export const I18nProvider: ParentComponent = (props) => {
|
|||||||
return configured ?? detectedLocale() ?? "en"
|
return configured ?? detectedLocale() ?? "en"
|
||||||
})
|
})
|
||||||
|
|
||||||
const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
|
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
||||||
|
|
||||||
function t(key: string, params?: TranslateParams): string {
|
function t(key: string, params?: TranslateParams): string {
|
||||||
return translateFrom(messages(), key, params)
|
return translateFrom(messages(), key, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const nextLocale = locale()
|
globalMessages = messages()
|
||||||
let cancelled = false
|
setGlobalRevision((value) => value + 1)
|
||||||
|
|
||||||
void loadLocaleMessages(nextLocale)
|
|
||||||
.then((loadedMessages) => {
|
|
||||||
if (cancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setResolvedLocale(nextLocale)
|
|
||||||
globalLocale = nextLocale
|
|
||||||
globalMessages = loadedMessages
|
|
||||||
setGlobalRevision((value) => value + 1)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setResolvedLocale("en")
|
|
||||||
globalMessages = enMessages
|
|
||||||
globalLocale = "en"
|
|
||||||
setGlobalRevision((value) => value + 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
cancelled = true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (typeof document === "undefined") return
|
|
||||||
const activeLocale = locale()
|
|
||||||
document.documentElement.dir = getLocaleDirection(activeLocale)
|
|
||||||
document.documentElement.lang = activeLocale
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
globalMessages = previousGlobalMessages
|
globalMessages = previousMessages
|
||||||
globalLocale = previousGlobalLocale
|
|
||||||
setGlobalRevision((value) => value + 1)
|
setGlobalRevision((value) => value + 1)
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.documentElement.lang = previousDocumentLanguage
|
|
||||||
document.documentElement.dir = previousDocumentDirection
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const value: I18nContextValue = {
|
const value: I18nContextValue = {
|
||||||
|
|||||||
@@ -95,18 +95,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.status": "Status",
|
"instanceShell.rightPanel.tabs.status": "Status",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
|
||||||
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
"instanceShell.rightPanel.actions.refresh": "Refresh",
|
||||||
"instanceShell.rightPanel.actions.save": "Save (Ctrl+S)",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Save",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Discard Changes",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.message": "File was modified by the agent. Overwrite agent's changes?",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Overwrite",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancel",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.message": "File has unsaved changes. Refresh will discard your edits. Continue?",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Refresh",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
|
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
|
|
||||||
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
|
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
@@ -126,7 +114,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||||
"instanceShell.sessionChanges.actions.show": "Show changes",
|
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||||
|
|
||||||
"instanceShell.gitChanges.noSessionSelected": "Select a session to view git changes.",
|
|
||||||
"instanceShell.gitChanges.loading": "Loading git changes...",
|
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||||
"instanceShell.gitChanges.empty": "No git changes yet.",
|
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||||
"instanceShell.gitChanges.deleted": "Deleted",
|
"instanceShell.gitChanges.deleted": "Deleted",
|
||||||
@@ -137,15 +124,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.filesShell.viewerTitle": "Change viewer",
|
"instanceShell.filesShell.viewerTitle": "Change viewer",
|
||||||
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
|
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
|
||||||
"instanceShell.filesShell.viewerEmpty": "No file selected.",
|
"instanceShell.filesShell.viewerEmpty": "No file selected.",
|
||||||
"instanceShell.filesShell.hideFiles": "Hide files",
|
|
||||||
"instanceShell.filesShell.showFiles": "Show files",
|
|
||||||
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
|
|
||||||
"instanceShell.diff.showFull": "Show full file",
|
|
||||||
"instanceShell.diff.switchToSplit": "Switch to split view",
|
|
||||||
"instanceShell.diff.switchToUnified": "Switch to unified view",
|
|
||||||
"instanceShell.diff.enableWordWrap": "Enable word wrap",
|
|
||||||
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
|
||||||
"instanceShell.worktree.create": "+ Create worktree",
|
|
||||||
|
|
||||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||||
|
|||||||
@@ -75,13 +75,6 @@ export const messagingMessages = {
|
|||||||
"messageItem.actions.copy": "Copy",
|
"messageItem.actions.copy": "Copy",
|
||||||
"messageItem.actions.copyTitle": "Copy message",
|
"messageItem.actions.copyTitle": "Copy message",
|
||||||
"messageItem.actions.copied": "Copied!",
|
"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.deleteMessage": "Delete message (doesn't undo changes)",
|
||||||
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
|
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
|
||||||
"messageItem.actions.deletingMessage": "Deleting...",
|
"messageItem.actions.deletingMessage": "Deleting...",
|
||||||
@@ -142,21 +135,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.overlay.againToAbort": "again to abort session",
|
"promptInput.overlay.againToAbort": "again to abort session",
|
||||||
"promptInput.stopSession.ariaLabel": "Stop session",
|
"promptInput.stopSession.ariaLabel": "Stop session",
|
||||||
"promptInput.stopSession.title": "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.ariaLabel": "Send message",
|
||||||
"promptInput.send.errorFallback": "Failed to send message",
|
"promptInput.send.errorFallback": "Failed to send message",
|
||||||
"promptInput.send.errorTitle": "Send failed",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
"settings.nav.speech": "Speech",
|
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -138,52 +137,6 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
|
"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.title": "Auto-cleanup blank sessions",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
"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.title": "Enter to submit",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -94,19 +94,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||||
"instanceShell.rightPanel.actions.refresh": "Actualizar",
|
|
||||||
"instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Guardar",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Descartar cambios",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.message": "El archivo fue modificado por el agente. ¿Sobrescribir los cambios del agente?",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Sobrescribir",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancelar",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.message": "El archivo tiene cambios sin guardar. Actualizar discardará tus ediciones. ¿Continuar?",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualizar",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
|
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
|
|
||||||
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
|
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
|
|||||||
@@ -77,13 +77,6 @@ export const messagingMessages = {
|
|||||||
"messageItem.actions.copy": "Copiar",
|
"messageItem.actions.copy": "Copiar",
|
||||||
"messageItem.actions.copyTitle": "Copiar mensaje",
|
"messageItem.actions.copyTitle": "Copiar mensaje",
|
||||||
"messageItem.actions.copied": "¡Copiado!",
|
"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.deleteMessage": "Eliminar mensaje (no deshace cambios)",
|
||||||
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
|
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
|
||||||
"messageItem.actions.deletingMessage": "Eliminando...",
|
"messageItem.actions.deletingMessage": "Eliminando...",
|
||||||
@@ -144,21 +137,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.overlay.againToAbort": "otra vez para abortar la sesión",
|
"promptInput.overlay.againToAbort": "otra vez para abortar la sesión",
|
||||||
"promptInput.stopSession.ariaLabel": "Detener sesión",
|
"promptInput.stopSession.ariaLabel": "Detener sesión",
|
||||||
"promptInput.stopSession.title": "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.ariaLabel": "Enviar mensaje",
|
||||||
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
||||||
"promptInput.send.errorTitle": "Error al enviar",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
"settings.nav.speech": "Speech",
|
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -138,52 +137,6 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
|
"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.title": "Limpieza automatica de sesiones en blanco",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
|
"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.title": "Enter para enviar",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -94,19 +94,6 @@ export const instanceMessages = {
|
|||||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||||
"instanceShell.rightPanel.actions.refresh": "Actualiser",
|
|
||||||
"instanceShell.rightPanel.actions.save": "Enregistrer (Ctrl+S)",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.message": "Voulez-vous enregistrer les modifications de \"{path}\" avant de changer ?",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Enregistrer",
|
|
||||||
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Annuler les modifications",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.message": "Le fichier a été modifié par l'agent. Écraser les modifications de l'agent ?",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Écraser",
|
|
||||||
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Annuler",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.message": "Le fichier a des modifications non enregistrées. Actualiser supprimera vos modifications. Continuer ?",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualiser",
|
|
||||||
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
|
|
||||||
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
|
|
||||||
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
|
|
||||||
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
|
||||||
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
|
||||||
"instanceShell.rightPanel.sections.plan": "Plan",
|
"instanceShell.rightPanel.sections.plan": "Plan",
|
||||||
|
|||||||
@@ -77,13 +77,6 @@ export const messagingMessages = {
|
|||||||
"messageItem.actions.copy": "Copier",
|
"messageItem.actions.copy": "Copier",
|
||||||
"messageItem.actions.copyTitle": "Copier le message",
|
"messageItem.actions.copyTitle": "Copier le message",
|
||||||
"messageItem.actions.copied": "Copié !",
|
"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.deleteMessage": "Supprimer le message (sans annuler les changements)",
|
||||||
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
|
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
|
||||||
"messageItem.actions.deletingMessage": "Suppression...",
|
"messageItem.actions.deletingMessage": "Suppression...",
|
||||||
@@ -144,21 +137,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.overlay.againToAbort": "à nouveau pour interrompre la session",
|
"promptInput.overlay.againToAbort": "à nouveau pour interrompre la session",
|
||||||
"promptInput.stopSession.ariaLabel": "Arrêter la session",
|
"promptInput.stopSession.ariaLabel": "Arrêter la session",
|
||||||
"promptInput.stopSession.title": "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.ariaLabel": "Envoyer le message",
|
||||||
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
||||||
"promptInput.send.errorTitle": "Échec de l'envoi",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export const settingsMessages = {
|
|||||||
"settings.nav.appearance": "Appearance",
|
"settings.nav.appearance": "Appearance",
|
||||||
"settings.nav.notifications": "Notifications",
|
"settings.nav.notifications": "Notifications",
|
||||||
"settings.nav.remote": "Remote Access",
|
"settings.nav.remote": "Remote Access",
|
||||||
"settings.nav.speech": "Speech",
|
|
||||||
"settings.nav.opencode": "OpenCode",
|
"settings.nav.opencode": "OpenCode",
|
||||||
"settings.scope.device": "This device",
|
"settings.scope.device": "This device",
|
||||||
"settings.scope.server": "Server setting",
|
"settings.scope.server": "Server setting",
|
||||||
@@ -138,52 +137,6 @@ 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.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.title": "Nettoyage auto des sessions vides",
|
||||||
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
"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.title": "Entrer pour envoyer",
|
||||||
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
|
"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
|
} as const
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export const advancedSettingsMessages = {
|
|
||||||
"advancedSettings.title": "הגדרות מתקדמות",
|
|
||||||
"advancedSettings.environmentVariables.title": "משתני סביבה",
|
|
||||||
"advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל",
|
|
||||||
"advancedSettings.actions.close": "סגור",
|
|
||||||
} as const
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
export const appMessages = {
|
|
||||||
"app.launchError.title": "לא ניתן להפעיל את OpenCode",
|
|
||||||
"app.launchError.description": "לא הצלחנו להפעיל את קובץ ה-OpenCode שנבחר. בדוק את פלט השגיאה למטה או בחר קובץ בינארי אחר מהגדרות OpenCode.",
|
|
||||||
"app.launchError.binaryPathLabel": "נתיב הקובץ הבינארי",
|
|
||||||
"app.launchError.errorOutputLabel": "פלט שגיאה",
|
|
||||||
"app.launchError.openAdvancedSettings": "פתח הגדרות OpenCode",
|
|
||||||
"app.launchError.close": "סגור",
|
|
||||||
"app.launchError.closeTitle": "סגור (Esc)",
|
|
||||||
"app.launchError.fallbackMessage": "הפעלת סביבת העבודה נכשלה",
|
|
||||||
|
|
||||||
"app.stopInstance.confirmMessage": "לעצור את מופע OpenCode? פעולה זו תעצור את השרת.",
|
|
||||||
"app.stopInstance.title": "עצור מופע",
|
|
||||||
"app.stopInstance.confirmLabel": "עצור",
|
|
||||||
"app.stopInstance.cancelLabel": "המשך להריץ",
|
|
||||||
|
|
||||||
"emptyState.logoAlt": "לוגו CodeNomad",
|
|
||||||
"emptyState.brandTitle": "CodeNomad",
|
|
||||||
"emptyState.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
|
|
||||||
"emptyState.actions.selectFolder": "בחר תיקייה",
|
|
||||||
"emptyState.actions.selecting": "בוחר...",
|
|
||||||
"emptyState.keyboardShortcut": "קיצור מקלדת: {shortcut}",
|
|
||||||
"emptyState.examples": "דוגמאות: {example}",
|
|
||||||
"emptyState.multipleInstances": "ניתן לפתוח מספר מופעים של אותה תיקייה",
|
|
||||||
|
|
||||||
"releases.upgradeRequired.title": "נדרש שדרוג",
|
|
||||||
"releases.upgradeRequired.message.withVersion": "שדרג ל-CodeNomad {version} כדי להשתמש בממשק המעודכן.",
|
|
||||||
"releases.upgradeRequired.message.noVersion": "שדרג את CodeNomad כדי להשתמש בממשק המעודכן.",
|
|
||||||
"releases.upgradeRequired.action.getUpdate": "קבל עדכון",
|
|
||||||
|
|
||||||
"releases.uiUpdated.title": "הממשק עודכן",
|
|
||||||
"releases.uiUpdated.message": "הממשק עודכן לגרסה {version}.",
|
|
||||||
|
|
||||||
"releases.devUpdateAvailable.title": "גרסת פיתוח זמינה",
|
|
||||||
"releases.devUpdateAvailable.message": "גרסת פיתוח חדשה זמינה: {version}.",
|
|
||||||
"releases.devUpdateAvailable.action": "צפה בגרסה",
|
|
||||||
|
|
||||||
"theme.mode.system": "מערכת",
|
|
||||||
"theme.mode.light": "בהיר",
|
|
||||||
"theme.mode.dark": "כהה",
|
|
||||||
"theme.toggle.title": "ערכת נושא: {mode}",
|
|
||||||
"theme.toggle.ariaLabel": "ערכת נושא: {mode}",
|
|
||||||
} as const
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user