feat(ui): shared ListPane primitive + misc polish

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 <select> → shadcn <Select> 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
This commit is contained in:
Jamie Pine
2026-04-24 17:23:14 -07:00
parent 736a661059
commit b97c565a45
21 changed files with 616 additions and 238 deletions
+18 -17
View File
@@ -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
+199 -51
View File
@@ -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<string | null>(null);
const [playbackState, setPlaybackState] = useState<PlaybackState>('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 */}
<div className="w-[340px] shrink-0 flex flex-col relative overflow-hidden border-r border-border">
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center mb-3">
<h1 className="text-2xl px-4 font-bold">{t('captures.title')}</h1>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
{t('captures.beta')}
</Badge>
</div>
<div className="relative">
<Input
placeholder={t('captures.searchPlaceholder')}
<div className="w-[340px] shrink-0">
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
<Badge
variant="secondary"
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
>
{t('captures.beta')}
</Badge>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={(e) => 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')}
/>
</div>
</div>
</ListPaneHeader>
<div
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden pt-24',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
filtered.map((capture) => {
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
<div className="px-4 pb-6 space-y-1">
{capturesLoading ? (
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{search ? (
<p>{t('captures.empty.noMatches', { query: search })}</p>
) : (
<p>{t('captures.empty.none')}</p>
)}
</div>
) : (
filtered.map((capture) => {
const isActive = selectedId === capture.id;
const refined = !!capture.transcript_refined;
return (
@@ -413,8 +516,9 @@ export function CapturesTab() {
);
})
)}
</div>
</div>
</div>
</ListPaneScroll>
</ListPane>
</div>
{/* Right: capture detail */}
@@ -670,17 +774,40 @@ export function CapturesTab() {
? t('captures.actions.reRefine')
: t('captures.actions.refine')}
</Button>
<Button variant="outline" size="sm" disabled>
<Send className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.sendTo')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('captures.actions.export')}
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{t('captures.actions.exportDropdownLabel')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleExportAudio}>
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportAudio')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportTranscript}>
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportTranscript')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
{t('captures.actions.exportMarkdown')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => deleteMutation.mutate(selected.id)}
onClick={() => setDeleteDialogOpen(true)}
disabled={deleteMutation.isPending}
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground "
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
@@ -744,6 +871,27 @@ export function CapturesTab() {
</div>
)}
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => selected && deleteMutation.mutate(selected.id)}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -350,7 +350,7 @@ function SortableEffectItem({
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-destructive"
className="p-0.5 text-muted-foreground "
onClick={onRemove}
title={t('effects.chain.remove')}
>
@@ -279,7 +279,7 @@ export function EffectsDetail() {
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive gap-1.5"
className="h-8 text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
+73 -64
View File
@@ -1,6 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
@@ -43,73 +51,74 @@ export function EffectsList() {
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{t('effects.title')}</h2>
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
<Plus className="h-3.5 w-3.5" />
{t('effects.newPreset')}
</Button>
</div>
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={handleCreateNew} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('effects.newPreset')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
</ListPaneHeader>
{/* Scrollable list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* Built-in presets */}
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.builtin')}
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* User presets */}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.custom')}
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* New preset placeholder */}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.new')}
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
<ListPaneScroll className="pt-16">
<div className="px-4 pb-6 space-y-4">
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.builtin')}
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</div>
</div>
)}
</div>
</div>
)}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.custom')}
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
{t('effects.sections.new')}
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
</div>
</div>
)}
</div>
</ListPaneScroll>
</ListPane>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { EffectsList } from './EffectsList';
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
@@ -11,7 +11,7 @@ export function EffectsTab() {
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex flex-col pr-8">
<EffectsDetail />
</div>
</div>
+2 -2
View File
@@ -462,7 +462,7 @@ export function HistoryTable() {
<div className="flex flex-col h-full min-h-0 relative">
{history.length === 0 ? (
<div className="text-center py-12 px-5 border-2 border-dashed mb-5 border-muted rounded-md text-muted-foreground flex-1 flex items-center justify-center">
No voice generations, yet...
{t('history.empty')}
</div>
) : (
<>
@@ -474,7 +474,7 @@ export function HistoryTable() {
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-destructive"
className="h-7 text-xs text-muted-foreground"
onClick={() => setClearFailedDialogOpen(true)}
disabled={clearFailed.isPending}
>
+99
View File
@@ -0,0 +1,99 @@
import type { CSSProperties, ReactNode } from 'react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils/cn';
interface ListPaneProps {
className?: string;
children: ReactNode;
}
export function ListPane({ className, children }: ListPaneProps) {
return (
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
<div
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
style={{
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
}}
/>
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
{children}
</div>
);
}
interface ListPaneHeaderProps {
className?: string;
children: ReactNode;
}
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
return (
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
);
}
interface ListPaneTitleRowProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
}
interface ListPaneTitleProps {
className?: string;
children: ReactNode;
}
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
}
interface ListPaneActionsProps {
className?: string;
children: ReactNode;
}
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
}
interface ListPaneSearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
return (
<div className={cn('relative', className)}>
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
}
interface ListPaneScrollProps {
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
return (
<div
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
style={style}
>
{children}
</div>
);
}
@@ -366,7 +366,7 @@ export function GpuAcceleration() {
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
className="w-full text-muted-foreground "
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
+38 -7
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown, Keyboard, Laptop, Lock, Trash2, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Check, ChevronDown, FolderOpen, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
@@ -27,6 +27,8 @@ import { useToast } from '@/components/ui/use-toast';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
import { cn } from '@/lib/utils/cn';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
@@ -136,6 +138,8 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
export function CapturesPage() {
const { t } = useTranslation();
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
@@ -159,6 +163,32 @@ export function CapturesPage() {
const [copyToClipboard, setCopyToClipboard] = useState(true);
const [retention, setRetention] = useState('forever');
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
const [opening, setOpening] = useState(false);
const [capturesPath, setCapturesPath] = useState<string | null>(null);
useEffect(() => {
fetch(`${serverUrl}/health/filesystem`)
.then((res) => res.json())
.then((data) => {
const dir = data.directories?.find((d: { path: string }) =>
d.path.includes('captures'),
);
if (dir?.path) setCapturesPath(dir.path);
})
.catch(() => {});
}, [serverUrl]);
const openCapturesFolder = useCallback(async () => {
if (!capturesPath) return;
setOpening(true);
try {
await platform.filesystem.openPath(capturesPath);
} catch (e) {
console.error('Failed to open captures folder:', e);
} finally {
setOpening(false);
}
}, [platform, capturesPath]);
const voices: VoiceProfileResponse[] = profiles ?? [];
const defaultVoice =
@@ -561,16 +591,17 @@ export function CapturesPage() {
/>
<SettingRow
title={t('settings.captures.storage.clearAll.title')}
description={t('settings.captures.storage.clearAll.description')}
title={t('settings.captures.storage.folder.title')}
description={capturesPath ?? t('settings.captures.storage.folder.description')}
action={
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10 border-destructive/30"
onClick={openCapturesFolder}
disabled={opening || !capturesPath}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.storage.clearAll.action')}
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{t('settings.captures.storage.folder.open')}
</Button>
}
/>
+1 -1
View File
@@ -388,7 +388,7 @@ export function GpuPage() {
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
className="text-muted-foreground "
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
+60 -35
View File
@@ -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={
<select
value={defaultProfileId}
onChange={(e) =>
<Select
value={defaultProfileId || '__default__'}
onValueChange={(v) =>
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]"
>
<option value="">{t('settings.mcp.defaultVoice.none')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.defaultVoice.none')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
</SettingSection>
@@ -153,24 +166,30 @@ export function MCPPage() {
)}
</div>
</div>
<select
value={b.profile_id ?? ''}
onChange={(e) =>
<Select
value={b.profile_id ?? '__default__'}
onValueChange={(v) =>
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]"
>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
@@ -201,18 +220,24 @@ export function MCPPage() {
onChange={(e) => setNewLabel(e.target.value)}
className="h-9 px-3 rounded-md border bg-background text-sm"
/>
<select
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className="h-9 px-2 rounded-md border bg-background text-sm min-w-[140px]"
<Select
value={newProfileId || '__default__'}
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
>
<option value="">{t('settings.mcp.bindings.defaultOption')}</option>
{(profiles ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<SelectTrigger className="h-9 min-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('settings.mcp.bindings.defaultOption')}
</SelectItem>
{(profiles ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
size="sm"
@@ -364,7 +364,7 @@ export function StoryContent() {
{/* Content */}
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-14 scroll-pt-14 relative z-0"
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
>
{sortedItems.length === 0 ? (
+37 -29
View File
@@ -29,6 +29,15 @@ import {
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
ListPane,
ListPaneActions,
ListPaneHeader,
ListPaneScroll,
ListPaneSearch,
ListPaneTitle,
ListPaneTitleRow,
} from '@/components/ListPane';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import {
@@ -202,32 +211,25 @@ export function StoryList() {
}
return (
<div className="h-full flex flex-col relative overflow-hidden border-r border-border">
{/* Scroll Mask */}
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
<ListPane>
<ListPaneHeader>
<ListPaneTitleRow>
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
<ListPaneActions>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('stories.newStory')}
</Button>
</ListPaneActions>
</ListPaneTitleRow>
<ListPaneSearch
value={search}
onChange={setSearch}
placeholder={t('stories.searchPlaceholder')}
/>
</ListPaneHeader>
{/* Fixed Header */}
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-2xl px-4 font-bold">{t('stories.title')}</h2>
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('stories.newStory')}
</Button>
</div>
<div className="relative">
<Input
placeholder={t('stories.searchPlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
</div>
{/* Scrollable Story List */}
<div
className="flex-1 overflow-y-auto pt-24 relative z-0"
<ListPaneScroll
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
>
{storyList.length === 0 ? (
@@ -268,8 +270,14 @@ export function StoryList() {
</span>
<div className="flex-1" />
</div>
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
{story.name}
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
<span className="text-foreground font-medium">{story.name}</span>
{story.description ? (
<>
<span className="mx-1.5 text-muted-foreground/50">·</span>
<span className="text-muted-foreground">{story.description}</span>
</>
) : null}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
@@ -311,7 +319,7 @@ export function StoryList() {
})}
</div>
)}
</div>
</ListPaneScroll>
{/* Create Story Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
@@ -422,6 +430,6 @@ export function StoryList() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</ListPane>
);
}
+7 -3
View File
@@ -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',
+18 -5
View File
@@ -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": {
+18 -5
View File
@@ -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": {
+18 -5
View File
@@ -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": {
+18 -5
View File
@@ -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": {
+1
View File
@@ -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(),
}
+3 -3
View File
@@ -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
</div>
<div className="inline-flex items-center gap-2 h-9 px-3 rounded-full border border-input bg-background text-sm font-medium text-foreground whitespace-nowrap">
<Send className="h-3.5 w-3.5" />
Send to
<Download className="h-3.5 w-3.5" />
Export
</div>
</div>
</div>