feat(kon): scaffold hybrid modular workspace

- Cargo workspace with 6 domain crates: core, audio, transcription, ai-formatting, storage, cloud-providers
- Minimal Tauri shell (lib.rs + main.rs) with plugin registration
- Svelte 5 frontend copied from Ramble v0.2
- All crates compile as empty stubs
- App identifier: uk.co.corbel.kon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:21:38 +00:00
commit 9926a42b7a
80 changed files with 13328 additions and 0 deletions

View File

@@ -0,0 +1,554 @@
<script>
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
import Card from "$lib/components/Card.svelte";
import Toggle from "$lib/components/Toggle.svelte";
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
import { open } from "@tauri-apps/plugin-dialog";
let engineStatus = $state("Checking...");
let engineOk = $state(false);
let downloadedModels = $state([]);
let downloadingModel = $state("");
let downloadProgress = $state(0);
let unlisten = null;
// Profiles & Templates
let showProfiles = $state(false);
let showTemplates = $state(false);
let editingProfile = $state(-1);
let editingTemplate = $state(-1);
let newProfileName = $state("");
let newTemplateName = $state("");
let showNewProfile = $state(false);
let showNewTemplate = $state(false);
onMount(async () => {
try {
const loaded = await invoke("check_engine");
engineOk = loaded;
engineStatus = loaded ? "Model loaded" : "No model loaded";
} catch {
engineStatus = "Engine not ready";
}
try {
downloadedModels = await invoke("list_models");
} catch {}
unlisten = await listen("model-download-progress", (event) => {
downloadProgress = event.payload.progress;
});
});
onDestroy(() => {
if (unlisten) unlisten();
});
// Auto-save on any change
$effect(() => {
void settings.engine;
void settings.modelSize;
void settings.language;
void settings.device;
void settings.formatMode;
void settings.removeFillers;
void settings.antiHallucination;
void settings.britishEnglish;
void settings.autoCopy;
void settings.includeTimestamps;
void settings.theme;
void settings.fontSize;
void settings.saveAudio;
void settings.outputFolder;
void settings.globalHotkey;
saveSettings();
});
async function downloadModel(size) {
downloadingModel = size;
downloadProgress = 0;
try {
await invoke("download_model", { size });
downloadedModels = await invoke("list_models");
downloadingModel = "";
} catch {
downloadingModel = "";
}
}
async function loadSelectedModel() {
const size = settings.modelSize.toLowerCase();
engineStatus = "Loading...";
engineOk = false;
try {
await invoke("load_model", { size });
engineOk = true;
engineStatus = `${settings.modelSize} model loaded`;
} catch (err) {
engineOk = false;
engineStatus = typeof err === "string" ? err : "Load failed";
}
}
function isModelDownloaded(size) {
return downloadedModels.includes(size.toLowerCase());
}
const modelDescriptions = {
Tiny: "~75MB · fastest, lower accuracy",
Base: "~150MB · balanced for most use",
Small: "~500MB · noticeably more accurate",
Medium: "~1.5GB · best quality, slower",
};
// --- Profile management ---
function createProfile() {
if (!newProfileName.trim()) return;
const name = newProfileName.trim();
profiles.push({ name, words: "" });
saveProfiles();
addProfileTaskList(name);
newProfileName = "";
showNewProfile = false;
editingProfile = profiles.length - 1;
}
function deleteProfile(index) {
const name = profiles[index].name;
if (page.activeProfile === name) {
page.activeProfile = "None";
}
removeProfileTaskList(name);
profiles.splice(index, 1);
saveProfiles();
editingProfile = -1;
}
function profileWordCount(words) {
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
}
// --- Template management ---
function createTemplate() {
if (!newTemplateName.trim()) return;
templates.push({ name: newTemplateName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
saveTemplates();
newTemplateName = "";
showNewTemplate = false;
editingTemplate = templates.length - 1;
}
function deleteTemplate(index) {
templates.splice(index, 1);
saveTemplates();
editingTemplate = -1;
}
</script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
<!-- Title -->
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-5">Settings</h2>
<div class="px-7 pb-8 space-y-4">
<!-- Transcription -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-5">Transcription</h3>
<!-- Engine selector -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Engine</p>
<SegmentedButton options={["whisper", "parakeet"]} bind:value={settings.engine} />
<p class="text-[11px] text-text-tertiary mt-2">
{settings.engine === "whisper" ? "OpenAI Whisper — 99+ languages, reliable" :
"Nvidia Parakeet — faster on CPU, English-focused, auto-punctuated"}
</p>
</div>
<!-- Format mode -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Format Mode</p>
<SegmentedButton options={["Raw", "Clean", "Smart"]} bind:value={settings.formatMode} />
<p class="text-[11px] text-text-tertiary mt-2">
{settings.formatMode === "Raw" ? "Exact Whisper output, no formatting" :
settings.formatMode === "Clean" ? "Grouped into paragraphs, punctuation tidied" :
"Structured with lists, headings, and sections"}
</p>
</div>
<!-- Whisper model -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Whisper Model</p>
<SegmentedButton options={["Tiny", "Base", "Small", "Medium"]} bind:value={settings.modelSize} />
<p class="text-[11px] text-text-tertiary mt-2">{modelDescriptions[settings.modelSize]}</p>
<!-- Model status per size -->
<div class="flex items-center gap-2 mt-3">
{#if isModelDownloaded(settings.modelSize)}
<span class="inline-flex items-center gap-1.5 text-[11px] text-success">
<span class="w-[6px] h-[6px] rounded-full bg-success"></span>
Downloaded
</span>
<button
class="text-[11px] text-text-tertiary hover:text-accent"
onclick={loadSelectedModel}
>Load model</button>
{:else if downloadingModel === settings.modelSize.toLowerCase()}
<span class="text-[11px] text-warning">{downloadProgress}% downloading...</span>
{:else}
<button
class="text-[11px] text-accent hover:text-accent-hover"
onclick={() => downloadModel(settings.modelSize.toLowerCase())}
>Download {settings.modelSize}</button>
{/if}
</div>
</div>
<!-- Compute device -->
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Compute Device</p>
<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 w-[220px]"
bind:value={settings.device}
>
<option value="auto">Auto (CPU)</option>
<option value="cuda">CUDA (NVIDIA GPU)</option>
<option value="cpu">CPU</option>
</select>
<p class="text-[11px] text-text-tertiary mt-2">GPU acceleration requires building with CUDA feature</p>
</div>
<!-- Language -->
<div>
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Language</p>
<div class="flex items-center gap-3">
<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 w-[140px]"
bind:value={settings.language}
>
<option value="en">English</option>
<option value="auto">Auto-detect</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="es">Spanish</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="nl">Dutch</option>
<option value="pl">Polish</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
</select>
{#if settings.language === "en"}
<button
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-[11px] border animate-fade-in
{settings.britishEnglish
? 'bg-accent/10 border-accent/30 text-accent font-medium'
: 'bg-bg-input border-border text-text-tertiary hover:text-text-secondary'}"
onclick={() => { settings.britishEnglish = !settings.britishEnglish; }}
title={settings.britishEnglish ? "British English spelling active" : "Click to enable British English spelling"}
>
{#if settings.britishEnglish}
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
British English
</button>
{/if}
</div>
</div>
</div>
</Card>
<!-- Processing -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-4">Processing</h3>
<div class="space-y-0.5">
<Toggle
bind:checked={settings.removeFillers}
label="Remove filler words"
description="Strips um, uh, like, you know from output"
/>
<Toggle
bind:checked={settings.antiHallucination}
label="Anti-hallucination shield"
description="Detects phantom phrases Whisper generates during silence"
/>
</div>
</div>
</Card>
<!-- AI Assistant (LLM) -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-1">AI Assistant</h3>
<p class="text-[11px] text-text-tertiary mb-4">Local LLM for smart task extraction, transcript cleanup, and formatting. Runs 100% offline.</p>
<div class="bg-bg-input rounded-lg px-3 py-2.5 border border-border-subtle">
<p class="text-[12px] text-text-secondary font-medium mb-1">Coming soon</p>
<p class="text-[11px] text-text-tertiary">AI-powered cleanup and smart extraction are being rebuilt with a faster engine. Task extraction currently uses rule-based matching, which runs automatically after each recording.</p>
</div>
</div>
</Card>
<!-- Profiles & Templates -->
<Card>
<div class="p-5">
<!-- Profiles section -->
<button
class="flex items-center gap-2 w-full text-left mb-1"
onclick={() => showProfiles = !showProfiles}
>
<svg class="w-3 h-3 text-text-tertiary transition-transform {showProfiles ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5l8 7-8 7z" />
</svg>
<h3 class="text-[14px] font-semibold text-text">Profiles</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{profiles.length}
</span>
</button>
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Custom vocabulary to improve transcription accuracy</p>
{#if showProfiles}
<div class="space-y-1.5 animate-fade-in ml-5">
{#each profiles as profile, i}
<div class="group">
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
<span class="text-[12px] text-text flex-1 truncate">{profile.name}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{profileWordCount(profile.words)} words
</span>
<button
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingProfile = editingProfile === i ? -1 : i}
>{editingProfile === i ? "Close" : "Edit"}</button>
<button
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteProfile(i)}
>Delete</button>
</div>
{#if editingProfile === i}
<div class="mt-1.5 animate-fade-in">
<textarea
class="w-full h-[120px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
text-[12px] text-text leading-relaxed resize-none
focus:outline-none focus:border-accent"
placeholder="One word or phrase per line..."
bind:value={profile.words}
oninput={() => saveProfiles()}
data-no-transition
></textarea>
</div>
{/if}
</div>
{/each}
{#if showNewProfile}
<div class="flex items-center gap-2 animate-fade-in">
<input
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Profile name..."
bind:value={newProfileName}
onkeydown={(e) => e.key === "Enter" && createProfile()}
data-no-transition
/>
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createProfile}>Create</button>
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewProfile = false; newProfileName = ""; }}>Cancel</button>
</div>
{:else}
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewProfile = true}>+ Add profile</button>
{/if}
</div>
{/if}
<div class="my-4 h-px bg-border-subtle"></div>
<!-- Templates section -->
<button
class="flex items-center gap-2 w-full text-left mb-1"
onclick={() => showTemplates = !showTemplates}
>
<svg class="w-3 h-3 text-text-tertiary transition-transform {showTemplates ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5l8 7-8 7z" />
</svg>
<h3 class="text-[14px] font-semibold text-text">Templates</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{templates.length}
</span>
</button>
<p class="text-[11px] text-text-tertiary mb-3 ml-5">Structured formats for dictation sessions</p>
{#if showTemplates}
<div class="space-y-1.5 animate-fade-in ml-5">
{#each templates as template, i}
<div class="group">
<div class="flex items-center gap-2 bg-bg-input rounded-lg px-3 h-[36px]">
<span class="text-[12px] text-text flex-1 truncate">{template.name}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{template.sections.length} sections
</span>
<button
class="text-[10px] text-text-tertiary hover:text-accent opacity-0 group-hover:opacity-100"
onclick={() => editingTemplate = editingTemplate === i ? -1 : i}
>{editingTemplate === i ? "Close" : "Edit"}</button>
<button
class="text-[10px] text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100"
onclick={() => deleteTemplate(i)}
>Delete</button>
</div>
{#if editingTemplate === i}
<div class="mt-1.5 animate-fade-in">
<textarea
class="w-full h-[100px] bg-bg-elevated border border-border-subtle rounded-lg px-3 py-2
text-[12px] text-text leading-relaxed resize-none
focus:outline-none focus:border-accent"
placeholder="One section per line..."
value={template.sections.join("\n")}
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
data-no-transition
></textarea>
</div>
{/if}
</div>
{/each}
{#if showNewTemplate}
<div class="flex items-center gap-2 animate-fade-in">
<input
class="flex-1 bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Template name..."
bind:value={newTemplateName}
onkeydown={(e) => e.key === "Enter" && createTemplate()}
data-no-transition
/>
<button class="text-[11px] text-accent hover:text-accent-hover" onclick={createTemplate}>Create</button>
<button class="text-[11px] text-text-tertiary" onclick={() => { showNewTemplate = false; newTemplateName = ""; }}>Cancel</button>
</div>
{:else}
<button class="text-[12px] text-accent hover:text-accent-hover" onclick={() => showNewTemplate = true}>+ Add template</button>
{/if}
</div>
{/if}
</div>
</Card>
<!-- Output -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-4">Output</h3>
<div class="space-y-0.5">
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
<Toggle bind:checked={settings.includeTimestamps} label="Include timestamps in exports" />
<Toggle
bind:checked={settings.saveAudio}
label="Save audio recordings"
description="Saves raw audio as .wav files (~2MB per minute). Stored locally."
/>
{#if settings.saveAudio}
<div class="ml-[50px] mt-2 animate-fade-in">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-1.5">Output Folder</p>
<div class="flex items-center gap-2">
<div class="flex-1 bg-bg-input rounded-lg px-3 py-1.5 border border-border-subtle">
<p class="text-[11px] text-text-secondary truncate">
{settings.outputFolder || "Default (app data)"}
</p>
</div>
<button
class="text-[11px] text-accent hover:text-accent-hover whitespace-nowrap"
onclick={async () => {
try {
const folder = await open({ directory: true, title: "Select output folder" });
if (folder) { settings.outputFolder = folder; saveSettings(); }
} catch {}
}}
>Change</button>
{#if settings.outputFolder}
<button
class="text-[11px] text-text-tertiary hover:text-text-secondary whitespace-nowrap"
onclick={() => { settings.outputFolder = ""; saveSettings(); }}
>Reset</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
</Card>
<!-- Hotkey -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-1">Global Hotkey</h3>
<p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p>
<HotkeyRecorder />
</div>
</Card>
<!-- Appearance -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-5">Appearance</h3>
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p>
<SegmentedButton options={["Dark", "Light", "System"]} bind:value={settings.theme} />
</div>
<div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Font Size <span class="font-normal text-text-secondary ml-1">{settings.fontSize}px</span>
</p>
<input
type="range" min="10" max="24" step="1"
bind:value={settings.fontSize}
class="w-[200px] accent-accent"
data-no-transition
/>
</div>
</div>
</Card>
<!-- About -->
<Card>
<div class="p-5">
<h3 class="text-[14px] font-semibold text-text mb-4">About</h3>
<!-- Engine status -->
<div class="flex items-center gap-2 mb-4">
<span class="w-[7px] h-[7px] rounded-full {engineOk ? 'bg-success' : 'bg-warning'}"></span>
<span class="text-[12px] text-text-secondary">{engineStatus}</span>
</div>
<div class="space-y-1.5">
{#each [
"100% offline — all processing on your machine",
"No Python required — compiled Whisper engine",
"No cloud — audio never leaves your computer",
"No accounts — no sign-up, no tracking",
"No telemetry — zero data collection",
] as item}
<div class="flex items-start gap-2">
<svg class="w-3.5 h-3.5 text-success mt-0.5 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17Z" />
</svg>
<p class="text-[11px] text-text-secondary">{item}</p>
</div>
{/each}
</div>
<p class="text-[11px] text-text-tertiary mt-5">Ramble v0.2 · Powered by whisper.cpp · Built by CORBEL Ltd</p>
</div>
</Card>
</div>
</div>