471 lines
13 KiB
JavaScript
471 lines
13 KiB
JavaScript
/** @type {{ current: string, status: string, statusColor: string, activeProfile: string, recording: boolean, timerText: string, handedness: string, taskSidebarOpen: boolean }} */
|
|
export const page = $state({
|
|
current: "dictation",
|
|
status: "Ready",
|
|
statusColor: "#7ec89a",
|
|
activeProfile: "None",
|
|
recording: false,
|
|
timerText: "00:00",
|
|
handedness: "Right",
|
|
taskSidebarOpen: false,
|
|
});
|
|
|
|
// ---- Settings (persisted to localStorage) ----
|
|
|
|
const SETTINGS_KEY = "kon_settings";
|
|
|
|
const defaults = {
|
|
engine: "whisper",
|
|
modelSize: "Base",
|
|
language: "en",
|
|
device: "auto",
|
|
formatMode: "Smart",
|
|
removeFillers: true,
|
|
antiHallucination: true,
|
|
britishEnglish: true,
|
|
autoCopy: true,
|
|
includeTimestamps: true,
|
|
theme: "Dark",
|
|
fontSize: 14,
|
|
llmModelSize: "small",
|
|
llmEnabled: false,
|
|
saveAudio: false,
|
|
outputFolder: "",
|
|
globalHotkey: "Ctrl+Shift+R",
|
|
sidebarCollapsed: false,
|
|
// Empty string = let backend auto-select. Otherwise, exact device name as
|
|
// returned by `list_audio_devices`. Set via Settings → Audio → Microphone.
|
|
microphoneDevice: "",
|
|
};
|
|
|
|
function loadSettings() {
|
|
try {
|
|
const raw = localStorage.getItem(SETTINGS_KEY);
|
|
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
|
} catch {}
|
|
return { ...defaults };
|
|
}
|
|
|
|
export const settings = $state(loadSettings());
|
|
|
|
/** Save current settings to localStorage */
|
|
export function saveSettings() {
|
|
try {
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
} catch {}
|
|
}
|
|
|
|
// ---- Profiles (persisted to localStorage) ----
|
|
|
|
const PROFILES_KEY = "kon_profiles";
|
|
|
|
function loadProfiles() {
|
|
try {
|
|
const raw = localStorage.getItem(PROFILES_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch {}
|
|
return [];
|
|
}
|
|
|
|
export const profiles = $state(loadProfiles());
|
|
|
|
export function saveProfiles() {
|
|
try {
|
|
localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles));
|
|
} catch {}
|
|
}
|
|
|
|
// ---- History (dual-write: SQLite via Tauri = canonical, localStorage = read-cache) ----
|
|
//
|
|
// As of Day 4 of the upgrade plan, the canonical store is the SQLite
|
|
// transcripts table reachable via the `add_transcript`, `list_transcripts`,
|
|
// `update_transcript`, `delete_transcript`, `search_transcripts` Tauri
|
|
// commands. localStorage continues to back the in-memory `history` array
|
|
// for UI snappiness and offline browser-preview support, but the source
|
|
// of truth is SQLite. Browser preview falls back to localStorage only.
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
|
|
|
const HISTORY_KEY = "kon_history";
|
|
|
|
function loadHistory() {
|
|
try {
|
|
const raw = localStorage.getItem(HISTORY_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch {}
|
|
return [];
|
|
}
|
|
|
|
export const history = $state(loadHistory());
|
|
|
|
export function saveHistory() {
|
|
try {
|
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Add a transcript to history. Dual-writes to SQLite (canonical) and
|
|
* localStorage (cache). The SQLite write is best-effort: if it fails,
|
|
* we still update the in-memory + localStorage copy so the UI stays
|
|
* responsive. The next session boot reconciles by reading SQLite first.
|
|
*
|
|
* `entry` shape:
|
|
* { id, text, source?, title?, audioPath?, duration?, engine?, modelId?,
|
|
* inferenceMs?, sampleRate?, audioChannels?, formatMode?,
|
|
* removeFillers?, britishEnglish?, antiHallucination?, ...uiOnlyFields }
|
|
*/
|
|
export async function addToHistory(entry) {
|
|
history.unshift(entry);
|
|
if (history.length > 100) history.length = 100;
|
|
saveHistory();
|
|
|
|
if (!hasTauriRuntime()) return; // Browser preview: localStorage only.
|
|
|
|
try {
|
|
await invoke("add_transcript", {
|
|
transcript: {
|
|
id: String(entry.id),
|
|
text: entry.text ?? "",
|
|
source: entry.source ?? "microphone",
|
|
title: entry.title ?? null,
|
|
audioPath: entry.audioPath ?? null,
|
|
duration: Number(entry.duration ?? 0),
|
|
engine: entry.engine ?? null,
|
|
modelId: entry.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 dual-write failed, kept in localStorage", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update text and/or title of an existing transcript. Persists to SQLite
|
|
* via update_transcript; updates the in-memory + localStorage cache.
|
|
* Closes the historic "rename never persists" bug from
|
|
* architecture-review.md §13.
|
|
*/
|
|
export async function renameHistoryEntry(id, updates) {
|
|
const idx = history.findIndex(h => String(h.id) === String(id));
|
|
if (idx >= 0) {
|
|
if (updates.title !== undefined) history[idx].title = updates.title;
|
|
if (updates.text !== undefined) history[idx].text = updates.text;
|
|
saveHistory();
|
|
}
|
|
|
|
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 function deleteFromHistory(index) {
|
|
const entry = history[index];
|
|
history.splice(index, 1);
|
|
saveHistory();
|
|
|
|
if (!hasTauriRuntime() || !entry?.id) return;
|
|
|
|
invoke("delete_transcript", { id: String(entry.id) })
|
|
.catch(err => console.warn("deleteFromHistory: SQLite delete failed", err));
|
|
}
|
|
|
|
// ---- Tasks (persisted to localStorage) ----
|
|
|
|
const TASKS_KEY = "kon_tasks";
|
|
|
|
function loadTasks() {
|
|
try {
|
|
const raw = localStorage.getItem(TASKS_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch {}
|
|
return [];
|
|
}
|
|
|
|
export const tasks = $state(loadTasks());
|
|
|
|
// On boot in the desktop runtime, load canonical tasks from SQLite.
|
|
// localStorage remains the warm cache and fallback.
|
|
if (typeof window !== "undefined" && hasTauriRuntime()) {
|
|
invoke("list_tasks_cmd")
|
|
.then((sqliteTasks) => {
|
|
tasks.length = 0;
|
|
tasks.push(...sqliteTasks);
|
|
saveTasks();
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
export function saveTasks() {
|
|
try {
|
|
localStorage.setItem(TASKS_KEY, JSON.stringify(tasks));
|
|
} catch {}
|
|
}
|
|
|
|
export function addTask(task) {
|
|
const newTask = {
|
|
id: crypto.randomUUID(),
|
|
text: task.text,
|
|
bucket: task.bucket || "inbox",
|
|
listId: task.listId || null,
|
|
context: task.context || "",
|
|
effort: task.effort || "",
|
|
done: false,
|
|
doneAt: null,
|
|
createdAt: new Date().toISOString(),
|
|
sourceTranscriptId: task.sourceTranscriptId || null,
|
|
parentTaskId: task.parentTaskId || null,
|
|
notes: "",
|
|
};
|
|
tasks.unshift(newTask);
|
|
saveTasks();
|
|
broadcastTasks();
|
|
if (hasTauriRuntime()) {
|
|
invoke("create_task_cmd", {
|
|
request: {
|
|
id: newTask.id,
|
|
text: newTask.text,
|
|
bucket: newTask.bucket,
|
|
sourceTranscriptId: newTask.sourceTranscriptId,
|
|
},
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export function updateTask(id, updates) {
|
|
const idx = tasks.findIndex((t) => t.id === id);
|
|
if (idx >= 0) {
|
|
Object.assign(tasks[idx], updates);
|
|
saveTasks();
|
|
broadcastTasks();
|
|
}
|
|
}
|
|
|
|
export function deleteTask(id) {
|
|
const idx = tasks.findIndex((t) => t.id === id);
|
|
if (idx >= 0) {
|
|
tasks.splice(idx, 1);
|
|
saveTasks();
|
|
broadcastTasks();
|
|
if (hasTauriRuntime()) {
|
|
invoke("delete_task_cmd", { id }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function completeTask(id) {
|
|
updateTask(id, { done: true, doneAt: new Date().toISOString() });
|
|
if (hasTauriRuntime()) {
|
|
invoke("complete_task_cmd", { id }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export function uncompleteTask(id) {
|
|
updateTask(id, { done: false, doneAt: null });
|
|
if (hasTauriRuntime()) {
|
|
invoke("uncomplete_task_cmd", { id }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ---- BroadcastChannel for multi-window task sync ----
|
|
|
|
const taskChannel = typeof BroadcastChannel !== "undefined"
|
|
? new BroadcastChannel("kon_tasks")
|
|
: null;
|
|
|
|
if (taskChannel) {
|
|
taskChannel.onmessage = (event) => {
|
|
if (event.data.type === "tasks_updated") {
|
|
tasks.length = 0;
|
|
tasks.push(...event.data.tasks);
|
|
}
|
|
};
|
|
}
|
|
|
|
function broadcastTasks() {
|
|
if (taskChannel) {
|
|
taskChannel.postMessage({ type: "tasks_updated", tasks: $state.snapshot(tasks) });
|
|
}
|
|
}
|
|
|
|
// ---- Task Lists (persisted to localStorage) ----
|
|
|
|
const TASK_LISTS_KEY = "kon_task_lists";
|
|
|
|
function loadTaskLists() {
|
|
try {
|
|
const raw = localStorage.getItem(TASK_LISTS_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch {}
|
|
return [
|
|
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
|
|
{ id: "inbox", name: "Inbox", builtIn: true, createdAt: null },
|
|
];
|
|
}
|
|
|
|
export const taskLists = $state(loadTaskLists());
|
|
|
|
export function saveTaskLists() {
|
|
try {
|
|
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
|
|
} catch {}
|
|
broadcastTaskLists();
|
|
}
|
|
|
|
export function addTaskList(name, profileId = null) {
|
|
taskLists.push({
|
|
id: crypto.randomUUID(),
|
|
name,
|
|
builtIn: false,
|
|
profileId,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
saveTaskLists();
|
|
}
|
|
|
|
export function addProfileTaskList(profileName) {
|
|
const exists = taskLists.find((l) => l.profileId === profileName);
|
|
if (exists) return exists.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) {
|
|
const idx = taskLists.findIndex((l) => l.profileId === profileName);
|
|
if (idx >= 0) {
|
|
const listId = taskLists[idx].id;
|
|
for (const t of tasks) {
|
|
if (t.listId === listId) t.listId = null;
|
|
}
|
|
saveTasks();
|
|
broadcastTasks();
|
|
taskLists.splice(idx, 1);
|
|
saveTaskLists();
|
|
}
|
|
}
|
|
|
|
export function moveTaskToList(taskId, listId) {
|
|
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
|
|
}
|
|
|
|
export function moveTaskListToProfile(listId, profileId) {
|
|
const list = taskLists.find((l) => l.id === listId);
|
|
if (list && !list.builtIn) {
|
|
list.profileId = profileId || null;
|
|
if (profileId && !list.name) list.name = profileId;
|
|
saveTaskLists();
|
|
}
|
|
}
|
|
|
|
export function renameTaskList(id, name) {
|
|
const list = taskLists.find((l) => l.id === id);
|
|
if (list && !list.builtIn) {
|
|
list.name = name;
|
|
saveTaskLists();
|
|
}
|
|
}
|
|
|
|
export function deleteTaskList(id) {
|
|
const idx = taskLists.findIndex((l) => l.id === id);
|
|
if (idx >= 0 && !taskLists[idx].builtIn) {
|
|
for (const t of tasks) {
|
|
if (t.listId === id) t.listId = null;
|
|
}
|
|
saveTasks();
|
|
broadcastTasks();
|
|
taskLists.splice(idx, 1);
|
|
saveTaskLists();
|
|
}
|
|
}
|
|
|
|
export function moveTaskList(id, direction) {
|
|
const idx = taskLists.findIndex((l) => l.id === id);
|
|
const targetIdx = idx + direction;
|
|
if (targetIdx < 0 || targetIdx >= taskLists.length) return;
|
|
if (taskLists[targetIdx].builtIn) return;
|
|
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
|
|
saveTaskLists();
|
|
}
|
|
|
|
export function sortTaskLists(mode) {
|
|
const builtIn = taskLists.filter((l) => l.builtIn);
|
|
const custom = taskLists.filter((l) => !l.builtIn);
|
|
if (mode === "alpha") {
|
|
custom.sort((a, b) => a.name.localeCompare(b.name));
|
|
} else if (mode === "date") {
|
|
custom.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
}
|
|
taskLists.length = 0;
|
|
taskLists.push(...builtIn, ...custom);
|
|
saveTaskLists();
|
|
}
|
|
|
|
// BroadcastChannel for task lists
|
|
const taskListChannel = typeof BroadcastChannel !== "undefined"
|
|
? new BroadcastChannel("kon_task_lists")
|
|
: null;
|
|
|
|
if (taskListChannel) {
|
|
taskListChannel.onmessage = (event) => {
|
|
if (event.data.type === "task_lists_updated") {
|
|
taskLists.length = 0;
|
|
taskLists.push(...event.data.lists);
|
|
}
|
|
};
|
|
}
|
|
|
|
function broadcastTaskLists() {
|
|
if (taskListChannel) {
|
|
taskListChannel.postMessage({ type: "task_lists_updated", lists: $state.snapshot(taskLists) });
|
|
}
|
|
}
|
|
|
|
// ---- Templates (persisted to localStorage) ----
|
|
|
|
const TEMPLATES_KEY = "kon_templates";
|
|
|
|
function loadTemplates() {
|
|
try {
|
|
const raw = localStorage.getItem(TEMPLATES_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch {}
|
|
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"] },
|
|
];
|
|
}
|
|
|
|
export const templates = $state(loadTemplates());
|
|
|
|
export function saveTemplates() {
|
|
try {
|
|
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
|
} catch {}
|
|
}
|