Files
Lumotia/src/lib/stores/page.svelte.ts
Jake ce2b4fdac6
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
feat(ai B.1 #15 + A.1 #28): cleanup presets and sequential-GPU guard
Two new Settings → AI knobs that compose cleanly with what already
shipped (aiTier, LLM model, translator prompt framing).

**B.1 #15 — Named cleanup presets.** LlmPromptPreset enum
(Default / Email / Notes / Code) appends a short context hint onto
the CLEANUP_PROMPT just before generation. Presets shape tone and
structure ("email paragraph", "bulleted meeting notes", "preserve
technical terms") without licensing the content-editing the
translator-not-editor framing forbids. cleanup_transcript_text_cmd
now takes `preset: Option<String>` which runs through the new
LlmPromptPreset::parse (normalises aliases like "meeting-notes",
collapses unknown values to Default).

**A.1 #28 — Sequential-GPU guard.** New LocalEngine::unload drops
the backend + model_id so a subsequent load actually reclaims VRAM.
load_llm_model, load_model, and load_parakeet_model Tauri commands
grow an optional `concurrent: bool` argument. When concurrent is
Some(false), loading LLM first unloads whisper+parakeet, and vice
versa — prevents VRAM OOM on tight-VRAM setups. Default is the
previous parallel behaviour so nothing changes for multi-GB cards.
Transcribe-in-progress paths (transcribe_pcm, transcribe_file, live)
pass None, so mid-dictation model loads don't accidentally tear
down the LLM.

Settings UI (AI section):
- Cleanup preset segmented button + descriptive copy for each option.
- GPU concurrency segmented button with explicit trade-off text
  ("faster transitions vs fits in tight VRAM").

Frontend wiring:
- settings.llmPromptPreset flows from DictationPage's
  cleanupTranscriptIfEnabled into the Tauri command.
- settings.aiGpuConcurrency flows from both DictationPage (auto-load
  on record) and SettingsPage (manual load/unload buttons) as
  `concurrent: "parallel" === true` to the load commands.

Tests: three new preset cases in crates/ai-formatting/src/llm_client.rs
(parse aliases, suffix non-empty for non-default, default suffix
empty). All 139 existing lib tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:12:48 +01:00

633 lines
18 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import type {
PageState,
Profile,
Segment,
SettingsState,
TaskDraft,
TaskDto,
TaskEntry,
TaskList,
TaskUpdate,
Template,
TranscriptDto,
TranscriptEntry,
TranscriptMetaPatch,
TranscriptWriteEntry,
} from "$lib/types/app";
import { toasts } from "$lib/stores/toasts.svelte.ts";
import { errorMessage } from "$lib/utils/errors.js";
import { parseStoredJson } from "$lib/utils/storage.js";
import {
loadSettingsWithMigration,
saveSettingsWithVersion,
} from "$lib/utils/settingsMigrations.js";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
export const page = $state<PageState>({
current: "dictation",
status: "Ready",
statusColor: "#7ec89a",
activeProfile: "None",
recording: false,
timerText: "00:00",
handedness: "Right",
taskSidebarOpen: false,
});
const SETTINGS_KEY = "kon_settings";
const PROFILES_KEY = "kon_profiles";
const TASK_LISTS_KEY = "kon_task_lists";
const TEMPLATES_KEY = "kon_templates";
const defaults: SettingsState = {
engine: "whisper",
modelSize: "Base",
language: "en",
device: "auto",
formatMode: "Smart",
removeFillers: true,
antiHallucination: true,
britishEnglish: true,
autoCopy: true,
autoPaste: false,
transcriptionPreview: false,
meetingAutoCapture: false,
meetingAutoCaptureApps: ["zoom", "teams"],
soundCues: false,
soundCueVolume: 0.15,
includeTimestamps: true,
theme: "Dark",
fontSize: 14,
aiTier: "cleanup",
llmModelId: null,
llmPromptPreset: "default",
aiGpuConcurrency: "parallel",
saveAudio: false,
outputFolder: "",
globalHotkey: "Ctrl+Shift+R",
sidebarCollapsed: false,
microphoneDevice: "",
};
function canUseStorage(): boolean {
return typeof localStorage !== "undefined";
}
function loadSettings(): SettingsState {
if (!canUseStorage()) return { ...defaults };
// Versioned migration: reads the envelope or an unversioned legacy
// blob, runs the migration chain (currently identity: bare object ->
// v1), and returns the current-shape data. Falls back to defaults
// on corruption — the toast below surfaces the reset to the user.
const migrated = loadSettingsWithMigration<Partial<SettingsState>>(SETTINGS_KEY);
if (migrated === null && localStorage.getItem(SETTINGS_KEY) !== null) {
// There was data but it failed to migrate — tell the user we
// reset rather than silently mangling their preferences.
queueMicrotask(() => {
toasts.warn(
"Settings reset",
"Your saved settings couldn't be read, so Kon fell back to defaults. "
+ "Earlier Kon builds can still read your old data.",
);
});
}
return {
...defaults,
...(migrated ?? {}),
};
}
export const settings = $state<SettingsState>(loadSettings());
export function saveSettings() {
if (!canUseStorage()) return;
// Versioned envelope {version, data}. Older Kon builds that used
// the bare-object layout can still spread the `data` key over
// their defaults — they will just silently drop fields they don't
// know about, which is the same behaviour as today.
saveSettingsWithVersion(SETTINGS_KEY, { ...settings });
}
function loadProfiles(): Profile[] {
if (!canUseStorage()) return [];
return parseStoredJson<Profile[]>(localStorage.getItem(PROFILES_KEY)) ?? [];
}
export const profiles = $state<Profile[]>(loadProfiles());
export function saveProfiles() {
if (!canUseStorage()) return;
try {
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
} catch {}
}
export const history = $state<TranscriptEntry[]>([]);
function mapTranscriptRow(row: TranscriptDto): TranscriptEntry {
const text = row.text ?? "";
const rawTags = row.manualTags ?? "";
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
const rawSegments = row.segmentsJson ?? "";
let segments: Segment[] = [];
if (rawSegments) {
try {
const parsed = JSON.parse(rawSegments);
if (Array.isArray(parsed)) segments = parsed as Segment[];
} catch (err) {
console.warn("mapTranscriptRow: segmentsJson parse failed", err);
}
}
return {
id: row.id,
text,
source: row.source ?? "",
profileId: row.profileId ?? "00000000-0000-0000-0000-000000000001",
title: row.title ?? "",
audioPath: row.audioPath ?? null,
duration: Number(row.duration ?? 0),
engine: row.engine ?? null,
modelId: row.modelId ?? null,
createdAt: row.createdAt ?? null,
date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "",
preview: text.slice(0, 120),
starred: !!row.starred,
manualTags,
template: row.template ?? "",
language: row.language ?? "",
segments,
};
}
function normaliseTranscriptEntry(entry: TranscriptWriteEntry): TranscriptEntry {
const createdAt = entry.createdAt ?? new Date().toISOString();
return {
id: String(entry.id),
text: entry.text ?? "",
source: entry.source ?? "microphone",
profileId: entry.profileId ?? "00000000-0000-0000-0000-000000000001",
title: entry.title ?? "",
audioPath: entry.audioPath ?? null,
duration: Number(entry.duration ?? 0),
engine: entry.engine ?? null,
modelId: entry.modelId ?? null,
createdAt,
date: entry.date ?? new Date(createdAt).toLocaleString("en-GB"),
preview: entry.preview ?? entry.text.slice(0, 120),
starred: !!entry.starred,
manualTags: Array.isArray(entry.manualTags) ? entry.manualTags : [],
template: entry.template ?? "",
language: entry.language ?? "",
segments: Array.isArray(entry.segments) ? entry.segments : [],
};
}
async function loadHistory() {
if (!hasTauriRuntime()) {
history.length = 0;
return;
}
try {
const rows = await invoke<TranscriptDto[]>("list_transcripts", { limit: 500, offset: 0 });
history.splice(0, history.length, ...(Array.isArray(rows) ? rows.map(mapTranscriptRow) : []));
} catch (err) {
console.error("loadHistory failed", err);
history.length = 0;
toasts.error("Failed to load history", errorMessage(err));
}
}
if (typeof window !== "undefined" && hasTauriRuntime()) {
loadHistory().catch(() => {});
}
export function saveHistory() {}
export async function addToHistory(entry: TranscriptWriteEntry) {
const normalised = normaliseTranscriptEntry(entry);
history.unshift(normalised);
if (history.length > 500) history.length = 500;
if (!hasTauriRuntime()) return;
try {
await invoke("add_transcript", {
transcript: {
id: normalised.id,
text: normalised.text,
source: normalised.source,
profileId: normalised.profileId,
title: normalised.title || null,
audioPath: normalised.audioPath ?? null,
duration: normalised.duration,
engine: normalised.engine ?? null,
modelId: normalised.modelId ?? null,
inferenceMs: entry.inferenceMs ?? null,
sampleRate: entry.sampleRate ?? null,
audioChannels: entry.audioChannels ?? null,
formatMode: entry.formatMode ?? null,
removeFillers: !!entry.removeFillers,
britishEnglish: !!entry.britishEnglish,
antiHallucination: !!entry.antiHallucination,
},
});
} catch (err) {
console.warn("addToHistory: SQLite write failed, entry will not persist past restart", err);
}
}
export async function renameHistoryEntry(
id: string,
updates: { title?: string; text?: string },
) {
const idx = history.findIndex((entry) => String(entry.id) === String(id));
if (idx >= 0) {
if (updates.title !== undefined) history[idx].title = updates.title;
if (updates.text !== undefined) history[idx].text = updates.text;
}
if (!hasTauriRuntime()) return;
try {
await invoke("update_transcript", {
id: String(id),
text: updates.text ?? null,
title: updates.title ?? null,
});
} catch (err) {
console.warn("renameHistoryEntry: SQLite update failed", err);
throw err;
}
}
export async function saveTranscriptMeta(id: string, patch: TranscriptMetaPatch) {
if (!hasTauriRuntime()) return;
try {
const payload = {
starred: patch.starred,
manualTags: patch.manualTags == null
? undefined
: (Array.isArray(patch.manualTags)
? patch.manualTags.join(",")
: String(patch.manualTags)),
template: patch.template,
language: patch.language,
segmentsJson: patch.segments === undefined ? undefined : JSON.stringify(patch.segments),
};
const row = await invoke<TranscriptDto>("update_transcript_meta_cmd", {
id: String(id),
patch: payload,
});
const idx = history.findIndex((entry) => String(entry.id) === String(id));
if (idx !== -1) history[idx] = mapTranscriptRow(row);
} catch (err) {
toasts.error("Failed to save transcript metadata", errorMessage(err));
}
}
export function deleteFromHistory(index: number) {
const entry = history[index];
history.splice(index, 1);
if (!hasTauriRuntime() || !entry?.id) return;
invoke("delete_transcript", { id: String(entry.id) })
.catch((err) => console.warn("deleteFromHistory: SQLite delete failed", err));
}
export const tasks = $state<TaskEntry[]>([]);
function mapTaskRow(row: TaskDto): TaskEntry {
return {
id: row.id,
text: row.text ?? "",
bucket: (row.bucket ?? "inbox") as TaskEntry["bucket"],
listId: row.listId ?? null,
effort: row.effort ?? "",
notes: row.notes ?? "",
done: !!row.done,
doneAt: row.doneAt ?? null,
createdAt: row.createdAt ?? new Date().toISOString(),
sourceTranscriptId: row.sourceTranscriptId ?? null,
parentTaskId: row.parentTaskId ?? null,
};
}
async function loadTasks() {
if (!hasTauriRuntime()) {
tasks.length = 0;
return;
}
try {
const rows = await invoke<TaskDto[]>("list_tasks_cmd");
tasks.splice(0, tasks.length, ...(Array.isArray(rows) ? rows.map(mapTaskRow) : []));
} catch (err) {
console.error("loadTasks failed", err);
tasks.length = 0;
toasts.error("Failed to load tasks", errorMessage(err));
}
}
if (typeof window !== "undefined" && hasTauriRuntime()) {
loadTasks().catch(() => {});
}
export function saveTasks() {}
export async function addTask(task: TaskDraft) {
if (!hasTauriRuntime()) return;
const id = crypto.randomUUID();
try {
const row = await invoke<TaskDto>("create_task_cmd", {
request: {
id,
text: task.text,
bucket: task.bucket || "inbox",
sourceTranscriptId: task.sourceTranscriptId || null,
listId: task.listId ?? null,
effort: task.effort ?? null,
},
});
tasks.unshift(mapTaskRow(row));
broadcastTasks();
} catch (err) {
toasts.error("Couldn't add task", errorMessage(err));
}
}
function applyLocalTaskUpdate(id: string, updates: Partial<TaskEntry>) {
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
Object.assign(tasks[idx], updates);
broadcastTasks();
}
}
export async function updateTask(id: string, updates: TaskUpdate) {
if (!hasTauriRuntime()) {
applyLocalTaskUpdate(id, updates as Partial<TaskEntry>);
return;
}
try {
const row = await invoke<TaskDto>("update_task_cmd", {
id,
patch: {
text: updates.text ?? null,
bucket: updates.bucket ?? null,
listId: updates.listId ?? null,
effort: updates.effort ?? null,
notes: updates.notes ?? null,
},
});
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't update task", errorMessage(err));
}
}
export async function deleteTask(id: string) {
if (!hasTauriRuntime()) return;
try {
await invoke("delete_task_cmd", { id });
const idx = tasks.findIndex((task) => task.id === id);
if (idx >= 0) {
tasks.splice(idx, 1);
broadcastTasks();
}
} catch (err) {
toasts.error("Couldn't delete task", errorMessage(err));
}
}
export async function completeTask(id: string) {
if (!hasTauriRuntime()) return;
try {
await invoke("complete_task_cmd", { id });
applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() });
} catch (err) {
toasts.error("Couldn't complete task", errorMessage(err));
}
}
export async function uncompleteTask(id: string) {
if (!hasTauriRuntime()) return;
try {
await invoke("uncomplete_task_cmd", { id });
applyLocalTaskUpdate(id, { done: false, doneAt: null });
} catch (err) {
toasts.error("Couldn't uncomplete task", errorMessage(err));
}
}
interface TaskChannelMessage {
type: "tasks_updated";
tasks: TaskEntry[];
}
const taskChannel = typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("kon_task_sync")
: null;
if (taskChannel) {
taskChannel.onmessage = (event: MessageEvent<TaskChannelMessage>) => {
if (event.data.type === "tasks_updated") {
tasks.length = 0;
tasks.push(...event.data.tasks);
}
};
}
function broadcastTasks() {
if (!taskChannel) return;
taskChannel.postMessage({
type: "tasks_updated",
tasks: $state.snapshot(tasks),
} satisfies TaskChannelMessage);
}
function defaultTaskLists(): TaskList[] {
return [
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
{ id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
];
}
function loadTaskLists(): TaskList[] {
if (!canUseStorage()) return defaultTaskLists();
return parseStoredJson<TaskList[]>(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists();
}
export const taskLists = $state<TaskList[]>(loadTaskLists());
export function saveTaskLists() {
if (canUseStorage()) {
try {
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
} catch {}
}
broadcastTaskLists();
}
export function addTaskList(name: string, profileId: string | null = null) {
taskLists.push({
id: crypto.randomUUID(),
name,
builtIn: false,
profileId,
createdAt: new Date().toISOString(),
});
saveTaskLists();
}
export function addProfileTaskList(profileName: string) {
const existing = taskLists.find((list) => list.profileId === profileName);
if (existing) return existing.id;
const id = crypto.randomUUID();
taskLists.push({
id,
name: profileName,
builtIn: false,
profileId: profileName,
createdAt: new Date().toISOString(),
});
saveTaskLists();
return id;
}
export function removeProfileTaskList(profileName: string) {
const idx = taskLists.findIndex((list) => list.profileId === profileName);
if (idx < 0) return;
const listId = taskLists[idx].id;
for (const task of tasks) {
if (task.listId === listId) task.listId = null;
}
saveTasks();
broadcastTasks();
taskLists.splice(idx, 1);
saveTaskLists();
}
export function moveTaskToList(taskId: string, listId: string) {
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
}
export function moveTaskListToProfile(listId: string, profileId: string | null) {
const list = taskLists.find((entry) => entry.id === listId);
if (list && !list.builtIn) {
list.profileId = profileId || null;
if (profileId && !list.name) list.name = profileId;
saveTaskLists();
}
}
export function renameTaskList(id: string, name: string) {
const list = taskLists.find((entry) => entry.id === id);
if (list && !list.builtIn) {
list.name = name;
saveTaskLists();
}
}
export function deleteTaskList(id: string) {
const idx = taskLists.findIndex((list) => list.id === id);
if (idx < 0 || taskLists[idx].builtIn) return;
for (const task of tasks) {
if (task.listId === id) task.listId = null;
}
saveTasks();
broadcastTasks();
taskLists.splice(idx, 1);
saveTaskLists();
}
export function moveTaskList(id: string, direction: number) {
const idx = taskLists.findIndex((list) => list.id === id);
const targetIdx = idx + direction;
if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return;
if (taskLists[targetIdx].builtIn) return;
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
saveTaskLists();
}
export function sortTaskLists(mode: "alpha" | "date") {
const builtIn = taskLists.filter((list) => list.builtIn);
const custom = taskLists.filter((list) => !list.builtIn);
if (mode === "alpha") {
custom.sort((a, b) => a.name.localeCompare(b.name));
} else {
custom.sort(
(a, b) => new Date(a.createdAt ?? 0).getTime() - new Date(b.createdAt ?? 0).getTime(),
);
}
taskLists.length = 0;
taskLists.push(...builtIn, ...custom);
saveTaskLists();
}
interface TaskListChannelMessage {
type: "task_lists_updated";
lists: TaskList[];
}
const taskListChannel = typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("kon_task_lists")
: null;
if (taskListChannel) {
taskListChannel.onmessage = (event: MessageEvent<TaskListChannelMessage>) => {
if (event.data.type === "task_lists_updated") {
taskLists.length = 0;
taskLists.push(...event.data.lists);
}
};
}
function broadcastTaskLists() {
if (!taskListChannel) return;
taskListChannel.postMessage({
type: "task_lists_updated",
lists: $state.snapshot(taskLists),
} satisfies TaskListChannelMessage);
}
function defaultTemplates(): Template[] {
return [
{ name: "Meeting Notes", sections: ["Attendees", "Agenda", "Discussion", "Action Items", "Next Steps"] },
{ name: "Report", sections: ["Summary", "Background", "Findings", "Recommendations"] },
{ name: "Interview", sections: ["Candidate", "Role", "Questions & Answers", "Assessment", "Next Steps"] },
];
}
function loadTemplates(): Template[] {
if (!canUseStorage()) return defaultTemplates();
return parseStoredJson<Template[]>(localStorage.getItem(TEMPLATES_KEY)) ?? defaultTemplates();
}
export const templates = $state<Template[]>(loadTemplates());
export function saveTemplates() {
if (!canUseStorage()) return;
try {
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
} catch {}
}