From 7474cd24ba6c8546b8129c6fe8b0a8877a7e52ae Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 19 Apr 2026 20:57:46 +0100 Subject: [PATCH] feat(ui): profile picker + per-profile vocabulary; transcribe invokes carry profileId; drop buildInitialPrompt --- src/lib/pages/DictationPage.svelte | 19 +- src/lib/pages/FilesPage.svelte | 2 + src/lib/pages/SettingsPage.svelte | 299 +++++++++++++++++++++++------ src/routes/+layout.svelte | 4 + 4 files changed, 252 insertions(+), 72 deletions(-) diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index 5126c7d..1341d7d 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -3,6 +3,7 @@ import { onMount, onDestroy } from "svelte"; import { Channel, invoke } from "@tauri-apps/api/core"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; + import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import { toasts } from "$lib/stores/toasts.svelte.js"; import Card from "$lib/components/Card.svelte"; import ModelDownloader from "$lib/components/ModelDownloader.svelte"; @@ -123,18 +124,6 @@ return currentModelIsEnglishOnly() ? "en" : settings.language; } - function buildInitialPrompt() { - if (!page.activeProfile || page.activeProfile === "None") return ""; - const profile = profiles.find((entry) => entry.name === page.activeProfile); - if (!profile?.words) return ""; - const words = profile.words - .split("\n") - .map((word) => word.trim()) - .filter(Boolean); - if (words.length === 0) return ""; - return `Use these terms when they match the audio: ${words.join(", ")}`; - } - async function refreshRuntimeCapabilities() { runtimeCapabilities = await invoke("get_runtime_capabilities"); } @@ -322,7 +311,11 @@ engine: settings.engine, modelId: selectedModelId(), language: effectiveLanguage(), - initialPrompt: buildInitialPrompt(), + // Backend (Task 13) falls back to profile.initial_prompt when this + // is empty. Vocabulary now lives on the active profile, not on the + // old page.activeProfile localStorage bag. + initialPrompt: "", + profileId: profilesStore.activeProfileId, saveAudio: settings.saveAudio, outputFolder: settings.outputFolder || null, removeFillers: settings.removeFillers, diff --git a/src/lib/pages/FilesPage.svelte b/src/lib/pages/FilesPage.svelte index 00d1b7d..83ad601 100644 --- a/src/lib/pages/FilesPage.svelte +++ b/src/lib/pages/FilesPage.svelte @@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { settings, addToHistory } from "$lib/stores/page.svelte.js"; + import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import Card from "$lib/components/Card.svelte"; import EmptyState from "$lib/components/EmptyState.svelte"; import { exportTranscript } from "$lib/utils/export.js"; @@ -76,6 +77,7 @@ path, language: settings.language, initialPrompt: "", + profileId: profilesStore.activeProfileId, removeFillers: settings.removeFillers, britishEnglish: settings.britishEnglish, antiHallucination: settings.antiHallucination, diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 1e0b932..40427a6 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -11,6 +11,7 @@ import ZonePicker from "$lib/components/ZonePicker.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; + import { profilesStore, DEFAULT_PROFILE_ID } from "$lib/stores/profiles.svelte.ts"; import { clampTextLines } from "$lib/utils/textMeasure.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { Check, ChevronRight } from "lucide-svelte"; @@ -103,19 +104,29 @@ } } - // --- Custom vocabulary (dictionary) — Day 5 of upgrade plan --- - // Backed by SQLite `dictionary` table via list_dictionary_command etc. - // Terms get injected into the LLM cleanup prompt so the model preserves - // the user's spellings (e.g. medication names, jargon, place names). + // --- Per-profile vocabulary (Task 15) --- + // Terms and the profile's initial_prompt travel with the active profile. + // The Rust side (Task 13) falls back to profile.initial_prompt whenever + // the invoke payload's initialPrompt is empty. let vocabulary = $state([]); let vocabularyError = $state(null); let newVocabTerm = $state(""); let newVocabNote = $state(""); + // Draft held locally so the textarea doesn't thrash the store on every + // keystroke. Saved on blur or explicit save. + let initialPromptDraft = $state(""); + let initialPromptDirty = $state(false); + let profileRenameDraft = $state(""); + let showNewVocabProfile = $state(false); + let newVocabProfileName = $state(""); + let newVocabProfilePrompt = $state(""); + async function refreshVocabulary() { vocabularyError = null; try { - vocabulary = await invoke("list_dictionary_command"); + const profileId = profilesStore.activeProfileId; + vocabulary = await profilesStore.listTerms(profileId); } catch (err) { vocabularyError = "Could not load vocabulary: " + (err?.message || err); } @@ -125,10 +136,11 @@ const term = newVocabTerm.trim(); if (!term) return; try { - await invoke("add_dictionary_entry_command", { + await profilesStore.addTerm( + profilesStore.activeProfileId, term, - note: newVocabNote.trim() || null, - }); + newVocabNote.trim(), + ); newVocabTerm = ""; newVocabNote = ""; await refreshVocabulary(); @@ -139,13 +151,66 @@ async function deleteVocabTerm(id) { try { - await invoke("delete_dictionary_entry_command", { id }); + await profilesStore.deleteTerm(id); await refreshVocabulary(); } catch (err) { vocabularyError = "Could not delete term: " + (err?.message || err); } } + // Sync the initial-prompt draft + term list when the active profile flips. + $effect(() => { + const active = profilesStore.active; + initialPromptDraft = active?.initialPrompt ?? ""; + initialPromptDirty = false; + profileRenameDraft = active?.name ?? ""; + // Refresh terms whenever the active profile id changes. + void profilesStore.activeProfileId; + refreshVocabulary(); + }); + + function onActiveProfileChange(e) { + const id = e.target.value; + profilesStore.setActive(id); + } + + async function saveInitialPrompt() { + if (!initialPromptDirty) return; + const active = profilesStore.active; + if (!active) return; + await profilesStore.update(active.id, active.name, initialPromptDraft); + initialPromptDirty = false; + } + + async function renameActiveProfile() { + const active = profilesStore.active; + if (!active) return; + const name = profileRenameDraft.trim(); + if (!name || name === active.name) { + profileRenameDraft = active.name; + return; + } + await profilesStore.update(active.id, name, active.initialPrompt); + } + + async function createVocabProfile() { + const name = newVocabProfileName.trim(); + if (!name) return; + const created = await profilesStore.create(name, newVocabProfilePrompt.trim()); + if (created) { + profilesStore.setActive(created.id); + newVocabProfileName = ""; + newVocabProfilePrompt = ""; + showNewVocabProfile = false; + } + } + + async function deleteActiveProfile() { + const active = profilesStore.active; + if (!active || active.id === DEFAULT_PROFILE_ID) return; + await profilesStore.delete(active.id); + } + // --- Diagnostics (Settings → About → Generate diagnostic report) --- // Layer 2 of the diagnostics plan: user previews exactly what would be // shared, then chooses to copy / save. Nothing leaves the machine @@ -290,9 +355,9 @@ // the user opens the Audio section. refreshAudioDevices(); - // Pre-load vocabulary too. Both lists are small; pre-fetching avoids - // a flash of "no data" when the section opens. - refreshVocabulary(); + // Vocabulary is loaded reactively via the $effect that tracks the + // active profile id (set once profilesStore.load() resolves in the + // root layout). No eager fetch needed here. try { downloadedModels = await invoke("list_models"); @@ -533,7 +598,9 @@ {/if} - + +
+ +
+

