feat(ui): profile picker + per-profile vocabulary; transcribe invokes carry profileId; drop buildInitialPrompt

This commit is contained in:
2026-04-19 20:57:46 +01:00
parent 7b804eacba
commit 7474cd24ba
4 changed files with 252 additions and 72 deletions

View File

@@ -3,6 +3,7 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { Channel, invoke } from "@tauri-apps/api/core"; import { Channel, invoke } from "@tauri-apps/api/core";
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; 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 { toasts } from "$lib/stores/toasts.svelte.js";
import Card from "$lib/components/Card.svelte"; import Card from "$lib/components/Card.svelte";
import ModelDownloader from "$lib/components/ModelDownloader.svelte"; import ModelDownloader from "$lib/components/ModelDownloader.svelte";
@@ -123,18 +124,6 @@
return currentModelIsEnglishOnly() ? "en" : settings.language; 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() { async function refreshRuntimeCapabilities() {
runtimeCapabilities = await invoke("get_runtime_capabilities"); runtimeCapabilities = await invoke("get_runtime_capabilities");
} }
@@ -322,7 +311,11 @@
engine: settings.engine, engine: settings.engine,
modelId: selectedModelId(), modelId: selectedModelId(),
language: effectiveLanguage(), 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, saveAudio: settings.saveAudio,
outputFolder: settings.outputFolder || null, outputFolder: settings.outputFolder || null,
removeFillers: settings.removeFillers, removeFillers: settings.removeFillers,

View File

@@ -4,6 +4,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { settings, addToHistory } from "$lib/stores/page.svelte.js"; 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 Card from "$lib/components/Card.svelte";
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import { exportTranscript } from "$lib/utils/export.js"; import { exportTranscript } from "$lib/utils/export.js";
@@ -76,6 +77,7 @@
path, path,
language: settings.language, language: settings.language,
initialPrompt: "", initialPrompt: "",
profileId: profilesStore.activeProfileId,
removeFillers: settings.removeFillers, removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish, britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination, antiHallucination: settings.antiHallucination,

View File

