ui+app: Day 5+6 — Settings → Vocabulary panel + Wayland self-relaunch

VOCABULARY PANEL (Day 5):
src/lib/pages/SettingsPage.svelte gains a new collapsible "Vocabulary"
section between Audio and Transcription. Wires the dictionary commands
shipped in 1cce567:
- list_dictionary_command on mount + onfocus refresh
- add_dictionary_entry_command (term + optional note)
- delete_dictionary_entry_command
- Inline error feedback via vocabularyError
- Empty-state copy + per-entry remove button with aria-label

The dictionary terms are intended to be injected into the LLM cleanup
prompt so the model preserves user-specific vocabulary (medication
names, place names, jargon, names of people in the user's support
network — particularly important for the ND audience). The LLM client
itself is currently a stub (crates/ai-formatting/src/llm_client.rs);
when it lands, it can import list_dictionary from kon_storage and
inject terms into the prompt suffix.

WAYLAND SELF-RELAUNCH (Day 6):
src-tauri/src/lib.rs gains ensure_x11_on_wayland() which runs before
tauri::Builder. On Linux Wayland sessions (XDG_SESSION_TYPE=wayland) it
sets GDK_BACKEND=x11, WINIT_UNIX_BACKEND=x11, and
WEBKIT_DISABLE_DMABUF_RENDERER=1 — the env vars HANDOVER.md documents
the user manually prefixing.

Drops the "users have to remember the env-var prefix" friction.
Idempotent: existing values are respected. Inspired by Open-Whispr's
Chromium --ozone-platform=x11 self-relaunch.

Compile-checked: cargo check -p kon-storage clean. Settings.svelte
$state / #if balanced (28 state, 34/34 if/endif).

NEXT: Phase 6 — dogfooding readiness doc with Jake test instructions.
This commit is contained in:
2026-04-17 13:16:07 +01:00
parent 0e22ec591d
commit 9f3be5c007
2 changed files with 162 additions and 0 deletions

View File

@@ -68,8 +68,53 @@ async fn save_preferences(
.map_err(|e| e.to_string())
}
/// On Linux Wayland sessions (KDE, GNOME) WebKitGTK + DMABUF rendering hits
/// known crashes that the HANDOVER documents working around with a manual
/// env-var prefix:
///
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
///
/// Detect the Wayland session at startup and apply the env vars before
/// anything else loads, so users do not need to remember the prefix and
/// the dev launcher / packaged app both work out of the box.
///
/// Inspired by Open-Whispr's similar Chromium self-relaunch (with
/// --ozone-platform=x11). Day 6 of the upgrade plan.
#[cfg(target_os = "linux")]
fn ensure_x11_on_wayland() {
// If the user explicitly opted in to Wayland (XDG_SESSION_TYPE set by
// their session, KDE / GNOME compositor flags), force WebKitGTK + GDK
// onto X11 via XWayland. Idempotent: if a value is already set, keep
// it (the user knows best).
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
let is_wayland = session_type.eq_ignore_ascii_case("wayland");
if !is_wayland {
return;
}
let set_if_unset = |key: &str, value: &str| {
if std::env::var_os(key).is_none() {
// SAFETY: setting env vars before any threads spawn (we are
// pre-Tauri-Builder here). This block is the only place these
// are written.
unsafe { std::env::set_var(key, value); }
eprintln!("[startup] Wayland workaround: {key}={value}");
}
};
set_if_unset("GDK_BACKEND", "x11");
set_if_unset("WINIT_UNIX_BACKEND", "x11");
set_if_unset("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
// PipeWire screen-capture portal still works fine through XWayland;
// we only redirect the GUI rendering path.
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
ensure_x11_on_wayland();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())

View File

@@ -43,6 +43,49 @@
audioDevices = [];
}
}
// --- 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).
let vocabulary = $state([]);
let vocabularyError = $state(null);
let newVocabTerm = $state("");
let newVocabNote = $state("");
async function refreshVocabulary() {
vocabularyError = null;
try {
vocabulary = await invoke("list_dictionary_command");
} catch (err) {
vocabularyError = "Could not load vocabulary: " + (err?.message || err);
}
}
async function addVocabTerm() {
const term = newVocabTerm.trim();
if (!term) return;
try {
await invoke("add_dictionary_entry_command", {
term,
note: newVocabNote.trim() || null,
});
newVocabTerm = "";
newVocabNote = "";
await refreshVocabulary();
} catch (err) {
vocabularyError = "Could not save term: " + (err?.message || err);
}
}
async function deleteVocabTerm(id) {
try {
await invoke("delete_dictionary_entry_command", { id });
await refreshVocabulary();
} catch (err) {
vocabularyError = "Could not delete term: " + (err?.message || err);
}
}
let parakeetDownloaded = $state(false);
let parakeetDownloading = $state(false);
let parakeetProgress = $state(0);
@@ -131,6 +174,10 @@
// 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();
try {
downloadedModels = await invoke("list_models");
} catch {}
@@ -380,6 +427,76 @@
{/if}
</div>
<!-- Vocabulary (custom dictionary injected into LLM cleanup prompt) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'vocabulary' ? null : 'vocabulary'}
>
<h3 class="font-display text-[18px] italic text-text">Vocabulary</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'vocabulary' ? '' : '+'}</span>
</button>
{#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.
</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}
</div>
{/if}
</div>
<!-- Transcription -->
<div class="border-b border-border-subtle">
<button