Active Profile

+
+ + + +
+ + {#if showNewVocabProfile} +
+
+ { if (e.key === 'Enter') createVocabProfile(); }} + class="bg-bg border border-border rounded-lg px-3 py-2 text-[13px] text-text + focus:outline-none focus:border-accent" + /> + +
+ + +
+
+
+ {/if}
- {#if vocabularyError} -

{vocabularyError}

- {/if} + {#if profilesStore.active} + +
+

Profile Name

+ { if (e.key === 'Enter') e.target.blur(); }} + class="bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text min-w-[280px] + focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]" + /> +
- {#if vocabulary.length === 0} -

No terms yet. Add one above.

- {:else} - + onclick={saveInitialPrompt} + class="text-[11px] text-accent hover:text-accent-hover" + >Save prompt + Unsaved changes — saves on blur. + {:else} + Saved. + {/if} +
+ + + +

Terms

+ +
+ { if (e.key === 'Enter') addVocabTerm(); }} + class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text + focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]" + /> + { if (e.key === 'Enter') addVocabTerm(); }} + class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px] text-text + focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]" + /> + +
+ + {#if vocabularyError} +

{vocabularyError}

+ {/if} + + {#if vocabulary.length === 0} +

No terms yet. Add one above.

+ {:else} + + {/if} + {:else} +

Loading profiles…

{/if} {/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d46bab5..db55a38 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,6 +17,7 @@ applyExternalPreferences, PREFERENCES_CHANGED_EVENT, } from "$lib/stores/preferences.svelte.js"; + import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { listen } from "@tauri-apps/api/event"; import { toasts } from "$lib/stores/toasts.svelte.js"; @@ -249,6 +250,9 @@ // Detect and initialise the correct hotkey backend await initHotkeyBackend(); + // Load profiles (per-profile vocabulary + initial prompt). Task 15. + await profilesStore.load(); + try { const whisper = await invoke("list_models"); const parakeet = await invoke("list_parakeet_models");