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 { 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,

View File

@@ -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,

View File

@@ -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}
</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">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
@@ -545,59 +612,173 @@
{#if openSection === 'vocabulary'}
<div class="px-5 pb-5 animate-fade-in">
<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>
<div class="flex gap-2 mb-4">
<input
type="text"
placeholder="Term (e.g. methylphenidate, Wren, Tartarus)"
bind:value={newVocabTerm}
onkeydown={(e) => { 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)]"
/>
<input
type="text"
placeholder="Note (optional)"
bind:value={newVocabNote}
onkeydown={(e) => { 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)]"
/>
<button
type="button"
onclick={addVocabTerm}
disabled={!newVocabTerm.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"
>Add</button>
<!-- 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 vocabularyError}
<p class="text-[11px] text-error mb-3">{vocabularyError}</p>
{/if}
{#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>
{#if vocabulary.length === 0}
<p class="text-[11px] text-text-tertiary italic">No terms yet. Add one above.</p>
{:else}
<ul class="space-y-1">
{#each vocabulary as entry (entry.id)}
<li class="flex items-center gap-3 py-2 px-3 bg-bg-input border border-border rounded-lg">
<div class="flex-1 min-w-0">
<div class="text-[13px] text-text font-medium truncate">{entry.term}</div>
{#if entry.note}
<div class="text-[11px] text-text-tertiary truncate">{entry.note}</div>
{/if}
</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={() => deleteVocabTerm(entry.id)}
class="text-[12px] text-text-tertiary hover:text-error border border-border hover:border-error rounded px-2 py-1"
aria-label="Delete {entry.term}"
>Remove</button>
</li>
{/each}
</ul>
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">
<input
type="text"
placeholder="Term (e.g. methylphenidate, Wren, Tartarus)"
bind:value={newVocabTerm}
onkeydown={(e) => { 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)]"
/>
<input
type="text"
placeholder="Note (optional)"
bind:value={newVocabNote}
onkeydown={(e) => { 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)]"
/>
<button
type="button"
onclick={addVocabTerm}
disabled={!newVocabTerm.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"
>Add</button>
</div>
{#if vocabularyError}
<p class="text-[11px] text-error mb-3">{vocabularyError}</p>
{/if}
{#if vocabulary.length === 0}
<p class="text-[11px] text-text-tertiary italic">No terms yet. Add one above.</p>
{:else}
<ul class="space-y-1">
{#each vocabulary as entry (entry.id)}
<li class="flex items-center gap-3 py-2 px-3 bg-bg-input border border-border rounded-lg">
<div class="flex-1 min-w-0">
<div class="text-[13px] text-text font-medium truncate">{entry.term}</div>
{#if entry.note}
<div class="text-[11px] text-text-tertiary truncate">{entry.note}</div>
{/if}
</div>
<button
type="button"
onclick={() => deleteVocabTerm(entry.id)}
class="text-[12px] text-text-tertiary hover:text-error border border-border hover:border-error rounded px-2 py-1"
aria-label="Delete {entry.term}"
>Remove</button>
</li>
{/each}
</ul>
{/if}
{:else}
<p class="text-[11px] text-text-tertiary italic">Loading profiles…</p>
{/if}
</div>
{/if}

View File

@@ -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");