@@ -11,6 +11,7 @@
import ZonePicker from "$lib/components/ZonePicker.svelte"; import ZonePicker from "$lib/components/ZonePicker.svelte";
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; 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 { clampTextLines } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { Check, ChevronRight } from "lucide-svelte"; import { Check, ChevronRight } from "lucide-svelte";
@@ -103,19 +104,29 @@
} }
} }
// --- Custom vocabulary (dictionary) — Day 5 of upgrade plan --- // --- Per-profile vocabulary (Task 15) ---
// Backed by SQLite `dictionary` table via list_dictionary_command etc. // Terms and the profile's initial_prompt travel with the active profile.
// Terms get injected into the LLM cleanup prompt so the model preserves // The Rust side (Task 13) falls back to profile.initial_prompt whenever
// the user's spellings (e.g. medication names, jargon, place names). // the invoke payload's initialPrompt is empty.
let vocabulary = $state([]); let vocabulary = $state([]);
let vocabularyError = $state(null); let vocabularyError = $state(null);
let newVocabTerm = $state(""); let newVocabTerm = $state("");
let newVocabNote = $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() { async function refreshVocabulary() {
vocabularyError = null; vocabularyError = null;
try { try {
vocabulary = await invoke("list_dictionary_command"); const profileId = profilesStore.activeProfileId;
vocabulary = await profilesStore.listTerms(profileId);
} catch (err) { } catch (err) {
vocabularyError = "Could not load vocabulary: " + (err?.message || err); vocabularyError = "Could not load vocabulary: " + (err?.message || err);
} }
@@ -125,10 +136,11 @@
const term = newVocabTerm.trim(); const term = newVocabTerm.trim();
if (!term) return; if (!term) return;
try { try {
await invoke("add_dictionary_entry_command", { await profilesStore.addTerm(
profilesStore.activeProfileId,
term, term,
note: newVocabNote.trim() || null, newVocabNote.trim(),
}); );
newVocabTerm = ""; newVocabTerm = "";
newVocabNote = ""; newVocabNote = "";
await refreshVocabulary(); await refreshVocabulary();
@@ -139,13 +151,66 @@
async function deleteVocabTerm(id) { async function deleteVocabTerm(id) {
try { try {
await invoke("delete_dictionary_entry_command", { id }); await profilesStore.deleteTerm(id);
await refreshVocabulary(); await refreshVocabulary();
} catch (err) { } catch (err) {
vocabularyError = "Could not delete term: " + (err?.message || 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) --- // --- Diagnostics (Settings → About → Generate diagnostic report) ---
// Layer 2 of the diagnostics plan: user previews exactly what would be // Layer 2 of the diagnostics plan: user previews exactly what would be
// shared, then chooses to copy / save. Nothing leaves the machine // shared, then chooses to copy / save. Nothing leaves the machine
@@ -290,9 +355,9 @@
// the user opens the Audio section. // the user opens the Audio section.
refreshAudioDevices(); refreshAudioDevices();
// Pre-load vocabulary too. Both lists are small; pre-fetching avoids // Vocabulary is loaded reactively via the $effect that tracks the
// a flash of "no data" when the section opens. // active profile id (set once profilesStore.load() resolves in the
refreshVocabulary(); // root layout). No eager fetch needed here.
try { try {
downloadedModels = await invoke("list_models"); downloadedModels = await invoke("list_models");
@@ -533,7 +598,9 @@
{/if} {/if}
</div> </div>
<!-- Vocabulary (custom dictionary injected into LLM cleanup prompt) --> <!-- Vocabulary — profile-scoped (Task 15) -->
<!-- Terms and the initial prompt travel with the active profile.
The default profile can't be deleted, only edited. -->
<div class="border-b border-border-subtle"> <div class="border-b border-border-subtle">
<button <button
class="flex items-center justify-between w-full py-4 px-5 text-left" class="flex items-center justify-between w-full py-4 px-5 text-left"
@@ -545,9 +612,120 @@
{#if openSection === 'vocabulary'} {#if openSection === 'vocabulary'}
<div class="px-5 pb-5 animate-fade-in"> <div class="px-5 pb-5 animate-fade-in">
<p class="text-[12px] text-text-tertiary mb-4"> <p class="text-[12px] text-text-tertiary mb-4">
Words and phrases the AI cleanup pass should preserve exactly. Useful for medication names, place names, jargon, names of people in your support network, anything Whisper tends to mishear. Words and phrases the AI cleanup pass should preserve exactly. Useful for medication names, place names, jargon, names of people in your support network, anything Whisper tends to mishear. Vocabulary is scoped to a profile — switch profiles to swap whole vocabularies.
</p> </p>
<!-- Profile selector -->
<div class="mb-5">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Active Profile</p>
<div class="flex items-center gap-2 flex-wrap">
<select
class="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)]
appearance-none cursor-pointer min-w-[220px]"
value={profilesStore.activeProfileId}
onchange={onActiveProfileChange}
>
{#each profilesStore.profiles as profile (profile.id)}
<option value={profile.id}>{profile.name}</option>
{/each}
</select>
<button
type="button"
class="px-3 py-2 text-[12px] text-text-secondary border border-border rounded-lg hover:border-accent hover:text-text"
onclick={() => { showNewVocabProfile = !showNewVocabProfile; }}
>{showNewVocabProfile ? "Cancel" : "New profile"}</button>
<button
type="button"
class="px-3 py-2 text-[12px] text-text-secondary border border-border rounded-lg
hover:border-error hover:text-error
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:text-text-secondary"
disabled={profilesStore.activeProfileId === DEFAULT_PROFILE_ID}
onclick={deleteActiveProfile}
title={profilesStore.activeProfileId === DEFAULT_PROFILE_ID ? "The default profile cannot be deleted" : "Delete this profile"}
>Delete profile</button>
</div>
{#if showNewVocabProfile}
<div class="mt-3 p-3 border border-border-subtle rounded-lg bg-bg-input animate-fade-in">
<div class="flex flex-col gap-2">
<input
type="text"
placeholder="Profile name (e.g. Medical notes, Podcast)"
bind:value={newVocabProfileName}
onkeydown={(e) => { 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"
/>
<textarea
placeholder="Initial prompt (optional) — extra context Whisper primes on before every session in this profile"
bind:value={newVocabProfilePrompt}
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text resize-none h-[72px]
focus:outline-none focus:border-accent"
></textarea>
<div class="flex gap-2">
<button
type="button"
onclick={createVocabProfile}
disabled={!newVocabProfileName.trim()}
class="px-4 py-2 text-[13px] bg-accent text-bg rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent-hover"
>Create</button>
<button
type="button"
onclick={() => { showNewVocabProfile = false; newVocabProfileName = ""; newVocabProfilePrompt = ""; }}
class="px-3 py-2 text-[12px] text-text-secondary"
>Cancel</button>
</div>
</div>
</div>
{/if}
</div>
{#if profilesStore.active}
<!-- Rename active profile -->
<div class="mb-5">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Profile Name</p>
<input
type="text"
bind:value={profileRenameDraft}
onblur={renameActiveProfile}
onkeydown={(e) => { 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)]"
/>
</div>
<!-- Initial prompt textarea -->
<div class="mb-5">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Initial Prompt</p>
<p class="text-[11px] text-text-tertiary mb-2">
Passed to Whisper before every transcription in this profile. Used as a biasing prompt — keep it short and topic-specific (names, domain terms, tone cues).
</p>
<textarea
bind:value={initialPromptDraft}
oninput={() => { initialPromptDirty = true; }}
onblur={saveInitialPrompt}
placeholder="e.g. Meeting between Jake and Wren about CORBEL's Furnished World architecture."
class="w-full h-[96px] bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text resize-none
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(232,168,124,0.1)]"
></textarea>
<div class="flex items-center gap-3 mt-1">
{#if initialPromptDirty}
<button
type="button"
onclick={saveInitialPrompt}
class="text-[11px] text-accent hover:text-accent-hover"
>Save prompt</button>
<span class="text-[11px] text-text-tertiary">Unsaved changes — saves on blur.</span>
{:else}
<span class="text-[11px] text-text-tertiary">Saved.</span>
{/if}
</div>
</div>
<!-- Terms -->
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Terms</p>
<div class="flex gap-2 mb-4"> <div class="flex gap-2 mb-4">
<input <input
type="text" type="text"
@@ -599,6 +777,9 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{:else}
<p class="text-[11px] text-text-tertiary italic">Loading profiles…</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

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