Compare commits
45 Commits
codenomad/
...
fix_local_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2236350f1b | ||
|
|
5f3e9317ca | ||
|
|
750e73f540 | ||
|
|
61e06ef883 | ||
|
|
b0b0a55e14 | ||
|
|
984743f3c7 | ||
|
|
27bccb8d6b | ||
|
|
1b4eff9419 | ||
|
|
6c1febf50e | ||
|
|
75622ef366 | ||
|
|
864f913e3e | ||
|
|
b7d4f8f869 | ||
|
|
0dc5867fb3 | ||
|
|
d13ecba322 | ||
|
|
740f37db86 | ||
|
|
d447b05821 | ||
|
|
1233121a13 | ||
|
|
153065d025 | ||
|
|
2abda0e6b4 | ||
|
|
800133361d | ||
|
|
034cb5dea9 | ||
|
|
d7ab84f245 | ||
|
|
201988b97c | ||
|
|
6a6fcff2c8 | ||
|
|
f29f197b9a | ||
|
|
dbde403b3e | ||
|
|
230c981cc2 | ||
|
|
34978c87fb | ||
|
|
3e6d0a402c | ||
|
|
e81c5f6443 | ||
|
|
b0d27bd127 | ||
|
|
7576470295 | ||
|
|
6d32e09db0 | ||
|
|
503cb3a02e | ||
|
|
0250c6350f | ||
|
|
24cc8fe939 | ||
|
|
282b234a7c | ||
|
|
4ba088a876 | ||
|
|
7b1817d606 | ||
|
|
5bc3c23ec5 | ||
|
|
127a51e3c3 | ||
|
|
daa22b6d8c | ||
|
|
23f2de2d7e | ||
|
|
80c9b76709 | ||
|
|
a29b77d60b |
35
package-lock.json
generated
35
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -8240,6 +8240,27 @@
|
||||
"regex-recursion": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
||||
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -12019,6 +12040,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -12033,7 +12055,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12070,7 +12092,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12080,6 +12102,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
@@ -12111,7 +12134,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12119,7 +12142,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
@@ -31,4 +31,4 @@
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.12.3",
|
||||
"minServerVersion": "0.13.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -327,7 +327,6 @@ function finalizeCliSwap(url: string) {
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||
let bootstrapExchangeInFlight = false
|
||||
|
||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||
@@ -350,6 +349,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
||||
}
|
||||
|
||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||
const sessionCookieName = cliManager.getAuthCookieName()
|
||||
const target = new URL("/api/auth/token", baseUrl)
|
||||
const body = JSON.stringify({ token })
|
||||
|
||||
@@ -380,14 +380,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
||||
if (!sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
await session.defaultSession.cookies.set({
|
||||
url: baseUrl,
|
||||
name: SESSION_COOKIE_NAME,
|
||||
name: sessionCookieName,
|
||||
value: sessionId,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
@@ -122,6 +123,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
private bootstrapToken: string | null = null
|
||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
private requestedStop = false
|
||||
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
@@ -132,6 +134,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.bootstrapToken = null
|
||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
||||
this.requestedStop = false
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
@@ -328,6 +331,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
getAuthCookieName(): string {
|
||||
return this.authCookieName
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
@@ -416,7 +423,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--generate-token"]
|
||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
||||
|
||||
if (options.dev) {
|
||||
// Dev: run plain HTTP + Vite dev server proxy.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -32,6 +32,7 @@
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"node-forge": "^1.3.3",
|
||||
"openai": "^6.27.0",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"yaml": "^2.4.2",
|
||||
@@ -46,4 +47,4 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,39 @@ export interface BinaryValidationResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SpeechSegment {
|
||||
startMs: number
|
||||
endMs: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SpeechCapabilitiesResponse {
|
||||
available: boolean
|
||||
configured: boolean
|
||||
provider: string
|
||||
supportsStt: boolean
|
||||
supportsTts: boolean
|
||||
supportsStreamingTts: boolean
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
ttsFormats: string[]
|
||||
streamingTtsFormats: string[]
|
||||
}
|
||||
|
||||
export interface SpeechTranscriptionResponse {
|
||||
text: string
|
||||
language?: string
|
||||
durationMs?: number
|
||||
segments?: SpeechSegment[]
|
||||
}
|
||||
|
||||
export interface SpeechSynthesisResponse {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
@@ -16,16 +16,18 @@ export interface AuthManagerInit {
|
||||
password?: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
cookieName?: string
|
||||
}
|
||||
|
||||
export class AuthManager {
|
||||
private readonly authStore: AuthStore | null
|
||||
private readonly tokenManager: TokenManager | null
|
||||
private readonly sessionManager = new SessionManager()
|
||||
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||
private readonly cookieName: string
|
||||
private readonly authEnabled: boolean
|
||||
|
||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||
|
||||
if (!this.authEnabled) {
|
||||
@@ -139,6 +141,16 @@ export class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeCookieName(value: string | undefined): string {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
||||
}
|
||||
|
||||
function resolveAuthFilePath(configPath: string) {
|
||||
const resolvedConfigPath = resolvePath(configPath)
|
||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||
|
||||
@@ -19,10 +19,11 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -54,6 +55,7 @@ interface CliOptions {
|
||||
launch: boolean
|
||||
authUsername: string
|
||||
authPassword?: string
|
||||
authCookieName: string
|
||||
generateToken: boolean
|
||||
dangerouslySkipAuth: boolean
|
||||
}
|
||||
@@ -99,6 +101,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.default(DEFAULT_AUTH_USERNAME),
|
||||
)
|
||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||
.addOption(
|
||||
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
||||
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
||||
.default(DEFAULT_AUTH_COOKIE_NAME),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||
.env("CODENOMAD_GENERATE_TOKEN")
|
||||
@@ -138,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch?: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authCookieName: string
|
||||
generateToken?: boolean
|
||||
dangerouslySkipAuth?: boolean
|
||||
}>()
|
||||
@@ -184,6 +192,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
launch: Boolean(parsed.launch),
|
||||
authUsername: parsed.username,
|
||||
authPassword: parsed.password,
|
||||
authCookieName: parsed.authCookieName,
|
||||
generateToken: Boolean(parsed.generateToken),
|
||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||
}
|
||||
@@ -265,6 +274,7 @@ async function main() {
|
||||
configPath: configLocation.configYamlPath,
|
||||
username: options.authUsername,
|
||||
password: options.authPassword,
|
||||
cookieName: options.authCookieName,
|
||||
generateToken: options.generateToken,
|
||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||
},
|
||||
@@ -304,6 +314,7 @@ async function main() {
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
@@ -388,6 +399,7 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
@@ -408,6 +420,7 @@ async function main() {
|
||||
eventBus,
|
||||
serverMeta,
|
||||
instanceStore,
|
||||
speechService,
|
||||
authManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
@@ -438,18 +451,22 @@ async function main() {
|
||||
// which can lead clients to talk to the wrong process.
|
||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||
let remoteUrl: string | undefined
|
||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
||||
if (remoteStart) {
|
||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||
let remoteHost = options.host
|
||||
if (wantsAll) {
|
||||
if (options.host === "0.0.0.0") {
|
||||
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||
remoteAddresses = resolved.userVisible
|
||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
||||
}
|
||||
} else {
|
||||
remoteHost = "localhost"
|
||||
}
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
if (!remoteUrl) {
|
||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||
}
|
||||
}
|
||||
|
||||
serverMeta.localUrl = localUrl
|
||||
@@ -460,7 +477,9 @@ async function main() {
|
||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||
|
||||
if (serverMeta.remotePort && remoteUrl) {
|
||||
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
serverMeta.addresses = remoteAddresses.length
|
||||
? remoteAddresses
|
||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||
} else {
|
||||
serverMeta.addresses = []
|
||||
}
|
||||
@@ -468,6 +487,16 @@ async function main() {
|
||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||
if (serverMeta.remoteUrl) {
|
||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||
const additionalRemoteUrls = serverMeta.addresses
|
||||
.map((addr) => addr.remoteUrl)
|
||||
.filter((url) => url !== serverMeta.remoteUrl)
|
||||
|
||||
if (additionalRemoteUrls.length > 0) {
|
||||
console.log("Other Accessible URLs:")
|
||||
for (const url of additionalRemoteUrls) {
|
||||
console.log(` - ${url}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.launch) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict"
|
||||
import os from "node:os"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
||||
|
||||
describe("resolveNetworkAddresses", () => {
|
||||
it("preserves interface order among external addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "10.0.0.8", family: 4, internal: false },
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((entry) => entry.ip),
|
||||
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveRemoteAddresses", () => {
|
||||
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers private LAN addresses over public addresses", () => {
|
||||
const addresses = [
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
||||
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.userVisible.map((entry) => entry.ip),
|
||||
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
||||
)
|
||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
||||
})
|
||||
})
|
||||
|
||||
it("uses a public address when no private LAN address is available", () => {
|
||||
const addresses = [
|
||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
||||
]
|
||||
|
||||
usingMockedNetworkInterfaces(addresses, () => {
|
||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
||||
|
||||
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
||||
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function usingMockedNetworkInterfaces(
|
||||
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
||||
callback: () => void,
|
||||
) {
|
||||
const original = os.networkInterfaces
|
||||
os.networkInterfaces = (() => ({
|
||||
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
||||
})) as typeof os.networkInterfaces
|
||||
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
os.networkInterfaces = original
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,14 @@ import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import { registerAuthRoutes } from "./routes/auth"
|
||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||
import type { SpeechService } from "../speech/service"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -41,6 +43,7 @@ interface HttpServerDeps {
|
||||
eventBus: EventBus
|
||||
serverMeta: ServerMeta
|
||||
instanceStore: InstanceStore
|
||||
speechService: SpeechService
|
||||
authManager: AuthManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
@@ -252,6 +255,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import os from "os"
|
||||
import type { NetworkAddress } from "../api-types"
|
||||
|
||||
export interface ResolvedRemoteAddresses {
|
||||
all: NetworkAddress[]
|
||||
userVisible: NetworkAddress[]
|
||||
primaryRemoteUrl?: string
|
||||
}
|
||||
|
||||
export function resolveNetworkAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemoteAddresses(args: {
|
||||
host: string
|
||||
protocol: "http" | "https"
|
||||
port: number
|
||||
}): ResolvedRemoteAddresses {
|
||||
const all = resolveNetworkAddresses(args)
|
||||
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
||||
return {
|
||||
all,
|
||||
userVisible,
|
||||
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
||||
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
||||
}
|
||||
|
||||
function getUserVisiblePriority(ip: string): number {
|
||||
if (isPrivateIPv4(ip)) return 0
|
||||
if (isLinkLocalIPv4(ip)) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function isLinkLocalIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
return first === 169 && second === 254
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const octets = parseIPv4(ip)
|
||||
if (!octets) return false
|
||||
const [first, second] = octets
|
||||
|
||||
if (first === 10) return true
|
||||
if (first === 192 && second === 168) return true
|
||||
return first === 172 && second >= 16 && second <= 31
|
||||
}
|
||||
|
||||
function parseIPv4(value: string): number[] | null {
|
||||
if (!isIPv4Address(value)) return null
|
||||
return value.split(".").map((part) => Number(part))
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import { resolveNetworkAddresses } from "../network-addresses"
|
||||
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const localPort = resolveLocalPort(meta)
|
||||
const remote = resolveRemote(meta)
|
||||
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
localPort,
|
||||
remotePort: remote?.port,
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
interface RouteDeps {
|
||||
settings: SettingsService
|
||||
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
|
||||
|
||||
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
// Full-document access
|
||||
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
|
||||
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
|
||||
app.patch("/api/storage/config", async (request, reply) => {
|
||||
try {
|
||||
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
||||
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
@@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
|
||||
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||
return deps.settings.getOwner("config", request.params.owner)
|
||||
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
|
||||
})
|
||||
|
||||
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||
try {
|
||||
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
|
||||
return sanitizeConfigOwner(
|
||||
request.params.owner,
|
||||
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
|
||||
)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||
|
||||
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 { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
@@ -45,10 +46,11 @@ export class SettingsService {
|
||||
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||
if (!this.eventBus) return
|
||||
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||
const nextValue = value ?? this.getOwner(kind, owner)
|
||||
const payload: WorkspaceEventPayload = {
|
||||
type,
|
||||
owner,
|
||||
value: value ?? this.getOwner(kind, owner),
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
}
|
||||
|
||||
204
packages/server/src/speech/providers/openai-compatible.ts
Normal file
204
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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"))
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: settings.ttsModel,
|
||||
voice: settings.ttsVoice,
|
||||
input: text,
|
||||
response_format: format,
|
||||
}),
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
#[cfg(windows)]
|
||||
@@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||
fn exchange_bootstrap_token(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
cookie_name: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||
@@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
for line in lines {
|
||||
// handle case-insensitive header name
|
||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
||||
return Ok(Some(session_id));
|
||||
}
|
||||
}
|
||||
@@ -172,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||
fn set_session_cookie(
|
||||
app: &AppHandle,
|
||||
base_url: &str,
|
||||
cookie_name: &str,
|
||||
session_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let parsed = Url::parse(base_url)?;
|
||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||
|
||||
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
||||
.domain(domain)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
@@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_auth_cookie_name() -> String {
|
||||
let pid = std::process::id();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
|
||||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -503,7 +522,8 @@ impl CliProcessManager {
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev, &host);
|
||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
@@ -584,6 +604,7 @@ impl CliProcessManager {
|
||||
let app_clone = app.clone();
|
||||
let ready_clone = ready.clone();
|
||||
let token_clone = bootstrap_token.clone();
|
||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
let stdout = child_clone
|
||||
@@ -605,6 +626,7 @@ impl CliProcessManager {
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
auth_cookie_name_clone.as_str(),
|
||||
);
|
||||
}
|
||||
if let Some(reader) = stderr {
|
||||
@@ -615,6 +637,7 @@ impl CliProcessManager {
|
||||
&status_clone,
|
||||
&ready_clone,
|
||||
&token_clone,
|
||||
auth_cookie_name_clone.as_str(),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -731,6 +754,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
) {
|
||||
let mut buffer = String::new();
|
||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||
@@ -766,7 +790,14 @@ impl CliProcessManager {
|
||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||
.map(|m| m.as_str().to_string())
|
||||
{
|
||||
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||
Self::mark_ready(
|
||||
app,
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
url,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -781,6 +812,7 @@ impl CliProcessManager {
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
format!("http://localhost:{port}"),
|
||||
);
|
||||
continue;
|
||||
@@ -793,6 +825,7 @@ impl CliProcessManager {
|
||||
status,
|
||||
ready,
|
||||
bootstrap_token,
|
||||
auth_cookie_name,
|
||||
format!("http://localhost:{}", port),
|
||||
);
|
||||
continue;
|
||||
@@ -811,6 +844,7 @@ impl CliProcessManager {
|
||||
status: &Arc<Mutex<CliStatus>>,
|
||||
ready: &Arc<AtomicBool>,
|
||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||
auth_cookie_name: &str,
|
||||
base_url: String,
|
||||
) {
|
||||
ready.store(true, Ordering::SeqCst);
|
||||
@@ -834,9 +868,11 @@ impl CliProcessManager {
|
||||
if scheme.as_deref() != Some("http") {
|
||||
navigate_main(app, &base_url);
|
||||
} else {
|
||||
match exchange_bootstrap_token(&base_url, &token) {
|
||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
||||
Ok(Some(session_id)) => {
|
||||
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||
if let Err(err) =
|
||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
||||
{
|
||||
log_line(&format!("failed to set session cookie: {err}"));
|
||||
navigate_main(app, &format!("{base_url}/login"));
|
||||
} else {
|
||||
@@ -932,11 +968,13 @@ impl CliEntry {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
host.to_string(),
|
||||
"--auth-cookie-name".to_string(),
|
||||
auth_cookie_name.to_string(),
|
||||
"--generate-token".to_string(),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -45,4 +45,4 @@
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ const App: Component = () => {
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -353,6 +354,7 @@ const App: Component = () => {
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
|
||||
@@ -14,6 +14,8 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -1384,6 +1386,13 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const viewHideLabel = () =>
|
||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
|
||||
text: reasoningText,
|
||||
})
|
||||
|
||||
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
createEffect(() => {
|
||||
if (!expanded()) return
|
||||
reasoningText()
|
||||
@@ -1462,6 +1471,20 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</button>
|
||||
|
||||
<div class="message-reasoning-actions">
|
||||
<Show when={canSpeakReasoning()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void speech.toggle()
|
||||
}}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
|
||||
@@ -11,6 +11,8 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { deleteMessage } from "../stores/session-actions"
|
||||
import { isTauriHost } from "../lib/runtime-env"
|
||||
import type { DeleteHoverState } from "../types/delete-hover"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
function DeleteUpToIcon() {
|
||||
return (
|
||||
@@ -294,6 +296,13 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
|
||||
text: getRawContent,
|
||||
})
|
||||
|
||||
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
@@ -443,6 +452,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakMessage()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.onFork}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
@@ -503,6 +522,16 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakMessage()}>
|
||||
<SpeechActionButton
|
||||
class="message-action-button"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
@@ -18,6 +18,8 @@ import { usePromptState } from "./prompt-input/usePromptState"
|
||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
|
||||
const log = getLogger("actions")
|
||||
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
|
||||
|
||||
@@ -350,6 +352,19 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function handleClearPrompt() {
|
||||
clearPrompt()
|
||||
clearHistoryDraft()
|
||||
resetHistoryNavigation()
|
||||
setShowPicker(false)
|
||||
setPickerMode("mention")
|
||||
setAtPosition(null)
|
||||
setSearchQuery("")
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
syncAttachmentCounters("")
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
function insertBlockContent(block: string) {
|
||||
const textarea = textareaRef
|
||||
const current = prompt()
|
||||
@@ -421,6 +436,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const canClearPrompt = () => prompt().length > 0
|
||||
|
||||
const shellHint = () =>
|
||||
mode() === "shell"
|
||||
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
|
||||
@@ -450,9 +467,52 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
})
|
||||
|
||||
const shouldShowOverlay = () => prompt().length === 0
|
||||
const voiceInput = usePromptVoiceInput({
|
||||
prompt,
|
||||
setPrompt,
|
||||
getTextarea: () => textareaRef ?? null,
|
||||
enabled: () => preferences().showPromptVoiceInput,
|
||||
disabled: () => Boolean(props.disabled),
|
||||
})
|
||||
const showVoiceInput = () =>
|
||||
preferences().showPromptVoiceInput &&
|
||||
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
|
||||
const conversationModeEnabled = () => isConversationModeEnabled(props.instanceId)
|
||||
const showConversationToggle = () => showVoiceInput() || conversationModeEnabled()
|
||||
const canToggleConversationMode = () => canUseConversationMode()
|
||||
const conversationModeButtonTitle = () =>
|
||||
conversationModeEnabled()
|
||||
? t("promptInput.conversationMode.disable.title")
|
||||
: t("promptInput.conversationMode.enable.title")
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
|
||||
let voiceButtonPressed = false
|
||||
|
||||
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
|
||||
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
|
||||
voiceButtonPressed = true
|
||||
|
||||
if (event instanceof PointerEvent) {
|
||||
const target = event.currentTarget
|
||||
if (target instanceof HTMLElement) {
|
||||
try {
|
||||
target.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void voiceInput.startRecording()
|
||||
}
|
||||
|
||||
const endVoicePress = () => {
|
||||
if (!voiceButtonPressed) return
|
||||
voiceButtonPressed = false
|
||||
voiceInput.stopRecording()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div
|
||||
@@ -506,42 +566,111 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-nav-buttons">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onPointerCancel={() => endVoicePress()}
|
||||
onLostPointerCapture={() => endVoicePress()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.repeat) return
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onBlur={() => endVoicePress()}
|
||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||
aria-label={voiceInput.buttonTitle()}
|
||||
title={voiceInput.buttonTitle()}
|
||||
>
|
||||
<Show
|
||||
when={voiceInput.isRecording()}
|
||||
fallback={
|
||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
|
||||
244
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
244
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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"
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
const list = addresses()
|
||||
if (!allowExternalConnections()) {
|
||||
return []
|
||||
return { recommended: null, hidden: [] }
|
||||
}
|
||||
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
return splitRemoteAddresses(list)
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
setShowAllAddresses(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -325,7 +327,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||
<div class="remote-address-list">
|
||||
<Show when={meta()?.localUrl}>
|
||||
{(url) => {
|
||||
@@ -372,8 +374,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(addressAccessor) => {
|
||||
const address = addressAccessor()
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
@@ -383,13 +386,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
@@ -424,7 +428,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(address) => {
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
|
||||
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -88,6 +89,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
on(
|
||||
() => props.isActive,
|
||||
(isActive) => {
|
||||
if (!isActive) {
|
||||
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
|
||||
return
|
||||
}
|
||||
if (!isActive) return
|
||||
|
||||
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
|
||||
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
|
||||
import { createMemo, For, type Component } from "solid-js"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
|
||||
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
||||
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const { t } = useI18n()
|
||||
@@ -21,6 +22,7 @@ export const SettingsScreen: Component = () => {
|
||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
])
|
||||
|
||||
@@ -30,6 +32,8 @@ export const SettingsScreen: Component = () => {
|
||||
return <NotificationsSettingsSection />
|
||||
case "remote":
|
||||
return <RemoteAccessSettingsSection />
|
||||
case "speech":
|
||||
return <SpeechSettingsSection />
|
||||
case "opencode":
|
||||
return <OpenCodeSettingsSection />
|
||||
case "appearance":
|
||||
|
||||
@@ -24,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
setDiffViewMode,
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import { restartCli } from "../../lib/native/cli"
|
||||
@@ -9,6 +9,7 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
|
||||
import { showConfirmDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -30,14 +31,15 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
||||
const list = addresses()
|
||||
if (!allowExternalConnections()) return []
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
if (!allowExternalConnections()) return { recommended: null, hidden: [] }
|
||||
return splitRemoteAddresses(list)
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
@@ -48,6 +50,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||
setMeta(metaResult)
|
||||
setAuthStatus(authResult)
|
||||
setShowAllAddresses(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -217,31 +220,35 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||
>
|
||||
<div class="settings-card-content">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
<div class="settings-password-summary-row">
|
||||
<div class="settings-password-summary-copy">
|
||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||
<p class="settings-help-text">
|
||||
{authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.status.set")
|
||||
: t("remoteAccess.password.status.unset")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
<div class="settings-password-actions">
|
||||
<button
|
||||
class="settings-pill-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPasswordFormOpen(!passwordFormOpen())
|
||||
setPasswordError(null)
|
||||
}}
|
||||
>
|
||||
{passwordFormOpen()
|
||||
? t("remoteAccess.password.actions.cancel")
|
||||
: authStatus()!.passwordUserProvided
|
||||
? t("remoteAccess.password.actions.change")
|
||||
: t("remoteAccess.password.actions.set")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={passwordFormOpen()}>
|
||||
<Show when={passwordFormOpen()}>
|
||||
<div class="settings-form-group">
|
||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||
<input
|
||||
@@ -291,7 +298,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show
|
||||
when={displayAddresses().length > 0 || meta()?.localUrl}
|
||||
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
|
||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||
>
|
||||
<div class="remote-address-list">
|
||||
@@ -341,8 +348,9 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
<Show when={displayAddresses().recommended}>
|
||||
{(addressAccessor) => {
|
||||
const address = addressAccessor()
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
@@ -382,7 +390,11 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||
<img
|
||||
src={dataUrl()}
|
||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
||||
class="remote-qr-img"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -390,7 +402,80 @@ export const RemoteAccessSettingsSection: Component = () => {
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={displayAddresses().hidden.length > 0}>
|
||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
||||
<button
|
||||
class="remote-address-disclosure-trigger"
|
||||
type="button"
|
||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
||||
aria-expanded={showAllAddresses()}
|
||||
>
|
||||
<span class="remote-address-disclosure-label">
|
||||
{showAllAddresses()
|
||||
? t("remoteAccess.addresses.actions.hideOther")
|
||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
||||
</span>
|
||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
||||
</button>
|
||||
|
||||
<Show when={showAllAddresses()}>
|
||||
<div class="remote-address-disclosure-content">
|
||||
<For each={displayAddresses().hidden}>
|
||||
{(address) => {
|
||||
const url = address.remoteUrl
|
||||
const expandedState = () => expandedUrl() === url
|
||||
const qr = () => qrCodes()[url]
|
||||
const scopeLabel = () =>
|
||||
address.scope === "external"
|
||||
? t("remoteAccess.address.scope.network")
|
||||
: address.scope === "loopback"
|
||||
? t("remoteAccess.address.scope.loopback")
|
||||
: t("remoteAccess.address.scope.internal")
|
||||
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
{t("remoteAccess.address.open")}
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => (
|
||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
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,
|
||||
} from "./tool-call/types"
|
||||
import {
|
||||
buildToolSpeechText,
|
||||
ensureMarkdownContent,
|
||||
getRelativePath,
|
||||
getToolIcon,
|
||||
@@ -41,6 +42,8 @@ import {
|
||||
} from "./tool-call/utils"
|
||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useSpeech } from "../lib/hooks/use-speech"
|
||||
import SpeechActionButton from "./speech-action-button"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -960,6 +963,21 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return renderToolTitle()
|
||||
})
|
||||
|
||||
const speechText = createMemo(() =>
|
||||
buildToolSpeechText({
|
||||
title: headerText(),
|
||||
state: toolState(),
|
||||
t,
|
||||
}),
|
||||
)
|
||||
|
||||
const speech = useSpeech({
|
||||
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId ?? "message"}:${toolCallIdentifier()}`,
|
||||
text: speechText,
|
||||
})
|
||||
|
||||
const canSpeakToolCall = () => speechText().trim().length > 0 && speech.canUseSpeech()
|
||||
|
||||
const handleCopyHeader = async (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -1023,6 +1041,16 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<Show when={canSpeakToolCall()}>
|
||||
<SpeechActionButton
|
||||
class="tool-call-header-copy"
|
||||
onClick={() => void speech.toggle()}
|
||||
title={speech.buttonTitle()}
|
||||
isLoading={speech.isLoading()}
|
||||
isPlaying={speech.isPlaying()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<span class="tool-call-header-status" aria-hidden="true">
|
||||
{statusIcon()}
|
||||
</span>
|
||||
|
||||
@@ -231,3 +231,37 @@ export function getDefaultToolAction(toolName: string) {
|
||||
return tGlobal("toolCall.renderer.action.working")
|
||||
}
|
||||
}
|
||||
|
||||
export function buildToolSpeechText(options: {
|
||||
title: string
|
||||
state?: ToolState
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}): string {
|
||||
const sections: string[] = []
|
||||
|
||||
if (options.title.trim()) {
|
||||
sections.push(options.title.trim())
|
||||
}
|
||||
|
||||
const { input, output } = readToolStatePayload(options.state)
|
||||
const formattedInput = formatUnknown(input)
|
||||
const formattedOutput = formatUnknown(output)
|
||||
|
||||
if (formattedInput?.text?.trim()) {
|
||||
sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`)
|
||||
}
|
||||
|
||||
if (formattedOutput?.text?.trim()) {
|
||||
sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`)
|
||||
}
|
||||
|
||||
if (options.state?.status === "error" && options.state.error?.trim()) {
|
||||
sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`)
|
||||
}
|
||||
|
||||
if (sections.length === 1 && options.state?.status === "pending") {
|
||||
sections.push(options.t("toolCall.pending.waitingToRun"))
|
||||
}
|
||||
|
||||
return sections.join("\n\n").trim()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
FileSystemCreateFolderResponse,
|
||||
FileSystemListResponse,
|
||||
InstanceData,
|
||||
SpeechCapabilitiesResponse,
|
||||
SpeechSynthesisResponse,
|
||||
SpeechTranscriptionResponse,
|
||||
ServerMeta,
|
||||
WorkspaceCreateRequest,
|
||||
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 = {
|
||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||
@@ -235,6 +260,37 @@ export const serverApi = {
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
},
|
||||
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
|
||||
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
|
||||
},
|
||||
transcribeAudio(payload: {
|
||||
audioBase64: string
|
||||
mimeType: string
|
||||
filename?: string
|
||||
language?: string
|
||||
prompt?: string
|
||||
}): Promise<SpeechTranscriptionResponse> {
|
||||
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" }): Promise<SpeechSynthesisResponse> {
|
||||
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
synthesizeSpeechStream(
|
||||
payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" },
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
return requestRaw("/api/speech/synthesize/stream", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
})
|
||||
},
|
||||
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (path && path !== ".") {
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
|
||||
toggleUsageMetrics: () => void
|
||||
toggleAutoCleanupBlankSessions: () => void
|
||||
togglePromptSubmitOnEnter: () => void
|
||||
toggleShowPromptVoiceInput: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||
@@ -435,6 +436,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
toggleUsageMetrics: options.toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
|
||||
setDiffViewMode: options.setDiffViewMode,
|
||||
setToolOutputExpansion: options.setToolOutputExpansion,
|
||||
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
||||
|
||||
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" }))
|
||||
}
|
||||
@@ -75,6 +75,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "Copy",
|
||||
"messageItem.actions.copyTitle": "Copy message",
|
||||
"messageItem.actions.copied": "Copied!",
|
||||
"messageItem.actions.speak": "Speak message",
|
||||
"messageItem.actions.generatingSpeech": "Generating speech",
|
||||
"messageItem.actions.stopSpeech": "Stop playback",
|
||||
"messageItem.actions.speak.error.title": "Speech playback failed",
|
||||
"messageItem.actions.speak.error.unsupported": "Speech playback is not supported in this browser.",
|
||||
"messageItem.actions.speak.error.unavailable": "Speech playback is unavailable until speech settings are configured.",
|
||||
"messageItem.actions.speak.error.generate": "Unable to generate speech for this message.",
|
||||
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
|
||||
"messageItem.actions.deletingMessage": "Deleting...",
|
||||
@@ -135,7 +142,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "again to abort session",
|
||||
"promptInput.stopSession.ariaLabel": "Stop session",
|
||||
"promptInput.stopSession.title": "Stop session",
|
||||
"promptInput.clear.ariaLabel": "Clear prompt text",
|
||||
"promptInput.clear.title": "Clear prompt text",
|
||||
"promptInput.send.ariaLabel": "Send message",
|
||||
"promptInput.send.errorFallback": "Failed to send message",
|
||||
"promptInput.send.errorTitle": "Send failed",
|
||||
"promptInput.conversationMode.enable.title": "Enable conversation mode",
|
||||
"promptInput.conversationMode.disable.title": "Disable conversation mode",
|
||||
"promptInput.conversationMode.error.title": "Conversation playback failed",
|
||||
"promptInput.conversationMode.error.message": "Unable to continue speaking assistant replies.",
|
||||
"promptInput.voiceInput.start.title": "Start voice input",
|
||||
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
|
||||
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||
} as const
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
||||
"remoteAccess.addresses.loading": "Loading addresses…",
|
||||
"remoteAccess.addresses.none": "No addresses available yet.",
|
||||
"remoteAccess.addresses.actions.showOther": "Show {count} other addresses",
|
||||
"remoteAccess.addresses.actions.hideOther": "Hide other addresses",
|
||||
"remoteAccess.address.scope.network": "Network",
|
||||
"remoteAccess.address.scope.loopback": "Loopback",
|
||||
"remoteAccess.address.scope.internal": "Internal",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
||||
"settings.nav.appearance": "Appearance",
|
||||
"settings.nav.notifications": "Notifications",
|
||||
"settings.nav.remote": "Remote Access",
|
||||
"settings.nav.speech": "Speech",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "This device",
|
||||
"settings.scope.server": "Server setting",
|
||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
|
||||
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
|
||||
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Enter to submit",
|
||||
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
|
||||
"settings.speech.title": "Speech",
|
||||
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||
"settings.speech.provider.title": "Provider",
|
||||
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
|
||||
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||
"settings.speech.status.loading": "Checking configuration...",
|
||||
"settings.speech.status.configured": "Configured",
|
||||
"settings.speech.status.missing": "Missing API key",
|
||||
"settings.speech.status.error": "Speech service unavailable",
|
||||
"settings.speech.apiKey.title": "API key",
|
||||
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
|
||||
"settings.speech.apiKey.placeholder": "Enter a new API key",
|
||||
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
|
||||
"settings.speech.apiKey.clearAction": "Clear saved key",
|
||||
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
|
||||
"settings.speech.baseUrl.title": "Base URL",
|
||||
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "Transcription model",
|
||||
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
|
||||
"settings.speech.ttsModel.title": "Speech model",
|
||||
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
|
||||
"settings.speech.ttsVoice.title": "Default voice",
|
||||
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
|
||||
"settings.speech.playbackMode.title": "Playback mode",
|
||||
"settings.speech.playbackMode.subtitle": "Choose whether TTS starts playing as audio streams in or after the full file is generated.",
|
||||
"settings.speech.playbackMode.streaming": "Streaming",
|
||||
"settings.speech.playbackMode.buffered": "Buffered",
|
||||
"settings.speech.ttsFormat.title": "Output format",
|
||||
"settings.speech.ttsFormat.subtitle": "Choose the audio format for synthesized speech. Streaming support depends on your provider and browser.",
|
||||
"settings.speech.help": "Prompt voice input appears when speech transcription is configured and supported. Message playback uses the TTS mode and format selected here.",
|
||||
"settings.speech.compatibility.streamingUnavailable": "Your current speech provider configuration does not advertise streaming TTS. Switch playback mode to buffered if you want playback to work now.",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "Your current browser cannot stream the selected TTS format. Choose buffered playback or switch to a different format.",
|
||||
"settings.speech.compatibility.runtimeNote": "All formats stay selectable in streaming mode. Some browser and provider combinations may still fail at playback time.",
|
||||
"settings.speech.testPlayback.action": "Test playback",
|
||||
"settings.speech.testPlayback.generating": "Generating sample",
|
||||
"settings.speech.testPlayback.stop": "Stop sample",
|
||||
"settings.speech.testPlayback.sample": "Thank you for using CodeNomad, your speech settings are working fine.",
|
||||
"settings.speech.testPlayback.note": "The test uses your current playback mode and format immediately. Save API key, base URL, model, or voice changes first if you want those reflected too.",
|
||||
"settings.speech.save.action": "Save",
|
||||
"settings.speech.save.saving": "Saving...",
|
||||
"settings.speech.save.saved": "Saved",
|
||||
"settings.speech.save.unsaved": "Unsaved changes",
|
||||
"settings.speech.save.error": "Save failed",
|
||||
} as const
|
||||
|
||||
@@ -77,6 +77,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "Copiar",
|
||||
"messageItem.actions.copyTitle": "Copiar mensaje",
|
||||
"messageItem.actions.copied": "¡Copiado!",
|
||||
"messageItem.actions.speak": "Reproducir mensaje",
|
||||
"messageItem.actions.generatingSpeech": "Generando audio",
|
||||
"messageItem.actions.stopSpeech": "Detener reproduccion",
|
||||
"messageItem.actions.speak.error.title": "La reproduccion de voz fallo",
|
||||
"messageItem.actions.speak.error.unsupported": "La reproduccion de voz no es compatible con este navegador.",
|
||||
"messageItem.actions.speak.error.unavailable": "La reproduccion de voz no estara disponible hasta que la configuracion de voz este lista.",
|
||||
"messageItem.actions.speak.error.generate": "No se pudo generar audio para este mensaje.",
|
||||
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
|
||||
"messageItem.actions.deletingMessage": "Eliminando...",
|
||||
@@ -137,7 +144,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "otra vez para abortar la sesión",
|
||||
"promptInput.stopSession.ariaLabel": "Detener sesión",
|
||||
"promptInput.stopSession.title": "Detener sesión",
|
||||
"promptInput.clear.ariaLabel": "Borrar el texto del prompt",
|
||||
"promptInput.clear.title": "Borrar el texto del prompt",
|
||||
"promptInput.send.ariaLabel": "Enviar mensaje",
|
||||
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
||||
"promptInput.send.errorTitle": "Error al enviar",
|
||||
"promptInput.conversationMode.enable.title": "Activar modo conversacion",
|
||||
"promptInput.conversationMode.disable.title": "Desactivar modo conversacion",
|
||||
"promptInput.conversationMode.error.title": "Fallo la reproduccion de la conversacion",
|
||||
"promptInput.conversationMode.error.message": "No se pudieron seguir reproduciendo las respuestas del asistente.",
|
||||
"promptInput.voiceInput.start.title": "Iniciar entrada de voz",
|
||||
"promptInput.voiceInput.stop.title": "Detener grabación y transcribir",
|
||||
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
|
||||
"promptInput.voiceInput.error.title": "La entrada de voz falló",
|
||||
"promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.",
|
||||
"promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.",
|
||||
"promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.",
|
||||
} as const
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
||||
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
||||
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
||||
"remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más",
|
||||
"remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones",
|
||||
"remoteAccess.address.scope.network": "Red",
|
||||
"remoteAccess.address.scope.loopback": "Loopback",
|
||||
"remoteAccess.address.scope.internal": "Interna",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
||||
"settings.nav.appearance": "Appearance",
|
||||
"settings.nav.notifications": "Notifications",
|
||||
"settings.nav.remote": "Remote Access",
|
||||
"settings.nav.speech": "Speech",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "This device",
|
||||
"settings.scope.server": "Server setting",
|
||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
|
||||
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
|
||||
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Enter para enviar",
|
||||
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
|
||||
"settings.speech.title": "Voz",
|
||||
"settings.speech.subtitle": "Configura ahora el reconocimiento de voz y prepara la base de texto a voz para funciones futuras.",
|
||||
"settings.speech.provider.title": "Proveedor",
|
||||
"settings.speech.provider.subtitle": "Las solicitudes de voz usan el adaptador de voz del servidor.",
|
||||
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||
"settings.speech.status.loading": "Comprobando configuración...",
|
||||
"settings.speech.status.configured": "Configurado",
|
||||
"settings.speech.status.missing": "Falta la clave API",
|
||||
"settings.speech.status.error": "Servicio de voz no disponible",
|
||||
"settings.speech.apiKey.title": "API key",
|
||||
"settings.speech.apiKey.subtitle": "Se usa para las solicitudes de voz gestionadas por CodeNomad.",
|
||||
"settings.speech.apiKey.placeholder": "Introduce una nueva clave API",
|
||||
"settings.speech.apiKey.storedNote": "Hay una clave API guardada y oculta. Introduce un nuevo valor para reemplazarla o deja el campo vacío para conservarla.",
|
||||
"settings.speech.apiKey.clearAction": "Borrar clave guardada",
|
||||
"settings.speech.apiKey.clearPending": "La clave API guardada se eliminará al guardar.",
|
||||
"settings.speech.baseUrl.title": "Base URL",
|
||||
"settings.speech.baseUrl.subtitle": "Anulación opcional para endpoints de voz compatibles con OpenAI.",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "Modelo de transcripción",
|
||||
"settings.speech.sttModel.subtitle": "Modelo usado para las solicitudes de voz a texto en el prompt.",
|
||||
"settings.speech.ttsModel.title": "Modelo de voz",
|
||||
"settings.speech.ttsModel.subtitle": "Modelo predeterminado de texto a voz reservado para futuras funciones de reproducción.",
|
||||
"settings.speech.ttsVoice.title": "Voz predeterminada",
|
||||
"settings.speech.ttsVoice.subtitle": "Voz predeterminada de texto a voz reservada para futuras funciones de reproducción.",
|
||||
"settings.speech.playbackMode.title": "Modo de reproduccion",
|
||||
"settings.speech.playbackMode.subtitle": "Elige si TTS empieza a reproducirse mientras llega el audio o despues de generar el archivo completo.",
|
||||
"settings.speech.playbackMode.streaming": "Streaming",
|
||||
"settings.speech.playbackMode.buffered": "Buffered",
|
||||
"settings.speech.ttsFormat.title": "Formato de salida",
|
||||
"settings.speech.ttsFormat.subtitle": "Elige el formato de audio para la voz sintetizada. La compatibilidad de streaming depende de tu proveedor y navegador.",
|
||||
"settings.speech.help": "La entrada de voz del prompt aparece cuando la transcripcion de voz esta configurada y es compatible. La reproduccion de mensajes usa el modo y formato TTS seleccionados aqui.",
|
||||
"settings.speech.compatibility.streamingUnavailable": "Tu configuracion actual del proveedor de voz no anuncia TTS por streaming. Cambia el modo de reproduccion a buffered si quieres que la reproduccion funcione ahora.",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "Tu navegador actual no puede reproducir por streaming el formato TTS seleccionado. Elige reproduccion buffered o cambia a otro formato.",
|
||||
"settings.speech.compatibility.runtimeNote": "Todos los formatos siguen disponibles en modo streaming. Algunas combinaciones de navegador y proveedor aun pueden fallar al reproducir.",
|
||||
"settings.speech.testPlayback.action": "Probar reproduccion",
|
||||
"settings.speech.testPlayback.generating": "Generando muestra",
|
||||
"settings.speech.testPlayback.stop": "Detener muestra",
|
||||
"settings.speech.testPlayback.sample": "Gracias por usar CodeNomad, tu configuracion de voz funciona correctamente.",
|
||||
"settings.speech.testPlayback.note": "La prueba usa de inmediato el modo y formato actuales. Guarda primero los cambios de API key, base URL, modelo o voz si tambien quieres probarlos.",
|
||||
"settings.speech.save.action": "Guardar",
|
||||
"settings.speech.save.saving": "Guardando...",
|
||||
"settings.speech.save.saved": "Guardado",
|
||||
"settings.speech.save.unsaved": "Cambios sin guardar",
|
||||
"settings.speech.save.error": "Error al guardar",
|
||||
} as const
|
||||
|
||||
@@ -77,6 +77,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "Copier",
|
||||
"messageItem.actions.copyTitle": "Copier le message",
|
||||
"messageItem.actions.copied": "Copié !",
|
||||
"messageItem.actions.speak": "Lire le message",
|
||||
"messageItem.actions.generatingSpeech": "Generation de l'audio",
|
||||
"messageItem.actions.stopSpeech": "Arreter la lecture",
|
||||
"messageItem.actions.speak.error.title": "La lecture vocale a echoue",
|
||||
"messageItem.actions.speak.error.unsupported": "La lecture vocale n'est pas prise en charge dans ce navigateur.",
|
||||
"messageItem.actions.speak.error.unavailable": "La lecture vocale n'est pas disponible tant que les parametres vocaux ne sont pas configures.",
|
||||
"messageItem.actions.speak.error.generate": "Impossible de generer l'audio pour ce message.",
|
||||
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
|
||||
"messageItem.actions.deletingMessage": "Suppression...",
|
||||
@@ -137,7 +144,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "à nouveau pour interrompre la session",
|
||||
"promptInput.stopSession.ariaLabel": "Arrêter la session",
|
||||
"promptInput.stopSession.title": "Arrêter la session",
|
||||
"promptInput.clear.ariaLabel": "Effacer le texte du prompt",
|
||||
"promptInput.clear.title": "Effacer le texte du prompt",
|
||||
"promptInput.send.ariaLabel": "Envoyer le message",
|
||||
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
||||
"promptInput.send.errorTitle": "Échec de l'envoi",
|
||||
"promptInput.conversationMode.enable.title": "Activer le mode conversation",
|
||||
"promptInput.conversationMode.disable.title": "Desactiver le mode conversation",
|
||||
"promptInput.conversationMode.error.title": "La lecture de la conversation a echoue",
|
||||
"promptInput.conversationMode.error.message": "Impossible de continuer a lire les reponses de l'assistant.",
|
||||
"promptInput.voiceInput.start.title": "Démarrer la saisie vocale",
|
||||
"promptInput.voiceInput.stop.title": "Arrêter l'enregistrement et transcrire",
|
||||
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
|
||||
"promptInput.voiceInput.error.title": "Échec de la saisie vocale",
|
||||
"promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.",
|
||||
"promptInput.voiceInput.error.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
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
||||
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
||||
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
||||
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
|
||||
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
|
||||
"remoteAccess.address.scope.network": "Réseau",
|
||||
"remoteAccess.address.scope.loopback": "Boucle locale",
|
||||
"remoteAccess.address.scope.internal": "Interne",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
||||
"settings.nav.appearance": "Appearance",
|
||||
"settings.nav.notifications": "Notifications",
|
||||
"settings.nav.remote": "Remote Access",
|
||||
"settings.nav.speech": "Speech",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "This device",
|
||||
"settings.scope.server": "Server setting",
|
||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
|
||||
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
|
||||
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
|
||||
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
|
||||
"settings.speech.title": "Voix",
|
||||
"settings.speech.subtitle": "Configurez dès maintenant la reconnaissance vocale et préparez la synthèse vocale pour de futures fonctionnalités.",
|
||||
"settings.speech.provider.title": "Fournisseur",
|
||||
"settings.speech.provider.subtitle": "Les requêtes vocales utilisent l'adaptateur vocal côté serveur.",
|
||||
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||
"settings.speech.status.loading": "Vérification de la configuration...",
|
||||
"settings.speech.status.configured": "Configuré",
|
||||
"settings.speech.status.missing": "Clé API manquante",
|
||||
"settings.speech.status.error": "Service vocal indisponible",
|
||||
"settings.speech.apiKey.title": "API key",
|
||||
"settings.speech.apiKey.subtitle": "Utilisée pour les requêtes vocales gérées par CodeNomad.",
|
||||
"settings.speech.apiKey.placeholder": "Saisissez une nouvelle clé API",
|
||||
"settings.speech.apiKey.storedNote": "Une clé API enregistrée est masquée. Saisissez une nouvelle valeur pour la remplacer ou laissez le champ vide pour la conserver.",
|
||||
"settings.speech.apiKey.clearAction": "Effacer la clé enregistrée",
|
||||
"settings.speech.apiKey.clearPending": "La clé API enregistrée sera supprimée lors de l'enregistrement.",
|
||||
"settings.speech.baseUrl.title": "Base URL",
|
||||
"settings.speech.baseUrl.subtitle": "Remplacement facultatif des points d'accès vocaux compatibles OpenAI.",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "Modèle de transcription",
|
||||
"settings.speech.sttModel.subtitle": "Modèle utilisé pour les requêtes vocales vers texte du prompt.",
|
||||
"settings.speech.ttsModel.title": "Modèle vocal",
|
||||
"settings.speech.ttsModel.subtitle": "Modèle de synthèse vocale par défaut réservé aux futures fonctions de lecture.",
|
||||
"settings.speech.ttsVoice.title": "Voix par défaut",
|
||||
"settings.speech.ttsVoice.subtitle": "Voix de synthèse vocale par défaut réservée aux futures fonctions de lecture.",
|
||||
"settings.speech.playbackMode.title": "Mode de lecture",
|
||||
"settings.speech.playbackMode.subtitle": "Choisissez si le TTS commence a jouer pendant le flux audio ou apres la generation complete du fichier.",
|
||||
"settings.speech.playbackMode.streaming": "Streaming",
|
||||
"settings.speech.playbackMode.buffered": "Buffered",
|
||||
"settings.speech.ttsFormat.title": "Format de sortie",
|
||||
"settings.speech.ttsFormat.subtitle": "Choisissez le format audio pour la voix synthetisee. La prise en charge du streaming depend du fournisseur et du navigateur.",
|
||||
"settings.speech.help": "La saisie vocale du prompt apparait lorsque la transcription vocale est configuree et prise en charge. La lecture des messages utilise le mode et le format TTS selectionnes ici.",
|
||||
"settings.speech.compatibility.streamingUnavailable": "Votre configuration actuelle du fournisseur vocal n'annonce pas le TTS en streaming. Passez le mode de lecture sur buffered si vous voulez que la lecture fonctionne maintenant.",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "Votre navigateur actuel ne peut pas lire en streaming le format TTS selectionne. Choisissez la lecture buffered ou passez a un autre format.",
|
||||
"settings.speech.compatibility.runtimeNote": "Tous les formats restent selectionnables en mode streaming. Certaines combinaisons navigateur/fournisseur peuvent quand meme echouer au moment de la lecture.",
|
||||
"settings.speech.testPlayback.action": "Tester la lecture",
|
||||
"settings.speech.testPlayback.generating": "Generation de l'extrait",
|
||||
"settings.speech.testPlayback.stop": "Arreter l'extrait",
|
||||
"settings.speech.testPlayback.sample": "Merci d'utiliser CodeNomad, vos parametres vocaux fonctionnent correctement.",
|
||||
"settings.speech.testPlayback.note": "Le test utilise immediatement le mode et le format actuels. Enregistrez d'abord les changements d'API key, d'URL de base, de modele ou de voix si vous voulez aussi les tester.",
|
||||
"settings.speech.save.action": "Enregistrer",
|
||||
"settings.speech.save.saving": "Enregistrement...",
|
||||
"settings.speech.save.saved": "Enregistré",
|
||||
"settings.speech.save.unsaved": "Modifications non enregistrées",
|
||||
"settings.speech.save.error": "Échec de l'enregistrement",
|
||||
} as const
|
||||
|
||||
@@ -75,6 +75,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "העתק",
|
||||
"messageItem.actions.copyTitle": "העתק הודעה",
|
||||
"messageItem.actions.copied": "הועתק!",
|
||||
"messageItem.actions.speak": "השמע הודעה",
|
||||
"messageItem.actions.generatingSpeech": "יוצר אודיו",
|
||||
"messageItem.actions.stopSpeech": "עצור ניגון",
|
||||
"messageItem.actions.speak.error.title": "ניגון הקול נכשל",
|
||||
"messageItem.actions.speak.error.unsupported": "ניגון קול אינו נתמך בדפדפן הזה.",
|
||||
"messageItem.actions.speak.error.unavailable": "ניגון קול לא זמין עד שהגדרות הקול יוגדרו.",
|
||||
"messageItem.actions.speak.error.generate": "לא ניתן היה ליצור אודיו עבור ההודעה הזו.",
|
||||
"messageItem.actions.deleteMessage": "מחק הודעה (לא מבטל שינויים)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "מחק הודעות עד כאן (לא מבטל שינויים)",
|
||||
"messageItem.actions.deletingMessage": "מוחק...",
|
||||
@@ -135,7 +142,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "שוב כדי לבטל את הסשן",
|
||||
"promptInput.stopSession.ariaLabel": "עצור סשן",
|
||||
"promptInput.stopSession.title": "עצור סשן",
|
||||
"promptInput.clear.ariaLabel": "נקה את טקסט הפרומפט",
|
||||
"promptInput.clear.title": "נקה את טקסט הפרומפט",
|
||||
"promptInput.send.ariaLabel": "שלח הודעה",
|
||||
"promptInput.send.errorFallback": "שליחת ההודעה נכשלה",
|
||||
"promptInput.send.errorTitle": "השליחה נכשלה",
|
||||
"promptInput.conversationMode.enable.title": "הפעל מצב שיחה",
|
||||
"promptInput.conversationMode.disable.title": "כבה מצב שיחה",
|
||||
"promptInput.conversationMode.error.title": "ניגון השיחה נכשל",
|
||||
"promptInput.conversationMode.error.message": "לא ניתן היה להמשיך להקריא את תגובות העוזר.",
|
||||
"promptInput.voiceInput.start.title": "התחל קלט קולי",
|
||||
"promptInput.voiceInput.stop.title": "עצור הקלטה ותמלל",
|
||||
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
|
||||
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
|
||||
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
|
||||
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
|
||||
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
|
||||
} as const
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
|
||||
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
|
||||
"remoteAccess.address.scope.network": "רשת",
|
||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||
"remoteAccess.address.scope.internal": "פנימי",
|
||||
|
||||
@@ -137,6 +137,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
|
||||
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
|
||||
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
|
||||
"settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.",
|
||||
"settings.behavior.promptSubmit.title": "Enter לשליחה",
|
||||
"settings.behavior.promptSubmit.subtitle": "השתמש ב-Enter לשליחת פקודות; Cmd/Ctrl+Enter מוסיף שורה חדשה.",
|
||||
"settings.speech.title": "קול",
|
||||
"settings.speech.subtitle": "הגדר כעת דיבור-לטקסט והכן תשתית לטקסט-לדיבור עבור יכולות עתידיות.",
|
||||
"settings.speech.provider.title": "ספק",
|
||||
"settings.speech.provider.subtitle": "בקשות קול משתמשות במתאם הקול שבצד השרת.",
|
||||
"settings.speech.provider.openaiCompatible": "תואם OpenAI",
|
||||
"settings.speech.status.loading": "בודק את ההגדרות...",
|
||||
"settings.speech.status.configured": "מוגדר",
|
||||
"settings.speech.status.missing": "חסר מפתח API",
|
||||
"settings.speech.status.error": "שירות הקול אינו זמין",
|
||||
"settings.speech.apiKey.title": "מפתח API",
|
||||
"settings.speech.apiKey.subtitle": "משמש עבור בקשות קול המנוהלות על ידי CodeNomad.",
|
||||
"settings.speech.apiKey.placeholder": "הזן מפתח API חדש",
|
||||
"settings.speech.apiKey.storedNote": "מפתח API שמור מוסתר. הזן ערך חדש כדי להחליף אותו, או השאר את השדה ריק כדי לשמור עליו.",
|
||||
"settings.speech.apiKey.clearAction": "נקה מפתח שמור",
|
||||
"settings.speech.apiKey.clearPending": "מפתח ה-API השמור יוסר בעת השמירה.",
|
||||
"settings.speech.baseUrl.title": "כתובת בסיס",
|
||||
"settings.speech.baseUrl.subtitle": "עקיפה אופציונלית עבור נקודות קצה קוליות התואמות ל-OpenAI.",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "מודל תמלול",
|
||||
"settings.speech.sttModel.subtitle": "המודל המשמש לבקשות דיבור-לטקסט בפרומפט.",
|
||||
"settings.speech.ttsModel.title": "מודל קול",
|
||||
"settings.speech.ttsModel.subtitle": "מודל ברירת מחדל לטקסט-לדיבור השמור ליכולות ניגון עתידיות.",
|
||||
"settings.speech.ttsVoice.title": "קול ברירת מחדל",
|
||||
"settings.speech.ttsVoice.subtitle": "קול ברירת מחדל לטקסט-לדיבור השמור ליכולות ניגון עתידיות.",
|
||||
"settings.speech.playbackMode.title": "מצב ניגון",
|
||||
"settings.speech.playbackMode.subtitle": "בחר אם TTS יתחיל לנגן בזמן שהאודיו מוזרם או רק אחרי שהקובץ כולו נוצר.",
|
||||
"settings.speech.playbackMode.streaming": "סטרימינג",
|
||||
"settings.speech.playbackMode.buffered": "באפר מלא",
|
||||
"settings.speech.ttsFormat.title": "פורמט פלט",
|
||||
"settings.speech.ttsFormat.subtitle": "בחר את פורמט האודיו לדיבור מסונתז. תמיכת סטרימינג תלויה בספק ובדפדפן.",
|
||||
"settings.speech.help": "קלט קולי לפרומפט מופיע כאשר תמלול קול מוגדר ונתמך. השמעת הודעות משתמשת במצב ובפורמט ה-TTS שנבחרו כאן.",
|
||||
"settings.speech.compatibility.streamingUnavailable": "תצורת ספק הקול הנוכחית שלך לא מצהירה על TTS בסטרימינג. עבור למצב buffered אם אתה רוצה שהניגון יעבוד כבר עכשיו.",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "הדפדפן הנוכחי שלך לא יכול לנגן בסטרימינג את פורמט ה-TTS שנבחר. בחר בניגון buffered או עבור לפורמט אחר.",
|
||||
"settings.speech.compatibility.runtimeNote": "כל הפורמטים נשארים זמינים במצב סטרימינג. חלק מהשילובים של דפדפן וספק עדיין עלולים להיכשל בזמן הניגון.",
|
||||
"settings.speech.testPlayback.action": "בדוק ניגון",
|
||||
"settings.speech.testPlayback.generating": "יוצר דוגמה",
|
||||
"settings.speech.testPlayback.stop": "עצור דוגמה",
|
||||
"settings.speech.testPlayback.sample": "תודה שאתה משתמש ב-CodeNomad, הגדרות הקול שלך פועלות כראוי.",
|
||||
"settings.speech.testPlayback.note": "המבחן משתמש מיד במצב ובפורמט הנוכחיים. שמור תחילה שינויים ב-API key, ב-Base URL, במודל או בקול אם גם אותם תרצה לבדוק.",
|
||||
"settings.speech.save.action": "שמור",
|
||||
"settings.speech.save.saving": "שומר...",
|
||||
"settings.speech.save.saved": "נשמר",
|
||||
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
|
||||
"settings.speech.save.error": "השמירה נכשלה",
|
||||
} as const
|
||||
|
||||
@@ -77,6 +77,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "コピー",
|
||||
"messageItem.actions.copyTitle": "メッセージをコピー",
|
||||
"messageItem.actions.copied": "コピーしました!",
|
||||
"messageItem.actions.speak": "メッセージを読み上げ",
|
||||
"messageItem.actions.generatingSpeech": "音声を生成中",
|
||||
"messageItem.actions.stopSpeech": "再生を停止",
|
||||
"messageItem.actions.speak.error.title": "音声再生に失敗しました",
|
||||
"messageItem.actions.speak.error.unsupported": "このブラウザでは音声再生に対応していません。",
|
||||
"messageItem.actions.speak.error.unavailable": "音声設定が完了するまで音声再生は利用できません。",
|
||||
"messageItem.actions.speak.error.generate": "このメッセージの音声を生成できませんでした。",
|
||||
"messageItem.actions.deleteMessage": "メッセージを削除(変更は元に戻さない)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "ここまでのメッセージを削除(変更は元に戻さない)",
|
||||
"messageItem.actions.deletingMessage": "削除中...",
|
||||
@@ -137,7 +144,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "もう一度押すとセッションを中断",
|
||||
"promptInput.stopSession.ariaLabel": "セッションを停止",
|
||||
"promptInput.stopSession.title": "セッションを停止",
|
||||
"promptInput.clear.ariaLabel": "プロンプトのテキストをクリア",
|
||||
"promptInput.clear.title": "プロンプトのテキストをクリア",
|
||||
"promptInput.send.ariaLabel": "メッセージを送信",
|
||||
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
|
||||
"promptInput.send.errorTitle": "送信に失敗",
|
||||
"promptInput.conversationMode.enable.title": "会話モードを有効化",
|
||||
"promptInput.conversationMode.disable.title": "会話モードを無効化",
|
||||
"promptInput.conversationMode.error.title": "会話の読み上げに失敗しました",
|
||||
"promptInput.conversationMode.error.message": "アシスタントの返信の読み上げを続行できませんでした。",
|
||||
"promptInput.voiceInput.start.title": "音声入力を開始",
|
||||
"promptInput.voiceInput.stop.title": "録音を停止して文字起こし",
|
||||
"promptInput.voiceInput.transcribing.title": "音声を文字起こし中",
|
||||
"promptInput.voiceInput.error.title": "音声入力に失敗しました",
|
||||
"promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。",
|
||||
"promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。",
|
||||
"promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。",
|
||||
} as const
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
|
||||
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
|
||||
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
|
||||
"remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示",
|
||||
"remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す",
|
||||
"remoteAccess.address.scope.network": "ネットワーク",
|
||||
"remoteAccess.address.scope.loopback": "ループバック",
|
||||
"remoteAccess.address.scope.internal": "内部",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
||||
"settings.nav.appearance": "Appearance",
|
||||
"settings.nav.notifications": "Notifications",
|
||||
"settings.nav.remote": "Remote Access",
|
||||
"settings.nav.speech": "Speech",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "This device",
|
||||
"settings.scope.server": "Server setting",
|
||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
|
||||
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
|
||||
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Enterで送信",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
||||
"settings.speech.title": "音声",
|
||||
"settings.speech.subtitle": "今すぐ音声入力を設定し、今後の機能のために音声合成の基盤も準備します。",
|
||||
"settings.speech.provider.title": "プロバイダー",
|
||||
"settings.speech.provider.subtitle": "音声リクエストはサーバー側の音声アダプターを使用します。",
|
||||
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||
"settings.speech.status.loading": "設定を確認しています...",
|
||||
"settings.speech.status.configured": "設定済み",
|
||||
"settings.speech.status.missing": "APIキーがありません",
|
||||
"settings.speech.status.error": "音声サービスを利用できません",
|
||||
"settings.speech.apiKey.title": "API key",
|
||||
"settings.speech.apiKey.subtitle": "CodeNomadが管理する音声リクエストに使用されます。",
|
||||
"settings.speech.apiKey.placeholder": "新しいAPIキーを入力",
|
||||
"settings.speech.apiKey.storedNote": "保存済みのAPIキーは非表示になっています。置き換えるには新しい値を入力し、そのまま使うには空欄のままにしてください。",
|
||||
"settings.speech.apiKey.clearAction": "保存済みキーを削除",
|
||||
"settings.speech.apiKey.clearPending": "保存すると、保存済みのAPIキーは削除されます。",
|
||||
"settings.speech.baseUrl.title": "Base URL",
|
||||
"settings.speech.baseUrl.subtitle": "OpenAI互換の音声エンドポイント用の任意の上書き設定です。",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "文字起こしモデル",
|
||||
"settings.speech.sttModel.subtitle": "プロンプトの音声入力を文字起こしする際に使用するモデルです。",
|
||||
"settings.speech.ttsModel.title": "音声モデル",
|
||||
"settings.speech.ttsModel.subtitle": "将来の再生機能のために予約されている既定の音声合成モデルです。",
|
||||
"settings.speech.ttsVoice.title": "既定の音声",
|
||||
"settings.speech.ttsVoice.subtitle": "将来の再生機能のために予約されている既定の音声合成ボイスです。",
|
||||
"settings.speech.playbackMode.title": "再生モード",
|
||||
"settings.speech.playbackMode.subtitle": "音声が届き次第再生を始めるか、ファイル全体の生成後に再生するかを選択します。",
|
||||
"settings.speech.playbackMode.streaming": "Streaming",
|
||||
"settings.speech.playbackMode.buffered": "Buffered",
|
||||
"settings.speech.ttsFormat.title": "出力形式",
|
||||
"settings.speech.ttsFormat.subtitle": "音声合成の出力形式を選択します。ストリーミング対応はプロバイダーとブラウザーに依存します。",
|
||||
"settings.speech.help": "プロンプト音声入力は音声文字起こしが設定され対応している場合に表示されます。メッセージ再生にはここで選んだTTSモードと形式が使われます。",
|
||||
"settings.speech.compatibility.streamingUnavailable": "現在の音声プロバイダー設定ではストリーミングTTSが利用可能として公開されていません。今すぐ再生を使いたい場合は再生モードを buffered に切り替えてください。",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "現在のブラウザーでは、選択したTTS形式をストリーミング再生できません。buffered 再生に切り替えるか、別の形式を選んでください。",
|
||||
"settings.speech.compatibility.runtimeNote": "ストリーミングモードでも全ての形式を選択できますが、ブラウザーとプロバイダーの組み合わせによっては再生時に失敗することがあります。",
|
||||
"settings.speech.testPlayback.action": "再生をテスト",
|
||||
"settings.speech.testPlayback.generating": "サンプルを生成中",
|
||||
"settings.speech.testPlayback.stop": "サンプルを停止",
|
||||
"settings.speech.testPlayback.sample": "CodeNomad をご利用いただきありがとうございます。音声設定は正常に動作しています。",
|
||||
"settings.speech.testPlayback.note": "このテストは現在の再生モードと形式をすぐに使います。APIキー、Base URL、モデル、音声の変更も試したい場合は先に保存してください。",
|
||||
"settings.speech.save.action": "保存",
|
||||
"settings.speech.save.saving": "保存中...",
|
||||
"settings.speech.save.saved": "保存済み",
|
||||
"settings.speech.save.unsaved": "未保存の変更",
|
||||
"settings.speech.save.error": "保存に失敗しました",
|
||||
} as const
|
||||
|
||||
@@ -77,6 +77,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "Копировать",
|
||||
"messageItem.actions.copyTitle": "Копировать сообщение",
|
||||
"messageItem.actions.copied": "Скопировано!",
|
||||
"messageItem.actions.speak": "Озвучить сообщение",
|
||||
"messageItem.actions.generatingSpeech": "Генерация аудио",
|
||||
"messageItem.actions.stopSpeech": "Остановить воспроизведение",
|
||||
"messageItem.actions.speak.error.title": "Не удалось воспроизвести речь",
|
||||
"messageItem.actions.speak.error.unsupported": "В этом браузере воспроизведение речи не поддерживается.",
|
||||
"messageItem.actions.speak.error.unavailable": "Воспроизведение речи недоступно, пока не настроены голосовые параметры.",
|
||||
"messageItem.actions.speak.error.generate": "Не удалось сгенерировать аудио для этого сообщения.",
|
||||
"messageItem.actions.deleteMessage": "Удалить сообщение (без отката изменений)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "Удалить сообщения до этого места (без отката изменений)",
|
||||
"messageItem.actions.deletingMessage": "Удаление...",
|
||||
@@ -137,7 +144,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "еще раз, чтобы прервать сессию",
|
||||
"promptInput.stopSession.ariaLabel": "Остановить сессию",
|
||||
"promptInput.stopSession.title": "Остановить сессию",
|
||||
"promptInput.clear.ariaLabel": "Очистить текст prompt",
|
||||
"promptInput.clear.title": "Очистить текст prompt",
|
||||
"promptInput.send.ariaLabel": "Отправить сообщение",
|
||||
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
|
||||
"promptInput.send.errorTitle": "Не удалось отправить",
|
||||
"promptInput.conversationMode.enable.title": "Включить режим разговора",
|
||||
"promptInput.conversationMode.disable.title": "Выключить режим разговора",
|
||||
"promptInput.conversationMode.error.title": "Сбой озвучивания разговора",
|
||||
"promptInput.conversationMode.error.message": "Не удалось продолжить озвучивание ответов ассистента.",
|
||||
"promptInput.voiceInput.start.title": "Начать голосовой ввод",
|
||||
"promptInput.voiceInput.stop.title": "Остановить запись и расшифровать",
|
||||
"promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио",
|
||||
"promptInput.voiceInput.error.title": "Сбой голосового ввода",
|
||||
"promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.",
|
||||
"promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.",
|
||||
"promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.",
|
||||
} as const
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.",
|
||||
"remoteAccess.addresses.loading": "Загрузка адресов…",
|
||||
"remoteAccess.addresses.none": "Пока нет доступных адресов.",
|
||||
"remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов",
|
||||
"remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса",
|
||||
"remoteAccess.address.scope.network": "Сеть",
|
||||
"remoteAccess.address.scope.loopback": "Loopback",
|
||||
"remoteAccess.address.scope.internal": "Внутренний",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
||||
"settings.nav.appearance": "Appearance",
|
||||
"settings.nav.notifications": "Notifications",
|
||||
"settings.nav.remote": "Remote Access",
|
||||
"settings.nav.speech": "Speech",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "This device",
|
||||
"settings.scope.server": "Server setting",
|
||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
|
||||
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
|
||||
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Enter для отправки",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
|
||||
"settings.speech.title": "Речь",
|
||||
"settings.speech.subtitle": "Настройте преобразование речи в текст сейчас и подготовьте основу для синтеза речи в будущих функциях.",
|
||||
"settings.speech.provider.title": "Провайдер",
|
||||
"settings.speech.provider.subtitle": "Речевые запросы используют серверный речевой адаптер.",
|
||||
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||
"settings.speech.status.loading": "Проверка конфигурации...",
|
||||
"settings.speech.status.configured": "Настроено",
|
||||
"settings.speech.status.missing": "Отсутствует API-ключ",
|
||||
"settings.speech.status.error": "Речевой сервис недоступен",
|
||||
"settings.speech.apiKey.title": "API key",
|
||||
"settings.speech.apiKey.subtitle": "Используется для речевых запросов, управляемых CodeNomad.",
|
||||
"settings.speech.apiKey.placeholder": "Введите новый API-ключ",
|
||||
"settings.speech.apiKey.storedNote": "Сохранённый API-ключ скрыт. Введите новое значение, чтобы заменить его, или оставьте поле пустым, чтобы сохранить текущий ключ.",
|
||||
"settings.speech.apiKey.clearAction": "Удалить сохранённый ключ",
|
||||
"settings.speech.apiKey.clearPending": "Сохранённый API-ключ будет удалён после сохранения.",
|
||||
"settings.speech.baseUrl.title": "Base URL",
|
||||
"settings.speech.baseUrl.subtitle": "Необязательная переопределяющая ссылка для речевых endpoint'ов, совместимых с OpenAI.",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "Модель распознавания",
|
||||
"settings.speech.sttModel.subtitle": "Модель, используемая для преобразования голосового ввода в тексте запроса.",
|
||||
"settings.speech.ttsModel.title": "Речевая модель",
|
||||
"settings.speech.ttsModel.subtitle": "Модель синтеза речи по умолчанию, зарезервированная для будущих функций воспроизведения.",
|
||||
"settings.speech.ttsVoice.title": "Голос по умолчанию",
|
||||
"settings.speech.ttsVoice.subtitle": "Голос синтеза речи по умолчанию, зарезервированный для будущих функций воспроизведения.",
|
||||
"settings.speech.playbackMode.title": "Режим воспроизведения",
|
||||
"settings.speech.playbackMode.subtitle": "Выберите, начинать ли воспроизведение TTS во время поступления аудио или только после полной генерации файла.",
|
||||
"settings.speech.playbackMode.streaming": "Потоковый",
|
||||
"settings.speech.playbackMode.buffered": "Буферизованный",
|
||||
"settings.speech.ttsFormat.title": "Формат вывода",
|
||||
"settings.speech.ttsFormat.subtitle": "Выберите аудиоформат для синтезированной речи. Поддержка потокового режима зависит от провайдера и браузера.",
|
||||
"settings.speech.help": "Голосовой ввод появляется, когда распознавание речи настроено и поддерживается. Для воспроизведения сообщений используются выбранные здесь режим и формат TTS.",
|
||||
"settings.speech.compatibility.streamingUnavailable": "Текущая конфигурация голосового провайдера не заявляет поддержку потокового TTS. Переключите режим воспроизведения на buffered, если хотите, чтобы воспроизведение работало уже сейчас.",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "Ваш текущий браузер не может воспроизводить потоково выбранный формат TTS. Выберите buffered-воспроизведение или переключитесь на другой формат.",
|
||||
"settings.speech.compatibility.runtimeNote": "В режиме streaming по-прежнему доступны все форматы. Некоторые сочетания браузера и провайдера все равно могут завершаться ошибкой во время воспроизведения.",
|
||||
"settings.speech.testPlayback.action": "Проверить воспроизведение",
|
||||
"settings.speech.testPlayback.generating": "Генерация примера",
|
||||
"settings.speech.testPlayback.stop": "Остановить пример",
|
||||
"settings.speech.testPlayback.sample": "Спасибо, что используете CodeNomad, ваши настройки речи работают нормально.",
|
||||
"settings.speech.testPlayback.note": "Тест сразу использует текущие режим и формат. Сначала сохраните изменения API key, Base URL, модели или голоса, если хотите проверить и их.",
|
||||
"settings.speech.save.action": "Сохранить",
|
||||
"settings.speech.save.saving": "Сохранение...",
|
||||
"settings.speech.save.saved": "Сохранено",
|
||||
"settings.speech.save.unsaved": "Есть несохранённые изменения",
|
||||
"settings.speech.save.error": "Не удалось сохранить",
|
||||
} as const
|
||||
|
||||
@@ -77,6 +77,13 @@ export const messagingMessages = {
|
||||
"messageItem.actions.copy": "复制",
|
||||
"messageItem.actions.copyTitle": "复制消息",
|
||||
"messageItem.actions.copied": "已复制!",
|
||||
"messageItem.actions.speak": "朗读消息",
|
||||
"messageItem.actions.generatingSpeech": "正在生成语音",
|
||||
"messageItem.actions.stopSpeech": "停止播放",
|
||||
"messageItem.actions.speak.error.title": "语音播放失败",
|
||||
"messageItem.actions.speak.error.unsupported": "此浏览器不支持语音播放。",
|
||||
"messageItem.actions.speak.error.unavailable": "语音设置完成前,语音播放不可用。",
|
||||
"messageItem.actions.speak.error.generate": "无法为这条消息生成语音。",
|
||||
"messageItem.actions.deleteMessage": "删除消息(不会撤销更改)",
|
||||
"messageItem.actions.deleteMessagesUpTo": "删除到此处的消息(不会撤销更改)",
|
||||
"messageItem.actions.deletingMessage": "正在删除...",
|
||||
@@ -137,7 +144,20 @@ export const messagingMessages = {
|
||||
"promptInput.overlay.againToAbort": "再次按下以中止会话",
|
||||
"promptInput.stopSession.ariaLabel": "停止会话",
|
||||
"promptInput.stopSession.title": "停止会话",
|
||||
"promptInput.clear.ariaLabel": "清除输入框文本",
|
||||
"promptInput.clear.title": "清除输入框文本",
|
||||
"promptInput.send.ariaLabel": "发送消息",
|
||||
"promptInput.send.errorFallback": "发送消息失败",
|
||||
"promptInput.send.errorTitle": "发送失败",
|
||||
"promptInput.conversationMode.enable.title": "开启对话模式",
|
||||
"promptInput.conversationMode.disable.title": "关闭对话模式",
|
||||
"promptInput.conversationMode.error.title": "对话播报失败",
|
||||
"promptInput.conversationMode.error.message": "无法继续播报助手回复。",
|
||||
"promptInput.voiceInput.start.title": "开始语音输入",
|
||||
"promptInput.voiceInput.stop.title": "停止录音并转写",
|
||||
"promptInput.voiceInput.transcribing.title": "正在转写音频",
|
||||
"promptInput.voiceInput.error.title": "语音输入失败",
|
||||
"promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。",
|
||||
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
|
||||
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
|
||||
} as const
|
||||
|
||||
@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
|
||||
"remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。",
|
||||
"remoteAccess.addresses.loading": "正在加载地址…",
|
||||
"remoteAccess.addresses.none": "暂时没有可用地址。",
|
||||
"remoteAccess.addresses.actions.showOther": "显示另外 {count} 个地址",
|
||||
"remoteAccess.addresses.actions.hideOther": "隐藏其他地址",
|
||||
"remoteAccess.address.scope.network": "网络",
|
||||
"remoteAccess.address.scope.loopback": "回环",
|
||||
"remoteAccess.address.scope.internal": "内部",
|
||||
|
||||
@@ -65,6 +65,7 @@ export const settingsMessages = {
|
||||
"settings.nav.appearance": "Appearance",
|
||||
"settings.nav.notifications": "Notifications",
|
||||
"settings.nav.remote": "Remote Access",
|
||||
"settings.nav.speech": "Speech",
|
||||
"settings.nav.opencode": "OpenCode",
|
||||
"settings.scope.device": "This device",
|
||||
"settings.scope.server": "Server setting",
|
||||
@@ -137,6 +138,52 @@ export const settingsMessages = {
|
||||
"settings.behavior.usageMetrics.subtitle": "显示或隐藏助手消息的令牌与成本统计。",
|
||||
"settings.behavior.autoCleanup.title": "自动清理空会话",
|
||||
"settings.behavior.autoCleanup.subtitle": "创建新会话时自动清理空会话。",
|
||||
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
|
||||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "回车发送",
|
||||
"settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。",
|
||||
"settings.speech.title": "语音",
|
||||
"settings.speech.subtitle": "立即配置语音转文字,并为后续功能预留文字转语音基础。",
|
||||
"settings.speech.provider.title": "提供商",
|
||||
"settings.speech.provider.subtitle": "语音请求使用服务器端语音适配器。",
|
||||
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
|
||||
"settings.speech.status.loading": "正在检查配置...",
|
||||
"settings.speech.status.configured": "已配置",
|
||||
"settings.speech.status.missing": "缺少 API 密钥",
|
||||
"settings.speech.status.error": "语音服务不可用",
|
||||
"settings.speech.apiKey.title": "API key",
|
||||
"settings.speech.apiKey.subtitle": "用于 CodeNomad 管理的语音请求。",
|
||||
"settings.speech.apiKey.placeholder": "输入新的 API 密钥",
|
||||
"settings.speech.apiKey.storedNote": "已保存的 API 密钥会被隐藏。输入新值可替换它,留空则保留当前密钥。",
|
||||
"settings.speech.apiKey.clearAction": "清除已保存的密钥",
|
||||
"settings.speech.apiKey.clearPending": "保存后将删除已保存的 API 密钥。",
|
||||
"settings.speech.baseUrl.title": "Base URL",
|
||||
"settings.speech.baseUrl.subtitle": "可选,用于覆盖 OpenAI 兼容语音端点的基础地址。",
|
||||
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
|
||||
"settings.speech.sttModel.title": "转写模型",
|
||||
"settings.speech.sttModel.subtitle": "用于提示框语音转文字请求的模型。",
|
||||
"settings.speech.ttsModel.title": "语音模型",
|
||||
"settings.speech.ttsModel.subtitle": "为未来播放功能预留的默认文字转语音模型。",
|
||||
"settings.speech.ttsVoice.title": "默认语音",
|
||||
"settings.speech.ttsVoice.subtitle": "为未来播放功能预留的默认文字转语音音色。",
|
||||
"settings.speech.playbackMode.title": "播放模式",
|
||||
"settings.speech.playbackMode.subtitle": "选择在音频流入时开始播放,还是在整个文件生成完成后再播放。",
|
||||
"settings.speech.playbackMode.streaming": "流式",
|
||||
"settings.speech.playbackMode.buffered": "缓冲后播放",
|
||||
"settings.speech.ttsFormat.title": "输出格式",
|
||||
"settings.speech.ttsFormat.subtitle": "选择语音合成的音频格式。流式支持取决于你的提供商和浏览器。",
|
||||
"settings.speech.help": "当语音转写已配置且受支持时,提示框语音输入会显示。消息播放会使用这里选择的 TTS 模式和格式。",
|
||||
"settings.speech.compatibility.streamingUnavailable": "你当前的语音提供商配置没有声明支持流式 TTS。如果你现在就想让播放可用,请把播放模式切换为 buffered。",
|
||||
"settings.speech.compatibility.browserStreamingUnavailable": "你当前的浏览器无法流式播放所选的 TTS 格式。请选择 buffered 播放,或切换到其他格式。",
|
||||
"settings.speech.compatibility.runtimeNote": "在流式模式下仍然可以选择所有格式,但某些浏览器与提供商的组合在播放时仍可能失败。",
|
||||
"settings.speech.testPlayback.action": "测试播放",
|
||||
"settings.speech.testPlayback.generating": "正在生成示例",
|
||||
"settings.speech.testPlayback.stop": "停止示例",
|
||||
"settings.speech.testPlayback.sample": "感谢你使用 CodeNomad,你的语音设置工作正常。",
|
||||
"settings.speech.testPlayback.note": "测试会立即使用当前播放模式和格式。如果你也想测试 API key、Base URL、模型或音色的更改,请先保存。",
|
||||
"settings.speech.save.action": "保存",
|
||||
"settings.speech.save.saving": "保存中...",
|
||||
"settings.speech.save.saved": "已保存",
|
||||
"settings.speech.save.unsaved": "有未保存的更改",
|
||||
"settings.speech.save.error": "保存失败",
|
||||
} as const
|
||||
|
||||
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
17
packages/ui/src/lib/remote-access-addresses.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { splitRemoteAddresses } from "./remote-access-addresses"
|
||||
|
||||
describe("splitRemoteAddresses", () => {
|
||||
it("keeps the first remote address visible and collapses the rest", () => {
|
||||
const result = splitRemoteAddresses([
|
||||
{ ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" },
|
||||
{ ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" },
|
||||
{ ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" },
|
||||
])
|
||||
|
||||
assert.equal(result.recommended?.ip, "192.168.1.128")
|
||||
assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"])
|
||||
})
|
||||
})
|
||||
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
14
packages/ui/src/lib/remote-access-addresses.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NetworkAddress } from "../../../server/src/api-types"
|
||||
|
||||
export interface RemoteAddressGroups {
|
||||
recommended: NetworkAddress | null
|
||||
hidden: NetworkAddress[]
|
||||
}
|
||||
|
||||
export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups {
|
||||
const remoteAddresses = addresses.filter((address) => address.scope !== "loopback")
|
||||
return {
|
||||
recommended: remoteAddresses[0] ?? null,
|
||||
hidden: remoteAddresses.slice(1),
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export type BehaviorRegistryActions = {
|
||||
toggleUsageMetrics: () => void
|
||||
toggleAutoCleanupBlankSessions: () => void
|
||||
togglePromptSubmitOnEnter: () => void
|
||||
toggleShowPromptVoiceInput: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||
@@ -248,6 +249,24 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.promptVoiceInput",
|
||||
titleKey: "settings.behavior.promptVoiceInput.title",
|
||||
subtitleKey: "settings.behavior.promptVoiceInput.subtitle",
|
||||
get: (p) => Boolean(p.showPromptVoiceInput ?? true),
|
||||
set: (next) => {
|
||||
if (updatePreferences) {
|
||||
updatePreferences({ showPromptVoiceInput: next })
|
||||
return
|
||||
}
|
||||
setBooleanByToggle(
|
||||
() => Boolean(prefs().showPromptVoiceInput ?? true),
|
||||
actions.toggleShowPromptVoiceInput,
|
||||
next,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.promptSubmitOnEnter",
|
||||
|
||||
58
packages/ui/src/lib/speech-playback-support.ts
Normal file
58
packages/ui/src/lib/speech-playback-support.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
|
||||
import type { SpeechPlaybackMode, SpeechTtsFormat } from "../stores/preferences"
|
||||
|
||||
export interface SpeechPlaybackSupportResult {
|
||||
available: boolean
|
||||
reason?: "unsupported-environment" | "provider-streaming-unavailable" | "browser-streaming-unavailable"
|
||||
}
|
||||
|
||||
export function formatToMimeType(format: SpeechTtsFormat): string {
|
||||
if (format === "wav") return "audio/wav"
|
||||
if (format === "opus") return getSupportedMimeType(format)
|
||||
if (format === "aac") return "audio/aac"
|
||||
return "audio/mpeg"
|
||||
}
|
||||
|
||||
export function getCandidateMimeTypes(format: SpeechTtsFormat): string[] {
|
||||
if (format === "wav") return ["audio/wav"]
|
||||
if (format === "opus") {
|
||||
return ['audio/ogg; codecs="opus"', 'audio/webm; codecs="opus"', "audio/opus"]
|
||||
}
|
||||
if (format === "aac") return ["audio/aac", "audio/mp4", 'audio/mp4; codecs="mp4a.40.2"']
|
||||
return ["audio/mpeg"]
|
||||
}
|
||||
|
||||
export function getSupportedMimeType(format: SpeechTtsFormat): string {
|
||||
const candidates = getCandidateMimeTypes(format)
|
||||
if (typeof MediaSource === "undefined") {
|
||||
return candidates[0]
|
||||
}
|
||||
return candidates.find((candidate) => MediaSource.isTypeSupported(candidate)) ?? candidates[0]
|
||||
}
|
||||
|
||||
export function getSpeechPlaybackSupport(options: {
|
||||
playbackMode: SpeechPlaybackMode
|
||||
ttsFormat: SpeechTtsFormat
|
||||
capabilities?: SpeechCapabilitiesResponse | null
|
||||
}): SpeechPlaybackSupportResult {
|
||||
if (typeof window === "undefined" || typeof window.Audio === "undefined") {
|
||||
return { available: false, reason: "unsupported-environment" }
|
||||
}
|
||||
|
||||
if (options.playbackMode !== "streaming") {
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
if (!options.capabilities?.supportsStreamingTts) {
|
||||
return { available: false, reason: "provider-streaming-unavailable" }
|
||||
}
|
||||
|
||||
if (
|
||||
typeof MediaSource === "undefined" ||
|
||||
!getCandidateMimeTypes(options.ttsFormat).some((candidate) => MediaSource.isTypeSupported(candidate))
|
||||
) {
|
||||
return { available: false, reason: "browser-streaming-unavailable" }
|
||||
}
|
||||
|
||||
return { available: true }
|
||||
}
|
||||
507
packages/ui/src/stores/conversation-speech.ts
Normal file
507
packages/ui/src/stores/conversation-speech.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { loadSpeechCapabilities, speechCapabilities } from "./speech"
|
||||
import { getActiveSession, sessions } from "./session-state"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { activeInstanceId } from "./instances"
|
||||
|
||||
type SpeechPlaybackMode = "streaming" | "buffered"
|
||||
type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac"
|
||||
|
||||
interface ConversationQueueEntry {
|
||||
key: string
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PlaybackHandle {
|
||||
stop: () => void
|
||||
done: Promise<void>
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
const [conversationModeInstances, setConversationModeInstances] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
const queuedKeys = new Set<string>()
|
||||
const spokenKeysBySession = new Map<string, Set<string>>()
|
||||
let queue: ConversationQueueEntry[] = []
|
||||
let currentPlayback:
|
||||
| {
|
||||
entry: ConversationQueueEntry
|
||||
handle: PlaybackHandle
|
||||
}
|
||||
| null = null
|
||||
let queueRunner: Promise<void> | null = null
|
||||
let playbackErrorShown = false
|
||||
|
||||
function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string {
|
||||
return `${instanceId}:${sessionId}:${messageId}:${partId}`
|
||||
}
|
||||
|
||||
function getSpokenKeySet(instanceId: string, sessionId: string): Set<string> {
|
||||
const sessionKey = `${instanceId}:${sessionId}`
|
||||
const existing = spokenKeysBySession.get(sessionKey)
|
||||
if (existing) return existing
|
||||
const next = new Set<string>()
|
||||
spokenKeysBySession.set(sessionKey, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function resolveTextPartContent(part: ClientPart): string {
|
||||
if (part.type !== "text") return ""
|
||||
if (typeof part.text === "string") {
|
||||
return part.text
|
||||
}
|
||||
|
||||
if (part.text && typeof part.text === "object") {
|
||||
const value = part.text as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||
const segments: string[] = []
|
||||
if (typeof value.text === "string") {
|
||||
segments.push(value.text)
|
||||
}
|
||||
if (typeof value.value === "string") {
|
||||
segments.push(value.value)
|
||||
}
|
||||
if (Array.isArray(value.content)) {
|
||||
for (const segment of value.content) {
|
||||
if (typeof segment === "string") {
|
||||
segments.push(segment)
|
||||
} else if (segment && typeof segment === "object") {
|
||||
const typedSegment = segment as { text?: unknown; value?: unknown }
|
||||
if (typeof typedSegment.text === "string") segments.push(typedSegment.text)
|
||||
if (typeof typedSegment.value === "string") segments.push(typedSegment.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments.join("\n")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function isConversationModeEnabled(instanceId: string): boolean {
|
||||
return conversationModeInstances().get(instanceId) === true
|
||||
}
|
||||
|
||||
export function canUseConversationMode(): boolean {
|
||||
const capabilities = speechCapabilities()
|
||||
if (!capabilities?.available || !capabilities.configured || !capabilities.supportsTts) {
|
||||
return false
|
||||
}
|
||||
|
||||
const settings = serverSettings().speech
|
||||
return getSpeechPlaybackSupport({
|
||||
playbackMode: settings.playbackMode,
|
||||
ttsFormat: settings.ttsFormat,
|
||||
capabilities,
|
||||
}).available
|
||||
}
|
||||
|
||||
export function setConversationModeEnabled(instanceId: string, enabled: boolean): void {
|
||||
setConversationModeInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (enabled) {
|
||||
next.set(instanceId, true)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
if (!enabled) {
|
||||
clearConversationPlaybackForInstance(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleConversationMode(instanceId: string): void {
|
||||
setConversationModeEnabled(instanceId, !isConversationModeEnabled(instanceId))
|
||||
}
|
||||
|
||||
export function clearConversationPlaybackForSession(instanceId: string, sessionId: string): void {
|
||||
const sessionKey = `${instanceId}:${sessionId}`
|
||||
queue = queue.filter((entry) => {
|
||||
if (`${entry.instanceId}:${entry.sessionId}` === sessionKey) {
|
||||
queuedKeys.delete(entry.key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (currentPlayback && `${currentPlayback.entry.instanceId}:${currentPlayback.entry.sessionId}` === sessionKey) {
|
||||
currentPlayback.handle.stop()
|
||||
currentPlayback = null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearConversationPlaybackForInstance(instanceId: string): void {
|
||||
queue = queue.filter((entry) => {
|
||||
if (entry.instanceId === instanceId) {
|
||||
queuedKeys.delete(entry.key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (currentPlayback?.entry.instanceId === instanceId) {
|
||||
currentPlayback.handle.stop()
|
||||
currentPlayback = null
|
||||
}
|
||||
}
|
||||
|
||||
function isSpeakableSession(instanceId: string, sessionId: string): boolean {
|
||||
if (activeInstanceId() !== instanceId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activeSession = getActiveSession(instanceId)
|
||||
if (!activeSession || activeSession.id !== sessionId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId) ?? activeSession
|
||||
return !session?.parentId
|
||||
}
|
||||
|
||||
export function handleConversationAssistantPartUpdated(instanceId: string, part: ClientPart, messageInfo?: MessageInfo): void {
|
||||
if (part.type !== "text") return
|
||||
|
||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : messageInfo?.sessionID
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : messageInfo?.id
|
||||
const partId = typeof part.id === "string" ? part.id : undefined
|
||||
if (!sessionId || !messageId || !partId) return
|
||||
|
||||
const messageRole =
|
||||
messageInfo?.role ??
|
||||
messageStoreBus.getOrCreate(instanceId).getMessage(messageId)?.role ??
|
||||
null
|
||||
if (messageRole !== "assistant") return
|
||||
|
||||
if (!isConversationModeEnabled(instanceId)) return
|
||||
if (!isSpeakableSession(instanceId, sessionId)) return
|
||||
|
||||
const text = resolveTextPartContent(part).trim()
|
||||
if (!text) return
|
||||
|
||||
const key = getEntryKey(instanceId, sessionId, messageId, partId)
|
||||
const spokenKeys = getSpokenKeySet(instanceId, sessionId)
|
||||
if (spokenKeys.has(key) || queuedKeys.has(key) || currentPlayback?.entry.key === key) {
|
||||
return
|
||||
}
|
||||
|
||||
queuedKeys.add(key)
|
||||
queue.push({ key, instanceId, sessionId, messageId, partId, text })
|
||||
void runConversationQueue()
|
||||
}
|
||||
|
||||
async function runConversationQueue(): Promise<void> {
|
||||
if (queueRunner) {
|
||||
await queueRunner
|
||||
return
|
||||
}
|
||||
|
||||
queueRunner = (async () => {
|
||||
while (queue.length > 0) {
|
||||
const entry = queue.shift()!
|
||||
queuedKeys.delete(entry.key)
|
||||
|
||||
if (!isConversationModeEnabled(entry.instanceId)) {
|
||||
continue
|
||||
}
|
||||
if (!isSpeakableSession(entry.instanceId, entry.sessionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const spokenKeys = getSpokenKeySet(entry.instanceId, entry.sessionId)
|
||||
spokenKeys.add(entry.key)
|
||||
|
||||
try {
|
||||
const handle = await createPlaybackHandle(entry.text)
|
||||
currentPlayback = { entry, handle }
|
||||
await handle.done
|
||||
} catch (error) {
|
||||
spokenKeys.delete(entry.key)
|
||||
clearConversationPlaybackForInstance(entry.instanceId)
|
||||
if (!playbackErrorShown) {
|
||||
playbackErrorShown = true
|
||||
showToastNotification({
|
||||
title: tGlobal("promptInput.conversationMode.error.title"),
|
||||
message:
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: tGlobal("promptInput.conversationMode.error.message"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
log.error("Conversation playback failed", error)
|
||||
break
|
||||
} finally {
|
||||
if (currentPlayback?.entry.key === entry.key) {
|
||||
currentPlayback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await queueRunner
|
||||
} finally {
|
||||
queueRunner = null
|
||||
if (queue.length === 0) {
|
||||
playbackErrorShown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlaybackHandle(text: string): Promise<PlaybackHandle> {
|
||||
const capabilities = (await loadSpeechCapabilities()) ?? speechCapabilities()
|
||||
const settings = serverSettings().speech
|
||||
|
||||
if (!capabilities?.available || !capabilities.configured || !capabilities.supportsTts) {
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.unavailable"))
|
||||
}
|
||||
|
||||
const support = getSpeechPlaybackSupport({
|
||||
playbackMode: settings.playbackMode,
|
||||
ttsFormat: settings.ttsFormat,
|
||||
capabilities,
|
||||
})
|
||||
if (!support.available) {
|
||||
if (support.reason === "provider-streaming-unavailable") {
|
||||
throw new Error(tGlobal("settings.speech.compatibility.streamingUnavailable"))
|
||||
}
|
||||
if (support.reason === "browser-streaming-unavailable") {
|
||||
throw new Error(tGlobal("settings.speech.compatibility.browserStreamingUnavailable"))
|
||||
}
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.unsupported"))
|
||||
}
|
||||
|
||||
return settings.playbackMode === "streaming"
|
||||
? createStreamingPlaybackHandle(text, settings.ttsFormat)
|
||||
: createBufferedPlaybackHandle(text, settings.ttsFormat)
|
||||
}
|
||||
|
||||
async function createBufferedPlaybackHandle(text: string, format: SpeechTtsFormat): Promise<PlaybackHandle> {
|
||||
const response = await serverApi.synthesizeSpeech({ text, format })
|
||||
const objectUrl = createObjectUrlFromBase64(response.audioBase64, response.mimeType)
|
||||
const audio = new Audio(objectUrl)
|
||||
|
||||
let settled = false
|
||||
let resolveDone!: () => void
|
||||
let rejectDone!: (error: unknown) => void
|
||||
|
||||
const cleanup = () => {
|
||||
audio.pause()
|
||||
audio.src = ""
|
||||
audio.load()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
resolveDone = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
rejectDone = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener("ended", () => resolveDone(), { once: true })
|
||||
audio.addEventListener("error", () => rejectDone(new Error(tGlobal("messageItem.actions.speak.error.generate"))), {
|
||||
once: true,
|
||||
})
|
||||
|
||||
await audio.play()
|
||||
|
||||
return {
|
||||
stop: () => resolveDone(),
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
async function createStreamingPlaybackHandle(text: string, format: SpeechTtsFormat): Promise<PlaybackHandle> {
|
||||
if (typeof MediaSource === "undefined") {
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.unsupported"))
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
const response = await serverApi.synthesizeSpeechStream({ text, format }, abortController.signal)
|
||||
const mimeType = response.headers.get("content-type") || formatToMimeType(format)
|
||||
const stream = response.body
|
||||
if (!stream) {
|
||||
throw new Error(tGlobal("messageItem.actions.speak.error.generate"))
|
||||
}
|
||||
|
||||
if (!MediaSource.isTypeSupported(mimeType)) {
|
||||
throw new Error(tGlobal("settings.speech.compatibility.browserStreamingUnavailable"))
|
||||
}
|
||||
|
||||
const mediaSource = new MediaSource()
|
||||
const objectUrl = URL.createObjectURL(mediaSource)
|
||||
const audio = new Audio(objectUrl)
|
||||
|
||||
let settled = false
|
||||
let startedPlayback = false
|
||||
let resolveDone!: () => void
|
||||
let rejectDone!: (error: unknown) => void
|
||||
|
||||
const cleanup = () => {
|
||||
abortController.abort()
|
||||
audio.pause()
|
||||
audio.src = ""
|
||||
audio.load()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
resolveDone = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
rejectDone = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener("ended", () => resolveDone(), { once: true })
|
||||
audio.addEventListener("error", () => rejectDone(new Error(tGlobal("messageItem.actions.speak.error.generate"))), {
|
||||
once: true,
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mediaSource.addEventListener(
|
||||
"sourceopen",
|
||||
() => {
|
||||
void streamToMediaSource({
|
||||
mediaSource,
|
||||
stream,
|
||||
mimeType,
|
||||
onPlayable: async () => {
|
||||
if (startedPlayback) return
|
||||
startedPlayback = true
|
||||
try {
|
||||
await audio.play()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
onError: reject,
|
||||
})
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
stop: () => resolveDone(),
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
async function streamToMediaSource(options: {
|
||||
mediaSource: MediaSource
|
||||
stream: ReadableStream<Uint8Array>
|
||||
mimeType: string
|
||||
onPlayable: () => Promise<void>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
try {
|
||||
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
|
||||
const reader = options.stream.getReader()
|
||||
const queue: Uint8Array[] = []
|
||||
let processing = false
|
||||
let playbackStarted = false
|
||||
|
||||
const flushQueue = async () => {
|
||||
if (processing || sourceBuffer.updating || queue.length === 0) return
|
||||
processing = true
|
||||
const chunk = queue.shift()!
|
||||
await appendChunk(sourceBuffer, chunk)
|
||||
if (!playbackStarted) {
|
||||
playbackStarted = true
|
||||
await options.onPlayable()
|
||||
}
|
||||
processing = false
|
||||
await flushQueue()
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value && value.byteLength > 0) {
|
||||
queue.push(value)
|
||||
await flushQueue()
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0 || sourceBuffer.updating) {
|
||||
if (queue.length > 0) {
|
||||
await flushQueue()
|
||||
} else {
|
||||
await waitForUpdateEnd(sourceBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.mediaSource.readyState === "open") {
|
||||
options.mediaSource.endOfStream()
|
||||
}
|
||||
} catch (error) {
|
||||
options.onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleUpdateEnd = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const handleError = () => {
|
||||
cleanup()
|
||||
reject(new Error(tGlobal("messageItem.actions.speak.error.generate")))
|
||||
}
|
||||
const cleanup = () => {
|
||||
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
|
||||
sourceBuffer.removeEventListener("error", handleError)
|
||||
}
|
||||
|
||||
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
|
||||
sourceBuffer.addEventListener("error", handleError, { once: true })
|
||||
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
|
||||
})
|
||||
}
|
||||
|
||||
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
|
||||
const binary = atob(audioBase64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
updateInstanceConfig as updateInstanceData,
|
||||
} from "./instance-config"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -27,6 +28,25 @@ export type DiffViewMode = "split" | "unified"
|
||||
export type ExpansionPreference = "expanded" | "collapsed"
|
||||
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
||||
export type ListeningMode = "local" | "all"
|
||||
export type SpeechProviderPreference = "openai-compatible"
|
||||
export type SpeechPlaybackMode = "streaming" | "buffered"
|
||||
export type SpeechTtsFormat = "mp3" | "wav" | "opus" | "aac"
|
||||
|
||||
export interface SpeechSettings {
|
||||
provider: SpeechProviderPreference
|
||||
apiKey?: string
|
||||
hasApiKey: boolean
|
||||
baseUrl?: string
|
||||
sttModel: string
|
||||
ttsModel: string
|
||||
ttsVoice: string
|
||||
playbackMode: SpeechPlaybackMode
|
||||
ttsFormat: SpeechTtsFormat
|
||||
}
|
||||
|
||||
export type SpeechSettingsUpdate = Partial<Omit<SpeechSettings, "apiKey">> & {
|
||||
apiKey?: string | null
|
||||
}
|
||||
|
||||
export interface UiSettings {
|
||||
showThinkingBlocks: boolean
|
||||
@@ -34,6 +54,7 @@ export interface UiSettings {
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
promptSubmitOnEnter: boolean
|
||||
showPromptVoiceInput: boolean
|
||||
locale?: string
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
@@ -75,6 +96,7 @@ interface ServerConfigBucket {
|
||||
listeningMode?: ListeningMode
|
||||
environmentVariables?: Record<string, string>
|
||||
opencodeBinary?: string
|
||||
speech?: Partial<SpeechSettings>
|
||||
}
|
||||
|
||||
interface UiStateBucket {
|
||||
@@ -107,6 +129,7 @@ const defaultUiSettings: UiSettings = {
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
showTimelineTools: true,
|
||||
promptSubmitOnEnter: false,
|
||||
showPromptVoiceInput: true,
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
@@ -120,6 +143,16 @@ const defaultUiSettings: UiSettings = {
|
||||
notifyOnIdle: true,
|
||||
}
|
||||
|
||||
const defaultSpeechSettings: SpeechSettings = {
|
||||
provider: "openai-compatible",
|
||||
hasApiKey: false,
|
||||
sttModel: "gpt-4o-mini-transcribe",
|
||||
ttsModel: "gpt-4o-mini-tts",
|
||||
ttsVoice: "alloy",
|
||||
playbackMode: "streaming",
|
||||
ttsFormat: "mp3",
|
||||
}
|
||||
|
||||
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
||||
const sanitized = input ?? {}
|
||||
return {
|
||||
@@ -129,6 +162,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
||||
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
||||
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
||||
locale: sanitized.locale ?? defaultUiSettings.locale,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
|
||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
|
||||
@@ -156,6 +190,36 @@ function normalizeRecord(value: unknown): Record<string, string> {
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeSpeechSettings(input?: Partial<SpeechSettings> | null): SpeechSettings {
|
||||
const sanitized = input ?? {}
|
||||
return {
|
||||
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
|
||||
apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined,
|
||||
hasApiKey: sanitized.hasApiKey === true || (typeof sanitized.apiKey === "string" && sanitized.apiKey.trim().length > 0),
|
||||
baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined,
|
||||
sttModel:
|
||||
typeof sanitized.sttModel === "string" && sanitized.sttModel.trim()
|
||||
? sanitized.sttModel.trim()
|
||||
: defaultSpeechSettings.sttModel,
|
||||
ttsModel:
|
||||
typeof sanitized.ttsModel === "string" && sanitized.ttsModel.trim()
|
||||
? sanitized.ttsModel.trim()
|
||||
: defaultSpeechSettings.ttsModel,
|
||||
ttsVoice:
|
||||
typeof sanitized.ttsVoice === "string" && sanitized.ttsVoice.trim()
|
||||
? sanitized.ttsVoice.trim()
|
||||
: defaultSpeechSettings.ttsVoice,
|
||||
playbackMode:
|
||||
sanitized.playbackMode === "buffered" || sanitized.playbackMode === "streaming"
|
||||
? sanitized.playbackMode
|
||||
: defaultSpeechSettings.playbackMode,
|
||||
ttsFormat:
|
||||
sanitized.ttsFormat === "wav" || sanitized.ttsFormat === "opus" || sanitized.ttsFormat === "aac" || sanitized.ttsFormat === "mp3"
|
||||
? sanitized.ttsFormat
|
||||
: defaultSpeechSettings.ttsFormat,
|
||||
}
|
||||
}
|
||||
|
||||
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const out: T[] = []
|
||||
@@ -206,12 +270,15 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerConfig(input?: ServerConfigBucket | null): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> {
|
||||
function normalizeServerConfig(
|
||||
input?: ServerConfigBucket | null,
|
||||
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
|
||||
const source = input ?? {}
|
||||
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
||||
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
||||
const environmentVariables = normalizeRecord(source.environmentVariables)
|
||||
return { listeningMode, opencodeBinary, environmentVariables }
|
||||
const speech = normalizeSpeechSettings(source.speech)
|
||||
return { listeningMode, opencodeBinary, environmentVariables, speech }
|
||||
}
|
||||
|
||||
function getModelKey(model: { providerId: string; modelId: string }): string {
|
||||
@@ -342,6 +409,27 @@ function updateLastUsedBinary(path: string): void {
|
||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
||||
}
|
||||
|
||||
async function updateSpeechSettings(updates: SpeechSettingsUpdate): Promise<void> {
|
||||
const apiKeyPatch = updates.apiKey
|
||||
const { apiKey: _apiKey, ...restUpdates } = updates
|
||||
const next = normalizeSpeechSettings({
|
||||
...serverSettings().speech,
|
||||
...restUpdates,
|
||||
...(apiKeyPatch === null ? {} : { apiKey: apiKeyPatch }),
|
||||
})
|
||||
const { hasApiKey: _hasApiKey, ...persistedSpeech } = next
|
||||
const patch = {
|
||||
...persistedSpeech,
|
||||
...(apiKeyPatch === null ? { apiKey: null } : {}),
|
||||
}
|
||||
try {
|
||||
await patchConfigOwner("server", { speech: patch })
|
||||
} catch (error) {
|
||||
log.error("Failed to update speech settings", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function addOpenCodeBinary(path: string, version?: string): void {
|
||||
const nextList = buildBinaryList(path, version, opencodeBinaries())
|
||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
|
||||
@@ -476,6 +564,10 @@ function togglePromptSubmitOnEnter(): void {
|
||||
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
|
||||
}
|
||||
|
||||
function toggleShowPromptVoiceInput(): void {
|
||||
updateUiSettings({ showPromptVoiceInput: !preferences().showPromptVoiceInput })
|
||||
}
|
||||
|
||||
function toggleAutoCleanupBlankSessions(): void {
|
||||
const nextValue = !preferences().autoCleanupBlankSessions
|
||||
log.info("toggle auto cleanup", { value: nextValue })
|
||||
@@ -521,6 +613,7 @@ interface ConfigContextValue {
|
||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||
updateSpeechSettings: typeof updateSpeechSettings
|
||||
|
||||
// ui-owned state
|
||||
recentFolders: typeof recentFolders
|
||||
@@ -544,6 +637,7 @@ interface ConfigContextValue {
|
||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
||||
toggleShowPromptVoiceInput: typeof toggleShowPromptVoiceInput
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
@@ -569,6 +663,7 @@ const configContextValue: ConfigContextValue = {
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateLastUsedBinary,
|
||||
updateSpeechSettings,
|
||||
recentFolders,
|
||||
opencodeBinaries,
|
||||
uiState,
|
||||
@@ -588,6 +683,7 @@ const configContextValue: ConfigContextValue = {
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
@@ -610,6 +706,8 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
|
||||
setServerConfigBucket(bucket as any)
|
||||
setIsLoaded(true)
|
||||
resetSpeechCapabilities()
|
||||
void loadSpeechCapabilities(true)
|
||||
})
|
||||
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
|
||||
setUiStateBucket(bucket as any)
|
||||
@@ -648,6 +746,7 @@ export {
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateLastUsedBinary,
|
||||
updateSpeechSettings,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
@@ -664,6 +763,7 @@ export {
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
togglePromptSubmitOnEnter,
|
||||
toggleShowPromptVoiceInput,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { messageStoreBus } from "./message-v2/bus"
|
||||
import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { clearConversationPlaybackForSession } from "./conversation-speech"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -165,6 +166,8 @@ async function sendMessage(
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const createdAt = Date.now()
|
||||
|
||||
clearConversationPlaybackForSession(instanceId, sessionId)
|
||||
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
import { handleConversationAssistantPartUpdated } from "./conversation-speech"
|
||||
|
||||
const log = getLogger("sse")
|
||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||
@@ -330,8 +331,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
if (messageInfo) {
|
||||
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
||||
}
|
||||
|
||||
|
||||
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||
handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo)
|
||||
|
||||
if (part.type === "tool" && part.tool === "question") {
|
||||
// Questions can arrive before their tool part exists; re-link now.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "opencode"
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
||||
|
||||
46
packages/ui/src/stores/speech.ts
Normal file
46
packages/ui/src/stores/speech.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const [speechCapabilities, setSpeechCapabilities] = createSignal<SpeechCapabilitiesResponse | null>(null)
|
||||
const [speechCapabilitiesLoading, setSpeechCapabilitiesLoading] = createSignal(false)
|
||||
const [speechCapabilitiesError, setSpeechCapabilitiesError] = createSignal<string | null>(null)
|
||||
|
||||
let speechCapabilitiesPromise: Promise<SpeechCapabilitiesResponse | null> | null = null
|
||||
|
||||
async function loadSpeechCapabilities(force = false): Promise<SpeechCapabilitiesResponse | null> {
|
||||
if (!force && speechCapabilities()) return speechCapabilities()
|
||||
if (speechCapabilitiesPromise) return speechCapabilitiesPromise
|
||||
|
||||
setSpeechCapabilitiesLoading(true)
|
||||
setSpeechCapabilitiesError(null)
|
||||
speechCapabilitiesPromise = serverApi
|
||||
.fetchSpeechCapabilities()
|
||||
.then((result) => {
|
||||
setSpeechCapabilities(result)
|
||||
setSpeechCapabilitiesError(null)
|
||||
return result
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to load speech capabilities", error)
|
||||
setSpeechCapabilities(null)
|
||||
setSpeechCapabilitiesError(error instanceof Error ? error.message : String(error))
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setSpeechCapabilitiesLoading(false)
|
||||
speechCapabilitiesPromise = null
|
||||
})
|
||||
|
||||
return speechCapabilitiesPromise
|
||||
}
|
||||
|
||||
function resetSpeechCapabilities(): void {
|
||||
setSpeechCapabilities(null)
|
||||
setSpeechCapabilitiesError(null)
|
||||
}
|
||||
|
||||
export { speechCapabilities, speechCapabilitiesLoading, speechCapabilitiesError, loadSpeechCapabilities, resetSpeechCapabilities }
|
||||
@@ -256,6 +256,55 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remote-address-disclosure {
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-address-disclosure-trigger {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remote-address-disclosure-label {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remote-address-disclosure-chevron {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.remote-address-disclosure-chevron.is-expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.remote-address-disclosure-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 0 10px 10px;
|
||||
border-top: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.remote-qr {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
.settings-screen-frame {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center px-4;
|
||||
padding-block: 5dvh;
|
||||
}
|
||||
|
||||
/* Override .modal-surface (defined later in panels.css). */
|
||||
.modal-surface.settings-screen-shell {
|
||||
width: min(1120px, 100%);
|
||||
height: min(88vh, 920px);
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
@@ -278,10 +279,25 @@
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-password-summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-password-summary-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-password-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.settings-form-group {
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
.prompt-input {
|
||||
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
padding-inline-start: 2.5rem;
|
||||
padding-inline-end: 0.75rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
@@ -83,23 +83,39 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Navigation buttons container (expand, prev, next).
|
||||
Intentionally at inline-start (left in LTR, right in RTL) so buttons never overlap
|
||||
the scrollbar, which browsers always place at inline-end. */
|
||||
/* Navigation buttons container (expand, prev, next). */
|
||||
.prompt-nav-buttons {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
inset-inline-start: 0.25rem;
|
||||
inset-inline-end: 0.25rem;
|
||||
bottom: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 0.125rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.prompt-nav-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.prompt-nav-column-left {
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.prompt-nav-column-right {
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.prompt-expand-button,
|
||||
.prompt-history-button {
|
||||
.prompt-history-button,
|
||||
.prompt-clear-button {
|
||||
@apply w-7 h-7 flex items-center justify-center rounded-md;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--control-ghost-bg);
|
||||
@@ -109,7 +125,8 @@
|
||||
}
|
||||
|
||||
.prompt-expand-button:hover:not(:disabled),
|
||||
.prompt-history-button:hover:not(:disabled) {
|
||||
.prompt-history-button:hover:not(:disabled),
|
||||
.prompt-clear-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -121,7 +138,8 @@
|
||||
}
|
||||
|
||||
.prompt-expand-button:disabled,
|
||||
.prompt-history-button:disabled {
|
||||
.prompt-history-button:disabled,
|
||||
.prompt-clear-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -179,6 +197,73 @@
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-voice-button {
|
||||
@apply h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||
min-width: 2.5rem;
|
||||
background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base));
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.prompt-voice-button:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background-color: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-secondary));
|
||||
@apply scale-105;
|
||||
}
|
||||
|
||||
.prompt-voice-button:active:not(:disabled) {
|
||||
@apply scale-95;
|
||||
}
|
||||
|
||||
.prompt-voice-button.is-recording {
|
||||
min-width: 3.5rem;
|
||||
background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%);
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-voice-button.is-recording:hover:not(:disabled) {
|
||||
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-voice-button.is-recording:active:not(:disabled) {
|
||||
background-color: var(--button-danger-active-bg, rgba(239, 68, 68, 1));
|
||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||
}
|
||||
|
||||
.prompt-nav-voice-button {
|
||||
min-width: 1.75rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.prompt-nav-voice-button.is-recording {
|
||||
min-width: 1.75rem;
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.prompt-voice-button:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.prompt-conversation-button.is-active {
|
||||
background-color: color-mix(in oklab, var(--accent-primary) 76%, var(--surface-secondary));
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.prompt-conversation-button.is-active:hover:not(:disabled) {
|
||||
background-color: color-mix(in oklab, var(--accent-primary) 88%, var(--surface-secondary));
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.prompt-voice-timer {
|
||||
font-size: 0.68rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.stop-button:hover:not(:disabled) {
|
||||
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
||||
@apply opacity-95 scale-105;
|
||||
@@ -344,7 +429,7 @@
|
||||
.prompt-input {
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-inline-start: 2.5rem; /* preserve space for nav buttons */
|
||||
padding-inline-end: 7.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user