From b97c565a45a5e29446abb4cbd085d5e0be874a47 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 24 Apr 2026 17:23:14 -0700 Subject: [PATCH] feat(ui): shared ListPane primitive + misc polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListPane is a compound component (Header / TitleRow / Title / Actions / Search / Scroll) that owns the relative wrapper, faded right divider (50px top fade), top scroll mask, and absolute-positioned header used by every list-detail tab. Wires up CapturesTab, StoryList, and EffectsList. EffectsTab gets -mx-8 / pr-8 to match the edge-to-edge layout used elsewhere. Other changes: - MCPPage: native for default voice and per-binding voice pickers - Button outline variant: add hover:border-accent - Drop hover:text-destructive from trailing delete buttons (HistoryTable, GpuAcceleration, GpuPage, EffectsChainEditor, EffectsDetail) - HistoryTable empty state moved behind t('history.empty') - StoryContent scroll padding pt-14 → pt-16 - backend health reports the captures dir - landing CapturesMockup: "Send to" → "Export" with Download icon - CHANGELOG: drop [Unreleased] personality section --- CHANGELOG.md | 35 +-- .../components/CapturesTab/CapturesTab.tsx | 250 ++++++++++++++---- .../components/Effects/EffectsChainEditor.tsx | 2 +- .../components/EffectsTab/EffectsDetail.tsx | 2 +- app/src/components/EffectsTab/EffectsList.tsx | 137 +++++----- app/src/components/EffectsTab/EffectsTab.tsx | 4 +- app/src/components/History/HistoryTable.tsx | 4 +- app/src/components/ListPane.tsx | 99 +++++++ .../ServerSettings/GpuAcceleration.tsx | 2 +- app/src/components/ServerTab/CapturesPage.tsx | 45 +++- app/src/components/ServerTab/GpuPage.tsx | 2 +- app/src/components/ServerTab/MCPPage.tsx | 95 ++++--- .../components/StoriesTab/StoryContent.tsx | 2 +- app/src/components/StoriesTab/StoryList.tsx | 66 +++-- app/src/components/ui/button.tsx | 10 +- app/src/i18n/locales/en/translation.json | 23 +- app/src/i18n/locales/ja/translation.json | 23 +- app/src/i18n/locales/zh-CN/translation.json | 23 +- app/src/i18n/locales/zh-TW/translation.json | 23 +- backend/routes/health.py | 1 + landing/src/components/CapturesMockup.tsx | 6 +- 21 files changed, 616 insertions(+), 238 deletions(-) create mode 100644 app/src/components/ListPane.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 38fe4e82..31d6c69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,6 @@ # Changelog -## [Unreleased] - -### Personality — simpler API, better UX - -Voice personalities have been collapsed from a three-mode (`compose` / `rewrite` / `respond`) API down to what they actually needed to be: a compose button and a boolean rewrite toggle. - -- **Generate box UI.** When a profile has a personality set, two controls appear next to the generate button: a shuffle button that drops a fresh in-character line into the textarea, and a wand toggle that runs your input through the personality LLM before TTS. No more intent dropdown. -- **API surface.** `POST /generate` and `POST /speak` accept `personality: bool` in place of the removed `intent` field. `voicebox.speak` (MCP) takes `personality: bool`. The standalone `/profiles/{id}/rewrite`, `/profiles/{id}/respond`, and `/profiles/{id}/speak` endpoints are gone; `/profiles/{id}/compose` remains for the shuffle button. -- **MCP bindings.** `default_intent` has been replaced with `default_personality: bool`. Existing bindings with a non-null `default_intent` are migrated to `false`; re-enable the rewrite path from **Settings → MCP** if you were relying on it. -- **Respond mode is gone.** Letting the personality LLM *reply* to input text was only useful for agent-as-LLM flows nobody was actually building. Agents that want conversational replies can produce the reply themselves and pipe it into `voicebox.speak`. - - ## [0.5.0] - 2026-04-22 **The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. The loop closes in both directions: your voice goes into your computer through a global hotkey, and any agent's voice comes out of your computer through a voice you own. @@ -26,21 +14,25 @@ When enabled, hold a key anywhere on your machine, speak, release — the transc ### Dictation — speak anywhere, paste anywhere - **Global hotkey capture.** Hold a customizable chord anywhere on your machine (defaults: right-Cmd + right-Option on macOS, right-Ctrl + right-Shift on Windows), speak, release. A floating on-screen pill surfaces over your current app and walks through recording → transcribing → refining → done with a live elapsed timer during the clip. Your dictation lands as a clean transcript. +- **Hotkey engine.** Chord detection runs on `keytap`, a small cross-platform library with first-class handling of left/right modifier variants on macOS. Resolves long-standing issues where left-Cmd / right-Option sometimes registered interchangeably on macOS 14+ and let the chord fire on the wrong hardware key. - **Push-to-talk and toggle modes, each with its own chord.** The default toggle chord adds Space to the push-to-talk chord. Holding PTT and tapping Space mid-hold upgrades a hold into a hands-free session without a gap in the recording — the session rolls forward, you stop when you want. - **Auto-paste into the focused app.** Once transcription finishes, Voicebox synthesizes a platform-native paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Your clipboard is saved before and restored after, so nothing you had copied goes missing. +- **macOS paste hardening.** Synthetic paste uses the keyboard-layout-aware V keycode so the physical V key fires regardless of Dvorak / AZERTY / non-QWERTY layouts. On macOS 14+, a cooperative `NSRunningApplication.activate` handshake ensures the target app is frontmost before keystrokes inject, so paste lands in the focused field instead of Voicebox. Clipboard restore only runs when Voicebox actually overwrote the clipboard, and a paste-failure fallback retries the injection after a brief delay. - **Chord picker UI.** Customize either chord from Settings → Captures by holding the keys you want. Left/right modifier badges show whether a key is the left or right variant, so you can pick the exact hardware signature you want to capture. - **Defaults picked to stay out of your way.** macOS defaults deliberately avoid left-hand Cmd+Option chords so Cmd+Option+I (devtools), Cmd+Option+Esc (force quit), and Cmd+Option+Space (Spotlight) all remain yours. Windows defaults route around AltGr collisions on German/French/Spanish layouts where Ctrl+Alt synthesizes AltGr. - **Accessibility permission is scoped.** The macOS permission prompt lives inline next to the auto-paste toggle in Settings → Captures, not as a global banner on every page. If permission isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. ### Personality — voice profiles that speak for themselves -Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new actions appear next to the generate button, each powered by a bundled Qwen3 LLM running entirely locally: +Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new controls appear next to the generate button, each powered by a bundled Qwen3 LLM running entirely locally: -- **Compose** — drop a fresh in-character line into the textarea. Click again for variety, edit before speaking. -- **Speak in character** — a toggle that rewrites your input text in the character's voice before TTS, preserving every idea. +- **Compose** — the shuffle button drops a fresh in-character line into the textarea. Click again for variety, edit before speaking. +- **Speak in character** — the wand toggle runs your input through the personality LLM before TTS, preserving every idea but delivering it in the character's voice. Temperatures are tuned per mode (compose hot for variety, rewrite cold for fidelity) and the character framing enforces "speech only" output — no narration, no action tags, no meta-commentary. The same LLM doubles as the refinement model, so there's one local LLM in the app, not two. +**API surface.** `POST /generate`, `POST /speak`, and the MCP `voicebox.speak` tool accept `personality: bool` to run input through the personality LLM before TTS. `POST /profiles/{id}/compose` powers the shuffle button. MCP client bindings carry a `default_personality: bool` that applies when `personality` isn't passed explicitly. + ### Agents — any MCP-aware agent gets a voice Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1:17493/mcp` so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names: @@ -55,6 +47,7 @@ Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1 - **Per-client voice binding.** Pin Claude Code to Morgan, Cursor to Scarlett, Cline to its own voice — the `X-Voicebox-Client-Id` header resolves to a bound voice whenever `speak` is called without an explicit `profile`. Managed in **Settings → MCP**, with an auto-stamped `last_seen_at` timestamp on each row so you can tell the install actually took. New clients register their binding row on first call. - **Profile resolution precedence.** Explicit `profile` arg (name or id, case-insensitive) → per-client binding → global default from `capture_settings.default_playback_voice_id` → error with a pointer to Settings. - **Speaking pill.** Agent-initiated speech surfaces the same on-screen pill as dictation, in a new `speaking` state with the profile name and an elapsed timer. Driven by SSE at `/events/speak`; a Tauri-side `dictate:show` handler repositions and reveals the pill over the current monitor even when the main Voicebox window is hidden. Silent background TTS is a trust hazard — the pill always shows what's coming out of your machine. +- **Pill resilience.** The Tauri-side `/events/speak` monitor retries with escalating backoff on disconnect and times out idle connections, so a server hiccup can't leave the pill stuck in `speaking` forever. Lifespan shutdown drains in-flight MCP requests before unloading models, so a `voicebox.speak` call racing with app exit returns cleanly. - **`POST /speak` REST wrapper.** Same code path and voice resolution for shell scripts, ACP, A2A, GitHub Actions, or anything else that isn't MCP-native. **Claude Code one-liner:** @@ -67,10 +60,11 @@ claude mcp add voicebox --transport http --url http://127.0.0.1:17493/mcp --head Refinement shipped earlier; 0.5.0 closes the stubborn edge cases: -- **Deterministic loop-stripping before the LLM sees the transcript.** Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Legitimate repetition ("no, no, no, no, no") doesn't cross the threshold. +- **Deterministic loop-stripping before the LLM sees the transcript.** Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Coverage spans single-word runs, multi-word phrases, CJK character runs, and Japanese emphasis patterns; legitimate repetition ("no, no, no, no, no") doesn't cross the threshold. - **Refinement flags snapshot per capture.** `smart_cleanup`, `self_correction`, and `preserve_technical` are stored on each capture, so refinement can be re-run later with different flags without losing the raw transcript. - **Ten-transcript evaluation harness** (`backend/tests/test_refinement_samples.py`) scoring prompt leaks, answer leaks, loop echoes, filler removal, length-ratio outliers, substring preservation ("npm install", "handleSubmit"), and question-mark survival against every bundled refinement model size. - **Refinement model picker** — Qwen3 0.6B (400 MB, very fast), 1.7B (1.1 GB, fast), 4B (2.5 GB, full quality). 0.6B is the default; 1.7B is the sweet spot for transcripts with code identifiers. +- **MLX bundling for Apple Silicon.** The PyInstaller build now collects the full `mlx_lm` tokenizer + config tree, so the MLX-backed Qwen3 refinement / personality LLM loads inside the shipped `.app` bundle without extra setup on Apple Silicon. ### Captures tab + settings @@ -82,6 +76,14 @@ Settings → Captures is now the home for the whole dictation flow: - **Playback**: default voice for the Captures tab's "Play as" action. - **Storage**: retention (forever / 90d / 30d / 7d), clear-all-captures. +### Interface + +- **Theme selector.** Light / dark / system in **Settings → General**, persisted across sessions. System mode listens for OS-level appearance changes and flips live without a restart. +- **Scrubbable waveform player on captures.** The capture detail card now embeds a WaveSurfer waveform with click-to-seek and a current / total timestamp pair, replacing the static duration label. +- **Capture pill light mode.** The on-screen pill gets a dedicated light palette so it stays legible against bright windows, and the inline waveform progress color reads against the pill background in both themes. +- **Stories polish.** The Stories sidebar matches the Captures sidebar (search, item layout, border treatment), the floating generate box moved into the right column with a top fade mask, and the track editor ships sticky track labels (aligned via flex rows) plus a custom horizontal scrollbar with dedicated left/right zoom handles. +- **Readiness checklist in the Captures settings sidebar.** The same six-gate checklist the Captures empty state uses mirrors into Settings → Captures so a red gate can't hide behind a green toggle. Hidden once every gate is green. macOS-only rows (Input Monitoring, Accessibility) hide entirely on Windows and Linux. + ### Windows parity - **Synthetic paste** via `SendInput` with correct scan codes, plus a `SetForegroundWindow` + `AttachThreadInput` handshake to defeat foreground-lock when pasting into a window that wasn't frontmost at chord-start. @@ -743,7 +745,6 @@ The first public release of Voicebox — an open-source voice synthesis studio p Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite -[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.5.0...HEAD [0.5.0]: https://github.com/jamiepine/voicebox/compare/v0.4.5...v0.5.0 [0.4.5]: https://github.com/jamiepine/voicebox/compare/v0.4.4...v0.4.5 [0.4.4]: https://github.com/jamiepine/voicebox/compare/v0.4.3...v0.4.4 diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx index c6f2ebc8..bec4b791 100644 --- a/app/src/components/CapturesTab/CapturesTab.tsx +++ b/app/src/components/CapturesTab/CapturesTab.tsx @@ -1,16 +1,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { save } from '@tauri-apps/plugin-dialog'; +import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { Captions, Check, ChevronDown, CircleDot, Copy, + Download, FileAudio, + FileText, Loader2, Mic, - Send, Settings2, Sparkles, Square, @@ -23,6 +26,16 @@ import { useTranslation } from 'react-i18next'; import { CapturePill } from '@/components/CapturePill/CapturePill'; import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -33,8 +46,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; +import { + ListPane, + ListPaneHeader, + ListPaneScroll, + ListPaneSearch, + ListPaneTitle, + ListPaneTitleRow, +} from '@/components/ListPane'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { @@ -126,6 +146,7 @@ export function CapturesTab() { const [showRefined, setShowRefined] = useState(true); const [playAsVoiceId, setPlayAsVoiceId] = useState(null); const [playbackState, setPlaybackState] = useState('idle'); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay); const audioUrl = usePlayerStore((s) => s.audioUrl); @@ -222,6 +243,7 @@ export function CapturesTab() { const deleteMutation = useMutation({ mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId), onSuccess: () => { + setDeleteDialogOpen(false); queryClient.invalidateQueries({ queryKey: ['captures'] }); }, onError: (err: Error) => { @@ -293,6 +315,96 @@ export function CapturesTab() { } }; + const exportToastSuccess = (path: string) => { + const name = path.split(/[\\/]/).pop() ?? path; + toast({ title: t('captures.toast.exportSuccess', { path: name }) }); + }; + + const exportToastError = (err: unknown) => { + toast({ + title: t('captures.toast.exportFailed'), + description: err instanceof Error ? err.message : String(err), + variant: 'destructive', + }); + }; + + const handleExportAudio = async () => { + if (!selected) return; + try { + const dest = await save({ + defaultPath: `capture_${selected.id.slice(0, 8)}.wav`, + filters: [{ name: 'Audio', extensions: ['wav'] }], + }); + if (!dest) return; + const res = await fetch(apiClient.getCaptureAudioUrl(selected.id)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = new Uint8Array(await res.arrayBuffer()); + await writeFile(dest, buf); + exportToastSuccess(dest); + } catch (err) { + exportToastError(err); + } + }; + + const handleExportTranscript = async () => { + if (!selected) return; + const text = (selected.transcript_refined || selected.transcript_raw || '').trim(); + if (!text) { + toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' }); + return; + } + try { + const dest = await save({ + defaultPath: `capture_${selected.id.slice(0, 8)}.txt`, + filters: [{ name: 'Text', extensions: ['txt'] }], + }); + if (!dest) return; + await writeTextFile(dest, text); + exportToastSuccess(dest); + } catch (err) { + exportToastError(err); + } + }; + + const buildCaptureMarkdown = (capture: CaptureResponse): string => { + const lines: string[] = []; + lines.push(`# Capture ${capture.id}`, ''); + lines.push(`- **Source:** ${capture.source}`); + lines.push(`- **Created:** ${capture.created_at}`); + if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`); + if (capture.language) lines.push(`- **Language:** ${capture.language}`); + if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`); + if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`); + lines.push(''); + if (capture.transcript_refined?.trim()) { + lines.push('## Refined transcript', '', capture.transcript_refined.trim(), ''); + } + if (capture.transcript_raw?.trim()) { + lines.push('## Raw transcript', '', capture.transcript_raw.trim(), ''); + } + return lines.join('\n'); + }; + + const handleExportMarkdown = async () => { + if (!selected) return; + const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim(); + if (!hasContent) { + toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' }); + return; + } + try { + const dest = await save({ + defaultPath: `capture_${selected.id.slice(0, 8)}.md`, + filters: [{ name: 'Markdown', extensions: ['md'] }], + }); + if (!dest) return; + await writeTextFile(dest, buildCaptureMarkdown(selected)); + exportToastSuccess(dest); + } catch (err) { + exportToastError(err); + } + }; + const handlePlayAs = (voice?: VoiceProfileResponse) => { if (!selected) return; const target = voice ?? playAsVoice; @@ -327,50 +439,41 @@ export function CapturesTab() { /> {/* Left: capture list */} -
-
- -
-
-

