From df6e6236cd65b6183ce8754e524bd1509177af43 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Jun 2026 23:54:51 +0200 Subject: [PATCH 01/10] refac --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b308131..98e783e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ `cptr` (short for "computer") runs on your machine and serves your whole computer (files, terminal, editor, git) to any browser. It literally is your computer. -Phone, tablet, laptop, another computer, even the one it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, any device. +Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, any device. ## Install From d8dd4bde888cc30bf39cfb518286ad8c0f62f828 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Jun 2026 23:58:04 +0200 Subject: [PATCH 02/10] refac --- Dockerfile | 3 ++- README.md | 4 ++++ cptr/routers/admin.py | 10 ++++++++++ pyproject.toml | 7 +++++++ uv.lock | 14 +++++++++++++- 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index eed06bd..25762cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,8 @@ WORKDIR /home/cptr # Install the wheel into an isolated venv COPY --chown=cptr:cptr --from=backend-builder /dist/*.whl /tmp/ RUN uv venv /home/cptr/.venv && \ - uv pip install --python /home/cptr/.venv/bin/python /tmp/*.whl && \ + set -- /tmp/*.whl && \ + uv pip install --python /home/cptr/.venv/bin/python "$1[all]" && \ rm /tmp/*.whl ENV PATH="/home/cptr/.venv/bin:$PATH" diff --git a/README.md b/README.md index 98e783e..dc50b34 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ pip install cptr cptr run ``` +MCP tool servers require the optional MCP dependencies: `pip install 'cptr[mcp]'`. +To install every optional feature group, use `pip install 'cptr[all]'`. +The Docker image includes all optional feature groups. + Or with [uv](https://docs.astral.sh/uv/): `uvx cptr@latest run` Opens in your browser at `http://localhost:8000`. diff --git a/cptr/routers/admin.py b/cptr/routers/admin.py index acc4d4c..6e2311b 100644 --- a/cptr/routers/admin.py +++ b/cptr/routers/admin.py @@ -646,5 +646,15 @@ async def verify_tool_server(server_id: str, request: Request): tools = convert_openapi_to_tool_specs(spec) return {"ok": True, "tools": tools} + except ModuleNotFoundError as e: + if e.name == "mcp": + return JSONResponse( + { + "ok": False, + "message": "MCP support is not installed. Run: pip install 'cptr[mcp]'", + }, + 400, + ) + return JSONResponse({"ok": False, "message": str(e)}, 400) except Exception as e: return JSONResponse({"ok": False, "message": str(e)}, 400) diff --git a/pyproject.toml b/pyproject.toml index 2b5d29f..521dbb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,13 @@ dependencies = [ pam = ["python-pam>=2.0"] mcp = ["mcp>=1.8"] docs = ["pypdf>=4.0", "python-docx>=1.0", "openpyxl>=3.1"] +all = [ + "mcp>=1.8", + "pypdf>=4.0", + "python-docx>=1.0", + "openpyxl>=3.1", + "python-pam>=2.0", +] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index a6fba69..7cb2e84 100644 --- a/uv.lock +++ b/uv.lock @@ -265,7 +265,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.4.10" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -286,6 +286,13 @@ dependencies = [ ] [package.optional-dependencies] +all = [ + { name = "mcp" }, + { name = "openpyxl" }, + { name = "pypdf" }, + { name = "python-docx" }, + { name = "python-pam" }, +] docs = [ { name = "openpyxl" }, { name = "pypdf" }, @@ -313,12 +320,17 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.128.8" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "mcp", marker = "extra == 'all'", specifier = ">=1.8" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.8" }, + { name = "openpyxl", marker = "extra == 'all'", specifier = ">=3.1" }, { name = "openpyxl", marker = "extra == 'docs'", specifier = ">=3.1" }, { name = "pyjwt", specifier = ">=2.8" }, + { name = "pypdf", marker = "extra == 'all'", specifier = ">=4.0" }, { name = "pypdf", marker = "extra == 'docs'", specifier = ">=4.0" }, { name = "python-dateutil", specifier = ">=2.8" }, + { name = "python-docx", marker = "extra == 'all'", specifier = ">=1.0" }, { name = "python-docx", marker = "extra == 'docs'", specifier = ">=1.0" }, + { name = "python-pam", marker = "extra == 'all'", specifier = ">=2.0" }, { name = "python-pam", marker = "extra == 'pam'", specifier = ">=2.0" }, { name = "python-socketio", extras = ["asgi"], specifier = ">=5.11" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0" }, From a3d0d44c11a9b90b44bceff76747756eab93dd8f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 00:23:45 +0200 Subject: [PATCH 03/10] refac --- cptr/frontend/src/routes/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cptr/frontend/src/routes/+page.svelte b/cptr/frontend/src/routes/+page.svelte index 308ae3b..51b8b5b 100644 --- a/cptr/frontend/src/routes/+page.svelte +++ b/cptr/frontend/src/routes/+page.svelte @@ -348,7 +348,7 @@

