feat(todo): voice quick-add in workbench ListView via shared <VoiceCaptureBar>

Speak a task and it lands in the list as a placeholder while mana-stt
transcribes it; the title swaps in once the transcript returns.

No date/priority/label parsing yet — that's a follow-up that needs an
LLM pass over the transcript. For now the whole transcript becomes the
task title and the user can edit inline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 16:01:50 +02:00
parent 9b3d7c7325
commit b841a24e73
2 changed files with 60 additions and 0 deletions

View file

@ -20,6 +20,7 @@
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -68,6 +69,10 @@
newTitle = '';
}
async function handleVoiceComplete(blob: Blob, durationMs: number) {
await tasksStore.createFromVoice(blob, durationMs, 'de');
}
// Context menu
let ctxMenu = $state<{
visible: boolean;
@ -160,6 +165,13 @@
<input bind:value={newTitle} placeholder="Neue Aufgabe..." class="add-input" />
</form>
<VoiceCaptureBar
idleLabel="Aufgabe sprechen"
feature="todo-voice-capture"
reason="Aufgaben werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<div class="task-list">
{#each filtered() as task (task.id)}
{@const taskTagIds = getTaskTagIds(task)}

View file

@ -83,6 +83,54 @@ export const tasksStore = {
return plaintextSnapshot;
},
/**
* Create a task from a voice recording. Inserts a placeholder task
* immediately so the user sees instant feedback in the list, then
* fills in the real title once mana-stt returns the transcript.
*
* No date/priority parsing yet that needs an LLM pass and is its
* own follow-up. The user can edit the task inline like any other.
*/
async createFromVoice(blob: Blob, _durationMs: number, language = 'de') {
const placeholder = await this.createTask({ title: 'Sprachaufgabe wird transkribiert…' });
void this.transcribeIntoTask(placeholder.id, blob, language);
return placeholder;
},
/**
* Upload an audio blob to /api/v1/voice/transcribe and write the
* transcript into an existing task as the new title. On failure,
* surfaces the error inline so the user isn't left with the
* "wird transkribiert…" placeholder forever.
*/
async transcribeIntoTask(taskId: string, blob: Blob, language?: string): Promise<void> {
try {
const form = new FormData();
const ext = blob.type.includes('webm')
? '.webm'
: blob.type.includes('mp4')
? '.m4a'
: '.audio';
form.append('file', blob, `task${ext}`);
if (language) form.append('language', language);
const response = await fetch('/api/v1/voice/transcribe', {
method: 'POST',
body: form,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
const result = (await response.json()) as { text: string };
const transcript = (result.text ?? '').trim() || 'Sprachaufgabe';
await this.updateTask(taskId, { title: transcript });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await this.updateTask(taskId, { title: `Sprachaufgabe (Fehler: ${msg})` });
}
},
async updateTask(id: string, data: Record<string, unknown>) {
const raw = await taskTable.get(id);
if (!raw) return;