{t('captures.title')}

- - {t('captures.beta')} - -
-
- + + + + {t('captures.title')} + + {t('captures.beta')} + + + setSearch(e.target.value)} - className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" + onChange={setSearch} + placeholder={t('captures.searchPlaceholder')} /> -
-
+ -
-
- {capturesLoading ? ( -
- -
- ) : filtered.length === 0 ? ( -
- {search ? ( -

{t('captures.empty.noMatches', { query: search })}

- ) : ( -

{t('captures.empty.none')}

- )} -
- ) : ( - filtered.map((capture) => { + +
+ {capturesLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ {search ? ( +

{t('captures.empty.noMatches', { query: search })}

+ ) : ( +

{t('captures.empty.none')}

+ )} +
+ ) : ( + filtered.map((capture) => { const isActive = selectedId === capture.id; const refined = !!capture.transcript_refined; return ( @@ -413,8 +516,9 @@ export function CapturesTab() { ); }) )} -
-
+
+ +
{/* Right: capture detail */} @@ -670,17 +774,40 @@ export function CapturesTab() { ? t('captures.actions.reRefine') : t('captures.actions.refine')} - + + + + + + + {t('captures.actions.exportDropdownLabel')} + + + + + {t('captures.actions.exportAudio')} + + + + {t('captures.actions.exportTranscript')} + + + + {t('captures.actions.exportMarkdown')} + + +
)}
+ + + + + {t('captures.deleteDialog.title')} + {t('captures.deleteDialog.description')} + + + {t('common.cancel')} + + + + + + ); } diff --git a/app/src/components/Effects/EffectsChainEditor.tsx b/app/src/components/Effects/EffectsChainEditor.tsx index 04728077..1ececd1c 100644 --- a/app/src/components/Effects/EffectsChainEditor.tsx +++ b/app/src/components/Effects/EffectsChainEditor.tsx @@ -350,7 +350,7 @@ function SortableEffectItem({ - + + + + {t('effects.title')} + + + + + - {/* Scrollable list */} -
- {/* Built-in presets */} - {builtIn.length > 0 && ( -
-
- {t('effects.sections.builtin')} -
-
- {builtIn.map((preset) => ( - handleSelect(preset)} - /> - ))} -
-
- )} - - {/* User presets */} - {userPresets.length > 0 && ( -
-
- {t('effects.sections.custom')} -
-
- {userPresets.map((preset) => ( - handleSelect(preset)} - /> - ))} -
-
- )} - - {/* New preset placeholder */} - {isCreatingNew && ( -
-
- {t('effects.sections.new')} -
-
-
- - {t('effects.unsaved.title')} + +
+ {builtIn.length > 0 && ( +
+
+ {t('effects.sections.builtin')} +
+
+ {builtIn.map((preset) => ( + handleSelect(preset)} + /> + ))}
-

{t('effects.unsaved.hint')}

-
- )} -
-
+ )} + + {userPresets.length > 0 && ( +
+
+ {t('effects.sections.custom')} +
+
+ {userPresets.map((preset) => ( + handleSelect(preset)} + /> + ))} +
+
+ )} + + {isCreatingNew && ( +
+
+ {t('effects.sections.new')} +
+
+
+ + {t('effects.unsaved.title')} +
+

{t('effects.unsaved.hint')}

+
+
+ )} +
+ + ); } diff --git a/app/src/components/EffectsTab/EffectsTab.tsx b/app/src/components/EffectsTab/EffectsTab.tsx index 98343aef..3a1b284a 100644 --- a/app/src/components/EffectsTab/EffectsTab.tsx +++ b/app/src/components/EffectsTab/EffectsTab.tsx @@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList'; export function EffectsTab() { return ( -
+
{/* Left - Presets list */}
@@ -11,7 +11,7 @@ export function EffectsTab() {
{/* Right - Detail / editor */} -
+
diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 3465beb9..0aa26e07 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -462,7 +462,7 @@ export function HistoryTable() {
{history.length === 0 ? (
- No voice generations, yet... + {t('history.empty')}
) : ( <> @@ -474,7 +474,7 @@ export function HistoryTable() { } /> diff --git a/app/src/components/ServerTab/GpuPage.tsx b/app/src/components/ServerTab/GpuPage.tsx index 0caae3aa..507e241f 100644 --- a/app/src/components/ServerTab/GpuPage.tsx +++ b/app/src/components/ServerTab/GpuPage.tsx @@ -388,7 +388,7 @@ export function GpuPage() { onClick={handleDelete} variant="ghost" size="sm" - className="text-muted-foreground hover:text-destructive" + className="text-muted-foreground " > {t('settings.gpu.remove.button')} diff --git a/app/src/components/ServerTab/MCPPage.tsx b/app/src/components/ServerTab/MCPPage.tsx index d2f98da7..b952a59a 100644 --- a/app/src/components/ServerTab/MCPPage.tsx +++ b/app/src/components/ServerTab/MCPPage.tsx @@ -2,6 +2,13 @@ import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react'; import { useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useMCPBindings } from '@/lib/hooks/useMCPBindings'; import { useProfiles } from '@/lib/hooks/useProfiles'; import { useCaptureSettings } from '@/lib/hooks/useSettings'; @@ -101,22 +108,28 @@ export function MCPPage() { title={t('settings.mcp.defaultVoice.label')} description={t('settings.mcp.defaultVoice.labelHint')} action={ - updateCapture({ - default_playback_voice_id: e.target.value || null, + default_playback_voice_id: v === '__default__' ? null : v, }) } - className="h-8 px-2 rounded-md border bg-background text-sm min-w-[180px]" > - - {(profiles ?? []).map((p) => ( - - ))} - + + + + + + {t('settings.mcp.defaultVoice.none')} + + {(profiles ?? []).map((p) => ( + + {p.name} + + ))} + + } /> @@ -153,24 +166,30 @@ export function MCPPage() { )}
- upsertAsync({ client_id: b.client_id, label: b.label, - profile_id: e.target.value || null, + profile_id: v === '__default__' ? null : v, }) } - className="h-8 px-2 rounded-md border bg-background text-sm min-w-[160px]" > - - {(profiles ?? []).map((p) => ( - - ))} - + + + + + + {t('settings.mcp.bindings.defaultOption')} + + {(profiles ?? []).map((p) => ( + + {p.name} + + ))} + +
+ + + + - {/* Fixed Header */} -
-
-

{t('stories.title')}

- -
-
- setSearch(e.target.value)} - className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" - /> -
-
- - {/* Scrollable Story List */} -
{storyList.length === 0 ? ( @@ -268,8 +270,14 @@ export function StoryList() {
-
- {story.name} +
+ {story.name} + {story.description ? ( + <> + · + {story.description} + + ) : null}
)} -
+ {/* Create Story Dialog */} @@ -422,6 +430,6 @@ export function StoryList() { -
+ ); } diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx index 58c8b8d3..4cf8d47f 100644 --- a/app/src/components/ui/button.tsx +++ b/app/src/components/ui/button.tsx @@ -3,14 +3,18 @@ import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; import { cn } from '@/lib/utils/cn'; -const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', +const buttonVariants = cva([ + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm', + 'font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2', + 'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0' + ], { variants: { variant: { default: 'bg-accent text-accent-foreground hover:bg-accent/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-accent underline-offset-4 hover:underline', diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 0a7095fb..1f746911 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -53,7 +53,11 @@ "copy": "Copy", "refine": "Refine", "reRefine": "Re-refine", - "sendTo": "Send to", + "export": "Export", + "exportDropdownLabel": "Export capture as", + "exportAudio": "Audio (WAV)", + "exportTranscript": "Transcript (TXT)", + "exportMarkdown": "Markdown (MD)", "delete": "Delete", "playAs": "Play as {{name}}", "playAsFallback": "Play as…", @@ -73,6 +77,11 @@ "turnOnShortcut": "Turn on the global shortcut to dictate from anywhere — or click Dictate above for an in-app capture.", "openSettings": "Open Captures settings" }, + "deleteDialog": { + "title": "Delete capture", + "description": "This will permanently delete the capture, its audio, and its transcript. This cannot be undone.", + "deleting": "Deleting…" + }, "toast": { "deleteFailed": "Delete failed", "playAsFailed": "Play-as failed", @@ -80,6 +89,9 @@ "noVoiceDescription": "Create a voice profile before using Play as.", "transcriptCopied": "Transcript copied", "copyFailed": "Copy failed", + "exportSuccess": "Exported to {{path}}", + "exportFailed": "Export failed", + "exportEmpty": "Nothing to export", "shortcutNotArmed": "Shortcut on, but not yet armed", "shortcutNotArmedDescription_one": "{{names}} still needs to download. Open the Captures tab to start.", "shortcutNotArmedDescription_other": "{{names}} still need to download. Open the Captures tab to start." @@ -628,6 +640,7 @@ } }, "history": { + "empty": "No voice generations, yet…", "actions": { "menu": "Actions", "play": "Play", @@ -983,10 +996,10 @@ "d30": "30 days", "d7": "7 days" }, - "clearAll": { - "title": "Clear all captures", - "description": "Permanently delete every capture and its audio. This cannot be undone.", - "action": "Clear captures" + "folder": { + "title": "Captures folder", + "description": "Where capture audio and transcripts are stored on disk.", + "open": "Open" } }, "sidebar": { diff --git a/app/src/i18n/locales/ja/translation.json b/app/src/i18n/locales/ja/translation.json index aef6e030..7e307b83 100644 --- a/app/src/i18n/locales/ja/translation.json +++ b/app/src/i18n/locales/ja/translation.json @@ -53,7 +53,11 @@ "copy": "コピー", "refine": "整形", "reRefine": "再整形", - "sendTo": "送信先", + "export": "エクスポート", + "exportDropdownLabel": "形式を選択", + "exportAudio": "音声 (WAV)", + "exportTranscript": "文字起こし (TXT)", + "exportMarkdown": "Markdown (MD)", "delete": "削除", "playAs": "{{name}} で再生", "playAsFallback": "ボイスで再生…", @@ -73,6 +77,11 @@ "turnOnShortcut": "グローバルショートカットを有効にしてどこからでもディクテーション — または上の「ディクテーション」をクリックしてアプリ内でキャプチャします。", "openSettings": "キャプチャ設定を開く" }, + "deleteDialog": { + "title": "キャプチャを削除", + "description": "このキャプチャと、その音声・文字起こしを完全に削除します。元に戻せません。", + "deleting": "削除中…" + }, "toast": { "deleteFailed": "削除に失敗しました", "playAsFailed": "ボイスでの再生に失敗しました", @@ -80,6 +89,9 @@ "noVoiceDescription": "「ボイスで再生」を使う前にボイスプロファイルを作成してください。", "transcriptCopied": "文字起こしをコピーしました", "copyFailed": "コピーに失敗しました", + "exportSuccess": "{{path}} に書き出しました", + "exportFailed": "書き出しに失敗しました", + "exportEmpty": "書き出す内容がありません", "shortcutNotArmed": "ショートカットは有効ですが、まだ準備が完了していません", "shortcutNotArmedDescription_one": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。", "shortcutNotArmedDescription_other": "{{names}} のダウンロードがまだ必要です。キャプチャタブを開いて開始してください。" @@ -628,6 +640,7 @@ } }, "history": { + "empty": "音声生成はまだありません…", "actions": { "menu": "操作", "play": "再生", @@ -983,10 +996,10 @@ "d30": "30 日", "d7": "7 日" }, - "clearAll": { - "title": "すべてのキャプチャをクリア", - "description": "すべてのキャプチャと音声を完全に削除します。元に戻せません。", - "action": "キャプチャをクリア" + "folder": { + "title": "キャプチャフォルダ", + "description": "キャプチャの音声と文字起こしをディスクに保存する場所。", + "open": "開く" } }, "sidebar": { diff --git a/app/src/i18n/locales/zh-CN/translation.json b/app/src/i18n/locales/zh-CN/translation.json index e8ff71b5..376eff9b 100644 --- a/app/src/i18n/locales/zh-CN/translation.json +++ b/app/src/i18n/locales/zh-CN/translation.json @@ -53,7 +53,11 @@ "copy": "复制", "refine": "精修", "reRefine": "重新精修", - "sendTo": "发送到", + "export": "导出", + "exportDropdownLabel": "导出格式", + "exportAudio": "音频 (WAV)", + "exportTranscript": "文字稿 (TXT)", + "exportMarkdown": "Markdown (MD)", "delete": "删除", "playAs": "以 {{name}} 播放", "playAsFallback": "播放为……", @@ -73,6 +77,11 @@ "turnOnShortcut": "开启全局快捷键以在任何位置进行听写——或点击上方的「听写」在应用内进行捕获。", "openSettings": "打开「捕获」设置" }, + "deleteDialog": { + "title": "删除捕获", + "description": "这将永久删除该捕获及其音频和转录。此操作不可撤销。", + "deleting": "删除中…" + }, "toast": { "deleteFailed": "删除失败", "playAsFailed": "播放失败", @@ -80,6 +89,9 @@ "noVoiceDescription": "使用「播放为」之前请先创建声音档案。", "transcriptCopied": "转录已复制", "copyFailed": "复制失败", + "exportSuccess": "已导出到 {{path}}", + "exportFailed": "导出失败", + "exportEmpty": "无可导出的内容", "shortcutNotArmed": "快捷键已开启,但尚未就绪", "shortcutNotArmedDescription_one": "{{names}} 仍需下载。打开「捕获」标签页开始下载。", "shortcutNotArmedDescription_other": "{{names}} 仍需下载。打开「捕获」标签页开始下载。" @@ -628,6 +640,7 @@ } }, "history": { + "empty": "暂无语音生成…", "actions": { "menu": "操作", "play": "播放", @@ -983,10 +996,10 @@ "d30": "30 天", "d7": "7 天" }, - "clearAll": { - "title": "清除所有捕获", - "description": "永久删除所有捕获及其音频。此操作不可撤销。", - "action": "清除捕获" + "folder": { + "title": "捕获文件夹", + "description": "捕获的音频和转录在磁盘上的存储位置。", + "open": "打开" } }, "sidebar": { diff --git a/app/src/i18n/locales/zh-TW/translation.json b/app/src/i18n/locales/zh-TW/translation.json index f96b75df..5cd0cf9f 100644 --- a/app/src/i18n/locales/zh-TW/translation.json +++ b/app/src/i18n/locales/zh-TW/translation.json @@ -53,7 +53,11 @@ "copy": "複製", "refine": "精修", "reRefine": "重新精修", - "sendTo": "傳送至", + "export": "匯出", + "exportDropdownLabel": "匯出格式", + "exportAudio": "音訊 (WAV)", + "exportTranscript": "文字稿 (TXT)", + "exportMarkdown": "Markdown (MD)", "delete": "刪除", "playAs": "以 {{name}} 播放", "playAsFallback": "以聲音播放……", @@ -73,6 +77,11 @@ "turnOnShortcut": "開啟全域快捷鍵以從任何地方口述——或點選上方的「口述」進行 App 內擷取。", "openSettings": "開啟擷取設定" }, + "deleteDialog": { + "title": "刪除擷取", + "description": "這將永久刪除該擷取及其音訊與轉錄。此操作無法復原。", + "deleting": "刪除中…" + }, "toast": { "deleteFailed": "刪除失敗", "playAsFailed": "以聲音播放失敗", @@ -80,6 +89,9 @@ "noVoiceDescription": "使用「以聲音播放」前請先建立聲音檔案。", "transcriptCopied": "已複製轉錄文字", "copyFailed": "複製失敗", + "exportSuccess": "已匯出至 {{path}}", + "exportFailed": "匯出失敗", + "exportEmpty": "沒有可匯出的內容", "shortcutNotArmed": "快捷鍵已開啟,但尚未就緒", "shortcutNotArmedDescription_one": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。", "shortcutNotArmedDescription_other": "{{names}} 仍需下載。請開啟「擷取」分頁開始下載。" @@ -628,6 +640,7 @@ } }, "history": { + "empty": "尚無語音生成…", "actions": { "menu": "操作", "play": "播放", @@ -983,10 +996,10 @@ "d30": "30 天", "d7": "7 天" }, - "clearAll": { - "title": "清除所有擷取", - "description": "永久刪除每一筆擷取與其音訊。此操作無法復原。", - "action": "清除擷取" + "folder": { + "title": "擷取資料夾", + "description": "擷取的音訊與轉錄在磁碟上的儲存位置。", + "open": "開啟" } }, "sidebar": { diff --git a/backend/routes/health.py b/backend/routes/health.py index 79c513f5..e5ad86ec 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -188,6 +188,7 @@ async def filesystem_health(): dirs_to_check = { "generations": config.get_generations_dir(), + "captures": config.get_captures_dir(), "profiles": config.get_profiles_dir(), "data": config.get_data_dir(), } diff --git a/landing/src/components/CapturesMockup.tsx b/landing/src/components/CapturesMockup.tsx index c9aec423..6e4994c1 100644 --- a/landing/src/components/CapturesMockup.tsx +++ b/landing/src/components/CapturesMockup.tsx @@ -9,8 +9,8 @@ import { Copy, FileAudio, Mic, + Download, Play, - Send, Settings, Sparkles, Subtitles, @@ -394,8 +394,8 @@ function DetailView({ capture }: { capture: Capture }) { Re-refine
- - Send to + + Export