{$t('home.recent')}

- {#each welcomeData.recent as item} + {#each welcomeData.recent.slice(0, 5) as item}
+ + {#if footer} +
+ {#if footerDivider} +
+ {/if} + {@render footer()} +
+ {/if}
diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index 9282e74..6e5b960 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -13,12 +13,14 @@ gitPull, gitPush, gitUncommit, + gitStash, checkoutBranch, createGitBranch } from '$lib/apis/git'; import { gitStatusStore } from '$lib/stores/gitStatus.svelte'; import Icon from './Icon.svelte'; import DropdownMenu from './DropdownMenu.svelte'; + import Modal from './Modal.svelte'; import { tooltip } from '$lib/tooltip'; import { t } from '$lib/i18n'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -32,10 +34,20 @@ let view = $state<'changes' | 'history'>('changes'); let showDiff = $state(false); let showBranches = $state(false); + let branchBtnEl = $state(); + let branchSearchInputEl = $state(); + let newBranchInputEl = $state(); let commits = $state([]); - let branchData = $state<{ current: string; local: string[]; remote: string[] } | null>(null); + let branchData = $state<{ + current: string; + local: string[]; + remote: string[]; + all?: { name: string; is_current?: boolean; is_local?: boolean; upstream?: string | null }[]; + } | null>(null); + let branchSearch = $state(''); let newBranchName = $state(''); let creatingBranch = $state(false); + let pendingCheckoutBranch = $state(null); let selectedFile = $state(null); let selectedCommit = $state(null); let fileDiff = $state([]); @@ -71,6 +83,21 @@ // Branch has never been pushed to remote const needsPublish = $derived(!gitStatus?.upstream); const unpushedCount = $derived(needsPublish ? (commits?.length ?? 0) : (gitStatus?.ahead ?? 0)); + const filteredBranches = $derived.by(() => { + const branches = branchData?.all ?? []; + const query = branchSearch.trim().toLowerCase(); + if (!query) return branches; + return branches.filter((branch) => branch.name.toLowerCase().includes(query)); + }); + const branchMenuItems = $derived( + filteredBranches.map((branch) => ({ + label: branch.name, + icon: 'git-branch', + active: Boolean(branch.is_current), + check: true, + onclick: () => switchBranch(branch.name) + })) + ); // Convert git remote URL to browser URL const remoteWebUrl = $derived.by(() => { @@ -175,6 +202,20 @@ } } + async function toggleBranches(e: MouseEvent) { + e.stopPropagation(); + if (showBranches) { + showBranches = false; + return; + } + showBranches = true; + branchSearch = ''; + creatingBranch = false; + newBranchName = ''; + await loadBranches(); + setTimeout(() => branchSearchInputEl?.focus(), 0); + } + function switchView(v: 'changes' | 'history') { view = v; showDiff = false; @@ -273,11 +314,41 @@ } async function switchBranch(branch: string) { + if (branch === gitStatus?.branch) { + showBranches = false; + return; + } + if (totalChanges > 0) { + pendingCheckoutBranch = branch; + showBranches = false; + return; + } + await performBranchCheckout(branch, 'bring'); + } + + async function performBranchCheckout(branch: string, changeMode: 'bring' | 'leave') { loading = true; - await checkoutBranch(workspacePath, branch); - showBranches = false; - loading = false; - await refresh(); + try { + if (changeMode === 'leave') { + const currentBranch = gitStatus?.branch || 'current branch'; + const message = `Changes on ${currentBranch} before checking out ${branch}`; + const stashResult = (await gitStash(workspacePath, message)) as { + ok?: boolean; + message?: string; + }; + if (!stashResult.ok) { + flash(stashResult.message || 'No changes stashed'); + return; + } + } + await checkoutBranch(workspacePath, branch); + pendingCheckoutBranch = null; + } catch (e) { + flash(e instanceof Error ? e.message : 'Failed to switch branch'); + } finally { + loading = false; + await refresh(); + } } async function createBranch() { @@ -490,12 +561,9 @@ > - - {:else} - - {/if} - - {#if branchData} - {#each branchData.all ?? [] as b} + }} + > + + + + + {:else} - {/each} - {:else} -
- -
- {/if} - + {/if} + {/snippet} + {/if} {#if expanded} @@ -948,6 +1050,42 @@ {/if} +{#if pendingCheckoutBranch} + (pendingCheckoutBranch = null)} class="w-full max-w-sm mx-4"> +
+

Switch branches?

+

+ You have {totalChanges} changed {totalChanges === 1 ? 'file' : 'files'} on + {gitStatus?.branch}. Choose + whether to leave those changes here or bring them to + {pendingCheckoutBranch}. +

+
+ + + +
+
+
+{/if} + {#if contextMenu} list[dict[str, str]]: async def stash_save(root: str, message: str | None = None) -> dict[str, Any]: """Stash changes.""" - args = ["stash", "push"] + args = ["stash", "push", "--include-untracked"] if message: args.extend(["-m", message]) code, out, err = await _run(*args, cwd=root, check=False) From 08d2afffc556605c067d2e269d1ec6ce36e91dbd Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 11:18:55 +0200 Subject: [PATCH 06/10] refac --- cptr/frontend/src/lib/apis/git.ts | 6 +++ .../frontend/src/lib/components/GitBar.svelte | 43 ++++++++++++++-- .../src/lib/stores/gitStatus.svelte.ts | 3 ++ cptr/utils/git.py | 49 ++++++++++++------- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/cptr/frontend/src/lib/apis/git.ts b/cptr/frontend/src/lib/apis/git.ts index 26edb93..19794da 100644 --- a/cptr/frontend/src/lib/apis/git.ts +++ b/cptr/frontend/src/lib/apis/git.ts @@ -45,6 +45,9 @@ export const getGitShow = (root: string, ref: string) => export const getGitBranches = (root: string) => fetchJSON(`/api/git/branches?root=${encodeURIComponent(root)}`); +export const getGitStashes = (root: string) => + fetchJSON(`/api/git/stashes?root=${encodeURIComponent(root)}`); + export const stageFiles = (root: string, files: string[]) => fetchJSON('/api/git/stage', jsonBody({ root, files })); @@ -75,6 +78,9 @@ export const gitUncommit = (root: string) => fetchJSON('/api/git/uncommit', json export const gitStash = (root: string, message?: string) => fetchJSON('/api/git/stash', jsonBody({ root, message })); +export const gitUnstash = (root: string, index = 0) => + fetchJSON('/api/git/unstash', jsonBody({ root, index })); + export const createGitBranch = (root: string, name: string) => fetchJSON('/api/git/branch', jsonBody({ root, name })); diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index 6e5b960..81c9912 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -5,6 +5,8 @@ getGitDiff, getGitShow, getGitBranches, + getGitStatusFresh, + getGitStashes, stageFiles, unstageFiles, discardChanges, @@ -14,6 +16,7 @@ gitPush, gitUncommit, gitStash, + gitUnstash, checkoutBranch, createGitBranch } from '$lib/apis/git'; @@ -25,7 +28,14 @@ import { t } from '$lib/i18n'; import Spinner from '$lib/components/common/Spinner.svelte'; - type GitFile = { path: string; status: string; staged: boolean }; + type GitFile = { + path: string; + status: string; + staged: boolean; + unstaged?: boolean; + staged_status?: string; + unstaged_status?: string; + }; type DiffHunk = { header: string; lines: { type: string; content: string }[] }; type DiffFile = { path: string; hunks: DiffHunk[] }; type Commit = { hash: string; short_hash: string; author: string; date: string; message: string }; @@ -253,6 +263,10 @@ async function doCommit() { if (!commitSummary.trim() || !stagedFiles.length) return; loading = true; + await stageFiles( + workspacePath, + stagedFiles.map((f) => f.path) + ); const msg = commitDescription.trim() ? `${commitSummary.trim()}\n\n${commitDescription.trim()}` : commitSummary.trim(); @@ -326,12 +340,34 @@ await performBranchCheckout(branch, 'bring'); } + async function restoreStashedChangesForBranch(branch: string) { + const stashes = (await getGitStashes(workspacePath)) as { message?: string }[]; + const index = stashes.findIndex((stash) => { + const message = stash.message ?? ''; + return ( + message.includes(`cptr-branch-switch:${branch}`) || + message.includes(`Changes on ${branch} before checking out`) + ); + }); + if (index < 0) return; + + const freshStatus = (await getGitStatusFresh(workspacePath)) as { files?: GitFile[] }; + if ((freshStatus.files ?? []).length > 0) return; + + const result = (await gitUnstash(workspacePath, index)) as { ok?: boolean; message?: string }; + if (result.ok) { + flash(`Restored changes for ${branch}`); + } else { + flash(result.message || `Could not restore changes for ${branch}`); + } + } + async function performBranchCheckout(branch: string, changeMode: 'bring' | 'leave') { loading = true; try { if (changeMode === 'leave') { const currentBranch = gitStatus?.branch || 'current branch'; - const message = `Changes on ${currentBranch} before checking out ${branch}`; + const message = `cptr-branch-switch:${currentBranch}`; const stashResult = (await gitStash(workspacePath, message)) as { ok?: boolean; message?: string; @@ -343,6 +379,7 @@ } await checkoutBranch(workspacePath, branch); pendingCheckoutBranch = null; + await restoreStashedChangesForBranch(branch); } catch (e) { flash(e instanceof Error ? e.message : 'Failed to switch branch'); } finally { @@ -806,7 +843,7 @@
- {#each gitStatus?.files ?? [] as file (`${file.path}:${file.staged}`)} + {#each gitStatus?.files ?? [] as file (file.path)} {@const fp = fPath(file.path)} {@const sc = statusChar(file.status)} + {#if item.actionIcon && item.actionOnclick} + {/if} - +
{/if} {/each} {/if} diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index 81c9912..77eee3a 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -18,7 +18,9 @@ gitStash, gitUnstash, checkoutBranch, - createGitBranch + createGitBranch, + deleteGitBranch, + renameGitBranch } from '$lib/apis/git'; import { gitStatusStore } from '$lib/stores/gitStatus.svelte'; import Icon from './Icon.svelte'; @@ -39,6 +41,13 @@ type DiffHunk = { header: string; lines: { type: string; content: string }[] }; type DiffFile = { path: string; hunks: DiffHunk[] }; type Commit = { hash: string; short_hash: string; author: string; date: string; message: string }; + type BranchItem = { + name: string; + is_current?: boolean; + is_local?: boolean; + is_remote?: boolean; + upstream?: string | null; + }; let expanded = $state(false); let view = $state<'changes' | 'history'>('changes'); @@ -52,12 +61,14 @@ current: string; local: string[]; remote: string[]; - all?: { name: string; is_current?: boolean; is_local?: boolean; upstream?: string | null }[]; + all?: BranchItem[]; } | null>(null); let branchSearch = $state(''); let newBranchName = $state(''); let creatingBranch = $state(false); let pendingCheckoutBranch = $state(null); + let pendingCreateBranch = $state(null); + let branchActionMenu = $state<{ branch: BranchItem; anchor: HTMLElement } | null>(null); let selectedFile = $state(null); let selectedCommit = $state(null); let fileDiff = $state([]); @@ -105,7 +116,12 @@ icon: 'git-branch', active: Boolean(branch.is_current), check: true, - onclick: () => switchBranch(branch.name) + onclick: () => switchBranch(branch.name), + actionIcon: 'three-dots', + actionLabel: 'Branch actions', + actionOnclick: (anchor: HTMLElement) => { + branchActionMenu = { branch, anchor }; + } })) ); @@ -390,13 +406,44 @@ async function createBranch() { if (!newBranchName.trim()) return; + const branch = newBranchName.trim(); + if (totalChanges > 0) { + pendingCreateBranch = branch; + creatingBranch = false; + showBranches = false; + return; + } + await performBranchCreate(branch, 'bring'); + } + + async function performBranchCreate(branch: string, changeMode: 'bring' | 'leave') { loading = true; - await createGitBranch(workspacePath, newBranchName.trim()); - newBranchName = ''; - creatingBranch = false; - showBranches = false; - loading = false; - await refresh(); + try { + if (changeMode === 'leave') { + const currentBranch = gitStatus?.branch || 'current branch'; + const stashResult = (await gitStash( + workspacePath, + `cptr-branch-switch:${currentBranch}` + )) as { + ok?: boolean; + message?: string; + }; + if (!stashResult.ok) { + flash(stashResult.message || 'No changes stashed'); + return; + } + } + await createGitBranch(workspacePath, branch); + newBranchName = ''; + creatingBranch = false; + showBranches = false; + pendingCreateBranch = null; + } catch (e) { + flash(e instanceof Error ? e.message : 'Failed to create branch'); + } finally { + loading = false; + await refresh(); + } } function flash(m: string) { @@ -428,6 +475,45 @@ commitMenu = null; } + function closeBranchActionMenu() { + branchActionMenu = null; + } + + async function renameBranch(branch: BranchItem) { + if (!branch.is_local) return; + const nextName = window.prompt('Rename branch', branch.name)?.trim(); + if (!nextName || nextName === branch.name) return; + try { + await renameGitBranch(workspacePath, branch.name, nextName); + flash('Branch renamed'); + closeBranchActionMenu(); + await refresh({ force: true }); + await loadBranches(); + } catch (e) { + flash(e instanceof Error ? e.message : 'Failed to rename branch'); + } + } + + function copyBranchName(branch: BranchItem) { + navigator.clipboard.writeText(branch.name); + flash('Branch name copied'); + closeBranchActionMenu(); + } + + async function deleteBranch(branch: BranchItem) { + if (!branch.is_local || branch.is_current) return; + if (!confirm(`Delete branch "${branch.name}"?`)) return; + try { + await deleteGitBranch(workspacePath, branch.name); + flash('Branch deleted'); + closeBranchActionMenu(); + await refresh({ force: true }); + await loadBranches(); + } catch (e) { + flash(e instanceof Error ? e.message : 'Failed to delete branch'); + } + } + async function discardFile(path: string) { await discardChanges(workspacePath, [path]); if (selectedFile === path) { @@ -1123,6 +1209,74 @@ {/if} +{#if pendingCreateBranch} + (pendingCreateBranch = null)} class="w-full max-w-sm mx-4"> +
+

Create branch?

+

+ You have {totalChanges} changed {totalChanges === 1 ? 'file' : 'files'} on + {gitStatus?.branch}. Choose + whether to leave those changes here or bring them to + {pendingCreateBranch}. +

+
+ + + +
+
+
+{/if} + +{#if branchActionMenu} + renameBranch(branchActionMenu!.branch) + } + ] + : []), + { + label: 'Copy branch name', + icon: 'copy', + onclick: () => copyBranchName(branchActionMenu!.branch) + }, + ...(branchActionMenu.branch.is_local && !branchActionMenu.branch.is_current + ? [ + { + label: 'Delete', + icon: 'xmark', + onclick: () => deleteBranch(branchActionMenu!.branch) + } + ] + : []) + ]} + onclose={closeBranchActionMenu} + /> +{/if} + {#if contextMenu} None: await _run("branch", "-d", name, cwd=root) +async def rename_branch(root: str, old_name: str, new_name: str) -> None: + """Rename a local branch.""" + await _run("branch", "-m", old_name, new_name, cwd=root) + + async def pull(root: str) -> dict[str, Any]: """Pull from remote.""" code, out, err = await _run("pull", cwd=root, check=False) From 82e312832b409b47f278cf6ef4376721040e4ccc Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 17 Jun 2026 11:33:36 +0200 Subject: [PATCH 08/10] refac --- .../lib/components/Admin/AudioSettings.svelte | 112 ++++++++++++++---- .../src/lib/components/chat/ChatPanel.svelte | 10 +- cptr/frontend/src/lib/i18n/locales/de.json | 2 + cptr/frontend/src/lib/i18n/locales/en.json | 2 + cptr/frontend/src/lib/i18n/locales/es.json | 2 + cptr/frontend/src/lib/i18n/locales/fr.json | 2 + cptr/frontend/src/lib/i18n/locales/ja.json | 2 + cptr/frontend/src/lib/i18n/locales/ko.json | 2 + cptr/frontend/src/lib/i18n/locales/pt-BR.json | 2 + cptr/frontend/src/lib/i18n/locales/ru.json | 2 + cptr/frontend/src/lib/i18n/locales/zh-CN.json | 2 + cptr/frontend/src/lib/i18n/locales/zh-TW.json | 2 + cptr/frontend/src/lib/stores/audio.ts | 11 +- cptr/routers/audio.py | 2 + 14 files changed, 129 insertions(+), 26 deletions(-) diff --git a/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte b/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte index e25a847..7c05c4d 100644 --- a/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte +++ b/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte @@ -25,6 +25,7 @@ let ttsVoice = $state('alloy'); let ttsFormat = $state('mp3'); let ttsPlaybackSpeed = $state(1); + let ttsAutoStreamEnabled = $state(false); let hasExistingTtsKey = $state(false); let voiceModeSystemPrompt = $state(''); let voiceModeSttMode = $state<'browser' | 'provider'>('browser'); @@ -49,6 +50,7 @@ ttsFormat = (config['audio.tts_format'] as string) || 'mp3'; const speed = Number(config['audio.tts_playback_speed']); ttsPlaybackSpeed = Number.isFinite(speed) ? Math.min(Math.max(speed, 0.5), 2) : 1; + ttsAutoStreamEnabled = config['audio.tts_auto_stream_enabled'] === true; hasExistingTtsKey = !!config['audio.tts_api_key']; voiceModeSystemPrompt = (config['audio.voice_mode_system_prompt'] as string) || ''; voiceModeSttMode = @@ -72,6 +74,7 @@ 'audio.tts_voice': ttsVoice, 'audio.tts_format': ttsFormat, 'audio.tts_playback_speed': ttsPlaybackSpeed, + 'audio.tts_auto_stream_enabled': ttsAutoStreamEnabled, 'audio.voice_mode_system_prompt': voiceModeSystemPrompt, 'audio.voice_mode_stt_mode': voiceModeSttMode }; @@ -105,23 +108,41 @@

{$t('admin.audio.voiceMemosHint')}

- {transcribeEnabled ? $t('admin.audio.transcribeOnHint') : $t('admin.audio.transcribeOffHint')} + {transcribeEnabled + ? $t('admin.audio.transcribeOnHint') + : $t('admin.audio.transcribeOffHint')}

- {$t('admin.audio.recordingQuality')} + {$t('admin.audio.recordingQuality')}
- +
- +

{$t('admin.audio.ttsEnabledHint')}

+ +

+ {$t('admin.audio.ttsAutoStreamHint')} +

- +
- +
- +
- + - {ttsPlaybackSpeed.toFixed(2)}x + {ttsPlaybackSpeed.toFixed(2)}x

@@ -263,11 +323,15 @@

-

{$t('admin.audio.voiceMode')}

+

+ {$t('admin.audio.voiceMode')} +

- {$t('admin.audio.voiceModeSttMode')} + {$t('admin.audio.voiceModeSttMode')}