Compare commits
24 Commits
ready/ui-m
...
v0.13.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a6479c0e | ||
|
|
f88064af06 | ||
|
|
1b4eff9419 | ||
|
|
6c1febf50e | ||
|
|
75622ef366 | ||
|
|
864f913e3e | ||
|
|
b7d4f8f869 | ||
|
|
0dc5867fb3 | ||
|
|
d13ecba322 | ||
|
|
740f37db86 | ||
|
|
d447b05821 | ||
|
|
1233121a13 | ||
|
|
a950d47df0 | ||
|
|
1c68f5d288 | ||
|
|
3bad0afd7d | ||
|
|
8567d49178 | ||
|
|
09284ee2ce | ||
|
|
a2e30f1b54 | ||
|
|
a4af811de3 | ||
|
|
c5aa59ca75 | ||
|
|
b8e0714b68 | ||
|
|
3f890e5de1 | ||
|
|
935926d875 | ||
|
|
74f753abf4 |
4
.github/workflows/comment-pr-artifacts.yml
vendored
4
.github/workflows/comment-pr-artifacts.yml
vendored
@@ -6,6 +6,7 @@ on:
|
|||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
@@ -20,6 +21,7 @@ jobs:
|
|||||||
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
ACTOR: ${{ github.actor }}
|
ACTOR: ${{ github.actor }}
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
RETENTION_DAYS: 7
|
RETENTION_DAYS: 7
|
||||||
@@ -42,7 +44,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Wait for PR build and comment
|
- name: Wait for PR build and comment
|
||||||
if: ${{ steps.auth.outputs.allowed == 'true' }}
|
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
3
.github/workflows/pr-build.yml
vendored
3
.github/workflows/pr-build.yml
vendored
@@ -6,6 +6,7 @@ on:
|
|||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -45,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
needs: authorize
|
needs: authorize
|
||||||
if: ${{ needs.authorize.outputs.allowed == 'true' }}
|
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|||||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -8240,6 +8240,27 @@
|
|||||||
"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",
|
||||||
@@ -12019,6 +12040,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -12033,7 +12055,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12070,7 +12092,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12080,6 +12102,7 @@
|
|||||||
"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",
|
||||||
@@ -12111,7 +12134,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12119,7 +12142,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"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.12.3",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -31,4 +31,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.12.3",
|
"minServerVersion": "0.13.1",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
.vite/
|
||||||
|
electron/resources/server/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { requestMicrophoneAccess } from "./permissions"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"media:requestMicrophoneAccess",
|
||||||
|
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { dirname, join } from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupCliIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
|
import { configureMediaPermissionHandlers } from "./permissions"
|
||||||
import { CliProcessManager } from "./process-manager"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
@@ -489,6 +490,7 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
|
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
})
|
})
|
||||||
|
|||||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
58
packages/electron-app/electron/main/permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { session, systemPreferences } from "electron"
|
||||||
|
|
||||||
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
||||||
|
if (!origin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = new URL(origin).origin
|
||||||
|
return allowedOrigins.includes(normalized)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||||
|
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||||
|
if (permission !== "media") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
||||||
|
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
||||||
|
})
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
||||||
|
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestMicrophoneAccess(): Promise<boolean> {
|
||||||
|
if (!isMac) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = systemPreferences.getMediaAccessStatus("microphone")
|
||||||
|
if (status === "granted") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemPreferences.askForMediaAccess("microphone")
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app } from "electron"
|
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ interface CliEntryResolution {
|
|||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedChild = ChildProcess | UtilityProcess
|
||||||
|
type ChildLaunchMode = "spawn" | "utility"
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
@@ -117,7 +123,8 @@ export declare interface CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ChildProcess
|
private child?: ManagedChild
|
||||||
|
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
@@ -135,33 +142,63 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
|
||||||
console.info(
|
let child: ManagedChild
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
const runtimePath = this.resolveShellNodeCommand()
|
||||||
|
const entryPath = this.resolveBundledProdEntry()
|
||||||
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
|
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
|
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||||
|
const supervisorPayload = JSON.stringify({
|
||||||
|
command: shellCommand.command,
|
||||||
|
args: shellCommand.args,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
console.info(
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
)
|
||||||
|
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||||
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
env: shellEnv,
|
||||||
cwd: process.cwd(),
|
stdio: "pipe",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
env,
|
})
|
||||||
shell: false,
|
this.childLaunchMode = "utility"
|
||||||
detached,
|
} else {
|
||||||
})
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
console.info(
|
||||||
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
|
)
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
if (!child.pid) {
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
|
|
||||||
|
const spawnDetails = supportsUserShell()
|
||||||
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
|
const detached = process.platform !== "win32"
|
||||||
|
child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
detached,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
|
this.childLaunchMode = "spawn"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "spawn" && !child.pid) {
|
||||||
console.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,23 +213,48 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("error", (error) => {
|
if (this.childLaunchMode === "utility") {
|
||||||
console.error("[cli] failed to start CLI:", error)
|
const utilityChild = child as UtilityProcess
|
||||||
this.updateStatus({ state: "error", error: error.message })
|
|
||||||
this.emit("error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
utilityChild.on("error", (error) => {
|
||||||
const failed = this.status.state !== "ready"
|
const message = this.describeUtilityProcessError(error)
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
console.error("[cli] utility supervisor failed:", error)
|
||||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
this.updateStatus({ state: "error", error: message })
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
this.emit("error", new Error(message))
|
||||||
if (failed && error) {
|
})
|
||||||
this.emit("error", new Error(error))
|
|
||||||
}
|
utilityChild.on("exit", (code) => {
|
||||||
this.emit("exit", this.status)
|
const failed = this.status.state !== "ready"
|
||||||
this.child = undefined
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
||||||
})
|
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
|
spawnedChild.on("error", (error) => {
|
||||||
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
spawnedChild.on("exit", (code, signal) => {
|
||||||
|
const failed = this.status.state !== "ready"
|
||||||
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<CliStatus>((resolve, reject) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -219,16 +281,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "utility") {
|
||||||
|
return this.stopUtilityChild(child as UtilityProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
const pid = child.pid
|
const pid = spawnedChild.pid
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
try {
|
try {
|
||||||
@@ -304,7 +372,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
sendStopSignal("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
spawnedChild.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
console.info("[cli] CLI process exited")
|
console.info("[cli] CLI process exited")
|
||||||
@@ -324,6 +392,46 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
||||||
|
this.requestedStop = true
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
console.info("[cli] CLI process exited")
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (child.pid === undefined) {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getStatus(): CliStatus {
|
getStatus(): CliStatus {
|
||||||
return { ...this.status }
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
@@ -335,14 +443,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
const pid = this.child.pid
|
const pid = this.child.pid
|
||||||
if (pid && process.platform !== "win32") {
|
if (this.childLaunchMode === "utility") {
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pid && process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, "SIGKILL")
|
process.kill(-pid, "SIGKILL")
|
||||||
} catch {
|
} catch {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
@@ -449,6 +565,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildExecutableCommand(command: string, args: string[]): string {
|
||||||
|
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
@@ -519,4 +639,58 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||||
|
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCliSupervisorPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||||
|
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveShellNodeCommand(): string {
|
||||||
|
const configured = process.env.NODE_BINARY?.trim()
|
||||||
|
return configured && configured.length > 0 ? configured : "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBundledProdEntry(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||||
|
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const typed = error as { type?: unknown; location?: unknown }
|
||||||
|
if (typeof typed.type === "string") {
|
||||||
|
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const electronAPI = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn } = require("child_process")
|
||||||
|
|
||||||
|
const SHUTDOWN_GRACE_MS = 30_000
|
||||||
|
|
||||||
|
let child = null
|
||||||
|
let shutdownTimer = null
|
||||||
|
|
||||||
|
function log(message, error) {
|
||||||
|
if (error) {
|
||||||
|
console.error(`[cli-supervisor] ${message}`, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[cli-supervisor] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShutdownTimer() {
|
||||||
|
if (shutdownTimer) {
|
||||||
|
clearTimeout(shutdownTimer)
|
||||||
|
shutdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardStream(stream, target) {
|
||||||
|
if (!stream) return
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
target.write(chunk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminateChild(force) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill(force ? "SIGKILL" : "SIGTERM")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestShutdown(force = false) {
|
||||||
|
if (!child) {
|
||||||
|
process.exit(force ? 1 : 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminateChild(force)
|
||||||
|
if (force) {
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearShutdownTimer()
|
||||||
|
shutdownTimer = setTimeout(() => {
|
||||||
|
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
||||||
|
terminateChild(true)
|
||||||
|
}, SHUTDOWN_GRACE_MS)
|
||||||
|
shutdownTimer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShutdownHandlers() {
|
||||||
|
process.on("SIGTERM", () => requestShutdown(false))
|
||||||
|
process.on("SIGINT", () => requestShutdown(false))
|
||||||
|
process.on("disconnect", () => requestShutdown(false))
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
log("uncaught exception", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
process.on("unhandledRejection", (error) => {
|
||||||
|
log("unhandled rejection", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload() {
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Supervisor payload is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Supervisor payload must be an object")
|
||||||
|
}
|
||||||
|
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
||||||
|
throw new Error("Supervisor payload command is required")
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
||||||
|
throw new Error("Supervisor payload args must be a string array")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: parsed.command,
|
||||||
|
args: parsed.args,
|
||||||
|
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
installShutdownHandlers()
|
||||||
|
|
||||||
|
const payload = parsePayload()
|
||||||
|
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
||||||
|
|
||||||
|
child = spawn(payload.command, payload.args, {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
|
||||||
|
forwardStream(child.stdout, process.stdout)
|
||||||
|
forwardStream(child.stderr, process.stderr)
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
log("failed to spawn shell command", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearShutdownTimer()
|
||||||
|
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
||||||
|
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
||||||
|
process.exit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
|
"prepare:resources": "node scripts/prepare-resources.js",
|
||||||
|
"prebuild": "npm run prepare:resources",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
@@ -33,8 +35,11 @@
|
|||||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||||
"build:all": "node scripts/build.js all",
|
"build:all": "node scripts/build.js all",
|
||||||
|
"prepackage:mac": "npm run prepare:resources",
|
||||||
"package:mac": "electron-builder --mac",
|
"package:mac": "electron-builder --mac",
|
||||||
|
"prepackage:win": "npm run prepare:resources",
|
||||||
"package:win": "electron-builder --win",
|
"package:win": "electron-builder --win",
|
||||||
|
"prepackage:linux": "npm run prepare:resources",
|
||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -82,6 +87,12 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"entitlements": "electron/resources/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
||||||
|
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
||||||
|
},
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
|||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||||
|
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
|
})
|
||||||
|
|
||||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
|
|||||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path, { join } from "path"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
|
const appDir = join(__dirname, "..")
|
||||||
|
const workspaceRoot = join(appDir, "..", "..")
|
||||||
|
const serverRoot = join(appDir, "..", "server")
|
||||||
|
const resourcesRoot = join(appDir, "electron", "resources")
|
||||||
|
const serverDest = join(resourcesRoot, "server")
|
||||||
|
const npmExecPath = process.env.npm_execpath
|
||||||
|
const npmNodeExecPath = process.env.npm_node_execpath
|
||||||
|
|
||||||
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
|
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[prepare-resources] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerBuild() {
|
||||||
|
const distPath = join(serverRoot, "dist")
|
||||||
|
const publicPath = join(serverRoot, "public")
|
||||||
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||||
|
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDependencies() {
|
||||||
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("installing production server dependencies")
|
||||||
|
const npmArgs = [
|
||||||
|
"install",
|
||||||
|
"--omit=dev",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--workspaces=false",
|
||||||
|
"--package-lock=false",
|
||||||
|
"--install-strategy=shallow",
|
||||||
|
"--fund=false",
|
||||||
|
"--audit=false",
|
||||||
|
]
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
npm_config_workspaces: "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||||
|
const result = npmCli
|
||||||
|
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||||
|
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyServerArtifacts() {
|
||||||
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
|
|
||||||
|
for (const name of serverSources) {
|
||||||
|
const from = join(serverRoot, name)
|
||||||
|
const to = join(serverDest, name)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
throw new Error(`Missing required server artifact: ${from}`)
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||||
|
log(`copied ${name} to Electron resources`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = join(serverDest, "node_modules")
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
fs.rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
log(`removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[prepare-resources] failed:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.24"
|
"@opencode-ai/plugin": "1.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.3",
|
"version": "0.13.1",
|
||||||
"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.12.3",
|
"version": "0.13.1",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"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",
|
||||||
@@ -46,4 +47,4 @@
|
|||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,39 @@ 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 type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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)
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ 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,
|
||||||
@@ -388,6 +390,7 @@ 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,
|
||||||
@@ -408,6 +411,7 @@ 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,12 +21,14 @@ 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"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -41,6 +43,7 @@ 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
|
||||||
@@ -252,6 +255,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 })
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
@@ -20,10 +21,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 () => deps.settings.getDoc("config"))
|
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
||||||
app.patch("/api/storage/config", async (request, reply) => {
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
return sanitizeConfigDoc(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" }
|
||||||
@@ -31,12 +32,15 @@ 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 deps.settings.getOwner("config", request.params.owner)
|
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
|
return sanitizeConfigOwner(
|
||||||
|
request.params.owner,
|
||||||
|
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
||||||
|
)
|
||||||
} catch (error) {
|
} 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" }
|
||||||
|
|||||||
74
packages/server/src/server/routes/speech.ts
Normal file
74
packages/server/src/server/routes/speech.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import type { SpeechService } from "../../speech/service"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
speechService: SpeechService
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscribeBodySchema = z.object({
|
||||||
|
audioBase64: z.string().min(1, "Audio payload is required"),
|
||||||
|
mimeType: z.string().min(1, "Audio MIME type is required"),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
prompt: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SynthesizeBodySchema = z.object({
|
||||||
|
text: z.string().trim().min(1, "Text is required"),
|
||||||
|
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function getSpeechErrorStatus(error: unknown): number {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
if (error instanceof Error && /not configured/i.test(error.message)) {
|
||||||
|
return 503
|
||||||
|
}
|
||||||
|
return 502
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSpeechErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error ? error.message : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
|
||||||
|
|
||||||
|
app.post("/api/speech/transcribe", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = TranscribeBodySchema.parse(request.body ?? {})
|
||||||
|
return await deps.speechService.transcribe(body)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to transcribe audio")
|
||||||
|
reply.code(getSpeechErrorStatus(error))
|
||||||
|
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/speech/synthesize", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||||
|
return await deps.speechService.synthesize(body)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to synthesize audio")
|
||||||
|
reply.code(getSpeechErrorStatus(error))
|
||||||
|
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/api/speech/synthesize/stream", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = SynthesizeBodySchema.parse(request.body ?? {})
|
||||||
|
const result = await deps.speechService.synthesizeStream(body)
|
||||||
|
reply.header("Content-Type", result.mimeType)
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
return reply.send(result.stream)
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error }, "Failed to stream synthesized audio")
|
||||||
|
reply.code(getSpeechErrorStatus(error))
|
||||||
|
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
40
packages/server/src/settings/public-config.ts
Normal file
40
packages/server/src/settings/public-config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { SettingsDoc } from "./yaml-doc-store"
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
|
||||||
|
|
||||||
|
if (!speech) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
|
||||||
|
if (rawApiKey) {
|
||||||
|
delete speech.apiKey
|
||||||
|
speech.hasApiKey = true
|
||||||
|
} else if (!("hasApiKey" in speech)) {
|
||||||
|
speech.hasApiKey = false
|
||||||
|
}
|
||||||
|
|
||||||
|
next.speech = speech
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
|
||||||
|
if (owner !== "server") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return sanitizeServerOwner(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
|
||||||
|
const next: SettingsDoc = { ...value }
|
||||||
|
if (isPlainObject(next.server)) {
|
||||||
|
next.server = sanitizeServerOwner(next.server)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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"
|
||||||
|
|
||||||
@@ -45,10 +46,11 @@ 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: value ?? this.getOwner(kind, owner),
|
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||||
} as any
|
} as any
|
||||||
this.eventBus.publish(payload)
|
this.eventBus.publish(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
234
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Readable } from "node:stream"
|
||||||
|
import OpenAI from "openai"
|
||||||
|
import { toFile } from "openai/uploads"
|
||||||
|
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
|
||||||
|
|
||||||
|
interface OpenAICompatibleSpeechProviderOptions {
|
||||||
|
settings: NormalizedSpeechSettings
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenAICompatibleSpeechProvider {
|
||||||
|
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
|
||||||
|
|
||||||
|
getCapabilities() {
|
||||||
|
const { settings } = this.options
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
configured: Boolean(settings.apiKey),
|
||||||
|
provider: settings.provider,
|
||||||
|
supportsStt: true,
|
||||||
|
supportsTts: true,
|
||||||
|
supportsStreamingTts: true,
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
sttModel: settings.sttModel,
|
||||||
|
ttsModel: settings.ttsModel,
|
||||||
|
ttsVoice: settings.ttsVoice,
|
||||||
|
ttsFormats: ["mp3", "wav", "opus", "aac"],
|
||||||
|
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
|
const client = this.createClient()
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const extension = extensionForMime(input.mimeType)
|
||||||
|
const buffer = Buffer.from(input.audioBase64, "base64")
|
||||||
|
const filename = input.filename?.trim() || `prompt-input.${extension}`
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
bytes: buffer.byteLength,
|
||||||
|
language: input.language,
|
||||||
|
model: this.options.settings.sttModel,
|
||||||
|
},
|
||||||
|
"speech.transcribe",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestTranscription(client, buffer, filename, input)
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: typeof response?.text === "string" ? response.text : "",
|
||||||
|
language: typeof response?.language === "string" ? response.language : input.language,
|
||||||
|
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
|
||||||
|
segments: Array.isArray(response?.segments)
|
||||||
|
? response.segments
|
||||||
|
.filter((segment: any) => typeof segment?.text === "string")
|
||||||
|
.map((segment: any) => ({
|
||||||
|
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
|
||||||
|
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
|
||||||
|
text: String(segment.text),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestTranscription(
|
||||||
|
client: OpenAI,
|
||||||
|
buffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
input: TranscribeAudioInput,
|
||||||
|
): Promise<any> {
|
||||||
|
const baseRequest = {
|
||||||
|
model: this.options.settings.sttModel,
|
||||||
|
...(input.language ? { language: input.language } : {}),
|
||||||
|
...(input.prompt ? { prompt: input.prompt } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await toFile(buffer, filename, { type: input.mimeType })
|
||||||
|
return (await client.audio.transcriptions.create({
|
||||||
|
...baseRequest,
|
||||||
|
file,
|
||||||
|
response_format: "verbose_json" as any,
|
||||||
|
} as any)) as any
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
|
||||||
|
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
|
||||||
|
return (await client.audio.transcriptions.create({
|
||||||
|
...baseRequest,
|
||||||
|
file: retryFile,
|
||||||
|
} as any)) as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
|
const format = input.format ?? this.options.settings.ttsFormat
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
"speech.synthesize",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestSpeechAudio(input.text, format)
|
||||||
|
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
|
||||||
|
|
||||||
|
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return {
|
||||||
|
audioBase64: audioBuffer.toString("base64"),
|
||||||
|
mimeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||||
|
const format = input.format ?? this.options.settings.ttsFormat
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
"speech.synthesize.stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await this.requestSpeechAudio(input.text, format)
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Speech provider did not return a stream.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream: Readable.fromWeb(response.body as any),
|
||||||
|
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
|
||||||
|
const { settings } = this.options
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${settings.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.ttsModel,
|
||||||
|
voice: settings.ttsVoice,
|
||||||
|
input: text,
|
||||||
|
response_format: format,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const detailedError = error as Error & {
|
||||||
|
cause?: unknown
|
||||||
|
code?: string
|
||||||
|
errno?: number | string
|
||||||
|
syscall?: string
|
||||||
|
address?: string
|
||||||
|
port?: number
|
||||||
|
}
|
||||||
|
this.options.logger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
endpoint: endpoint.toString(),
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
model: settings.ttsModel,
|
||||||
|
voice: settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
cause: detailedError.cause,
|
||||||
|
code: detailedError.code,
|
||||||
|
errno: detailedError.errno,
|
||||||
|
syscall: detailedError.syscall,
|
||||||
|
address: detailedError.address,
|
||||||
|
port: detailedError.port,
|
||||||
|
},
|
||||||
|
"speech.synthesize fetch failed",
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text()
|
||||||
|
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClient(): OpenAI {
|
||||||
|
const { settings } = this.options
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
baseURL: settings.baseUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForMime(mimeType: string): string {
|
||||||
|
const normalized = mimeType.toLowerCase()
|
||||||
|
if (normalized.includes("webm")) return "webm"
|
||||||
|
if (normalized.includes("ogg")) return "ogg"
|
||||||
|
if (normalized.includes("wav")) return "wav"
|
||||||
|
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
|
||||||
|
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
|
||||||
|
return "webm"
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
|
||||||
|
if (format === "wav") return "audio/wav"
|
||||||
|
if (format === "opus") return 'audio/ogg; codecs="opus"'
|
||||||
|
if (format === "aac") return "audio/aac"
|
||||||
|
return "audio/mpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrailingSlash(value: string): string {
|
||||||
|
return value.endsWith("/") ? value : `${value}/`
|
||||||
|
}
|
||||||
106
packages/server/src/speech/service.ts
Normal file
106
packages/server/src/speech/service.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import type { Readable } from "node:stream"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { SettingsService } from "../settings/service"
|
||||||
|
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
|
||||||
|
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
|
||||||
|
|
||||||
|
const ServerSpeechSettingsSchema = z.object({
|
||||||
|
speech: z
|
||||||
|
.object({
|
||||||
|
provider: z.string().optional(),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
sttModel: z.string().optional(),
|
||||||
|
ttsModel: z.string().optional(),
|
||||||
|
ttsVoice: z.string().optional(),
|
||||||
|
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface TranscribeAudioInput {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
filename?: string
|
||||||
|
language?: string
|
||||||
|
prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizeSpeechInput {
|
||||||
|
text: string
|
||||||
|
format?: "mp3" | "wav" | "opus" | "aac"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechSynthesisStreamResponse {
|
||||||
|
stream: Readable
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechProvider {
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse
|
||||||
|
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||||
|
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||||
|
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSpeechSettings {
|
||||||
|
provider: string
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
ttsFormat: "mp3" | "wav" | "opus" | "aac"
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDER = "openai-compatible"
|
||||||
|
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||||
|
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
|
||||||
|
const DEFAULT_TTS_VOICE = "alloy"
|
||||||
|
const DEFAULT_TTS_FORMAT = "mp3"
|
||||||
|
export class SpeechService {
|
||||||
|
constructor(
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse {
|
||||||
|
return this.createProvider().getCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
|
||||||
|
return this.createProvider().transcribe(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
|
||||||
|
return this.createProvider().synthesize(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
|
||||||
|
return this.createProvider().synthesizeStream(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createProvider(): SpeechProvider {
|
||||||
|
const settings = this.resolveSettings()
|
||||||
|
return new OpenAICompatibleSpeechProvider({
|
||||||
|
settings,
|
||||||
|
logger: this.logger.child({ provider: settings.provider }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSettings(): NormalizedSpeechSettings {
|
||||||
|
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
|
||||||
|
const speech = parsed.speech ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
|
||||||
|
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
|
||||||
|
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
|
||||||
|
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
|
||||||
|
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
|
||||||
|
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
|
||||||
|
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
packages/tauri-app/Cargo.lock
generated
67
packages/tauri-app/Cargo.lock
generated
@@ -473,6 +473,7 @@ 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",
|
||||||
@@ -1350,6 +1351,16 @@ 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"
|
||||||
@@ -1482,6 +1493,24 @@ 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"
|
||||||
@@ -4055,6 +4084,21 @@ 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"
|
||||||
@@ -5735,6 +5779,29 @@ 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.12.3",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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"
|
||||||
|
|
||||||
|
|||||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
File diff suppressed because one or more lines are too long
@@ -2378,6 +2378,72 @@
|
|||||||
"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,6 +51,8 @@ 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) {
|
||||||
@@ -402,6 +404,8 @@ 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;
|
||||||
@@ -414,9 +418,7 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !kill_process_tree_windows(child.id(), false) {
|
let _ = kill_process_tree_windows(child.id(), false);
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -424,6 +426,21 @@ 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={}",
|
||||||
@@ -440,7 +457,11 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
if !forced_tree_shutdown
|
||||||
|
&& !kill_process_tree_windows(child.id(), true)
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
} else if forced_tree_shutdown {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ 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, Wry};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, 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;
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ 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";
|
||||||
@@ -32,6 +40,7 @@ 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)]
|
||||||
@@ -157,6 +166,83 @@ 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)
|
||||||
@@ -181,15 +267,48 @@ 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();
|
||||||
@@ -214,36 +333,42 @@ 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" => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
reload_main_window(app_handle);
|
||||||
let _ = window.eval("window.location.reload()");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"force_reload" => {
|
"force_reload" => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
force_reload_main_window(app_handle);
|
||||||
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") {
|
||||||
window.open_devtools();
|
if window.is_devtools_open() {
|
||||||
|
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" => {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
toggle_fullscreen_window(app_handle);
|
||||||
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
@@ -257,6 +382,11 @@ 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" => {
|
||||||
@@ -344,6 +474,7 @@ 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();
|
||||||
@@ -371,16 +502,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
Some("CmdOrCtrl+N"),
|
Some("CmdOrCtrl+N"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "File")
|
let file_menu = if is_mac {
|
||||||
.item(&new_instance_item)
|
SubmenuBuilder::new(app, "File")
|
||||||
.separator()
|
.item(&new_instance_item)
|
||||||
.text(
|
.separator()
|
||||||
if is_mac { "close" } else { "quit" },
|
.close_window()
|
||||||
if is_mac { "Close" } else { "Quit" },
|
.build()?
|
||||||
)
|
} else {
|
||||||
.build()?;
|
SubmenuBuilder::new(app, "File")
|
||||||
|
.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()
|
||||||
@@ -396,20 +585,39 @@ 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")
|
||||||
.text("reload", "Reload")
|
.item(&reload_item)
|
||||||
.text("force_reload", "Force Reload")
|
.item(&force_reload_item)
|
||||||
.text("toggle_devtools", "Toggle Developer Tools")
|
.item(&toggle_devtools_item)
|
||||||
.separator()
|
.separator()
|
||||||
|
.item(&reset_zoom_item)
|
||||||
|
.item(&zoom_in_item)
|
||||||
|
.item(&zoom_out_item)
|
||||||
.separator()
|
.separator()
|
||||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
.item(&toggle_fullscreen_item)
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(view_menu);
|
submenus.push(view_menu);
|
||||||
|
|
||||||
// Window menu
|
// Window menu
|
||||||
let window_menu = SubmenuBuilder::new(app, "Window")
|
let window_menu = if is_linux {
|
||||||
.text("minimize", "Minimize")
|
SubmenuBuilder::new(app, "Window")
|
||||||
.text("zoom", "Zoom")
|
.text("minimize", "Minimize")
|
||||||
.build()?;
|
.text("zoom", "Zoom")
|
||||||
|
.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.12.3",
|
"version": "0.13.1",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
|||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { SettingsScreen } from "./components/settings-screen"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
|
|||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
@@ -71,6 +68,7 @@ const App: Component = () => {
|
|||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -183,10 +181,6 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
@@ -360,6 +354,7 @@ const App: Component = () => {
|
|||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
import { getSharedHighlighter } from "../lib/markdown"
|
||||||
|
import { escapeHtml } from "../lib/text-render-utils"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
import { ErrorBoundary } from "solid-js"
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/markdown"
|
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
import type { CacheEntryParams } from "../lib/global-cache"
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
@@ -134,4 +135,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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]
|
||||||
@@ -341,7 +342,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 left-6">
|
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
||||||
<Select<LanguageOption>
|
<Select<LanguageOption>
|
||||||
value={selectedLanguageOption()}
|
value={selectedLanguageOption()}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -385,7 +386,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
||||||
<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"
|
||||||
|
|||||||
@@ -82,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 class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div dir="ltr" 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>
|
||||||
@@ -94,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 class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
<div dir="ltr" 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>
|
||||||
@@ -137,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 class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div dir="ltr" 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>
|
||||||
@@ -151,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 class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div dir="ltr" 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,6 +404,7 @@ 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,7 +81,8 @@ interface InstanceShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t, locale } = 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(
|
||||||
@@ -371,7 +372,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `${sessionSidebarWidth()}px`,
|
width: `${sessionSidebarWidth()}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRight: "1px solid var(--border-base)",
|
borderInlineEnd: "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -413,7 +414,7 @@ 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="left"
|
anchor={isRTL() ? "right" : "left"}
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
open={leftOpen()}
|
open={leftOpen()}
|
||||||
onClose={closeLeftDrawer}
|
onClose={closeLeftDrawer}
|
||||||
@@ -422,7 +423,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
borderInlineEnd: 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)",
|
||||||
@@ -480,7 +481,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: `${rightDrawerWidth()}px`,
|
width: `${rightDrawerWidth()}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderLeft: "1px solid var(--border-base)",
|
borderInlineStart: "1px solid var(--border-base)",
|
||||||
backgroundColor: "var(--surface-secondary)",
|
backgroundColor: "var(--surface-secondary)",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -523,7 +524,7 @@ 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="right"
|
anchor={isRTL() ? "left" : "right"}
|
||||||
variant="temporary"
|
variant="temporary"
|
||||||
open={rightOpen()}
|
open={rightOpen()}
|
||||||
onClose={closeRightDrawer}
|
onClose={closeRightDrawer}
|
||||||
@@ -532,7 +533,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
"& .MuiDrawer-paper": {
|
"& .MuiDrawer-paper": {
|
||||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
borderInlineStart: 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)",
|
||||||
@@ -742,7 +743,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-3">
|
<div class="ms-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,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Show,
|
Show,
|
||||||
|
Suspense,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
|
lazy,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
type Accessor,
|
type Accessor,
|
||||||
type Component,
|
type Component,
|
||||||
@@ -20,11 +22,6 @@ 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 { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||||
@@ -49,6 +46,15 @@ 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
|
||||||
|
|
||||||
@@ -243,7 +249,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const mode = activeSplitResize()
|
const mode = activeSplitResize()
|
||||||
if (!mode) return
|
if (!mode) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const delta = event.clientX - splitResizeStartX()
|
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||||
|
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)
|
||||||
@@ -266,7 +273,8 @@ 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 delta = touch.clientX - splitResizeStartX()
|
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||||
|
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)
|
||||||
@@ -565,6 +573,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
void loadBrowserEntries(browserPath())
|
void loadBrowserEntries(browserPath())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() === "files") return
|
||||||
|
setBrowserSelectedContent(null)
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (rightPanelTab() !== "git-changes") return
|
if (rightPanelTab() !== "git-changes") return
|
||||||
if (gitStatusLoading()) return
|
if (gitStatusLoading()) return
|
||||||
@@ -572,6 +587,14 @@ 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) {
|
||||||
@@ -738,101 +761,109 @@ 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"}>
|
||||||
<ChangesTab
|
<Suspense fallback={<RightPanelTabFallback />}>
|
||||||
t={props.t}
|
<LazyChangesTab
|
||||||
instanceId={props.instanceId}
|
t={props.t}
|
||||||
activeSessionId={props.activeSessionId}
|
instanceId={props.instanceId}
|
||||||
activeSessionDiffs={props.activeSessionDiffs}
|
activeSessionId={props.activeSessionId}
|
||||||
selectedFile={selectedFile}
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
onSelectFile={handleSelectChangesFile}
|
selectedFile={selectedFile}
|
||||||
diffViewMode={diffViewMode}
|
onSelectFile={handleSelectChangesFile}
|
||||||
diffContextMode={diffContextMode}
|
diffViewMode={diffViewMode}
|
||||||
diffWordWrapMode={diffWordWrapMode}
|
diffContextMode={diffContextMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
listOpen={changesListOpen}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onToggleList={toggleChangesList}
|
listOpen={changesListOpen}
|
||||||
splitWidth={changesSplitWidth}
|
onToggleList={toggleChangesList}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
splitWidth={changesSplitWidth}
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||||
/>
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "git-changes"}>
|
<Show when={rightPanelTab() === "git-changes"}>
|
||||||
<GitChangesTab
|
<Suspense fallback={<RightPanelTabFallback />}>
|
||||||
t={props.t}
|
<LazyGitChangesTab
|
||||||
activeSessionId={props.activeSessionId}
|
t={props.t}
|
||||||
entries={gitStatusEntries}
|
activeSessionId={props.activeSessionId}
|
||||||
statusLoading={gitStatusLoading}
|
entries={gitStatusEntries}
|
||||||
statusError={gitStatusError}
|
statusLoading={gitStatusLoading}
|
||||||
selectedPath={gitSelectedPath}
|
statusError={gitStatusError}
|
||||||
selectedLoading={gitSelectedLoading}
|
selectedPath={gitSelectedPath}
|
||||||
selectedError={gitSelectedError}
|
selectedLoading={gitSelectedLoading}
|
||||||
selectedBefore={gitSelectedBefore}
|
selectedError={gitSelectedError}
|
||||||
selectedAfter={gitSelectedAfter}
|
selectedBefore={gitSelectedBefore}
|
||||||
mostChangedPath={gitMostChangedPath}
|
selectedAfter={gitSelectedAfter}
|
||||||
scopeKey={gitScopeKey}
|
mostChangedPath={gitMostChangedPath}
|
||||||
diffViewMode={diffViewMode}
|
scopeKey={gitScopeKey}
|
||||||
diffContextMode={diffContextMode}
|
diffViewMode={diffViewMode}
|
||||||
diffWordWrapMode={diffWordWrapMode}
|
diffContextMode={diffContextMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onWordWrapModeChange={setDiffWordWrapMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
onOpenFile={(path) => void openGitFile(path)}
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
onOpenFile={(path: string) => void openGitFile(path)}
|
||||||
listOpen={gitChangesListOpen}
|
onRefresh={() => void refreshGitStatus()}
|
||||||
onToggleList={toggleGitList}
|
listOpen={gitChangesListOpen}
|
||||||
splitWidth={gitChangesSplitWidth}
|
onToggleList={toggleGitList}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
splitWidth={gitChangesSplitWidth}
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||||
/>
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "files"}>
|
<Show when={rightPanelTab() === "files"}>
|
||||||
<FilesTab
|
<Suspense fallback={<RightPanelTabFallback />}>
|
||||||
t={props.t}
|
<LazyFilesTab
|
||||||
browserPath={browserPath}
|
t={props.t}
|
||||||
browserEntries={browserEntries}
|
browserPath={browserPath}
|
||||||
browserLoading={browserLoading}
|
browserEntries={browserEntries}
|
||||||
browserError={browserError}
|
browserLoading={browserLoading}
|
||||||
browserSelectedPath={browserSelectedPath}
|
browserError={browserError}
|
||||||
browserSelectedContent={browserSelectedContent}
|
browserSelectedPath={browserSelectedPath}
|
||||||
browserSelectedLoading={browserSelectedLoading}
|
browserSelectedContent={browserSelectedContent}
|
||||||
browserSelectedError={browserSelectedError}
|
browserSelectedLoading={browserSelectedLoading}
|
||||||
parentPath={browserParentPath}
|
browserSelectedError={browserSelectedError}
|
||||||
scopeKey={browserScopeKey}
|
parentPath={browserParentPath}
|
||||||
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
scopeKey={browserScopeKey}
|
||||||
onOpenFile={(path) => void openBrowserFile(path)}
|
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
|
||||||
onRefresh={() => void refreshFilesTab()}
|
onOpenFile={(path: string) => void openBrowserFile(path)}
|
||||||
listOpen={filesListOpen}
|
onRefresh={() => void refreshFilesTab()}
|
||||||
onToggleList={toggleFilesList}
|
listOpen={filesListOpen}
|
||||||
splitWidth={filesSplitWidth}
|
onToggleList={toggleFilesList}
|
||||||
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
splitWidth={filesSplitWidth}
|
||||||
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||||
isPhoneLayout={props.isPhoneLayout}
|
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||||
/>
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={rightPanelTab() === "status"}>
|
<Show when={rightPanelTab() === "status"}>
|
||||||
<StatusTab
|
<Suspense fallback={<RightPanelTabFallback />}>
|
||||||
t={props.t}
|
<LazyStatusTab
|
||||||
instanceId={props.instanceId}
|
t={props.t}
|
||||||
instance={props.instance}
|
instanceId={props.instanceId}
|
||||||
activeSessionId={props.activeSessionId}
|
instance={props.instance}
|
||||||
activeSession={props.activeSession}
|
activeSessionId={props.activeSessionId}
|
||||||
activeSessionDiffs={props.activeSessionDiffs}
|
activeSession={props.activeSession}
|
||||||
latestTodoState={props.latestTodoState}
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
backgroundProcessList={props.backgroundProcessList}
|
latestTodoState={props.latestTodoState}
|
||||||
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
backgroundProcessList={props.backgroundProcessList}
|
||||||
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||||
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||||
expandedItems={rightPanelExpandedItems}
|
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||||
onExpandedItemsChange={handleAccordionChange}
|
expandedItems={rightPanelExpandedItems}
|
||||||
onOpenChangesTab={openChangesTabFromStatus}
|
onExpandedItemsChange={handleAccordionChange}
|
||||||
/>
|
onOpenChangesTab={openChangesTabFromStatus}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 {
|
||||||
@@ -14,14 +15,15 @@ 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" ? "Switch to split view" : "Switch to unified view")
|
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
|
||||||
const contextModeTitle = () =>
|
const contextModeTitle = () =>
|
||||||
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
|
||||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="file-viewer-toolbar">
|
<div class="file-viewer-toolbar">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 = {
|
||||||
@@ -24,12 +25,13 @@ 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 ? "Hide files" : "Show files"}
|
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{props.header}
|
{props.header}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<MonacoDiffViewer
|
<Suspense
|
||||||
scopeKey={scopeKey()}
|
fallback={
|
||||||
path={String(file().file || "")}
|
<div class="file-viewer-empty">
|
||||||
before={String((file() as any).before || "")}
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
after={String((file() as any).after || "")}
|
</div>
|
||||||
viewMode={props.diffViewMode()}
|
}
|
||||||
contextMode={props.diffContextMode()}
|
>
|
||||||
wordWrap={props.diffWordWrapMode()}
|
<LazyMonacoDiffViewer
|
||||||
/>
|
scopeKey={scopeKey()}
|
||||||
|
path={String(file().file || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel="Changes"
|
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
|
||||||
|
const LazyMonacoFileViewer = lazy(() =>
|
||||||
|
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
const emptyViewerMessage = () => {
|
const emptyViewerMessage = () => {
|
||||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
||||||
return "Select a file to preview"
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(payload) => (
|
{(payload) => (
|
||||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">Loading…</span>
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.browserLoading() && entriesValue === null}>
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={sorted}>
|
<For each={sorted}>
|
||||||
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.browserLoading()}>
|
<Show when={props.browserLoading()}>
|
||||||
<span>Loading…</span>
|
<span>{props.t("instanceInfo.loading")}</span>
|
||||||
</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>
|
||||||
@@ -165,7 +175,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
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" }}
|
style={{ "margin-inline-start": "auto" }}
|
||||||
onClick={() => props.onRefresh()}
|
onClick={() => props.onRefresh()}
|
||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||||
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel="Files"
|
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emptyViewerMessage = createMemo(() => {
|
const emptyViewerMessage = createMemo(() => {
|
||||||
if (!hasSession()) return "Select a session to view changes."
|
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||||
const currentEntries = entries()
|
const currentEntries = entries()
|
||||||
if (currentEntries === null) return "Loading git changes…"
|
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||||
if (nonDeleted().length === 0) return "No git changes yet."
|
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||||
return "No file selected."
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<MonacoDiffViewer
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyMonacoDiffViewer
|
||||||
scopeKey={props.scopeKey()}
|
scopeKey={props.scopeKey()}
|
||||||
path={String(file().path || "")}
|
path={String(file().path || "")}
|
||||||
before={String((file() as any).before || "")}
|
before={String((file() as any).before || "")}
|
||||||
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
wordWrap={props.diffWordWrapMode()}
|
wordWrap={props.diffWordWrapMode()}
|
||||||
/>
|
/>
|
||||||
)}
|
</Suspense>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">Loading…</span>
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">deleted</span>
|
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">deleted</span>
|
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||||
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel="Git Changes"
|
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ 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 delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
type MarkdownModule = typeof import("../lib/markdown")
|
||||||
|
|
||||||
|
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
||||||
|
|
||||||
|
function loadMarkdownModule(): Promise<MarkdownModule> {
|
||||||
|
if (!markdownModulePromise) {
|
||||||
|
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
||||||
|
markdownModulePromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return markdownModulePromise
|
||||||
|
}
|
||||||
|
|
||||||
function hashText(value: string): string {
|
function hashText(value: string): string {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index++) {
|
for (let index = 0; index < value.length; index++) {
|
||||||
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
|||||||
return `text-${hashText(text)}`
|
return `text-${hashText(text)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePartCacheId(part: TextPart, text: string): string {
|
||||||
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||||
|
if (partId) {
|
||||||
|
return partId
|
||||||
|
}
|
||||||
|
|
||||||
|
return `anonymous:${hashText(text)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntitiesLocally(content: string): string {
|
||||||
|
if (!content.includes("&") || typeof document === "undefined") {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.innerHTML = content
|
||||||
|
return textarea.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(content: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFallbackHtml(content: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapeHtml(content).replace(/\n/g, "<br />")
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestedText = ""
|
let latestRequestKey = ""
|
||||||
|
let cleanupLanguageListener: (() => void) | undefined
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
Promise.resolve().then(() => props.onRendered?.())
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const resolved = createMemo(() => {
|
const resolved = createMemo(() => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
const text = decodeHtmlEntities(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 partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||||
if (!partId) {
|
const cacheId = resolvePartCacheId(part, text)
|
||||||
throw new Error("Markdown rendering requires a part id")
|
|
||||||
}
|
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||||
|
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { partId, themeKey, highlightEnabled } = resolved()
|
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(async () => {
|
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
const cacheEntry: RenderCache = {
|
||||||
|
text: snapshot.text,
|
||||||
|
html: renderedHtml,
|
||||||
|
theme: snapshot.themeKey,
|
||||||
|
mode: snapshot.version,
|
||||||
|
}
|
||||||
|
setHtml(renderedHtml)
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||||
setMarkdownTheme(themeKey === "dark")
|
const markdown = await loadMarkdownModule()
|
||||||
|
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||||
|
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||||
|
suppressHighlight: !snapshot.highlightEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
latestRequestedText = text
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
|
commitCacheEntry(snapshot, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const snapshot = resolved()
|
||||||
|
latestRequestKey = snapshot.requestKey
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === themeKey && cache.mode === version
|
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = part.renderCache
|
const localCache = snapshot.part.renderCache
|
||||||
if (localCache && cacheMatches(localCache)) {
|
if (localCache && cacheMatches(localCache)) {
|
||||||
setHtml(localCache.html)
|
setHtml(localCache.html)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -96,111 +168,83 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitCacheEntry = (renderedHtml: string) => {
|
setHtml(renderFallbackHtml(snapshot.text))
|
||||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
notifyRendered()
|
||||||
setHtml(renderedHtml)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
void renderSnapshot(snapshot).catch((error) => {
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
|
||||||
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(rendered)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to render markdown:", error)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(rendered)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
commitCacheEntry(text)
|
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleClick = async (e: Event) => {
|
const handleClick = async (event: Event) => {
|
||||||
const target = e.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
|
|
||||||
if (copyButton) {
|
if (!copyButton) {
|
||||||
e.preventDefault()
|
return
|
||||||
const code = copyButton.getAttribute("data-code")
|
|
||||||
if (code) {
|
|
||||||
const decodedCode = decodeURIComponent(code)
|
|
||||||
const success = await copyToClipboard(decodedCode)
|
|
||||||
const copyText = copyButton.querySelector(".copy-text")
|
|
||||||
if (copyText) {
|
|
||||||
if (success) {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
const success = await copyToClipboard(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
|
if (!copyText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
let disposed = false
|
||||||
if (props.disableHighlight) {
|
void loadMarkdownModule()
|
||||||
return
|
.then((markdown) => {
|
||||||
}
|
if (disposed) {
|
||||||
|
return
|
||||||
const { part, text, themeKey, version } = resolved()
|
|
||||||
|
|
||||||
setMarkdownTheme(themeKey === "dark")
|
|
||||||
|
|
||||||
if (latestRequestedText !== text) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
|
||||||
setHtml(rendered)
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to re-render markdown after language load:", error)
|
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
||||||
}
|
const snapshot = resolved()
|
||||||
})
|
if (!snapshot.highlightEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestRequestKey = snapshot.requestKey
|
||||||
|
void renderSnapshot(snapshot).catch((error) => {
|
||||||
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("Failed to load markdown module:", error)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
disposed = true
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
cleanupLanguageListener()
|
cleanupLanguageListener?.()
|
||||||
|
cleanupLanguageListener = undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const proseClass = () => "markdown-body"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
class={proseClass()}
|
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,7 +1,6 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, 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"
|
||||||
@@ -15,6 +14,8 @@ 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 (
|
||||||
@@ -29,6 +30,12 @@ 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" }>
|
||||||
|
|
||||||
|
|
||||||
@@ -500,16 +507,18 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToolCall
|
<Suspense fallback={<ToolCallFallback />}>
|
||||||
toolCall={resolvedToolPart()}
|
<LazyToolCall
|
||||||
toolCallId={props.partId}
|
toolCall={resolvedToolPart()}
|
||||||
messageId={props.messageId}
|
toolCallId={props.partId}
|
||||||
messageVersion={messageVersion()}
|
messageId={props.messageId}
|
||||||
partVersion={partVersion()}
|
messageVersion={messageVersion()}
|
||||||
instanceId={props.instanceId}
|
partVersion={partVersion()}
|
||||||
sessionId={props.sessionId}
|
instanceId={props.instanceId}
|
||||||
onContentRendered={props.onContentRendered}
|
sessionId={props.sessionId}
|
||||||
/>
|
onContentRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -902,6 +911,7 @@ 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>
|
||||||
@@ -1280,6 +1290,7 @@ 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) {
|
||||||
@@ -1288,6 +1299,25 @@ 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))
|
||||||
@@ -1356,6 +1386,19 @@ 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) => {
|
||||||
@@ -1428,6 +1471,20 @@ 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"
|
||||||
@@ -1497,7 +1554,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">{reasoningText() || ""}</pre>
|
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ 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 (
|
||||||
@@ -294,6 +296,13 @@ 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
|
||||||
@@ -443,6 +452,16 @@ 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"
|
||||||
@@ -503,6 +522,16 @@ 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"
|
||||||
@@ -542,7 +571,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
|
||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
@@ -550,7 +579,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={errorMessage()}>
|
<Show when={errorMessage()}>
|
||||||
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
<div class="message-error-block" dir="auto">⚠️ {errorMessage()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={isGenerating()}>
|
<Show when={isGenerating()}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Show, Match, Switch } from "solid-js"
|
import { Match, Show, Suspense, Switch, lazy } 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"
|
||||||
@@ -7,6 +6,8 @@ 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"
|
||||||
@@ -133,11 +134,12 @@ 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">{plainTextContent()}</span>}>
|
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
|
||||||
<Markdown
|
<Markdown
|
||||||
part={createTextPartForMarkdown()}
|
part={createTextPartForMarkdown()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
@@ -152,12 +154,14 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
<Match when={partType() === "tool"}>
|
<Match when={partType() === "tool"}>
|
||||||
<ToolCall
|
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
||||||
toolCall={props.part as ToolCallPart}
|
<LazyToolCall
|
||||||
toolCallId={props.part?.id}
|
toolCall={props.part as ToolCallPart}
|
||||||
instanceId={props.instanceId}
|
toolCallId={props.part?.id}
|
||||||
sessionId={props.sessionId}
|
instanceId={props.instanceId}
|
||||||
/>
|
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 = 48
|
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||||
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">
|
<span class="selector-trigger-secondary" dir="ltr">
|
||||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
|
import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, 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,7 +12,8 @@ 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
|
||||||
@@ -408,15 +409,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<ToolCall
|
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
|
||||||
toolCall={data().toolPart}
|
<LazyToolCall
|
||||||
toolCallId={data().toolPart.id}
|
toolCall={data().toolPart}
|
||||||
messageId={data().messageId}
|
toolCallId={data().toolPart.id}
|
||||||
messageVersion={data().messageVersion}
|
messageId={data().messageId}
|
||||||
partVersion={data().partVersion}
|
messageVersion={data().messageVersion}
|
||||||
instanceId={props.instanceId}
|
partVersion={data().partVersion}
|
||||||
sessionId={data().sessionId}
|
instanceId={props.instanceId}
|
||||||
/>
|
sessionId={data().sessionId}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } 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"
|
||||||
@@ -19,7 +18,10 @@ 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 []
|
||||||
@@ -350,6 +352,19 @@ 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()
|
||||||
@@ -421,6 +436,8 @@ 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") }
|
||||||
@@ -450,9 +467,52 @@ 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
|
||||||
@@ -467,18 +527,20 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<Show when={showPicker() && instance()}>
|
<Show when={showPicker() && instance()}>
|
||||||
<UnifiedPicker
|
<Suspense fallback={null}>
|
||||||
open={showPicker()}
|
<LazyUnifiedPicker
|
||||||
mode={pickerMode()}
|
open={showPicker()}
|
||||||
onClose={handlePickerClose}
|
mode={pickerMode()}
|
||||||
onSelect={handlePickerSelect}
|
onClose={handlePickerClose}
|
||||||
agents={instanceAgents()}
|
onSelect={handlePickerSelect}
|
||||||
commands={getCommands(props.instanceId)}
|
agents={instanceAgents()}
|
||||||
instanceClient={instance()!.client}
|
commands={getCommands(props.instanceId)}
|
||||||
searchQuery={searchQuery()}
|
instanceClient={instance()!.client}
|
||||||
textareaRef={textareaRef}
|
searchQuery={searchQuery()}
|
||||||
workspaceId={props.instanceId}
|
textareaRef={textareaRef}
|
||||||
/>
|
workspaceId={props.instanceId}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col">
|
<div class="flex flex-1 flex-col">
|
||||||
@@ -488,6 +550,7 @@ 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}
|
||||||
@@ -503,42 +566,111 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<div class="prompt-nav-buttons">
|
<div class="prompt-nav-buttons">
|
||||||
<ExpandButton
|
<div class="prompt-nav-column prompt-nav-column-left">
|
||||||
expandState={expandState}
|
<Show when={showVoiceInput()}>
|
||||||
onToggleExpand={handleExpandToggle}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<Show when={hasHistory()}>
|
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => endVoicePress()}
|
||||||
|
onLostPointerCapture={() => endVoicePress()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.repeat) return
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
beginVoicePress(event)
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
endVoicePress()
|
||||||
|
}}
|
||||||
|
onBlur={() => endVoicePress()}
|
||||||
|
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||||
|
aria-label={voiceInput.buttonTitle()}
|
||||||
|
title={voiceInput.buttonTitle()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={voiceInput.isRecording()}
|
||||||
|
fallback={
|
||||||
|
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showConversationToggle()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||||
|
onClick={() => toggleConversationMode(props.instanceId)}
|
||||||
|
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||||
|
aria-pressed={conversationModeEnabled()}
|
||||||
|
aria-label={conversationModeButtonTitle()}
|
||||||
|
title={conversationModeButtonTitle()}
|
||||||
|
>
|
||||||
|
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="prompt-history-button"
|
class="prompt-clear-button"
|
||||||
onClick={() =>
|
onClick={handleClearPrompt}
|
||||||
selectPreviousHistory({
|
disabled={!canClearPrompt()}
|
||||||
force: true,
|
aria-label={t("promptInput.clear.ariaLabel")}
|
||||||
isPickerOpen: showPicker(),
|
title={t("promptInput.clear.title")}
|
||||||
getTextarea: () => textareaRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={!canHistoryGoPrevious()}
|
|
||||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
|
||||||
>
|
>
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
<X class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div class="prompt-nav-column prompt-nav-column-right">
|
||||||
class="prompt-history-button"
|
<ExpandButton
|
||||||
onClick={() =>
|
expandState={expandState}
|
||||||
selectNextHistory({
|
onToggleExpand={handleExpandToggle}
|
||||||
force: true,
|
/>
|
||||||
isPickerOpen: showPicker(),
|
<Show when={hasHistory()}>
|
||||||
getTextarea: () => textareaRef,
|
<button
|
||||||
})
|
type="button"
|
||||||
}
|
class="prompt-history-button"
|
||||||
disabled={!canHistoryGoNext()}
|
onClick={() =>
|
||||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
selectPreviousHistory({
|
||||||
>
|
force: true,
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
isPickerOpen: showPicker(),
|
||||||
</button>
|
getTextarea: () => textareaRef,
|
||||||
</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" : ""}`}>
|
||||||
|
|||||||
253
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
253
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
|
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { isElectronHost } from "../../lib/runtime-env"
|
||||||
|
|
||||||
|
interface UsePromptVoiceInputOptions {
|
||||||
|
prompt: Accessor<string>
|
||||||
|
setPrompt: (value: string) => void
|
||||||
|
getTextarea: () => HTMLTextAreaElement | null
|
||||||
|
enabled: Accessor<boolean>
|
||||||
|
disabled: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoiceInputState = "idle" | "recording" | "transcribing"
|
||||||
|
|
||||||
|
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [state, setState] = createSignal<VoiceInputState>("idle")
|
||||||
|
const [elapsedMs, setElapsedMs] = createSignal(0)
|
||||||
|
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
let mediaStream: MediaStream | null = null
|
||||||
|
let timerId: number | undefined
|
||||||
|
let shouldTranscribe = true
|
||||||
|
let recordedChunks: Blob[] = []
|
||||||
|
let recordingStartedAt = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void loadSpeechCapabilities()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cleanupMedia(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSupported = () => {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canUseVoiceInput = () => {
|
||||||
|
const capabilities = speechCapabilities()
|
||||||
|
return Boolean(
|
||||||
|
options.enabled() &&
|
||||||
|
isSupported() &&
|
||||||
|
capabilities?.available &&
|
||||||
|
capabilities?.configured &&
|
||||||
|
capabilities?.supportsStt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecording(): Promise<void> {
|
||||||
|
if (state() === "recording") {
|
||||||
|
stopRecording()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (!mediaRecorder || state() !== "recording") return
|
||||||
|
shouldTranscribe = true
|
||||||
|
mediaRecorder.stop()
|
||||||
|
setState("transcribing")
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRecording() {
|
||||||
|
if (!mediaRecorder || state() !== "recording") return
|
||||||
|
shouldTranscribe = false
|
||||||
|
mediaRecorder.stop()
|
||||||
|
cleanupMedia(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
|
||||||
|
|
||||||
|
if (!isSupported()) {
|
||||||
|
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
|
||||||
|
title: t("promptInput.voiceInput.error.title"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recordedChunks = []
|
||||||
|
shouldTranscribe = true
|
||||||
|
|
||||||
|
if (isElectronHost()) {
|
||||||
|
const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.()
|
||||||
|
if (granted && !granted.granted) {
|
||||||
|
throw new Error(t("promptInput.voiceInput.error.permissionDenied"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
mediaRecorder = createRecorder(mediaStream)
|
||||||
|
|
||||||
|
mediaRecorder.addEventListener("dataavailable", (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
recordedChunks.push(event.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaRecorder.addEventListener("stop", () => {
|
||||||
|
void finalizeRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
recordingStartedAt = Date.now()
|
||||||
|
setElapsedMs(0)
|
||||||
|
setState("recording")
|
||||||
|
startTimer()
|
||||||
|
mediaRecorder.start()
|
||||||
|
} catch (error) {
|
||||||
|
cleanupMedia(false)
|
||||||
|
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
|
||||||
|
title: t("promptInput.voiceInput.error.title"),
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeRecording() {
|
||||||
|
const recorder = mediaRecorder
|
||||||
|
const stream = mediaStream
|
||||||
|
mediaRecorder = null
|
||||||
|
mediaStream = null
|
||||||
|
|
||||||
|
if (!shouldTranscribe || recordedChunks.length === 0) {
|
||||||
|
recordedChunks = []
|
||||||
|
stopTracks(stream)
|
||||||
|
setState("idle")
|
||||||
|
setElapsedMs(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioBlob = new Blob(recordedChunks, { type: mimeType })
|
||||||
|
const transcription = await serverApi.transcribeAudio({
|
||||||
|
audioBase64: await blobToBase64(audioBlob),
|
||||||
|
mimeType,
|
||||||
|
})
|
||||||
|
if (transcription.text.trim()) {
|
||||||
|
insertTranscript(transcription.text.trim())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
|
||||||
|
title: t("promptInput.voiceInput.error.title"),
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
recordedChunks = []
|
||||||
|
stopTracks(stream)
|
||||||
|
setState("idle")
|
||||||
|
setElapsedMs(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTranscript(text: string) {
|
||||||
|
const current = options.prompt()
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
const start = textarea ? textarea.selectionStart : current.length
|
||||||
|
const end = textarea ? textarea.selectionEnd : current.length
|
||||||
|
const before = current.slice(0, start)
|
||||||
|
const after = current.slice(end)
|
||||||
|
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
|
||||||
|
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
|
||||||
|
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||||
|
const cursor = before.length + prefix.length + text.length
|
||||||
|
|
||||||
|
options.setPrompt(nextValue)
|
||||||
|
if (textarea) {
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupMedia(resetState = true) {
|
||||||
|
stopTimer()
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
}
|
||||||
|
mediaRecorder = null
|
||||||
|
stopTracks(mediaStream)
|
||||||
|
mediaStream = null
|
||||||
|
recordedChunks = []
|
||||||
|
if (resetState) {
|
||||||
|
setState("idle")
|
||||||
|
setElapsedMs(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
stopTimer()
|
||||||
|
timerId = window.setInterval(() => {
|
||||||
|
setElapsedMs(Date.now() - recordingStartedAt)
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timerId !== undefined) {
|
||||||
|
window.clearInterval(timerId)
|
||||||
|
timerId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
elapsedMs,
|
||||||
|
canUseVoiceInput,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
toggleRecording,
|
||||||
|
cancelRecording,
|
||||||
|
isRecording: () => state() === "recording",
|
||||||
|
isTranscribing: () => state() === "transcribing",
|
||||||
|
buttonTitle: () => {
|
||||||
|
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
|
||||||
|
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
|
||||||
|
return t("promptInput.voiceInput.start.title")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecorder(stream: MediaStream): MediaRecorder {
|
||||||
|
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
|
||||||
|
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
|
||||||
|
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTracks(stream: MediaStream | null) {
|
||||||
|
stream?.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let binary = ""
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
@@ -444,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">{title()}</span>
|
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-item-row session-item-meta">
|
<div class="session-item-row session-item-meta">
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ 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,6 +16,7 @@ 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")
|
||||||
|
|
||||||
@@ -88,6 +89,10 @@ 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, X } from "lucide-solid"
|
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, 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,6 +13,7 @@ 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()
|
||||||
@@ -21,6 +22,7 @@ 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") },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ 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,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
|
|||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
setDiffViewMode,
|
toggleShowPromptVoiceInput,
|
||||||
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
|||||||
373
packages/ui/src/components/settings/speech-settings-card.tsx
Normal file
373
packages/ui/src/components/settings/speech-settings-card.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import { For, Show, createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import { Loader2, Mic, Square, Volume2 } from "lucide-solid"
|
||||||
|
import { useConfig, type SpeechSettings } from "../../stores/preferences"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
import { useSpeech } from "../../lib/hooks/use-speech"
|
||||||
|
import { getSpeechPlaybackSupport } from "../../lib/speech-playback-support"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
type DraftFields = {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
playbackMode: SpeechSettings["playbackMode"]
|
||||||
|
ttsFormat: SpeechSettings["ttsFormat"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDraftFields(speech: SpeechSettings): DraftFields {
|
||||||
|
return {
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: speech.baseUrl ?? "",
|
||||||
|
sttModel: speech.sttModel,
|
||||||
|
ttsModel: speech.ttsModel,
|
||||||
|
ttsVoice: speech.ttsVoice,
|
||||||
|
playbackMode: speech.playbackMode,
|
||||||
|
ttsFormat: speech.ttsFormat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
|
||||||
|
return (
|
||||||
|
a.apiKey === b.apiKey &&
|
||||||
|
a.baseUrl === b.baseUrl &&
|
||||||
|
a.sttModel === b.sttModel &&
|
||||||
|
a.ttsModel === b.ttsModel &&
|
||||||
|
a.ttsVoice === b.ttsVoice &&
|
||||||
|
a.playbackMode === b.playbackMode &&
|
||||||
|
a.ttsFormat === b.ttsFormat
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpeechSettingsCard: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { serverSettings, updateSpeechSettings } = useConfig()
|
||||||
|
const initialDrafts = createDraftFields(serverSettings().speech)
|
||||||
|
const [isSaving, setIsSaving] = createSignal(false)
|
||||||
|
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
|
||||||
|
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
|
||||||
|
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
|
||||||
|
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
|
||||||
|
|
||||||
|
const testSpeech = useSpeech({
|
||||||
|
id: () => "settings-speech-test",
|
||||||
|
text: () => t("settings.speech.testPlayback.sample"),
|
||||||
|
settingsOverride: () => ({
|
||||||
|
playbackMode: drafts().playbackMode,
|
||||||
|
ttsFormat: drafts().ttsFormat,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const speech = serverSettings().speech
|
||||||
|
const nextDrafts = createDraftFields(speech)
|
||||||
|
if (!isSaving() && !isDirty()) {
|
||||||
|
if (!isDraftEqual(drafts(), nextDrafts)) {
|
||||||
|
setDrafts(nextDrafts)
|
||||||
|
}
|
||||||
|
if (apiKeyTouched()) {
|
||||||
|
setApiKeyTouched(false)
|
||||||
|
}
|
||||||
|
if (clearStoredApiKey()) {
|
||||||
|
setClearStoredApiKey(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void loadSpeechCapabilities()
|
||||||
|
})
|
||||||
|
|
||||||
|
const capabilityLabel = () => {
|
||||||
|
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
|
||||||
|
if (speechCapabilitiesError()) return t("settings.speech.status.error")
|
||||||
|
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDraft = (key: keyof DraftFields, value: string) => {
|
||||||
|
setSaveStatus("idle")
|
||||||
|
if (key === "apiKey") {
|
||||||
|
setApiKeyTouched(true)
|
||||||
|
setClearStoredApiKey(false)
|
||||||
|
}
|
||||||
|
setDrafts((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
|
||||||
|
const playbackSupport = createMemo(() =>
|
||||||
|
getSpeechPlaybackSupport({
|
||||||
|
playbackMode: drafts().playbackMode,
|
||||||
|
ttsFormat: drafts().ttsFormat,
|
||||||
|
capabilities: speechCapabilities(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const compatibilityMessage = createMemo(() => {
|
||||||
|
const capabilities = speechCapabilities()
|
||||||
|
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (drafts().playbackMode === "streaming" && !capabilities.supportsStreamingTts) {
|
||||||
|
return t("settings.speech.compatibility.streamingUnavailable")
|
||||||
|
}
|
||||||
|
if (drafts().playbackMode === "streaming" && !playbackSupport().available) {
|
||||||
|
return t("settings.speech.compatibility.browserStreamingUnavailable")
|
||||||
|
}
|
||||||
|
return t("settings.speech.compatibility.runtimeNote")
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDirty = createMemo(() => {
|
||||||
|
const speech = serverSettings().speech
|
||||||
|
const current = drafts()
|
||||||
|
return (
|
||||||
|
apiKeyDirty() ||
|
||||||
|
(current.baseUrl || "") !== (speech.baseUrl || "") ||
|
||||||
|
current.sttModel !== speech.sttModel ||
|
||||||
|
current.ttsModel !== speech.ttsModel ||
|
||||||
|
current.ttsVoice !== speech.ttsVoice ||
|
||||||
|
current.playbackMode !== speech.playbackMode ||
|
||||||
|
current.ttsFormat !== speech.ttsFormat
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveStatusLabel = () => {
|
||||||
|
if (isSaving()) return t("settings.speech.save.saving")
|
||||||
|
if (saveStatus() === "saved") return t("settings.speech.save.saved")
|
||||||
|
if (saveStatus() === "error") return t("settings.speech.save.error")
|
||||||
|
return t("settings.speech.save.unsaved")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!isDirty() || isSaving()) return
|
||||||
|
const current = drafts()
|
||||||
|
setIsSaving(true)
|
||||||
|
setSaveStatus("idle")
|
||||||
|
try {
|
||||||
|
const trimmedApiKey = current.apiKey.trim()
|
||||||
|
await updateSpeechSettings({
|
||||||
|
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
|
||||||
|
baseUrl: current.baseUrl.trim() || undefined,
|
||||||
|
sttModel: current.sttModel.trim() || undefined,
|
||||||
|
ttsModel: current.ttsModel.trim() || undefined,
|
||||||
|
ttsVoice: current.ttsVoice.trim() || undefined,
|
||||||
|
playbackMode: current.playbackMode,
|
||||||
|
ttsFormat: current.ttsFormat,
|
||||||
|
})
|
||||||
|
await loadSpeechCapabilities(true)
|
||||||
|
setDrafts({
|
||||||
|
apiKey: "",
|
||||||
|
baseUrl: current.baseUrl.trim(),
|
||||||
|
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
|
||||||
|
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
|
||||||
|
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
|
||||||
|
playbackMode: current.playbackMode,
|
||||||
|
ttsFormat: current.ttsFormat,
|
||||||
|
})
|
||||||
|
setApiKeyTouched(false)
|
||||||
|
setClearStoredApiKey(false)
|
||||||
|
setSaveStatus("saved")
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to save speech settings", error)
|
||||||
|
setSaveStatus("error")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Volume2 class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toolbar-inline">
|
||||||
|
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
|
||||||
|
<span class="settings-inline-note">{capabilityLabel()}</span>
|
||||||
|
<span class="settings-inline-note">{saveStatusLabel()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap inline-flex items-center gap-2"
|
||||||
|
onClick={() => void testSpeech.toggle()}
|
||||||
|
disabled={isSaving()}
|
||||||
|
title={testSpeech.buttonTitle()}
|
||||||
|
aria-label={testSpeech.buttonTitle()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={testSpeech.isLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show when={testSpeech.isPlaying()} fallback={<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />}>
|
||||||
|
<Square class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
<span>
|
||||||
|
{testSpeech.isPlaying()
|
||||||
|
? t("settings.speech.testPlayback.stop")
|
||||||
|
: testSpeech.isLoading()
|
||||||
|
? t("settings.speech.testPlayback.generating")
|
||||||
|
: t("settings.speech.testPlayback.action")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={!isDirty() || isSaving()}
|
||||||
|
>
|
||||||
|
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.apiKey.title")}
|
||||||
|
caption={t("settings.speech.apiKey.subtitle")}
|
||||||
|
value={drafts().apiKey}
|
||||||
|
onInput={(value) => updateDraft("apiKey", value)}
|
||||||
|
type="password"
|
||||||
|
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
|
||||||
|
/>
|
||||||
|
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
|
||||||
|
<div class="settings-inline-note">
|
||||||
|
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
|
||||||
|
<Show when={!clearStoredApiKey()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
setClearStoredApiKey(true)
|
||||||
|
setSaveStatus("idle")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settings.speech.apiKey.clearAction")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.baseUrl.title")}
|
||||||
|
caption={t("settings.speech.baseUrl.subtitle")}
|
||||||
|
value={drafts().baseUrl}
|
||||||
|
onInput={(value) => updateDraft("baseUrl", value)}
|
||||||
|
placeholder={t("settings.speech.baseUrl.placeholder")}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.sttModel.title")}
|
||||||
|
caption={t("settings.speech.sttModel.subtitle")}
|
||||||
|
value={drafts().sttModel}
|
||||||
|
onInput={(value) => updateDraft("sttModel", value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.ttsModel.title")}
|
||||||
|
caption={t("settings.speech.ttsModel.subtitle")}
|
||||||
|
value={drafts().ttsModel}
|
||||||
|
onInput={(value) => updateDraft("ttsModel", value)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={t("settings.speech.ttsVoice.title")}
|
||||||
|
caption={t("settings.speech.ttsVoice.subtitle")}
|
||||||
|
value={drafts().ttsVoice}
|
||||||
|
onInput={(value) => updateDraft("ttsVoice", value)}
|
||||||
|
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label={t("settings.speech.playbackMode.title")}
|
||||||
|
caption={t("settings.speech.playbackMode.subtitle")}
|
||||||
|
value={drafts().playbackMode}
|
||||||
|
onInput={(value) => updateDraft("playbackMode", value as DraftFields["playbackMode"])}
|
||||||
|
options={[
|
||||||
|
{ value: "streaming", label: t("settings.speech.playbackMode.streaming") },
|
||||||
|
{ value: "buffered", label: t("settings.speech.playbackMode.buffered") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label={t("settings.speech.ttsFormat.title")}
|
||||||
|
caption={t("settings.speech.ttsFormat.subtitle")}
|
||||||
|
value={drafts().ttsFormat}
|
||||||
|
onInput={(value) => updateDraft("ttsFormat", value as DraftFields["ttsFormat"])}
|
||||||
|
options={[
|
||||||
|
{ value: "mp3", label: "MP3" },
|
||||||
|
{ value: "wav", label: "WAV" },
|
||||||
|
{ value: "opus", label: "Opus" },
|
||||||
|
{ value: "aac", label: "AAC" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="settings-inline-note">{t("settings.speech.help")}</div>
|
||||||
|
<Show when={compatibilityMessage()}>{(message) => <div class="settings-inline-note">{message()}</div>}</Show>
|
||||||
|
<div class="settings-inline-note">{t("settings.speech.testPlayback.note")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Field: Component<{
|
||||||
|
label: string
|
||||||
|
caption: string
|
||||||
|
value: string
|
||||||
|
type?: string
|
||||||
|
placeholder?: string
|
||||||
|
onInput: (value: string) => void
|
||||||
|
icon?: any
|
||||||
|
}> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
|
||||||
|
{props.icon}
|
||||||
|
<input
|
||||||
|
type={props.type ?? "text"}
|
||||||
|
value={props.value}
|
||||||
|
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||||
|
class="selector-input w-full"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectField: Component<{
|
||||||
|
label: string
|
||||||
|
caption: string
|
||||||
|
value: string
|
||||||
|
onInput: (value: string) => void
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
}> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-[18rem] max-w-[24rem] w-full">
|
||||||
|
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
|
||||||
|
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeechSettingsCard
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
import SpeechSettingsCard from "./speech-settings-card"
|
||||||
|
|
||||||
|
export const SpeechSettingsSection: Component = () => {
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<SpeechSettingsCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
packages/ui/src/components/speech-action-button.tsx
Normal file
34
packages/ui/src/components/speech-action-button.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Loader2, Volume2 } from "lucide-solid"
|
||||||
|
import type { JSX } from "solid-js"
|
||||||
|
|
||||||
|
interface SpeechActionButtonProps {
|
||||||
|
class?: string
|
||||||
|
title: string
|
||||||
|
isLoading: boolean
|
||||||
|
isPlaying: boolean
|
||||||
|
onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpeechActionButton(props: SpeechActionButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={props.type ?? "button"}
|
||||||
|
class={props.class}
|
||||||
|
onClick={props.onClick}
|
||||||
|
aria-label={props.title}
|
||||||
|
title={props.title}
|
||||||
|
>
|
||||||
|
{props.isLoading ? (
|
||||||
|
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
|
||||||
|
) : props.isPlaying ? (
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
ToolScrollHelpers,
|
ToolScrollHelpers,
|
||||||
} from "./tool-call/types"
|
} from "./tool-call/types"
|
||||||
import {
|
import {
|
||||||
|
buildToolSpeechText,
|
||||||
ensureMarkdownContent,
|
ensureMarkdownContent,
|
||||||
getRelativePath,
|
getRelativePath,
|
||||||
getToolIcon,
|
getToolIcon,
|
||||||
@@ -41,6 +42,8 @@ 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")
|
||||||
|
|
||||||
@@ -514,6 +517,7 @@ 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,
|
||||||
@@ -959,6 +963,21 @@ 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()
|
||||||
@@ -1022,6 +1041,16 @@ 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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/markdown"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|
||||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||||
@@ -20,6 +20,14 @@ 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
|
||||||
@@ -36,6 +44,8 @@ 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
|
||||||
|
|
||||||
@@ -87,9 +97,9 @@ export function createAnsiContentRenderer(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
|
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
|
||||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
|
||||||
{params.scrollHelpers.renderSentinel()}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</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">{entry.message}</span>
|
<span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import type { Accessor, 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 { ToolCallDiffViewer } from "../diff-viewer"
|
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
import { getCacheEntry } from "../../lib/global-cache"
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
|
||||||
|
const LazyToolCallDiffViewer = lazy(() =>
|
||||||
|
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
|
||||||
|
onMount(() => {
|
||||||
|
props.onRendered?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="tool-call-diff-viewer">
|
||||||
|
<div innerHTML={props.html} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type CacheHandle = {
|
type CacheHandle = {
|
||||||
get<T>(): T | undefined
|
get<T>(): T | undefined
|
||||||
params(): unknown
|
params(): unknown
|
||||||
@@ -16,6 +32,7 @@ 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>
|
||||||
@@ -43,7 +60,10 @@ 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 disableScrollTracking = Boolean(options?.disableScrollTracking)
|
const state = params.toolState()
|
||||||
|
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
|
||||||
@@ -101,15 +121,20 @@ export function createDiffContentRenderer(params: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToolCallDiffViewer
|
{cachedHtml ? (
|
||||||
diffText={payload.diffText}
|
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
||||||
filePath={payload.filePath}
|
) : (
|
||||||
theme={themeKey}
|
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||||
mode={diffMode()}
|
<LazyToolCallDiffViewer
|
||||||
cachedHtml={cachedHtml}
|
diffText={payload.diffText}
|
||||||
cacheEntryParams={cacheEntryParams as any}
|
filePath={payload.filePath}
|
||||||
onRendered={handleDiffRendered}
|
theme={themeKey}
|
||||||
/>
|
mode={diffMode()}
|
||||||
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
|
onRendered={handleDiffRendered}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ 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 disableScrollTracking = options.disableScrollTracking || false
|
|
||||||
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
|
||||||
|
|
||||||
const state = params.toolState()
|
const state = params.toolState()
|
||||||
|
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
|
||||||
|
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
|
||||||
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 (
|
||||||
@@ -43,7 +42,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">{options.content}</pre>
|
<pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||||
import { getLanguageFromPath } from "../../lib/markdown"
|
import { getLanguageFromPath } from "../../lib/text-render-utils"
|
||||||
import type { ToolState } from "@opencode-ai/sdk/v2"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { DiffPayload } from "./types"
|
import type { DiffPayload } from "./types"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
@@ -231,3 +231,37 @@ 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,6 +18,7 @@ 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")
|
||||||
|
|
||||||
@@ -25,8 +26,6 @@ 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.
|
||||||
@@ -71,6 +70,7 @@ 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,7 +99,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
directory: wt.directory,
|
directory: wt.directory,
|
||||||
raw: wt,
|
raw: wt,
|
||||||
}))
|
}))
|
||||||
return [CREATE_OPTION, ...mapped]
|
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
|
||||||
|
return [createOption, ...mapped]
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
FileSystemCreateFolderResponse,
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
|
SpeechCapabilitiesResponse,
|
||||||
|
SpeechSynthesisResponse,
|
||||||
|
SpeechTranscriptionResponse,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
@@ -120,6 +123,28 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||||
|
const headers = normalizeHeaders(init?.headers)
|
||||||
|
if (init?.body !== undefined && !headers["Content-Type"]) {
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = (init?.method ?? "GET").toUpperCase()
|
||||||
|
const startedAt = Date.now()
|
||||||
|
logHttp(`${method} ${path}`)
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text()
|
||||||
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
|
||||||
|
throw new Error(message || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt })
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const serverApi = {
|
export const serverApi = {
|
||||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||||
@@ -235,6 +260,37 @@ 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 !== ".") {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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
|
||||||
@@ -435,6 +436,7 @@ 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,
|
||||||
|
|||||||
416
packages/ui/src/lib/hooks/use-speech.ts
Normal file
416
packages/ui/src/lib/hooks/use-speech.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||||
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
|
import { serverApi } from "../api-client"
|
||||||
|
import { useI18n } from "../i18n"
|
||||||
|
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
||||||
|
import { useConfig, type SpeechSettings } from "../../stores/preferences"
|
||||||
|
import { formatToMimeType, getSpeechPlaybackSupport } from "../speech-playback-support"
|
||||||
|
|
||||||
|
type SpeechPlaybackState = "idle" | "loading" | "playing"
|
||||||
|
|
||||||
|
interface UseSpeechOptions {
|
||||||
|
id: Accessor<string>
|
||||||
|
text: Accessor<string>
|
||||||
|
settingsOverride?: Accessor<Partial<Pick<SpeechSettings, "playbackMode" | "ttsFormat">>>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivePlaybackEntry {
|
||||||
|
ownerId: string
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateResetters = new Map<string, () => void>()
|
||||||
|
|
||||||
|
let activePlayback: ActivePlaybackEntry | null = null
|
||||||
|
|
||||||
|
function resetOwnerState(ownerId: string) {
|
||||||
|
stateResetters.get(ownerId)?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActivePlayback(ownerId?: string) {
|
||||||
|
if (!activePlayback) return
|
||||||
|
if (ownerId && activePlayback.ownerId !== ownerId) return
|
||||||
|
const current = activePlayback
|
||||||
|
activePlayback = null
|
||||||
|
current.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivePlayback(ownerId: string, stop: () => void) {
|
||||||
|
if (activePlayback?.ownerId === ownerId) {
|
||||||
|
activePlayback = { ownerId, stop }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopActivePlayback()
|
||||||
|
activePlayback = { ownerId, stop }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeech(options: UseSpeechOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { serverSettings } = useConfig()
|
||||||
|
const [state, setState] = createSignal<SpeechPlaybackState>("idle")
|
||||||
|
|
||||||
|
let requestVersion = 0
|
||||||
|
let audio: HTMLAudioElement | null = null
|
||||||
|
let objectUrl: string | null = null
|
||||||
|
let mediaSource: MediaSource | null = null
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void loadSpeechCapabilities()
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanupAudio = () => {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio) {
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
audio.src = ""
|
||||||
|
audio.load()
|
||||||
|
audio = null
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSource = null
|
||||||
|
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
objectUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
requestVersion += 1
|
||||||
|
cleanupAudio()
|
||||||
|
setState("idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
stateResetters.set(options.id(), resetState)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stateResetters.delete(options.id())
|
||||||
|
stopActivePlayback(options.id())
|
||||||
|
resetState()
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSupported = () => typeof window !== "undefined" && typeof window.Audio !== "undefined"
|
||||||
|
|
||||||
|
const resolvedSettings = () => ({
|
||||||
|
...serverSettings().speech,
|
||||||
|
...(options.settingsOverride?.() ?? {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const canUseSpeech = () => {
|
||||||
|
const capabilities = speechCapabilities()
|
||||||
|
if (!isSupported() || !capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return getSpeechPlaybackSupport({
|
||||||
|
playbackMode: resolvedSettings().playbackMode,
|
||||||
|
ttsFormat: resolvedSettings().ttsFormat,
|
||||||
|
capabilities,
|
||||||
|
}).available
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (activePlayback?.ownerId === options.id()) {
|
||||||
|
activePlayback = null
|
||||||
|
}
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
const ownerId = options.id()
|
||||||
|
const text = options.text().trim()
|
||||||
|
if (!text || state() === "loading" || state() === "playing") return
|
||||||
|
|
||||||
|
if (!isSupported()) {
|
||||||
|
showAlertDialog(t("messageItem.actions.speak.error.unsupported"), {
|
||||||
|
title: t("messageItem.actions.speak.error.title"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const capabilities = (await loadSpeechCapabilities()) ?? speechCapabilities()
|
||||||
|
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
|
||||||
|
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
|
||||||
|
title: t("messageItem.actions.speak.error.title"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const support = getSpeechPlaybackSupport({
|
||||||
|
playbackMode: resolvedSettings().playbackMode,
|
||||||
|
ttsFormat: resolvedSettings().ttsFormat,
|
||||||
|
capabilities,
|
||||||
|
})
|
||||||
|
if (!support.available) {
|
||||||
|
const detailKey =
|
||||||
|
support.reason === "provider-streaming-unavailable"
|
||||||
|
? "settings.speech.compatibility.streamingUnavailable"
|
||||||
|
: support.reason === "browser-streaming-unavailable"
|
||||||
|
? "settings.speech.compatibility.browserStreamingUnavailable"
|
||||||
|
: "messageItem.actions.speak.error.unsupported"
|
||||||
|
|
||||||
|
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
|
||||||
|
title: t("messageItem.actions.speak.error.title"),
|
||||||
|
detail: t(detailKey),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestVersion += 1
|
||||||
|
const currentRequest = requestVersion
|
||||||
|
stopActivePlayback()
|
||||||
|
cleanupAudio()
|
||||||
|
setState("loading")
|
||||||
|
|
||||||
|
const settings = resolvedSettings()
|
||||||
|
const format = settings.ttsFormat
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (settings.playbackMode === "streaming") {
|
||||||
|
await startStreamingPlayback(ownerId, currentRequest, text, format)
|
||||||
|
} else {
|
||||||
|
await startBufferedPlayback(ownerId, currentRequest, text, format)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (currentRequest !== requestVersion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetState()
|
||||||
|
showAlertDialog(t("messageItem.actions.speak.error.generate"), {
|
||||||
|
title: t("messageItem.actions.speak.error.title"),
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBufferedPlayback(
|
||||||
|
ownerId: string,
|
||||||
|
currentRequest: number,
|
||||||
|
text: string,
|
||||||
|
format: "mp3" | "wav" | "opus" | "aac",
|
||||||
|
) {
|
||||||
|
const response = await serverApi.synthesizeSpeech({ text, format })
|
||||||
|
|
||||||
|
if (currentRequest !== requestVersion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = createObjectUrlFromBase64(response.audioBase64, response.mimeType)
|
||||||
|
const nextAudio = new Audio(nextUrl)
|
||||||
|
objectUrl = nextUrl
|
||||||
|
audio = nextAudio
|
||||||
|
|
||||||
|
attachPlaybackLifecycle(ownerId, nextAudio)
|
||||||
|
setActivePlayback(ownerId, () => {
|
||||||
|
cleanupAudio()
|
||||||
|
setState("idle")
|
||||||
|
})
|
||||||
|
setState("playing")
|
||||||
|
await nextAudio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startStreamingPlayback(
|
||||||
|
ownerId: string,
|
||||||
|
currentRequest: number,
|
||||||
|
text: string,
|
||||||
|
format: "mp3" | "wav" | "opus" | "aac",
|
||||||
|
) {
|
||||||
|
if (typeof MediaSource === "undefined") {
|
||||||
|
throw new Error("MediaSource is not available in this browser.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortController = controller
|
||||||
|
const response = await serverApi.synthesizeSpeechStream({ text, format }, controller.signal)
|
||||||
|
const mimeType = response.headers.get("content-type") || formatToMimeType(format)
|
||||||
|
|
||||||
|
if (!MediaSource.isTypeSupported(mimeType)) {
|
||||||
|
throw new Error(`Streaming playback is not supported for ${mimeType}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = response.body
|
||||||
|
if (!stream) {
|
||||||
|
throw new Error("Speech stream did not include a response body.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMediaSource = new MediaSource()
|
||||||
|
const nextObjectUrl = URL.createObjectURL(nextMediaSource)
|
||||||
|
const nextAudio = new Audio(nextObjectUrl)
|
||||||
|
mediaSource = nextMediaSource
|
||||||
|
objectUrl = nextObjectUrl
|
||||||
|
audio = nextAudio
|
||||||
|
|
||||||
|
attachPlaybackLifecycle(ownerId, nextAudio)
|
||||||
|
setActivePlayback(ownerId, () => {
|
||||||
|
cleanupAudio()
|
||||||
|
setState("idle")
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const handleSourceOpen = () => {
|
||||||
|
nextMediaSource.removeEventListener("sourceopen", handleSourceOpen)
|
||||||
|
void streamToMediaSource({
|
||||||
|
mediaSource: nextMediaSource,
|
||||||
|
stream,
|
||||||
|
mimeType,
|
||||||
|
audioElement: nextAudio,
|
||||||
|
onPlayable: async () => {
|
||||||
|
if (currentRequest !== requestVersion) return
|
||||||
|
if (state() !== "playing") {
|
||||||
|
setState("playing")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await nextAudio.play()
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onComplete: resolve,
|
||||||
|
onError: reject,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextMediaSource.addEventListener("sourceopen", handleSourceOpen, { once: true })
|
||||||
|
nextAudio.addEventListener(
|
||||||
|
"error",
|
||||||
|
() => reject(new Error("Unable to play streamed speech.")),
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
if (state() === "idle") {
|
||||||
|
await start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
canUseSpeech,
|
||||||
|
isLoading: () => state() === "loading",
|
||||||
|
isPlaying: () => state() === "playing",
|
||||||
|
toggle,
|
||||||
|
stop,
|
||||||
|
buttonTitle: () => {
|
||||||
|
if (state() === "loading") return t("messageItem.actions.generatingSpeech")
|
||||||
|
if (state() === "playing") return t("messageItem.actions.stopSpeech")
|
||||||
|
return t("messageItem.actions.speak")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPlaybackLifecycle(ownerId: string, audio: HTMLAudioElement) {
|
||||||
|
const finish = () => {
|
||||||
|
if (activePlayback?.ownerId === ownerId) {
|
||||||
|
activePlayback = null
|
||||||
|
}
|
||||||
|
resetOwnerState(ownerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener("ended", finish, { once: true })
|
||||||
|
audio.addEventListener("error", finish, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToMediaSource(options: {
|
||||||
|
mediaSource: MediaSource
|
||||||
|
stream: ReadableStream<Uint8Array>
|
||||||
|
mimeType: string
|
||||||
|
audioElement: HTMLAudioElement
|
||||||
|
onPlayable: () => Promise<void>
|
||||||
|
onComplete: () => void
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
|
||||||
|
const reader = options.stream.getReader()
|
||||||
|
let startedPlayback = false
|
||||||
|
let queue: Uint8Array[] = []
|
||||||
|
let processing = false
|
||||||
|
|
||||||
|
const flushQueue = async () => {
|
||||||
|
if (processing || sourceBuffer.updating || queue.length === 0) return
|
||||||
|
processing = true
|
||||||
|
const chunk = queue.shift()!
|
||||||
|
await appendChunk(sourceBuffer, chunk)
|
||||||
|
if (!startedPlayback) {
|
||||||
|
startedPlayback = true
|
||||||
|
await options.onPlayable()
|
||||||
|
}
|
||||||
|
processing = false
|
||||||
|
await flushQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
if (value && value.byteLength > 0) {
|
||||||
|
queue.push(value)
|
||||||
|
await flushQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0 || sourceBuffer.updating) {
|
||||||
|
if (queue.length > 0) {
|
||||||
|
await flushQueue()
|
||||||
|
} else {
|
||||||
|
await waitForUpdateEnd(sourceBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mediaSource.readyState === "open") {
|
||||||
|
options.mediaSource.endOfStream()
|
||||||
|
}
|
||||||
|
options.onComplete()
|
||||||
|
} catch (error) {
|
||||||
|
options.onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const handleUpdateEnd = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const handleError = () => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error("Failed to append audio stream chunk."))
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
|
||||||
|
sourceBuffer.removeEventListener("error", handleError)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
|
||||||
|
sourceBuffer.addEventListener("error", handleError, { once: true })
|
||||||
|
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
|
||||||
|
const binary = atob(audioBase64)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index)
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
|
||||||
|
}
|
||||||
@@ -2,27 +2,32 @@ 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"
|
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" | "he"
|
||||||
|
|
||||||
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
|
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans", "he"] as const
|
||||||
|
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||||
|
const RTL_LOCALES = new Set<Locale>(["he"])
|
||||||
|
|
||||||
const messagesByLocale: Record<Locale, Messages> = {
|
const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
|
||||||
en: enMessages,
|
const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
|
||||||
es: esMessages,
|
|
||||||
fr: frMessages,
|
const localeLoaders: Record<Locale, () => Promise<Messages>> = {
|
||||||
ru: ruMessages,
|
en: async () => enMessages,
|
||||||
ja: jaMessages,
|
es: async () => (await import("./messages/es")).esMessages,
|
||||||
"zh-Hans": zhHansMessages,
|
fr: async () => (await import("./messages/fr")).frMessages,
|
||||||
|
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 {
|
||||||
@@ -34,8 +39,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
|||||||
|
|
||||||
const normalized = normalizeLocaleTag(value)
|
const normalized = normalizeLocaleTag(value)
|
||||||
const lower = normalized.toLowerCase()
|
const lower = normalized.toLowerCase()
|
||||||
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
|
||||||
const exact = supportedLower.get(lower)
|
|
||||||
if (exact) return exact
|
if (exact) return exact
|
||||||
|
|
||||||
const parts = lower.split("-")
|
const parts = lower.split("-")
|
||||||
@@ -43,11 +47,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
|
|||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
if (base === "zh") {
|
if (base === "zh") {
|
||||||
const zhHans = supportedLower.get("zh-hans")
|
const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
|
||||||
return zhHans ?? null
|
return zhHans ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseMatch = supportedLower.get(base)
|
const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
|
||||||
return baseMatch ?? null
|
return baseMatch ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +88,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [globalRevision, setGlobalRevision] = createSignal(0)
|
const [globalRevision, setGlobalRevision] = createSignal(0)
|
||||||
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
|
let globalMessages: Messages = enMessages
|
||||||
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
|
let globalLocale: Locale = "en"
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -101,9 +151,12 @@ 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>("en")
|
const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
|
||||||
|
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
|
||||||
const previousMessages = globalMessages
|
const previousGlobalMessages = 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()
|
||||||
@@ -115,20 +168,56 @@ export const I18nProvider: ParentComponent = (props) => {
|
|||||||
return configured ?? detectedLocale() ?? "en"
|
return configured ?? detectedLocale() ?? "en"
|
||||||
})
|
})
|
||||||
|
|
||||||
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
|
||||||
|
|
||||||
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(() => {
|
||||||
globalMessages = messages()
|
const nextLocale = locale()
|
||||||
setGlobalRevision((value) => value + 1)
|
let cancelled = false
|
||||||
|
|
||||||
|
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 = previousMessages
|
globalMessages = previousGlobalMessages
|
||||||
|
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 = {
|
||||||
|
|||||||
@@ -114,12 +114,26 @@ 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.empty": "No git changes yet.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Deleted",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "File list",
|
"instanceShell.filesShell.fileListTitle": "File list",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||||
"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,6 +75,13 @@ 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...",
|
||||||
@@ -135,7 +142,21 @@ 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,6 +65,7 @@ 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",
|
||||||
@@ -137,6 +138,52 @@ 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
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panel de estado",
|
"instanceShell.rightPanel.title": "Panel de estado",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
|
||||||
"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",
|
||||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
||||||
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||||
|
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ 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...",
|
||||||
@@ -137,7 +144,21 @@ 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,6 +65,7 @@ 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",
|
||||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
|
"settings.behavior.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
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panneau d'état",
|
"instanceShell.rightPanel.title": "Panneau d'état",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
|
||||||
"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",
|
||||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
||||||
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||||
|
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ 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...",
|
||||||
@@ -137,7 +144,21 @@ 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,6 +65,7 @@ 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",
|
||||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
|||||||
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
|
"settings.behavior.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
|
||||||
|
|||||||
6
packages/ui/src/lib/i18n/messages/he/advancedSettings.ts
Normal file
6
packages/ui/src/lib/i18n/messages/he/advancedSettings.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const advancedSettingsMessages = {
|
||||||
|
"advancedSettings.title": "הגדרות מתקדמות",
|
||||||
|
"advancedSettings.environmentVariables.title": "משתני סביבה",
|
||||||
|
"advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל",
|
||||||
|
"advancedSettings.actions.close": "סגור",
|
||||||
|
} as const
|
||||||
42
packages/ui/src/lib/i18n/messages/he/app.ts
Normal file
42
packages/ui/src/lib/i18n/messages/he/app.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
176
packages/ui/src/lib/i18n/messages/he/commands.ts
Normal file
176
packages/ui/src/lib/i18n/messages/he/commands.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
export const commandMessages = {
|
||||||
|
"commandPalette.title": "לוח פקודות",
|
||||||
|
"commandPalette.description": "חיפוש והפעלה של פקודות",
|
||||||
|
"commandPalette.searchPlaceholder": "הקלד פקודה או חיפוש...",
|
||||||
|
"commandPalette.empty": "לא נמצאו פקודות עבור \"{query}\"",
|
||||||
|
"commandPalette.category.customCommands": "פקודות מותאמות אישית",
|
||||||
|
"commandPalette.category.instance": "מופע",
|
||||||
|
"commandPalette.category.session": "סשן",
|
||||||
|
"commandPalette.category.agentModel": "סוכן ומודל",
|
||||||
|
"commandPalette.category.inputFocus": "קלט ופוקוס",
|
||||||
|
"commandPalette.category.system": "מערכת",
|
||||||
|
"commandPalette.category.other": "אחר",
|
||||||
|
|
||||||
|
"commands.newInstance.label": "מופע חדש",
|
||||||
|
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
|
||||||
|
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
|
||||||
|
|
||||||
|
"commands.closeInstance.label": "סגור מופע",
|
||||||
|
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
|
||||||
|
"commands.closeInstance.keywords": "עצור, סגור",
|
||||||
|
|
||||||
|
"commands.nextInstance.label": "מופע הבא",
|
||||||
|
"commands.nextInstance.description": "עבור למופע הבא",
|
||||||
|
"commands.nextInstance.keywords": "החלף, נווט",
|
||||||
|
|
||||||
|
"commands.previousInstance.label": "מופע קודם",
|
||||||
|
"commands.previousInstance.description": "עבור למופע הקודם",
|
||||||
|
"commands.previousInstance.keywords": "החלף, נווט",
|
||||||
|
|
||||||
|
"commands.newSession.label": "סשן חדש",
|
||||||
|
"commands.newSession.description": "צור סשן הורה חדש",
|
||||||
|
"commands.newSession.keywords": "צור, התחל",
|
||||||
|
|
||||||
|
"commands.closeSession.label": "סגור סשן",
|
||||||
|
"commands.closeSession.description": "סגור את סשן ההורה הנוכחי",
|
||||||
|
"commands.closeSession.keywords": "סגור, עצור",
|
||||||
|
|
||||||
|
"commands.scrubSessions.label": "נקה סשנים",
|
||||||
|
"commands.scrubSessions.description": "הסר סשנים ריקים, סשני תת-סוכן שסיימו את משימתם הראשית, וסשני פיצול מיותרים.",
|
||||||
|
"commands.scrubSessions.keywords": "ניקוי, ריק, סשנים, הסר, מחק",
|
||||||
|
|
||||||
|
"commands.instanceInfo.label": "מידע על מופע",
|
||||||
|
"commands.instanceInfo.description": "פתח את סקירת המופע ללוגים וסטטוס",
|
||||||
|
"commands.instanceInfo.keywords": "מידע, לוגים, קונסולה, פלט",
|
||||||
|
|
||||||
|
"commands.nextSession.label": "סשן הבא",
|
||||||
|
"commands.nextSession.description": "עבור לסשן הבא",
|
||||||
|
"commands.nextSession.keywords": "החלף, נווט",
|
||||||
|
|
||||||
|
"commands.previousSession.label": "סשן קודם",
|
||||||
|
"commands.previousSession.description": "עבור לסשן הקודם",
|
||||||
|
"commands.previousSession.keywords": "החלף, נווט",
|
||||||
|
|
||||||
|
"commands.compactSession.label": "סכם סשן",
|
||||||
|
"commands.compactSession.description": "סכם ודחוס את הסשן הנוכחי",
|
||||||
|
"commands.compactSession.keywords": "סיכום, דחיסה",
|
||||||
|
"commands.compactSession.errorFallback": "סיכום הסשן נכשל",
|
||||||
|
"commands.compactSession.alert.title": "הסיכום נכשל",
|
||||||
|
"commands.compactSession.alert.message": "הסיכום נכשל: {message}",
|
||||||
|
|
||||||
|
"commands.undoLastMessage.label": "בטל הודעה אחרונה",
|
||||||
|
"commands.undoLastMessage.description": "בטל את ההודעה האחרונה",
|
||||||
|
"commands.undoLastMessage.keywords": "חזרה, ביטול",
|
||||||
|
"commands.undoLastMessage.none.title": "אין פעולות לביטול",
|
||||||
|
"commands.undoLastMessage.none.message": "אין מה לבטל",
|
||||||
|
"commands.undoLastMessage.failed.title": "הביטול נכשל",
|
||||||
|
"commands.undoLastMessage.failed.message": "ביטול ההודעה נכשל",
|
||||||
|
|
||||||
|
"commands.openModelSelector.label": "פתח בורר מודלים",
|
||||||
|
"commands.openModelSelector.description": "בחר מודל אחר",
|
||||||
|
"commands.openModelSelector.keywords": "מודל, llm, ai",
|
||||||
|
|
||||||
|
"commands.selectModelVariant.label": "בחר גרסת מודל",
|
||||||
|
"commands.selectModelVariant.description": "בחר רמת מאמץ חשיבה למודל הנוכחי",
|
||||||
|
"commands.selectModelVariant.keywords": "גרסה, חשיבה, מאמץ",
|
||||||
|
|
||||||
|
"commands.openAgentSelector.label": "פתח בורר סוכנים",
|
||||||
|
"commands.openAgentSelector.description": "בחר סוכן אחר",
|
||||||
|
"commands.openAgentSelector.keywords": "סוכן, מצב",
|
||||||
|
|
||||||
|
"commands.clearInput.label": "נקה קלט",
|
||||||
|
"commands.clearInput.description": "נקה את תיבת הטקסט של הפקודה",
|
||||||
|
"commands.clearInput.keywords": "נקה, אפס",
|
||||||
|
|
||||||
|
"commands.promptSubmitShortcut.label.default": "Enter: שורה חדשה, Cmd/Ctrl+Enter: שלח פקודה",
|
||||||
|
"commands.promptSubmitShortcut.label.swapped": "Enter: שלח פקודה, Cmd/Ctrl+Enter: שורה חדשה",
|
||||||
|
"commands.promptSubmitShortcut.description": "החלף את התנהגות Enter ו-Cmd/Ctrl+Enter בקלט הפקודה",
|
||||||
|
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, שלח, שורה חדשה, קיצור",
|
||||||
|
|
||||||
|
"commands.thinkingBlocks.label.show": "הצג חשיבה",
|
||||||
|
"commands.thinkingBlocks.label.hide": "הסתר חשיבה",
|
||||||
|
"commands.thinkingBlocks.description": "הצג או הסתר קטעי חשיבה של ה-AI",
|
||||||
|
"commands.thinkingBlocks.keywords": "חשיבה, הצג, הסתר",
|
||||||
|
|
||||||
|
"commands.timelineToolCalls.label.show": "הצג קריאות כלי בציר הזמן",
|
||||||
|
"commands.timelineToolCalls.label.hide": "הסתר קריאות כלי בציר הזמן",
|
||||||
|
"commands.timelineToolCalls.description": "הצג/הסתר קריאות כלי בציר הודעות",
|
||||||
|
"commands.timelineToolCalls.keywords": "ציר זמן, כלי, הצג, הסתר",
|
||||||
|
|
||||||
|
"commands.keyboardShortcutHints.label.show": "הצג רמזי קיצורי מקלדת",
|
||||||
|
"commands.keyboardShortcutHints.label.hide": "הסתר רמזי קיצורי מקלדת",
|
||||||
|
"commands.keyboardShortcutHints.description": "הצג או הסתר רמזי קיצורי מקלדת בכל הממשק",
|
||||||
|
"commands.keyboardShortcutHints.description.disabledWeb": "מושבת בממשק Web (רמזי קיצורים תמיד מוסתרים)",
|
||||||
|
"commands.keyboardShortcutHints.keywords": "קיצור, מקלדת, רמזים",
|
||||||
|
|
||||||
|
"commands.common.expanded": "פרוס",
|
||||||
|
"commands.common.collapsed": "מכווץ",
|
||||||
|
"commands.common.visible": "גלוי",
|
||||||
|
"commands.common.hidden": "מוסתר",
|
||||||
|
"commands.common.enabled": "מופעל",
|
||||||
|
"commands.common.disabled": "מושבת",
|
||||||
|
|
||||||
|
"commands.thinkingBlocksDefault.label": "תצוגת חשיבה: {state}",
|
||||||
|
"commands.thinkingBlocksDefault.description": "כווץ / פרוס קטעי חשיבה של ה-AI",
|
||||||
|
"commands.thinkingBlocksDefault.keywords": "חשיבה, פרוס, כווץ, ברירת מחדל",
|
||||||
|
|
||||||
|
"commands.diffViewSplit.label": "השתמש בתצוגת diff מפוצלת",
|
||||||
|
"commands.diffViewSplit.description": "הצג diff של קריאות כלי זה לצד זה",
|
||||||
|
"commands.diffViewSplit.keywords": "diff, מפוצל, תצוגה",
|
||||||
|
|
||||||
|
"commands.diffViewUnified.label": "השתמש בתצוגת diff מאוחדת",
|
||||||
|
"commands.diffViewUnified.description": "הצג diff של קריאות כלי בשורה אחת",
|
||||||
|
"commands.diffViewUnified.keywords": "diff, מאוחד, תצוגה",
|
||||||
|
|
||||||
|
"commands.toolOutputsDefault.label": "ברירת מחדל לפלטי כלים · {state}",
|
||||||
|
"commands.toolOutputsDefault.description": "החלף ברירת מחדל לפריסת פלטי כלים",
|
||||||
|
"commands.toolOutputsDefault.keywords": "כלי, פלט, פרוס, כווץ",
|
||||||
|
|
||||||
|
"commands.diagnosticsDefault.label": "ברירת מחדל לאבחון · {state}",
|
||||||
|
"commands.diagnosticsDefault.description": "החלף ברירת מחדל לפריסת פלט אבחון",
|
||||||
|
"commands.diagnosticsDefault.keywords": "אבחון, פרוס, כווץ",
|
||||||
|
|
||||||
|
"commands.toolInputsVisibility.label": "נראות קלטי כלים · {state}",
|
||||||
|
"commands.toolInputsVisibility.description": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי",
|
||||||
|
"commands.toolInputsVisibility.keywords": "כלי, קלטים, ארגומנטים, נראות, הסתר, הצג",
|
||||||
|
|
||||||
|
"commands.tokenUsageDisplay.label": "תצוגת שימוש בטוקנים · {state}",
|
||||||
|
"commands.tokenUsageDisplay.description": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן",
|
||||||
|
"commands.tokenUsageDisplay.keywords": "טוקן, שימוש, עלות, נתונים",
|
||||||
|
|
||||||
|
"commands.autoCleanupBlankSessions.label": "ניקוי אוטומטי של סשנים ריקים · {state}",
|
||||||
|
"commands.autoCleanupBlankSessions.description": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים",
|
||||||
|
"commands.autoCleanupBlankSessions.keywords": "אוטומטי, ניקוי, ריק, סשנים",
|
||||||
|
|
||||||
|
"commands.showHelp.label": "הצג עזרה",
|
||||||
|
"commands.showHelp.description": "הצג קיצורי מקלדת ועזרה",
|
||||||
|
"commands.showHelp.keywords": "קיצורים, עזרה",
|
||||||
|
|
||||||
|
"commands.custom.argumentsPrompt.message": "ארגומנטים עבור /{name}",
|
||||||
|
"commands.custom.argumentsPrompt.title": "פקודה מותאמת אישית",
|
||||||
|
"commands.custom.argumentsPrompt.inputLabel": "ארגומנטים",
|
||||||
|
"commands.custom.argumentsPrompt.inputPlaceholder": "למשל: foo bar",
|
||||||
|
"commands.custom.argumentsPrompt.confirmLabel": "הפעל",
|
||||||
|
"commands.custom.argumentsPrompt.cancelLabel": "ביטול",
|
||||||
|
"commands.custom.argumentsPrompt.openFailed.message": "פתיחת תיבת ארגומנטים נכשלה.",
|
||||||
|
"commands.custom.argumentsPrompt.openFailed.title": "ארגומנטים לפקודה",
|
||||||
|
"commands.custom.entries.descriptionFallback": "פקודה מותאמת אישית",
|
||||||
|
"commands.custom.sessionRequired.message": "בחר סשן לפני הפעלת פקודה מותאמת אישית.",
|
||||||
|
"commands.custom.sessionRequired.title": "נדרש סשן",
|
||||||
|
"commands.custom.runFailed.message": "הפעלת הפקודה המותאמת אישית נכשלה. בדוק את הקונסולה לפרטים.",
|
||||||
|
"commands.custom.runFailed.title": "הפקודה נכשלה",
|
||||||
|
|
||||||
|
"unifiedPicker.loading.searching": "מחפש...",
|
||||||
|
"unifiedPicker.loading.loadingWorkspace": "טוען סביבת עבודה...",
|
||||||
|
"unifiedPicker.title.command": "בחר פקודה",
|
||||||
|
"unifiedPicker.title.mention": "בחר סוכן או קובץ",
|
||||||
|
"unifiedPicker.empty": "לא נמצאו תוצאות",
|
||||||
|
"unifiedPicker.sections.commands": "פקודות",
|
||||||
|
"unifiedPicker.sections.agents": "סוכנים",
|
||||||
|
"unifiedPicker.sections.files": "קבצים",
|
||||||
|
"unifiedPicker.sections.workspaceRoot": "שורש סביבת העבודה",
|
||||||
|
"unifiedPicker.badge.subagent": "תת-סוכן",
|
||||||
|
"unifiedPicker.footer.navigate": "ניווט",
|
||||||
|
"unifiedPicker.footer.select": "בחירה",
|
||||||
|
"unifiedPicker.footer.close": "סגירה",
|
||||||
|
} as const
|
||||||
16
packages/ui/src/lib/i18n/messages/he/dialogs.ts
Normal file
16
packages/ui/src/lib/i18n/messages/he/dialogs.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const dialogMessages = {
|
||||||
|
"alertDialog.fallbackTitle.info": "לתשומת לבך",
|
||||||
|
"alertDialog.fallbackTitle.warning": "נא לבדוק",
|
||||||
|
"alertDialog.fallbackTitle.error": "משהו השתבש",
|
||||||
|
"alertDialog.actions.confirm": "אישור",
|
||||||
|
"alertDialog.actions.run": "הפעל",
|
||||||
|
"alertDialog.actions.ok": "אישור",
|
||||||
|
"alertDialog.actions.cancel": "ביטול",
|
||||||
|
"alertDialog.prompt.inputLabel": "קלט",
|
||||||
|
|
||||||
|
"backgroundProcessOutputDialog.title": "פלט תהליך רקע",
|
||||||
|
"backgroundProcessOutputDialog.actions.close": "סגור",
|
||||||
|
"backgroundProcessOutputDialog.loading": "טוען פלט...",
|
||||||
|
"backgroundProcessOutputDialog.truncatedNotice": "הפלט קוצר לצורך התצוגה.",
|
||||||
|
"backgroundProcessOutputDialog.loadErrorFallback": "טעינת הפלט נכשלה.",
|
||||||
|
} as const
|
||||||
43
packages/ui/src/lib/i18n/messages/he/filesystem.ts
Normal file
43
packages/ui/src/lib/i18n/messages/he/filesystem.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export const filesystemMessages = {
|
||||||
|
"directoryBrowser.defaultDescription": "עיון בתיקיות תחת שורש סביבת העבודה המוגדר.",
|
||||||
|
"directoryBrowser.close": "סגור",
|
||||||
|
"directoryBrowser.currentFolder": "תיקייה נוכחית",
|
||||||
|
"directoryBrowser.selectCurrent": "בחר נוכחית",
|
||||||
|
"directoryBrowser.newFolder": "תיקייה חדשה",
|
||||||
|
"directoryBrowser.creating": "יוצר…",
|
||||||
|
"directoryBrowser.loadingFolders": "טוען תיקיות…",
|
||||||
|
"directoryBrowser.noFolders": "אין תיקיות זמינות.",
|
||||||
|
"directoryBrowser.upOneLevel": "עלה רמה אחת",
|
||||||
|
"directoryBrowser.select": "בחר",
|
||||||
|
"directoryBrowser.load.errorFallback": "לא ניתן לטעון את מערכת הקבצים",
|
||||||
|
"directoryBrowser.createFolder.promptMessage": "צור תיקייה חדשה בספרייה הנוכחית.",
|
||||||
|
"directoryBrowser.createFolder.title": "תיקייה חדשה",
|
||||||
|
"directoryBrowser.createFolder.inputLabel": "שם תיקייה",
|
||||||
|
"directoryBrowser.createFolder.inputPlaceholder": "למשל: my-new-project",
|
||||||
|
"directoryBrowser.createFolder.confirmLabel": "צור",
|
||||||
|
"directoryBrowser.createFolder.cancelLabel": "ביטול",
|
||||||
|
"directoryBrowser.createFolder.invalidNameMessage": "נא להזין שם תיקייה יחיד.",
|
||||||
|
"directoryBrowser.createFolder.invalidNameDetail": "שמות תיקיות אינם יכולים לכלול נטויות, '..', או '~'.",
|
||||||
|
"directoryBrowser.createFolder.errorFallback": "יצירת התיקייה נכשלה",
|
||||||
|
|
||||||
|
"filesystemBrowser.descriptionFallback": "חפש נתיב תחת שורש סביבת העבודה המוגדר.",
|
||||||
|
"filesystemBrowser.rootLabel": "שורש: {root}",
|
||||||
|
"filesystemBrowser.actions.close": "סגור",
|
||||||
|
"filesystemBrowser.actions.retry": "נסה שוב",
|
||||||
|
"filesystemBrowser.actions.select": "בחר",
|
||||||
|
"filesystemBrowser.filterLabel": "סינון",
|
||||||
|
"filesystemBrowser.search.placeholder.directories": "חפש תיקיות",
|
||||||
|
"filesystemBrowser.search.placeholder.files": "חפש קבצים",
|
||||||
|
"filesystemBrowser.currentFolder.label": "תיקייה נוכחית",
|
||||||
|
"filesystemBrowser.currentFolder.selectCurrent": "בחר נוכחית",
|
||||||
|
"filesystemBrowser.loading.filesystem": "מערכת קבצים",
|
||||||
|
"filesystemBrowser.loading.workspaceRoot": "שורש סביבת עבודה",
|
||||||
|
"filesystemBrowser.loading.loadingWithPath": "טוען {path}…",
|
||||||
|
"filesystemBrowser.empty.noEntries": "לא נמצאו רשומות.",
|
||||||
|
"filesystemBrowser.navigation.upOneLevel": "עלה רמה אחת",
|
||||||
|
"filesystemBrowser.hints.navigate": "ניווט",
|
||||||
|
"filesystemBrowser.hints.select": "בחירה",
|
||||||
|
"filesystemBrowser.hints.close": "סגירה",
|
||||||
|
"filesystemBrowser.errors.loadFilesystemFallback": "לא ניתן לטעון את מערכת הקבצים",
|
||||||
|
"filesystemBrowser.errors.openDirectoryFallback": "לא ניתן לפתוח את הספרייה",
|
||||||
|
} as const
|
||||||
42
packages/ui/src/lib/i18n/messages/he/folderSelection.ts
Normal file
42
packages/ui/src/lib/i18n/messages/he/folderSelection.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export const folderSelectionMessages = {
|
||||||
|
"folderSelection.language.ariaLabel": "שפה",
|
||||||
|
|
||||||
|
"folderSelection.logoAlt": "לוגו CodeNomad",
|
||||||
|
"folderSelection.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
|
||||||
|
|
||||||
|
"folderSelection.links.github": "CodeNomad GitHub",
|
||||||
|
"folderSelection.links.githubStars": "כוכבי CodeNomad ב-GitHub",
|
||||||
|
"folderSelection.links.discord": "CodeNomad Discord",
|
||||||
|
|
||||||
|
"folderSelection.empty.title": "אין תיקיות אחרונות",
|
||||||
|
"folderSelection.empty.description": "עיין בתיקייה כדי להתחיל",
|
||||||
|
|
||||||
|
"folderSelection.recent.title": "תיקיות אחרונות",
|
||||||
|
"folderSelection.recent.subtitle.one": "תיקייה אחת זמינה",
|
||||||
|
"folderSelection.recent.subtitle.other": "{count} תיקיות זמינות",
|
||||||
|
"folderSelection.recent.remove": "הסר מהרשימה האחרונה",
|
||||||
|
|
||||||
|
"folderSelection.browse.title": "עיון בתיקייה",
|
||||||
|
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
|
||||||
|
"folderSelection.browse.button": "עיון בתיקיות",
|
||||||
|
"folderSelection.browse.buttonOpening": "פותח...",
|
||||||
|
|
||||||
|
"folderSelection.advancedSettings": "הגדרות מתקדמות",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
|
"folderSelection.hints.navigate": "ניווט",
|
||||||
|
"folderSelection.hints.select": "בחירה",
|
||||||
|
"folderSelection.hints.remove": "הסרה",
|
||||||
|
"folderSelection.hints.browse": "עיון",
|
||||||
|
|
||||||
|
"folderSelection.loading.title": "מפעיל מופע...",
|
||||||
|
"folderSelection.loading.subtitle": "המתן בזמן שאנו מכינים את סביבת העבודה שלך.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "שחרר תיקייה כדי לפתוח אותה",
|
||||||
|
"folderSelection.drop.subtitle": "התחל מופע חדש בתיקייה שנשחררה.",
|
||||||
|
"folderSelection.drop.invalidTitle": "לא ניתן לפתוח את הפריט שנשחרר",
|
||||||
|
"folderSelection.drop.invalidMessage": "שחרר תיקייה כדי להתחיל מופע חדש.",
|
||||||
|
|
||||||
|
"folderSelection.dialog.title": "בחר סביבת עבודה",
|
||||||
|
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
|
||||||
|
} as const
|
||||||
36
packages/ui/src/lib/i18n/messages/he/index.ts
Normal file
36
packages/ui/src/lib/i18n/messages/he/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { advancedSettingsMessages } from "./advancedSettings"
|
||||||
|
import { appMessages } from "./app"
|
||||||
|
import { commandMessages } from "./commands"
|
||||||
|
import { dialogMessages } from "./dialogs"
|
||||||
|
import { filesystemMessages } from "./filesystem"
|
||||||
|
import { folderSelectionMessages } from "./folderSelection"
|
||||||
|
import { instanceMessages } from "./instance"
|
||||||
|
import { loadingScreenMessages } from "./loadingScreen"
|
||||||
|
import { logMessages } from "./logs"
|
||||||
|
import { markdownMessages } from "./markdown"
|
||||||
|
import { messagingMessages } from "./messaging"
|
||||||
|
import { remoteAccessMessages } from "./remoteAccess"
|
||||||
|
import { sessionMessages } from "./session"
|
||||||
|
import { settingsMessages } from "./settings"
|
||||||
|
import { timeMessages } from "./time"
|
||||||
|
import { toolCallMessages } from "./toolCall"
|
||||||
|
import { mergeMessageParts } from "../merge"
|
||||||
|
|
||||||
|
export const heMessages = mergeMessageParts(
|
||||||
|
folderSelectionMessages,
|
||||||
|
advancedSettingsMessages,
|
||||||
|
loadingScreenMessages,
|
||||||
|
timeMessages,
|
||||||
|
appMessages,
|
||||||
|
dialogMessages,
|
||||||
|
filesystemMessages,
|
||||||
|
instanceMessages,
|
||||||
|
logMessages,
|
||||||
|
sessionMessages,
|
||||||
|
messagingMessages,
|
||||||
|
toolCallMessages,
|
||||||
|
markdownMessages,
|
||||||
|
settingsMessages,
|
||||||
|
remoteAccessMessages,
|
||||||
|
commandMessages,
|
||||||
|
)
|
||||||
166
packages/ui/src/lib/i18n/messages/he/instance.ts
Normal file
166
packages/ui/src/lib/i18n/messages/he/instance.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
export const instanceMessages = {
|
||||||
|
"instanceTabs.new.title": "מופע חדש (Cmd/Ctrl+N)",
|
||||||
|
"instanceTabs.new.ariaLabel": "מופע חדש",
|
||||||
|
"instanceTabs.remote.title": "חיבור מרוחק",
|
||||||
|
"instanceTabs.remote.ariaLabel": "חיבור מרוחק",
|
||||||
|
|
||||||
|
"instanceInfo.title": "מידע על המופע",
|
||||||
|
"instanceInfo.labels.folder": "תיקייה",
|
||||||
|
"instanceInfo.labels.project": "פרויקט",
|
||||||
|
"instanceInfo.labels.versionControl": "בקרת גרסאות",
|
||||||
|
"instanceInfo.labels.opencodeVersion": "גרסת OpenCode",
|
||||||
|
"instanceInfo.labels.binaryPath": "נתיב קובץ בינארי",
|
||||||
|
"instanceInfo.labels.environmentVariables": "משתני סביבה ({count})",
|
||||||
|
"instanceInfo.loading": "טוען...",
|
||||||
|
"instanceInfo.server.title": "שרת",
|
||||||
|
"instanceInfo.server.port": "פורט:",
|
||||||
|
"instanceInfo.server.pid": "PID:",
|
||||||
|
"instanceInfo.server.status": "סטטוס:",
|
||||||
|
|
||||||
|
"instanceTab.status.permission": "ממתין לאישור",
|
||||||
|
"instanceTab.status.compacting": "מסכם",
|
||||||
|
"instanceTab.status.working": "עובד",
|
||||||
|
"instanceTab.status.idle": "מוכן",
|
||||||
|
"instanceTab.status.ariaLabel": "סטטוס מופע: {status}",
|
||||||
|
"instanceTab.actions.close.ariaLabel": "סגור מופע",
|
||||||
|
|
||||||
|
"instanceShell.leftPanel.sessionsTitle": "סשנים",
|
||||||
|
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
|
||||||
|
|
||||||
|
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
|
||||||
|
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
|
||||||
|
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
|
||||||
|
"instanceShell.leftDrawer.toggle.open": "פתח מגירה שמאלית",
|
||||||
|
"instanceShell.leftDrawer.toggle.close": "סגור מגירה שמאלית",
|
||||||
|
|
||||||
|
"instanceShell.rightDrawer.pin": "נעץ מגירה ימנית",
|
||||||
|
"instanceShell.rightDrawer.unpin": "שחרר נעיצת מגירה ימנית",
|
||||||
|
"instanceShell.rightDrawer.toggle.pinned": "המגירה הימנית נעוצה",
|
||||||
|
"instanceShell.rightDrawer.toggle.open": "פתח מגירה ימנית",
|
||||||
|
"instanceShell.rightDrawer.toggle.close": "סגור מגירה ימנית",
|
||||||
|
|
||||||
|
"instanceShell.fullscreen.enter": "מסך מלא",
|
||||||
|
"instanceShell.fullscreen.exit": "יציאה ממסך מלא",
|
||||||
|
|
||||||
|
"instanceShell.metrics.usedLabel": "בשימוש",
|
||||||
|
"instanceShell.metrics.availableLabel": "זמין",
|
||||||
|
|
||||||
|
"instanceShell.commandPalette.openAriaLabel": "פתח לוח פקודות",
|
||||||
|
"instanceShell.commandPalette.button": "לוח פקודות",
|
||||||
|
|
||||||
|
"instanceShell.connection.ariaLabel": "חיבור {status}",
|
||||||
|
"instanceShell.connection.connected": "מחובר",
|
||||||
|
"instanceShell.connection.connecting": "מתחבר...",
|
||||||
|
"instanceShell.connection.disconnected": "מנותק",
|
||||||
|
"instanceShell.connection.unknown": "לא ידוע",
|
||||||
|
|
||||||
|
"instanceWelcome.shortcuts.newSession": "סשן חדש",
|
||||||
|
"instanceWelcome.empty.title": "אין סשנים קודמים",
|
||||||
|
"instanceWelcome.empty.description": "צור סשן חדש למטה כדי להתחיל",
|
||||||
|
"instanceWelcome.loading.title": "טוען סשנים",
|
||||||
|
"instanceWelcome.loading.description": "מאחזר את הסשנים הקודמים שלך...",
|
||||||
|
"instanceWelcome.resume.title": "המשך סשן",
|
||||||
|
"instanceWelcome.resume.subtitle.one": "סשן אחד זמין",
|
||||||
|
"instanceWelcome.resume.subtitle.other": "{count} סשנים זמינים",
|
||||||
|
"instanceWelcome.session.untitled": "סשן ללא שם",
|
||||||
|
"instanceWelcome.new.title": "התחל סשן חדש",
|
||||||
|
"instanceWelcome.new.subtitle": "ישתמש אוטומטית בסוכן/מודל האחרון שלך",
|
||||||
|
"instanceWelcome.new.createButton": "צור סשן",
|
||||||
|
"instanceWelcome.overlay.close": "סגור",
|
||||||
|
"instanceWelcome.actions.viewInstanceInfo": "צפה במידע על המופע",
|
||||||
|
"instanceWelcome.actions.renameTitle": "שנה שם סשן",
|
||||||
|
"instanceWelcome.actions.deleteTitle": "מחק סשן",
|
||||||
|
"instanceWelcome.hints.navigate": "ניווט",
|
||||||
|
"instanceWelcome.hints.jump": "קפיצה",
|
||||||
|
"instanceWelcome.hints.firstLast": "ראשון/אחרון",
|
||||||
|
"instanceWelcome.hints.resume": "המשך",
|
||||||
|
"instanceWelcome.hints.delete": "מחיקה",
|
||||||
|
"instanceWelcome.toasts.renameError": "לא ניתן לשנות שם הסשן",
|
||||||
|
|
||||||
|
"instanceDisconnected.title": "המופע התנתק",
|
||||||
|
"instanceDisconnected.folderFallback": "סביבת עבודה זו",
|
||||||
|
"instanceDisconnected.reasonFallback": "השרת הפסיק להגיב",
|
||||||
|
"instanceDisconnected.description": "לא ניתן עוד להגיע ל-{folder}. סגור את הלשונית כדי להמשיך לעבוד.",
|
||||||
|
"instanceDisconnected.details.title": "פרטים",
|
||||||
|
"instanceDisconnected.details.folderLabel": "תיקייה:",
|
||||||
|
"instanceDisconnected.actions.closeInstance": "סגור מופע",
|
||||||
|
|
||||||
|
"instanceShell.empty.title": "לא נבחר סשן",
|
||||||
|
"instanceShell.empty.description": "בחר סשן לצפייה בהודעות",
|
||||||
|
|
||||||
|
"instanceShell.rightPanel.title": "לוח סטטוס",
|
||||||
|
"instanceShell.rightPanel.tabs.changes": "שינויי סשן",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "שינויי Git",
|
||||||
|
"instanceShell.rightPanel.tabs.files": "קבצים",
|
||||||
|
"instanceShell.rightPanel.tabs.status": "סטטוס",
|
||||||
|
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
|
||||||
|
"instanceShell.rightPanel.actions.refresh": "רענן",
|
||||||
|
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
|
||||||
|
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
|
||||||
|
"instanceShell.rightPanel.sections.plan": "תוכנית",
|
||||||
|
"instanceShell.rightPanel.sections.plan.tooltip": "מפת הדרכים של הסוכן לסשן זה. עוקב אחר משימות, תת-משימות וסטטוס השלמתן.",
|
||||||
|
"instanceShell.rightPanel.sections.backgroundProcesses": "מעטפות רקע",
|
||||||
|
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "תהליכים ממושכים שהופעלו על ידי הסוכן. ניתן לעקוב אחר פלטם, לעצור אותם או לסיים אותם.",
|
||||||
|
"instanceShell.rightPanel.sections.mcp": "שרתי MCP",
|
||||||
|
"instanceShell.rightPanel.sections.mcp.tooltip": "שרתי Model Context Protocol המרחיבים את יכולות הסוכן עם כלים ושירותים חיצוניים.",
|
||||||
|
"instanceShell.rightPanel.sections.lsp": "שרתי LSP",
|
||||||
|
"instanceShell.rightPanel.sections.lsp.tooltip": "שרתי Language Server Protocol המספקים בינת קוד, אבחון ותכונות ספציפיות לשפה.",
|
||||||
|
"instanceShell.rightPanel.sections.plugins": "תוספים",
|
||||||
|
"instanceShell.rightPanel.sections.plugins.tooltip": "תוספים המתאימים אישית את הממשק ואת התנהגות השרת, ומוסיפים תכונות מעבר ל-MCP ו-LSP.",
|
||||||
|
|
||||||
|
"instanceShell.sessionChanges.noSessionSelected": "בחר סשן לצפייה בשינויים.",
|
||||||
|
"instanceShell.sessionChanges.loading": "מאחזר שינויי סשן...",
|
||||||
|
"instanceShell.sessionChanges.empty": "אין שינויי סשן עדיין.",
|
||||||
|
"instanceShell.sessionChanges.filesChanged": "{count} קבצים שונו",
|
||||||
|
"instanceShell.sessionChanges.actions.show": "הצג שינויים",
|
||||||
|
|
||||||
|
"instanceShell.filesShell.fileListTitle": "רשימת קבצים",
|
||||||
|
"instanceShell.filesShell.mobileSelectorLabel": "בחר קובץ",
|
||||||
|
"instanceShell.filesShell.mobileSelectorEmpty": "בחר קובץ",
|
||||||
|
"instanceShell.filesShell.viewerTitle": "מציג שינויים",
|
||||||
|
"instanceShell.filesShell.viewerPlaceholder": "תצוגת שינויים מפורטת תתווסף בשלב הבא.",
|
||||||
|
"instanceShell.filesShell.viewerEmpty": "לא נבחר קובץ.",
|
||||||
|
"instanceShell.filesShell.hideFiles": "הסתר קבצים",
|
||||||
|
"instanceShell.filesShell.showFiles": "הצג קבצים",
|
||||||
|
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
|
||||||
|
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
|
||||||
|
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
|
||||||
|
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
|
||||||
|
"instanceShell.diff.showFull": "הצג קובץ מלא",
|
||||||
|
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
|
||||||
|
"instanceShell.diff.switchToUnified": "עבור לתצוגה מאוחדת",
|
||||||
|
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
|
||||||
|
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
|
||||||
|
"instanceShell.worktree.create": "+ צור worktree",
|
||||||
|
|
||||||
|
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
|
||||||
|
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
|
||||||
|
|
||||||
|
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
|
||||||
|
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
|
||||||
|
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
|
||||||
|
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
||||||
|
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
||||||
|
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
|
||||||
|
|
||||||
|
"versionPill.appWithVersion": "אפליקציה {version}",
|
||||||
|
"versionPill.ui": "ממשק",
|
||||||
|
"versionPill.uiWithVersion": "ממשק {version}",
|
||||||
|
"versionPill.source": " ({source})",
|
||||||
|
|
||||||
|
"opencodeBinarySelector.title": "קובץ בינארי של OpenCode",
|
||||||
|
"opencodeBinarySelector.subtitle": "בחר איזה קובץ הרצה OpenCode ישתמש",
|
||||||
|
"opencodeBinarySelector.customPath.placeholder": "הזן נתיב לקובץ בינארי של opencode…",
|
||||||
|
"opencodeBinarySelector.actions.add": "הוסף",
|
||||||
|
"opencodeBinarySelector.actions.browse": "עיין אחר קובץ בינארי…",
|
||||||
|
"opencodeBinarySelector.actions.removeTitle": "הסר קובץ בינארי",
|
||||||
|
"opencodeBinarySelector.badge.systemPath": "השתמש בקובץ בינארי מנתיב המערכת",
|
||||||
|
"opencodeBinarySelector.status.checkingVersions": "בודק גרסאות…",
|
||||||
|
"opencodeBinarySelector.status.checking": "בודק…",
|
||||||
|
"opencodeBinarySelector.dialog.title": "בחר קובץ בינארי של OpenCode",
|
||||||
|
"opencodeBinarySelector.dialog.description": "עיין בקבצים החשופים על ידי שרת ה-CLI.",
|
||||||
|
"opencodeBinarySelector.validation.invalidBinary": "קובץ בינארי לא תקין של OpenCode",
|
||||||
|
"opencodeBinarySelector.validation.alreadyValidating": "כבר מאמת",
|
||||||
|
"opencodeBinarySelector.display.systemPath": "{name} (נתיב מערכת)",
|
||||||
|
"opencodeBinarySelector.versionLabel": "v{version}",
|
||||||
|
} as const
|
||||||
17
packages/ui/src/lib/i18n/messages/he/loadingScreen.ts
Normal file
17
packages/ui/src/lib/i18n/messages/he/loadingScreen.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const loadingScreenMessages = {
|
||||||
|
"loadingScreen.logoAlt": "לוגו CodeNomad",
|
||||||
|
"loadingScreen.status.issue": "נתקלנו בבעיה",
|
||||||
|
"loadingScreen.actions.showAnother": "הצג עוד",
|
||||||
|
"loadingScreen.errors.missingRoot": "אלמנט השורש לטעינה לא נמצא",
|
||||||
|
|
||||||
|
"loadingScreen.phrases.neurons": "מחמם את הנוירונים של ה-AI…",
|
||||||
|
"loadingScreen.phrases.daydreaming": "משכנע את ה-AI להפסיק לחלום בהקיץ…",
|
||||||
|
"loadingScreen.phrases.goggles": "מצחצח את משקפי הקוד של ה-AI…",
|
||||||
|
"loadingScreen.phrases.reorganizingFiles": "מבקש מה-AI להפסיק לארגן מחדש את הקבצים שלך…",
|
||||||
|
"loadingScreen.phrases.coffee": "מאכיל את ה-AI עוד קפה…",
|
||||||
|
"loadingScreen.phrases.nodeModules": "מלמד את ה-AI לא למחוק node_modules (שוב)…",
|
||||||
|
"loadingScreen.phrases.actNatural": "אומר ל-AI להיראות טבעי לפני שתגיע…",
|
||||||
|
"loadingScreen.phrases.rewritingHistory": "מבקש מה-AI בבקשה להפסיק לשכתב היסטוריה…",
|
||||||
|
"loadingScreen.phrases.stretch": "מאפשר ל-AI להתמתח לפני ספרינט הקוד שלו…",
|
||||||
|
"loadingScreen.phrases.keyboardControl": "משכנע את ה-AI לתת לך שליטה על המקלדת…",
|
||||||
|
} as const
|
||||||
27
packages/ui/src/lib/i18n/messages/he/logs.ts
Normal file
27
packages/ui/src/lib/i18n/messages/he/logs.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export const logMessages = {
|
||||||
|
"logsView.title": "לוגי שרת",
|
||||||
|
"logsView.actions.show": "הצג לוגי שרת",
|
||||||
|
"logsView.actions.hide": "הסתר לוגי שרת",
|
||||||
|
"logsView.envVars.title": "משתני סביבה ({count})",
|
||||||
|
"logsView.paused.title": "לוגי השרת מושהים",
|
||||||
|
"logsView.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
|
||||||
|
"logsView.empty.waiting": "ממתין לפלט שרת...",
|
||||||
|
"logsView.scrollToBottom": "גלול למטה",
|
||||||
|
|
||||||
|
"infoView.logs.title": "לוגי שרת",
|
||||||
|
"infoView.logs.actions.show": "הצג לוגי שרת",
|
||||||
|
"infoView.logs.actions.hide": "הסתר לוגי שרת",
|
||||||
|
"infoView.logs.paused.title": "לוגי השרת מושהים",
|
||||||
|
"infoView.logs.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
|
||||||
|
"infoView.logs.empty.waiting": "ממתין לפלט שרת...",
|
||||||
|
"infoView.logs.scrollToBottom": "גלול למטה",
|
||||||
|
|
||||||
|
"infoView.dispose.actions.dispose": "בטל מופע",
|
||||||
|
"infoView.dispose.actions.disposing": "מבטל...",
|
||||||
|
"infoView.dispose.confirm.title": "לבטל את המופע?",
|
||||||
|
"infoView.dispose.confirm.message": "פעולה זו מנקה את המצב השמור לפי פרויקט עבור ספרייה זו ומטעינה מחדש את המופע.",
|
||||||
|
"infoView.dispose.confirm.confirmLabel": "בטל",
|
||||||
|
"infoView.dispose.confirm.cancelLabel": "ביטול",
|
||||||
|
"infoView.dispose.toast.success": "המופע בוטל. מטעין מחדש...",
|
||||||
|
"infoView.dispose.toast.error": "ביטול המופע נכשל.",
|
||||||
|
} as const
|
||||||
7
packages/ui/src/lib/i18n/messages/he/markdown.ts
Normal file
7
packages/ui/src/lib/i18n/messages/he/markdown.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const markdownMessages = {
|
||||||
|
"markdown.codeBlock.copy.label": "העתק",
|
||||||
|
"markdown.codeBlock.copy.copied": "הועתק!",
|
||||||
|
"markdown.codeBlock.copy.failed": "נכשל",
|
||||||
|
|
||||||
|
"markdown.copy": "העתק",
|
||||||
|
} 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