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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
143
src-tauri/src/commands/llm.rs
Normal file
143
src-tauri/src/commands/llm.rs
Normal 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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user