feat(llm): wire Phase 3 local LLM runtime via llama-cpp-2

kon-llm now owns a real LlamaBackend + LlamaModel, with three Qwen3 tiers
(1.7B Q4, 4B-Instruct-2507 Q4, 14B Q5) selectable per hardware. Downloads
are resumable with SHA-256 verification and stored under ~/.kon/models/llm.

Engine exposes three high-level surfaces — all greedy/temp-0, GBNF-constrained
where output shape matters:
- cleanup_text (prompt-injection-hardened system prompt; profile terms
  appended as "preserve these spellings" suffix)
- decompose_task (3–7 micro-steps, constrained JSON array)
- extract_tasks (optional-array; empty when no explicit commitments)

post_process_segments now takes an Option<&LlmEngine> and, when loaded and
format_mode != Raw, joins segments → cleanup → replaces segments with the
cleaned text (first segment span). Rule-based path still runs first; LLM
errors log and keep rule-based output.

Tauri commands: recommend_llm_tier, check_llm_model, download_llm_model,
load_llm_model, unload_llm_model, delete_llm_model, get_llm_status,
cleanup_transcript_text_cmd, extract_tasks_from_transcript_cmd,
decompose_and_store (LLM-backed subtasks).

Settings: AI tier toggle (off / cleanup / tasks), model picker with
downloaded/loaded status, download progress events via
kon:llm-download-progress.

Dictation: ensureLlmModelLoaded on mount, cleanupTranscriptIfEnabled after
stop when tier != off and format_mode != Raw, LLM task extraction when
tier=tasks (regex fallback on failure).

Interim: both llama-cpp-sys-2 and whisper-rs-sys statically link their own
ggml, so src-tauri/build.rs emits -Wl,--allow-multiple-definition on Linux.
Replace with a system-ggml shared-lib setup as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 07:31:51 +01:00
parent 34fce3cf9e
commit d1eb56fac9
21 changed files with 1598 additions and 43 deletions

View File

@@ -1,3 +1,13 @@
fn main() {
// INTERIM: both llama-cpp-sys-2 and whisper-rs-sys statically link
// their own copy of ggml, so GNU ld / lld see duplicate symbols when
// linking the kon binary and its lib-tests. --allow-multiple-definition
// makes the linker pick the first definition; safe while both crates
// pin compatible ggml revisions. Replace with a system-ggml shared-lib
// setup as a follow-up.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
println!("cargo:rustc-link-arg=-Wl,--allow-multiple-definition");
}
tauri_build::build()
}

View File

