Compare commits
19 Commits
b2d584f999
...
8c9c9390d8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c9c9390d8 | |||
| f54d55d110 | |||
| 674fc2c4b8 | |||
| 442fa6656e | |||
| 0bce8e6ec4 | |||
| 8d3d302b17 | |||
| 8640b255e9 | |||
| b1b3c689d6 | |||
| 6f264d8bec | |||
| 35efed53e5 | |||
| fa20cb313a | |||
| b479a368e7 | |||
| d959a82a4b | |||
| cc0dc1b57c | |||
| 82c2631f28 | |||
| ac46949b01 | |||
| e436a69839 | |||
| 61c96d7805 | |||
| 0004433f2d |
71
HANDOVER.md
Normal file
71
HANDOVER.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: handover-2026-04-18
|
||||
type: reference
|
||||
tags: [handover, session, kon]
|
||||
description: Session handover — 2026/04/18 dogfooding sprint
|
||||
---
|
||||
|
||||
# Kon Handover — 2026/04/18
|
||||
|
||||
## Current state
|
||||
|
||||
Phase 1 brand migration and Phase 2 polish are both **complete and committed**. Today was the first dogfood attempt — Vulkan GPU build is in progress but not yet confirmed working. Three bugs were caught and fixed during the first launch attempt.
|
||||
|
||||
## What's working
|
||||
|
||||
- **18/18 automated validation checks pass** (Playwright, `python3 /tmp/kon_validation.py`)
|
||||
- **Pre-warm fixed** — `tauri::async_runtime::spawn` instead of `tokio::spawn`; model loads in background before first dictation
|
||||
- **Preferences infinite loop fixed** — `Object.assign` mutation instead of object reassignment; Svelte 5 module state now stable
|
||||
- **DOM hydration fixed** — `applyToDOM` called on store init so `data-theme` is always set, even without Tauri webview injection
|
||||
- **Vulkan feature flag committed** — `whisper-vulkan` in `crates/transcription/Cargo.toml`
|
||||
- **`docs/dev-setup.md`** — authoritative dependency and launch reference
|
||||
|
||||
## What's left
|
||||
|
||||
### Immediate — Vulkan GPU build
|
||||
Vulkan build was not yet confirmed. Three system packages needed before it will compile:
|
||||
|
||||
```bash
|
||||
sudo dnf install vulkan-headers vulkan-loader-devel glslc
|
||||
```
|
||||
|
||||
Then launch:
|
||||
|
||||
```bash
|
||||
cd /home/jake/Documents/CORBEL-Projects/kon
|
||||
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
|
||||
```
|
||||
|
||||
Confirm GPU active in startup logs:
|
||||
```
|
||||
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070
|
||||
```
|
||||
|
||||
### Manual validation (requires running app)
|
||||
Three items from the validation checklist that need real Tauri runtime:
|
||||
- [ ] Persistence test — set non-default zone/font, close, relaunch, verify zero flash
|
||||
- [ ] Cross-window preferences — open float/viewer windows, check they hydrate correctly
|
||||
- [ ] 90-second onboarding — fresh-model launch, first dictation under 90s
|
||||
|
||||
### Pre-release (before any build beyond Jake's machine)
|
||||
- [ ] Updater signing key — `tauri signer generate`, public key → `tauri.conf.json`, private key → CI secrets
|
||||
- [ ] ggml dedup — plan at `docs/superpowers/plans/2026-04-18-kon-ggml-dedup.md`, Option A (system-ggml shared lib), execute at Phase 3
|
||||
|
||||
## Gotchas discovered today
|
||||
|
||||
| Issue | Fix |
|
||||
|---|---|
|
||||
| `libclang` not on PATH | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` |
|
||||
| `tokio::spawn` panics in Tauri `setup()` | Use `tauri::async_runtime::spawn` — Tokio runtime isn't live yet during setup |
|
||||
| Svelte 5 `$effect` infinite loop on `updatePreferences` | Module-level `$state` must be mutated (`Object.assign`), never reassigned — stale references break loop guards |
|
||||
| Duplicate theme sync `$effect` in both `+layout.svelte` and `SettingsPage.svelte` | Removed from SettingsPage — layout handles it |
|
||||
| Vulkan build needs dev headers + shader compiler | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` |
|
||||
|
||||
## Resume prompt
|
||||
|
||||
```
|
||||
Picking up Kon dogfooding from the 2026/04/18 session.
|
||||
HANDOVER is at HANDOVER.md in the project root.
|
||||
First job: confirm Vulkan GPU build compiles and check startup logs for RTX 4070.
|
||||
Then run the three manual validation items from the handover.
|
||||
```
|
||||
6
crates/llm/Cargo.toml
Normal file
6
crates/llm/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "kon-llm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
73
crates/llm/src/lib.rs
Normal file
73
crates/llm/src/lib.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct LlmState {
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
/// Shared handle to the LLM engine. Cheap to clone (Arc).
|
||||
/// Phase 3 will replace the stub body with a real llama-cpp-2 model.
|
||||
#[derive(Clone)]
|
||||
pub struct LlmEngine {
|
||||
state: Arc<Mutex<LlmState>>,
|
||||
}
|
||||
|
||||
impl LlmEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(LlmState { loaded: false })),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.state.lock().unwrap().loaded
|
||||
}
|
||||
|
||||
/// Break a task description into 3-7 physical micro-steps.
|
||||
/// Returns Err if no model is loaded — the caller surfaces this to the UI.
|
||||
pub fn decompose_task(&self, _task_text: &str) -> Result<Vec<String>, String> {
|
||||
if !self.is_loaded() {
|
||||
return Err(
|
||||
"Download an AI model in Settings to break down tasks.".to_string(),
|
||||
);
|
||||
}
|
||||
// Phase 3: call llama-cpp-2 with GBNF-constrained prompt here.
|
||||
Err("LLM not yet wired.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LlmEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decompose_returns_error_when_not_loaded() {
|
||||
let engine = LlmEngine::new();
|
||||
assert!(!engine.is_loaded());
|
||||
let result = engine.decompose_task("Write a blog post");
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("Download an AI model"),
|
||||
"error message should tell user to download a model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_unloaded_engine() {
|
||||
let engine = LlmEngine::default();
|
||||
assert!(!engine.is_loaded());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_is_clone_and_shares_state() {
|
||||
let engine = LlmEngine::new();
|
||||
let clone = engine.clone();
|
||||
// Both point to the same Arc — neither is loaded
|
||||
assert!(!clone.is_loaded());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Database connect failed: {e}")))?;
|
||||
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("foreign_keys pragma failed: {e}")))?;
|
||||
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
@@ -286,27 +291,106 @@ pub async fn insert_task(
|
||||
|
||||
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id FROM tasks ORDER BY created_at DESC")
|
||||
sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, parent_task_id FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| TaskRow {
|
||||
id: r.get("id"),
|
||||
text: r.get("text"),
|
||||
bucket: r.get("bucket"),
|
||||
list_id: r.get("list_id"),
|
||||
effort: r.get("effort"),
|
||||
done: r.get("done"),
|
||||
done_at: r.get("done_at"),
|
||||
created_at: r.get("created_at"),
|
||||
source_transcript_id: r.get("source_transcript_id"),
|
||||
})
|
||||
.map(task_row_from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id FROM tasks WHERE id = ?"
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get task failed: {e}")))?;
|
||||
|
||||
Ok(row.map(task_row_from))
|
||||
}
|
||||
|
||||
pub async fn insert_subtask(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
text: &str,
|
||||
parent_task_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)"
|
||||
)
|
||||
.bind(id)
|
||||
.bind(text)
|
||||
.bind(parent_task_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \
|
||||
source_transcript_id, parent_task_id \
|
||||
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC"
|
||||
)
|
||||
.bind(parent_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List subtasks failed: {e}")))?;
|
||||
|
||||
Ok(rows.into_iter().map(task_row_from).collect())
|
||||
}
|
||||
|
||||
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
|
||||
/// Runs in a transaction so concurrent completions see consistent sibling counts.
|
||||
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
|
||||
let mut tx = pool.begin().await
|
||||
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
.bind(subtask_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Complete subtask failed: {e}")))?;
|
||||
|
||||
let parent_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = ?"
|
||||
)
|
||||
.bind(subtask_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
let pending: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0"
|
||||
)
|
||||
.bind(&pid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Count pending subtasks failed: {e}")))?;
|
||||
|
||||
if pending == 0 {
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
.bind(&pid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await
|
||||
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
.bind(id)
|
||||
@@ -316,6 +400,15 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Uncomplete task failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM tasks WHERE id = ?")
|
||||
.bind(id)
|
||||
@@ -379,6 +472,7 @@ pub struct TaskRow {
|
||||
pub done_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub source_transcript_id: Option<String>,
|
||||
pub parent_task_id: Option<String>,
|
||||
}
|
||||
|
||||
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
@@ -402,6 +496,21 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
||||
TaskRow {
|
||||
id: r.get("id"),
|
||||
text: r.get("text"),
|
||||
bucket: r.get("bucket"),
|
||||
list_id: r.get("list_id"),
|
||||
effort: r.get("effort"),
|
||||
done: r.get("done"),
|
||||
done_at: r.get("done_at"),
|
||||
created_at: r.get("created_at"),
|
||||
source_transcript_id: r.get("source_transcript_id"),
|
||||
parent_task_id: r.get("parent_task_id"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Error Logging ---
|
||||
|
||||
/// Log a structured error to the `error_log` table.
|
||||
@@ -552,6 +661,33 @@ mod tests {
|
||||
assert!(tasks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subtask_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "p1", "Write report", "inbox", None).await.unwrap();
|
||||
insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap();
|
||||
|
||||
// list_tasks must exclude subtasks
|
||||
let top_level = list_tasks(&pool).await.unwrap();
|
||||
assert_eq!(top_level.len(), 1);
|
||||
assert_eq!(top_level[0].id, "p1");
|
||||
|
||||
// list_subtasks returns both children
|
||||
let subs = list_subtasks(&pool, "p1").await.unwrap();
|
||||
assert_eq!(subs.len(), 2);
|
||||
|
||||
// completing s1 should NOT auto-complete parent (s2 still pending)
|
||||
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
|
||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||
assert!(!parent.done, "parent should not be done yet");
|
||||
|
||||
// completing s2 SHOULD auto-complete parent
|
||||
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
|
||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||
assert!(parent.done, "parent should auto-complete when all children done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
@@ -3,10 +3,11 @@ pub mod file_storage;
|
||||
pub mod migrations;
|
||||
|
||||
pub use database::{
|
||||
add_dictionary_entry, complete_task, count_transcripts, delete_dictionary_entry,
|
||||
delete_task, delete_transcript, get_setting, get_transcript, init, insert_task,
|
||||
insert_transcript, list_dictionary, list_recent_errors, list_tasks, list_transcripts,
|
||||
list_transcripts_paged, log_error, search_transcripts, set_setting, update_transcript,
|
||||
DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||
add_dictionary_entry, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
|
||||
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
|
||||
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
|
||||
log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry, ErrorLogRow,
|
||||
InsertTranscriptParams, TaskRow, TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -108,6 +108,10 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dictionary_term ON dictionary(term)
|
||||
"#),
|
||||
(3, "micro-stepping: parent_task_id on tasks", r#"
|
||||
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)
|
||||
"#),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -207,7 +211,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count, 3);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -229,6 +233,40 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
// Insert parent task
|
||||
sqlx::query("INSERT INTO tasks (id, text) VALUES ('parent-1', 'Parent task')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Insert child task with parent_task_id
|
||||
sqlx::query("INSERT INTO tasks (id, text, parent_task_id) VALUES ('child-1', 'Child task', 'parent-1')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete parent — child should cascade
|
||||
sqlx::query("DELETE FROM tasks WHERE id = 'parent-1'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0, "cascade delete should remove child when parent is deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ description = "Speech-to-text engine wrappers, model management, and inference c
|
||||
kon-core = { path = "../core" }
|
||||
|
||||
# Unified STT engine (Parakeet via ONNX, Whisper via whisper.cpp)
|
||||
transcribe-rs = { version = "0.3", features = ["onnx", "whisper-cpp"] }
|
||||
transcribe-rs = { version = "0.3", features = ["onnx", "whisper-cpp", "whisper-vulkan"] }
|
||||
|
||||
# Async runtime for spawn_blocking
|
||||
tokio = { version = "1", features = ["rt", "sync"] }
|
||||
|
||||
129
docs/dev-setup.md
Normal file
129
docs/dev-setup.md
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: dev-setup
|
||||
type: reference
|
||||
tags: [setup, dependencies, build, linux, fedora]
|
||||
description: Authoritative build dependencies and launch instructions for Kon on Fedora Linux
|
||||
---
|
||||
|
||||
# Kon — Developer Setup
|
||||
|
||||
Last updated: 2026/04/18. Primary dev target: Fedora 43, x86_64, KDE Wayland, NVIDIA RTX 4070.
|
||||
|
||||
---
|
||||
|
||||
## System dependencies
|
||||
|
||||
### Required (CPU build)
|
||||
|
||||
```bash
|
||||
sudo dnf install cmake clang-devel
|
||||
```
|
||||
|
||||
| Package | Why |
|
||||
|---|---|
|
||||
| `cmake` | whisper-rs-sys build system |
|
||||
| `clang-devel` | bindgen header generation for whisper-rs-sys |
|
||||
|
||||
**Fedora-specific:** `libclang.so` lives in `/usr/lib64/llvm21/lib64/`, not on the standard search path. Set permanently:
|
||||
|
||||
```bash
|
||||
set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64
|
||||
```
|
||||
|
||||
Or prefix every build command:
|
||||
|
||||
```bash
|
||||
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
|
||||
```
|
||||
|
||||
### Required (Vulkan GPU build)
|
||||
|
||||
```bash
|
||||
sudo dnf install vulkan-headers vulkan-loader-devel glslc
|
||||
```
|
||||
|
||||
| Package | Why |
|
||||
|---|---|
|
||||
| `vulkan-headers` | `vulkan.h` needed by ggml-vulkan CMake |
|
||||
| `vulkan-loader-devel` | `libvulkan.so` link target for CMake |
|
||||
| `glslc` | Compiles GLSL compute shaders to SPIR-V at build time |
|
||||
|
||||
The NVIDIA Vulkan ICD (`nvidia_icd.json`) is included in the standard NVIDIA driver package — no extra install needed if the driver is already installed.
|
||||
|
||||
---
|
||||
|
||||
## Node / Rust
|
||||
|
||||
```bash
|
||||
npm install # frontend deps — run once after clone
|
||||
```
|
||||
|
||||
Rust toolchain managed by `rustup`. No extra steps needed beyond what Tauri requires.
|
||||
|
||||
---
|
||||
|
||||
## Launch commands
|
||||
|
||||
### CPU build (default)
|
||||
|
||||
```bash
|
||||
cd /home/jake/Documents/CORBEL-Projects/kon
|
||||
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
|
||||
```
|
||||
|
||||
Once `set -Ux LIBCLANG_PATH` is in fish config, this becomes:
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
### Vulkan GPU build
|
||||
|
||||
Same command — the `whisper-vulkan` feature flag is already set in `crates/transcription/Cargo.toml`. First build compiles Vulkan compute shaders and takes longer than usual.
|
||||
|
||||
Confirm GPU is active in startup logs:
|
||||
|
||||
```
|
||||
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 ← GPU active
|
||||
```
|
||||
|
||||
vs CPU fallback:
|
||||
|
||||
```
|
||||
whisper_backend_init_gpu: device 0: CPU (type: 0) ← no GPU
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Startup log reference
|
||||
|
||||
Normal startup sequence:
|
||||
|
||||
```
|
||||
[startup] Wayland workaround: GDK_BACKEND=x11
|
||||
[startup] DB init: ~4ms
|
||||
[startup] Preferences load: ~200µs
|
||||
[startup] Whisper model pre-warmed successfully
|
||||
```
|
||||
|
||||
The Wayland workarounds are injected automatically by `ensure_x11_on_wayland()` in `src-tauri/src/lib.rs` — no manual env-var prefix needed.
|
||||
|
||||
---
|
||||
|
||||
## Known build gotchas
|
||||
|
||||
| Issue | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Unable to find libclang` | Fedora puts clang libs in versioned path | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` |
|
||||
| `Could NOT find Vulkan (missing: glslc)` | Shader compiler not installed | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` |
|
||||
| `there is no reactor running` | `tokio::spawn` called before runtime starts in `setup()` | Use `tauri::async_runtime::spawn` instead |
|
||||
| `effect_update_depth_exceeded` | Svelte 5 `$state` object reassigned instead of mutated | Use `Object.assign(state, updates)` — never spread-replace module-level state |
|
||||
|
||||
---
|
||||
|
||||
## GPU notes
|
||||
|
||||
- **Vulkan** is the GPU backend used here. CUDA is not required.
|
||||
- `crates/transcription/Cargo.toml` feature: `whisper-vulkan` → `whisper-rs/vulkan` → `ggml-vulkan`
|
||||
- CPU and GPU builds are otherwise identical — same binary, same model files.
|
||||
- Expected speedup on RTX 4070: ~10–15× over CPU for `whisper-base.en`.
|
||||
@@ -21,6 +21,7 @@ kon-ai-formatting = { path = "../crates/ai-formatting" }
|
||||
kon-storage = { path = "../crates/storage" }
|
||||
kon-cloud-providers = { path = "../crates/cloud-providers" }
|
||||
kon-hotkey = { path = "../crates/hotkey" }
|
||||
kon-llm = { path = "../crates/llm" }
|
||||
|
||||
# Tauri
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
@@ -41,6 +42,7 @@ arboard = "3.6.1"
|
||||
# stores it). Must be unconditional, not Linux-only — naming a type from
|
||||
# a transitive dep requires the dep be listed here too.
|
||||
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod hotkey;
|
||||
pub mod live;
|
||||
pub mod models;
|
||||
pub mod transcription;
|
||||
pub mod tasks;
|
||||
pub mod transcripts;
|
||||
pub mod update;
|
||||
pub mod windows;
|
||||
|
||||
@@ -175,8 +175,8 @@ pub fn prewarm_default_model(whisper_engine: Arc<LocalEngine>) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
load_model_from_disk(&model_id).map(|model| {
|
||||
whisper_engine.load(model, model_id);
|
||||
})
|
||||
|
||||
168
src-tauri/src/commands/tasks.rs
Normal file
168
src-tauri/src/commands/tasks.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
// Tauri commands wrapping kon_storage task CRUD.
|
||||
// Pattern mirrors transcripts.rs — TaskDto is the camelCase frontend shape,
|
||||
// storage functions are aliased with db_ prefix to avoid name collisions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use kon_storage::{
|
||||
complete_subtask_and_check_parent as db_complete_subtask,
|
||||
complete_task as db_complete_task,
|
||||
delete_task as db_delete_task,
|
||||
get_task_by_id as db_get_task,
|
||||
insert_subtask as db_insert_subtask,
|
||||
insert_task as db_insert_task,
|
||||
list_subtasks as db_list_subtasks,
|
||||
list_tasks as db_list_tasks,
|
||||
uncomplete_task as db_uncomplete_task,
|
||||
TaskRow,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TaskDto {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub bucket: String,
|
||||
pub list_id: Option<String>,
|
||||
pub effort: Option<String>,
|
||||
pub done: bool,
|
||||
pub done_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub source_transcript_id: Option<String>,
|
||||
pub parent_task_id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<TaskRow> for TaskDto {
|
||||
fn from(r: TaskRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
text: r.text,
|
||||
bucket: r.bucket,
|
||||
list_id: r.list_id,
|
||||
effort: r.effort,
|
||||
done: r.done,
|
||||
done_at: r.done_at,
|
||||
created_at: r.created_at,
|
||||
source_transcript_id: r.source_transcript_id,
|
||||
parent_task_id: r.parent_task_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateTaskRequest {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub bucket: String,
|
||||
pub source_transcript_id: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_task_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
request: CreateTaskRequest,
|
||||
) -> Result<(), String> {
|
||||
db_insert_task(
|
||||
&state.db,
|
||||
&request.id,
|
||||
&request.text,
|
||||
&request.bucket,
|
||||
request.source_transcript_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tasks_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Vec<TaskDto>, String> {
|
||||
db_list_tasks(&state.db)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn complete_task_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
db_complete_task(&state.db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_task_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
db_delete_task(&state.db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uncomplete_task_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
db_uncomplete_task(&state.db, &id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn decompose_and_store(
|
||||
state: tauri::State<'_, AppState>,
|
||||
parent_task_id: String,
|
||||
) -> Result<Vec<TaskDto>, String> {
|
||||
let parent = db_get_task(&state.db, &parent_task_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Task {parent_task_id} not found"))?;
|
||||
|
||||
let steps = state.llm_engine.decompose_task(&parent.text)?;
|
||||
|
||||
let mut created = Vec::new();
|
||||
for text in steps {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
db_insert_subtask(&state.db, &id, &text, &parent_task_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(row) = db_get_task(&state.db, &id).await.map_err(|e| e.to_string())? {
|
||||
created.push(TaskDto::from(row));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_subtasks_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
parent_task_id: String,
|
||||
) -> Result<Vec<TaskDto>, String> {
|
||||
db_list_subtasks(&state.db, &parent_task_id)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn complete_subtask_cmd(
|
||||
state: tauri::State<'_, AppState>,
|
||||
subtask_id: String,
|
||||
) -> Result<(), String> {
|
||||
db_complete_subtask(&state.db, &subtask_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use sqlx::SqlitePool;
|
||||
use tauri::Manager;
|
||||
|
||||
use kon_core::types::EngineName;
|
||||
use kon_llm::LlmEngine;
|
||||
use kon_storage::{database_path, get_setting, init as init_db, set_setting};
|
||||
use kon_transcription::LocalEngine;
|
||||
|
||||
@@ -16,6 +17,7 @@ pub struct AppState {
|
||||
pub whisper_engine: Arc<LocalEngine>,
|
||||
pub parakeet_engine: Arc<LocalEngine>,
|
||||
pub db: SqlitePool,
|
||||
pub llm_engine: Arc<LlmEngine>,
|
||||
}
|
||||
|
||||
/// Holds the preferences init script for injection into secondary windows.
|
||||
@@ -214,6 +216,7 @@ pub fn run() {
|
||||
EngineName::new("parakeet"),
|
||||
)),
|
||||
db,
|
||||
llm_engine: Arc::new(LlmEngine::new()),
|
||||
});
|
||||
|
||||
{
|
||||
@@ -252,6 +255,15 @@ pub fn run() {
|
||||
commands::audio::start_native_capture,
|
||||
commands::audio::stop_native_capture,
|
||||
commands::audio::list_audio_devices,
|
||||
// Tasks (canonical SQLite-backed task CRUD)
|
||||
commands::tasks::create_task_cmd,
|
||||
commands::tasks::list_tasks_cmd,
|
||||
commands::tasks::complete_task_cmd,
|
||||
commands::tasks::delete_task_cmd,
|
||||
commands::tasks::uncomplete_task_cmd,
|
||||
commands::tasks::decompose_and_store,
|
||||
commands::tasks::list_subtasks_cmd,
|
||||
commands::tasks::complete_subtask_cmd,
|
||||
// Transcripts (canonical SQLite-backed history) — Day 4
|
||||
commands::transcripts::add_transcript,
|
||||
commands::transcripts::list_transcripts,
|
||||
|
||||
@@ -384,8 +384,6 @@ textarea, input, [data-no-transition] {
|
||||
animation: sinhala-spin 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Recording indicator dot (follows mouse via JS, see +layout.svelte) */
|
||||
|
||||
/* === Reduced Motion === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
111
src/lib/components/MicroSteps.svelte
Normal file
111
src/lib/components/MicroSteps.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
|
||||
|
||||
let { parentTaskId, reduceMotion = false } = $props();
|
||||
|
||||
let subtasks = $state([]);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let decomposing = $state(false);
|
||||
|
||||
async function loadSubtasks() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
subtasks = await invoke('list_subtasks_cmd', { parentTaskId });
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function decompose() {
|
||||
decomposing = true;
|
||||
error = '';
|
||||
try {
|
||||
subtasks = await invoke('decompose_and_store', { parentTaskId });
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
decomposing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkStep(subtaskId) {
|
||||
try {
|
||||
await invoke('complete_subtask_cmd', { subtaskId });
|
||||
const idx = subtasks.findIndex(s => s.id === subtaskId);
|
||||
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], done: true };
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function startTimer(subtaskId) {
|
||||
window.dispatchEvent(new CustomEvent('kon:start-timer', {
|
||||
detail: { taskId: subtaskId, seconds: 120 }
|
||||
}));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (parentTaskId) loadSubtasks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="pl-6 mt-1 flex flex-col gap-1">
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 text-[12px] text-text-tertiary py-1">
|
||||
<Loader2 size={12} class="animate-spin" aria-hidden="true" />
|
||||
Loading steps…
|
||||
</div>
|
||||
{:else if subtasks.length === 0 && !error}
|
||||
<button
|
||||
class="flex items-center gap-1.5 text-[12px] text-text-tertiary hover:text-accent py-1 disabled:opacity-50"
|
||||
onclick={decompose}
|
||||
disabled={decomposing}
|
||||
aria-label="Break down this task into micro-steps"
|
||||
>
|
||||
{#if decomposing}
|
||||
<Loader2 size={12} class="animate-spin" aria-hidden="true" />
|
||||
Breaking down…
|
||||
{:else}
|
||||
<ListTree size={12} aria-hidden="true" />
|
||||
Break down
|
||||
{/if}
|
||||
</button>
|
||||
{:else if error}
|
||||
<p class="text-[11px] text-text-tertiary py-1">{error}</p>
|
||||
{:else}
|
||||
{#each subtasks as step (step.id)}
|
||||
<div class="flex items-center gap-2 group py-0.5">
|
||||
<button
|
||||
class="w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0
|
||||
{step.done
|
||||
? 'bg-success/20 border-success text-success'
|
||||
: 'border-border-subtle hover:border-accent'}"
|
||||
onclick={() => checkStep(step.id)}
|
||||
disabled={step.done}
|
||||
aria-label={step.done ? 'Step complete' : 'Mark step complete'}
|
||||
>
|
||||
{#if step.done}
|
||||
<Check size={9} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
<span class="text-[12px] flex-1 min-w-0 {step.done ? 'line-through text-text-tertiary' : 'text-text-secondary'} truncate">
|
||||
{step.text}
|
||||
</span>
|
||||
{#if !step.done}
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
|
||||
onclick={() => startTimer(step.id)}
|
||||
aria-label="Start 2-minute timer for this step"
|
||||
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
|
||||
>
|
||||
<Timer size={10} aria-hidden="true" />
|
||||
Just Start
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,13 +1,16 @@
|
||||
<script>
|
||||
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
import MicroSteps from '$lib/components/MicroSteps.svelte';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||
|
||||
let { wipLimit = 3 } = $props();
|
||||
|
||||
let newTaskText = $state('');
|
||||
let showOverflow = $state(false);
|
||||
let expandedTaskIds = $state(new Set());
|
||||
|
||||
let activeTasks = $derived(tasks.filter(t => !t.done));
|
||||
// Exclude subtasks from WIP count — only top-level tasks count
|
||||
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
|
||||
let visibleTasks = $derived(activeTasks.slice(0, wipLimit));
|
||||
let overflowTasks = $derived(activeTasks.slice(wipLimit));
|
||||
let hasOverflow = $derived(overflowTasks.length > 0);
|
||||
@@ -22,6 +25,16 @@
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
}
|
||||
|
||||
function toggleExpand(taskId) {
|
||||
const next = new Set(expandedTaskIds);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
} else {
|
||||
next.add(taskId);
|
||||
}
|
||||
expandedTaskIds = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -44,23 +57,42 @@
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each visibleTasks as task (task.id)}
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
|
||||
style="transition-duration: var(--duration-ui)">
|
||||
<button
|
||||
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
|
||||
hover:border-accent"
|
||||
onclick={() => completeTask(task.id)}
|
||||
aria-label="Complete task"
|
||||
>
|
||||
</button>
|
||||
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
aria-label="Delete task"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div>
|
||||
<!-- Task row -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-hover group"
|
||||
style="transition-duration: var(--duration-ui)">
|
||||
<button
|
||||
class="w-4 h-4 rounded border border-border-subtle flex items-center justify-center flex-shrink-0
|
||||
hover:border-accent"
|
||||
onclick={() => completeTask(task.id)}
|
||||
aria-label="Complete task"
|
||||
></button>
|
||||
<span class="text-[13px] text-text flex-1 min-w-0 truncate">{task.text}</span>
|
||||
<!-- Expand/collapse micro-steps toggle -->
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 text-text-tertiary hover:text-accent"
|
||||
onclick={() => toggleExpand(task.id)}
|
||||
aria-label={expandedTaskIds.has(task.id) ? 'Collapse steps' : 'Expand steps'}
|
||||
style="transition: opacity var(--duration-ui)"
|
||||
>
|
||||
{#if expandedTaskIds.has(task.id)}
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={12} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-danger opacity-0 group-hover:opacity-100 text-[11px]"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
aria-label="Delete task"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<!-- Micro-steps panel (expanded) -->
|
||||
{#if expandedTaskIds.has(task.id)}
|
||||
<MicroSteps parentTaskId={task.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -417,7 +417,9 @@
|
||||
async function finaliseTranscription(audioPath = null) {
|
||||
if (transcript.trim()) {
|
||||
if (settings.autoCopy) {
|
||||
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
navigator.clipboard.writeText(transcript).catch(() => {
|
||||
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
const historyId = crypto.randomUUID();
|
||||
@@ -464,7 +466,11 @@
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
if (transcript) invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
if (transcript) {
|
||||
navigator.clipboard.writeText(transcript).catch(() => {
|
||||
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearTranscript() {
|
||||
|
||||
@@ -118,7 +118,11 @@
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
if (fileTranscript) invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
|
||||
if (fileTranscript) {
|
||||
navigator.clipboard.writeText(fileTranscript).catch(() => {
|
||||
invoke("copy_to_clipboard", { text: fileTranscript }).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport(format) {
|
||||
|
||||
@@ -178,7 +178,9 @@
|
||||
}
|
||||
|
||||
function copyItem(item) {
|
||||
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
|
||||
navigator.clipboard.writeText(item.text).catch(() => {
|
||||
invoke("copy_to_clipboard", { text: item.text }).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(item) {
|
||||
|
||||
@@ -281,14 +281,6 @@
|
||||
saveSettings();
|
||||
});
|
||||
|
||||
// Sync theme setting to preferences store
|
||||
$effect(() => {
|
||||
const map = { Dark: 'dark', Light: 'light', System: 'system' };
|
||||
const mapped = map[settings.theme] || 'dark';
|
||||
if (prefs.theme !== mapped) {
|
||||
updatePreferences({ theme: mapped });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
settings.engine;
|
||||
|
||||
@@ -201,6 +201,18 @@ function loadTasks() {
|
||||
|
||||
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));
|
||||
@@ -208,7 +220,7 @@ export function saveTasks() {
|
||||
}
|
||||
|
||||
export function addTask(task) {
|
||||
tasks.unshift({
|
||||
const newTask = {
|
||||
id: crypto.randomUUID(),
|
||||
text: task.text,
|
||||
bucket: task.bucket || "inbox",
|
||||
@@ -219,10 +231,22 @@ export function addTask(task) {
|
||||
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) {
|
||||
@@ -240,15 +264,24 @@ export function deleteTask(id) {
|
||||
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 ----
|
||||
|
||||
@@ -98,23 +98,29 @@ function persistToSQLite(prefs) {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
let preferences = $state(readFromDOM());
|
||||
// Export as const so it is never reassigned — consumers hold a stable reference
|
||||
// and mutations are tracked by Svelte 5's deep reactivity.
|
||||
export const preferences = $state(readFromDOM());
|
||||
|
||||
// Ensure data-theme and zone attributes are always present on the DOM, even
|
||||
// when there is no Tauri webview injection script (e.g. browser dev mode).
|
||||
if (typeof window !== 'undefined') {
|
||||
applyToDOM(preferences);
|
||||
}
|
||||
|
||||
/** @deprecated Use `preferences` directly — kept for backwards compatibility */
|
||||
export function getPreferences() {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
export function updatePreferences(updates) {
|
||||
preferences = { ...preferences, ...updates };
|
||||
Object.assign(preferences, updates);
|
||||
applyToDOM(preferences);
|
||||
persistToSQLite(preferences);
|
||||
}
|
||||
|
||||
export function updateAccessibility(updates) {
|
||||
preferences = {
|
||||
...preferences,
|
||||
accessibility: { ...preferences.accessibility, ...updates }
|
||||
};
|
||||
Object.assign(preferences.accessibility, updates);
|
||||
applyToDOM(preferences);
|
||||
persistToSQLite(preferences);
|
||||
}
|
||||
|
||||
@@ -27,15 +27,6 @@
|
||||
$sveltePage.url.pathname.startsWith("/viewer")
|
||||
);
|
||||
|
||||
// Recording indicator dot (follows mouse)
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
|
||||
function handleMouseMove(e) {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
}
|
||||
|
||||
// Theme — migrate from old class-based to new data-attribute system
|
||||
// The preferences store handles DOM application via data-theme attribute
|
||||
$effect(() => {
|
||||
@@ -262,14 +253,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onmousemove={handleMouseMove} onkeydown={handleKeydown} />
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if page.recording}
|
||||
<div
|
||||
class="fixed w-3 h-3 rounded-full bg-danger animate-pulse-soft pointer-events-none"
|
||||
style="left: {mouseX + 18}px; top: {mouseY + 18}px; z-index: 100;"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if isSecondaryWindow}
|
||||
<!-- Secondary windows (float, viewer) render children only — no shell chrome -->
|
||||
|
||||
@@ -194,7 +194,10 @@
|
||||
|
||||
function copySegment(idx) {
|
||||
if (item?.segments?.[idx]) {
|
||||
invoke("copy_to_clipboard", { text: item.segments[idx].text.trim() }).catch(() => {});
|
||||
const text = item.segments[idx].text.trim();
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
invoke("copy_to_clipboard", { text }).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user