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