@@ -627,6 +627,7 @@ fn poll_inference(
format_mode: FormatMode::parse(&config.format_mode),
dictionary_terms: dictionary_terms.to_vec(),
},
None,
);
let chunk_start_secs = task.chunk_start_sample as f64 / WHISPER_SAMPLE_RATE as f64;
let skipped_duplicates = filter_duplicate_boundary_segments(

View File

@@ -0,0 +1,143 @@
use tauri::{Emitter, State};
use crate::AppState;
use kon_ai_formatting::llm_cleanup_text;
use kon_core::hardware;
use kon_llm::model_manager::{self, model_info};
use kon_llm::LlmModelId;
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LlmModelStatusDto {
pub id: String,
pub display_name: String,
pub downloaded: bool,
pub loaded: bool,
pub size_bytes: u64,
pub description: String,
pub minimum_ram_bytes: u64,
pub recommended_vram_bytes: Option<u64>,
}
fn parse_model_id(model_id: String) -> Result<LlmModelId, String> {
model_id.parse()
}
#[tauri::command]
pub fn recommend_llm_tier() -> Result<String, String> {
let profile = hardware::probe_system();
let ram_bytes = profile.ram.0.saturating_mul(1024 * 1024);
let vram_bytes = profile
.gpu
.map(|gpu| gpu.vram.0.saturating_mul(1024 * 1024));
Ok(model_manager::recommend_tier(ram_bytes, vram_bytes)
.as_str()
.to_string())
}
#[tauri::command]
pub fn check_llm_model(
state: State<'_, AppState>,
model_id: String,
) -> Result<LlmModelStatusDto, String> {
let id = parse_model_id(model_id)?;
let info = model_info(id);
let loaded_model_id = state.llm_engine.loaded_model_id();
Ok(LlmModelStatusDto {
id: info.id,
display_name: info.display_name.to_string(),
downloaded: model_manager::is_downloaded(id),
loaded: loaded_model_id.as_deref() == Some(id.as_str()),
size_bytes: info.size_bytes,
description: info.description.to_string(),
minimum_ram_bytes: info.minimum_ram_bytes,
recommended_vram_bytes: info.recommended_vram_bytes,
})
}
#[tauri::command]
pub async fn download_llm_model(app: tauri::AppHandle, model_id: String) -> Result<(), String> {
let id = parse_model_id(model_id)?;
let app_clone = app.clone();
model_manager::download_model(id, move |done, total| {
let percent = if total > 0 {
((done as f64 / total as f64) * 100.0).round() as u8
} else {
0
};
let _ = app_clone.emit(
"kon:llm-download-progress",
serde_json::json!({
"modelId": id.as_str(),
"done": done,
"total": total,
"percent": percent,
}),
);
})
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn load_llm_model(
state: State<'_, AppState>,
model_id: String,
use_gpu: Option<bool>,
) -> Result<(), String> {
let id = parse_model_id(model_id)?;
let path = model_manager::model_path(id);
if !path.exists() {
return Err("Model not downloaded — call download_llm_model first".to_string());
}
let engine = state.llm_engine.clone();
let use_gpu = use_gpu.unwrap_or(true);
tokio::task::spawn_blocking(move || engine.load_model(id, &path, use_gpu))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn unload_llm_model(state: State<'_, AppState>) -> Result<(), String> {
state.llm_engine.unload().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_llm_model(state: State<'_, AppState>, model_id: String) -> Result<(), String> {
let id = parse_model_id(model_id)?;
if state.llm_engine.loaded_model_id().as_deref() == Some(id.as_str()) {
state.llm_engine.unload().map_err(|e| e.to_string())?;
}
model_manager::delete_model(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_llm_status(state: State<'_, AppState>) -> Result<bool, String> {
Ok(state.llm_engine.is_loaded())
}
#[tauri::command]
pub async fn cleanup_transcript_text_cmd(
state: State<'_, AppState>,
transcript: String,
profile_id: Option<String>,
) -> Result<String, String> {
let resolved_profile_id =
profile_id.unwrap_or_else(|| kon_storage::DEFAULT_PROFILE_ID.to_string());
let profile_terms: Vec<String> =
kon_storage::database::list_profile_terms(&state.db, &resolved_profile_id)
.await
.map_err(|e| e.to_string())?
.into_iter()
.map(|term| term.term)
.collect();
let engine = state.llm_engine.clone();
tokio::task::spawn_blocking(move || llm_cleanup_text(&engine, &transcript, &profile_terms))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}

View File

@@ -4,6 +4,7 @@ pub mod diagnostics;
pub mod hardware;
pub mod hotkey;
pub mod live;
pub mod llm;
pub mod models;
pub mod profiles;
pub mod tasks;

View File

@@ -176,7 +176,12 @@ pub async fn decompose_and_store(
.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 engine = state.llm_engine.clone();
let parent_text = parent.text.clone();
let steps = tokio::task::spawn_blocking(move || engine.decompose_task(&parent_text))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut created = Vec::new();
for text in steps {
@@ -195,6 +200,18 @@ pub async fn decompose_and_store(
Ok(created)
}
#[tauri::command]
pub async fn extract_tasks_from_transcript_cmd(
state: tauri::State<'_, AppState>,
transcript: String,
) -> Result<Vec<String>, String> {
let engine = state.llm_engine.clone();
tokio::task::spawn_blocking(move || engine.extract_tasks(&transcript))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_subtasks_cmd(
state: tauri::State<'_, AppState>,

View File

@@ -202,6 +202,7 @@ pub async fn transcribe_pcm(
format_mode: FormatMode::parse(&format_mode),
dictionary_terms,
},
Some(state.llm_engine.as_ref()),
);
app.emit(
@@ -298,6 +299,7 @@ pub async fn transcribe_file(
format_mode: FormatMode::parse(&format_mode),
dictionary_terms,
},
Some(state.llm_engine.as_ref()),
);
Ok(serde_json::json!({
@@ -362,6 +364,7 @@ pub async fn transcribe_pcm_parakeet(
format_mode: FormatMode::parse(&format_mode),
dictionary_terms,
},
Some(state.llm_engine.as_ref()),
);
app.emit(

View File

@@ -239,6 +239,15 @@ pub fn run() {
commands::models::load_model,
commands::models::check_engine,
commands::models::get_runtime_capabilities,
// Local LLM management
commands::llm::recommend_llm_tier,
commands::llm::check_llm_model,
commands::llm::download_llm_model,
commands::llm::load_llm_model,
commands::llm::unload_llm_model,
commands::llm::delete_llm_model,
commands::llm::get_llm_status,
commands::llm::cleanup_transcript_text_cmd,
// Parakeet model management
commands::models::download_parakeet_model,
commands::models::check_parakeet_model,
@@ -262,6 +271,7 @@ pub fn run() {
commands::tasks::delete_task_cmd,
commands::tasks::uncomplete_task_cmd,
commands::tasks::decompose_and_store,
commands::tasks::extract_tasks_from_transcript_cmd,
commands::tasks::list_subtasks_cmd,
commands::tasks::complete_subtask_cmd,
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12