commands.rs
Language Rust25.5 KB717 lines
//! The commands the interface can call. Each one delegates to cortex-core.use std::path::{Path, PathBuf};use cortex_core::api::*;use cortex_core::attachments;use cortex_core::chat::{build_request, request_tokens, run_turn};use cortex_core::cost::cost_nanousd;use cortex_core::estimate;use cortex_core::export::{to_markdown, ImageLinks};use cortex_core::pricing;use cortex_core::provider::{Client, ProviderError};use cortex_core::storage::{self, month_start_ms, AttachmentMeta, Conversation, ConversationSummary, NewAttachment, Preset, Provider,SearchHit, DEEPINFRA_ID,};use cortex_core::types::Usage;use tauri::ipc::{Channel, InvokeBody, Request, Response};use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow};use tokio_util::sync::CancellationToken;use crate::state::{lock, AppState};use crate::windows;type R<T> = Result<T, String>;fn e<E: ToString>(err: E) -> String {err.to_string()}pub const SETTINGS_KEY: &str = "settings";pub const DAY_MS: i64 = 24 * 3600 * 1000;pub fn load_settings(state: &AppState) -> Settings {state.db().get_setting::<Settings>(SETTINGS_KEY).ok().flatten().unwrap_or_default()}// ---- app and windows ------------------------------------------------------#[tauri::command]pub fn app_info(state: State<'_, AppState>) -> AppInfo {AppInfo {version: env!("CARGO_PKG_VERSION").into(),platform: std::env::consts::OS.into(),vibrancy: cfg!(target_os = "macos"),data_dir: state.data_dir.display().to_string(),}}/// The page has painted: show the window now, so it never flashes blank.#[tauri::command]pub fn window_ready(window: WebviewWindow) {if window.label() == "main" {let _ = window.show();let _ = window.set_focus();}}#[tauri::command]pub fn show_main(app: AppHandle) {windows::show_main(&app);}#[tauri::command]pub fn quick_open_in_main(app: AppHandle, conversation_id: String) {windows::hide_quick(&app);windows::show_main(&app);let _ = app.emit_to("main", "open-conversation", conversation_id);}#[tauri::command]pub fn hide_quick(app: AppHandle) {windows::hide_quick(&app);}#[tauri::command]pub fn resize_quick(app: AppHandle, height: f64) {if let Some(w) = app.get_webview_window("quick") {let _ = w.set_size(tauri::LogicalSize::new(windows::QUICK_WIDTH, height.clamp(64.0, windows::QUICK_MAX)));}}// ---- providers and keys ---------------------------------------------------#[tauri::command]pub fn list_providers(state: State<'_, AppState>) -> R<Vec<Provider>> {state.db().list_providers().map_err(e)}#[tauri::command]pub fn save_provider(state: State<'_, AppState>, mut provider: Provider) -> R<Provider> {if provider.id.trim().is_empty() {provider.id = storage::new_id();}if provider.id != DEEPINFRA_ID {provider.kind = "openai_compatible".into();}let url = provider.base_url.trim();if !(url.starts_with("http://") || url.starts_with("https://")) {return Err("The address must start with http:// or https://".into());}state.db().upsert_provider(&provider).map_err(e)?;state.db().get_provider(&provider.id).map_err(e)}#[tauri::command]pub fn delete_provider(state: State<'_, AppState>, id: String) -> R<()> {let _ = state.keys.clear(&id);state.db().delete_provider(&id).map_err(e)}/// Check a key with the provider, then store it. A key the provider refuses is/// not stored; one that could not be checked (offline, say) is, and says so.#[tauri::command]pub async fn set_api_key(app: AppHandle, provider_id: String, key: String) -> R<KeySaved> {let key = cortex_core::secrets::clean_key(&key);if key.is_empty() {return Err("The key is empty.".into());}let state = app.state::<AppState>();let provider = state.db().get_provider(&provider_id).map_err(e)?;// Listing models with a bad key gets a 401, so it doubles as a free check.let detail = match Client::new(&provider.base_url, Some(key.clone())).list_models().await {Ok(_) => None,Err(ProviderError::InvalidKey) => {return Err(format!("{} rejected this key, so it was not saved. Copy the whole key again and paste it.",provider.name))}Err(err) => Some(err.to_string()),};state.keys.set(&provider_id, &key).map_err(|err| format!("Could not store the key in the keychain: {err}"))?;Ok(KeySaved { accepted: detail.is_none(), detail })}#[tauri::command]pub fn clear_api_key(state: State<'_, AppState>, provider_id: String) -> R<()> {state.keys.clear(&provider_id)}#[tauri::command]pub fn has_api_key(state: State<'_, AppState>, provider_id: String) -> R<bool> {state.keys.has(&provider_id)}// ---- models ---------------------------------------------------------------fn models_response(state: &AppState, provider_id: &str, warning: Option<String>) -> R<ModelsResponse> {let db = state.db();let mut models = db.list_models(provider_id).map_err(e)?;let mut source = db.models_source(provider_id).map_err(e)?;let mut snapshot_date = None;if models.is_empty() && provider_id == DEEPINFRA_ID {// First run offline: the bundled prices.let snap = pricing::snapshot(DEEPINFRA_ID);db.replace_models(DEEPINFRA_ID, &snap.models, "snapshot", 0).map_err(e)?;models = snap.models;snapshot_date = Some(snap.fetched_at);source = Some(("snapshot".into(), 0));}let (source, fetched_at) = source.unwrap_or(("local".into(), 0));Ok(ModelsResponse {models,snapshot_date: (source == "snapshot").then(|| snapshot_date.unwrap_or_else(|| pricing::snapshot(DEEPINFRA_ID).fetched_at)),fetched_at: (fetched_at > 0).then_some(fetched_at),source,warning,})}#[tauri::command]pub fn list_models(state: State<'_, AppState>, provider_id: String) -> R<ModelsResponse> {models_response(&state, &provider_id, None)}/// Fetch a provider's model list (DeepInfra's carries prices). On failure the/// stored list stays, and the response says why.pub async fn refresh_models_for(app: &AppHandle, provider_id: &str) -> R<ModelsResponse> {let state = app.state::<AppState>();let provider = state.db().get_provider(provider_id).map_err(e)?;let key = state.keys.get(provider_id).ok().flatten();let client = Client::new(&provider.base_url, key);let warning = match client.list_models().await {Ok(v) => {let (models, source) = if provider.kind == "deepinfra" {(pricing::parse_priced_models(provider_id, &v), "live")} else {(pricing::parse_local_models(provider_id, &v), "local")};if models.is_empty() {Some("The provider listed no chat models.".to_string())} else {state.db().replace_models(provider_id, &models, source, storage::now_ms()).map_err(e)?;None}}Err(err) => Some(err.to_string()),};models_response(&state, provider_id, warning)}#[tauri::command]pub async fn refresh_models(app: AppHandle, provider_id: String) -> R<ModelsResponse> {refresh_models_for(&app, &provider_id).await}/// At startup: refresh any list older than a day, quietly.pub async fn refresh_stale(app: AppHandle) {let providers = app.state::<AppState>().db().list_providers().unwrap_or_default();for p in providers {let age = app.state::<AppState>().db().models_source(&p.id).ok().flatten();let stale = match age {Some((src, at)) => src == "snapshot" || storage::now_ms() - at > DAY_MS,None => true,};if stale {let _ = refresh_models_for(&app, &p.id).await;}}let _ = app.emit("models-updated", ());}// ---- conversations --------------------------------------------------------#[tauri::command]pub fn list_conversations(state: State<'_, AppState>, include_archived: bool) -> R<Vec<ConversationSummary>> {state.db().list_conversations(include_archived, 500).map_err(e)}#[tauri::command]pub fn get_conversation(state: State<'_, AppState>, id: String) -> R<ConversationView> {let db = state.db();Ok(ConversationView {conversation: db.get_conversation(&id).map_err(e)?,messages: db.list_messages(&id).map_err(e)?,})}#[tauri::command]pub fn create_conversation(state: State<'_, AppState>,provider_id: String,model_id: String,preset_id: Option<String>,origin: Option<String>,) -> R<Conversation> {let db = state.db();let preset = match &preset_id {Some(id) => db.list_presets().map_err(e)?.into_iter().find(|p| &p.id == id),None => None,};let (provider_id, model_id) = match &preset {Some(Preset { provider_id: Some(p), model_id: Some(m), .. }) => (p.clone(), m.clone()),_ => (provider_id, model_id),};let system = preset.as_ref().map(|p| p.system_prompt.as_str()).unwrap_or("");let origin = if origin.as_deref() == Some("quick") { "quick" } else { "main" };db.create_conversation(&provider_id, &model_id, system, preset_id.as_deref(), origin).map_err(e)}#[tauri::command]pub fn rename_conversation(state: State<'_, AppState>, id: String, title: String) -> R<()> {state.db().rename_conversation(&id, &title).map_err(e)}#[tauri::command]pub fn set_pinned(state: State<'_, AppState>, id: String, pinned: bool) -> R<()> {state.db().set_pinned(&id, pinned).map_err(e)}#[tauri::command]pub fn set_archived(state: State<'_, AppState>, id: String, archived: bool) -> R<()> {state.db().set_archived(&id, archived).map_err(e)}#[tauri::command]pub fn delete_conversation(state: State<'_, AppState>, id: String) -> R<()> {let paths: Vec<String> = {let db = state.db();let msgs = db.list_messages(&id).map_err(e)?;let mut paths = Vec::new();for m in &msgs {for a in db.attachment_data_for(&m.id).map_err(e)? {paths.extend(a.path);}}db.delete_conversation(&id).map_err(e)?;paths};remove_unused_files(&state, &paths);Ok(())}pub fn remove_unused_files(state: &AppState, paths: &[String]) {for p in paths {if !state.db().attachment_path_in_use(p).unwrap_or(true) {let _ = std::fs::remove_file(p);}}}#[tauri::command]pub fn set_conversation_model(state: State<'_, AppState>, id: String, provider_id: String, model_id: String) -> R<()> {state.db().set_conversation_model(&id, &provider_id, &model_id).map_err(e)}#[tauri::command]pub fn set_system_prompt(state: State<'_, AppState>,id: String,system_prompt: String,preset_id: Option<String>,) -> R<()> {state.db().set_system_prompt(&id, &system_prompt, preset_id.as_deref()).map_err(e)}#[tauri::command]pub fn truncate_from(state: State<'_, AppState>, conversation_id: String, message_id: String) -> R<()> {state.db().truncate_from(&conversation_id, &message_id).map(|_| ()).map_err(e)}// ---- sending --------------------------------------------------------------#[tauri::command]pub fn send_message(app: AppHandle,state: State<'_, AppState>,req: SendRequest,on_event: Channel<StreamEvent>,) -> R<SendAck> {let (conv, provider) = {let db = state.db();let conv = db.get_conversation(&req.conversation_id).map_err(e)?;let provider = db.get_provider(&conv.provider_id).map_err(e)?;(conv, provider)};let key = state.keys.get(&provider.id)?;if provider.requires_key && key.as_deref().unwrap_or("").is_empty() {return Err(format!("Add your {} API key in Settings first.", provider.name));}let (user_message, request, assistant, prices) = {let db = state.db();let user_message = if !req.text.trim().is_empty() || !req.attachment_ids.is_empty() {Some(db.insert_user_message(&conv.id, &req.text, &req.attachment_ids).map_err(e)?)} else {None};let request = build_request(&db, &conv.id).map_err(e)?;let assistant = db.insert_assistant_placeholder(&conv.id, &provider.id, &conv.model_id).map_err(e)?;let prices = db.model(&provider.id, &conv.model_id).map_err(e)?.map(|m| m.prices);(user_message, request, assistant, prices)};let stream_id = storage::new_id();let token = CancellationToken::new();lock(&state.streams).insert(stream_id.clone(), token.clone());let client = Client::new(&provider.base_url, key);let (app2, sid, placeholder) = (app.clone(), stream_id.clone(), assistant.clone());tauri::async_runtime::spawn(async move {let state = app2.state::<AppState>();let _ = run_turn(&state.db, &client, &request, &placeholder, prices, &token, |ev| {let _ = on_event.send(ev);}).await;lock(&state.streams).remove(&sid);windows::refresh_tray_spend(&app2);let _ = app2.emit("conversations-changed", ());});Ok(SendAck { stream_id, user_message, assistant_message: assistant })}#[tauri::command]pub fn cancel_stream(state: State<'_, AppState>, stream_id: String) {if let Some(t) = lock(&state.streams).get(&stream_id) {t.cancel();}}/// What sending `text` (plus images) would cost at the input price.#[tauri::command]pub fn estimate_prompt(state: State<'_, AppState>,conversation_id: Option<String>,provider_id: String,model_id: String,text: String,image_count: u32,extra_text: Option<String>,) -> R<Estimate> {let db = state.db();let history = match &conversation_id {Some(id) => build_request(&db, id).map(|r| request_tokens(&r)).unwrap_or(0),None => 0,};let new = estimate::estimate_prompt([(text.as_str(), image_count as usize)])+ estimate::estimate_tokens(extra_text.as_deref().unwrap_or(""));let prompt_tokens = history + new;let model = db.model(&provider_id, &model_id).map_err(e)?;let prompt_cost_nanousd = model.as_ref().map(|m| cost_nanousd(&Usage { prompt_tokens, completion_tokens: 0, cached_tokens: 0 }, &m.prices)).unwrap_or(0);Ok(Estimate { prompt_tokens, prompt_cost_nanousd, context_length: model.and_then(|m| m.context_length) })}// ---- search, spend, usage -------------------------------------------------#[tauri::command]pub fn search(state: State<'_, AppState>, query: String) -> R<Vec<SearchHit>> {state.db().search(&query, 40).map_err(e)}pub fn spend(state: &AppState) -> R<SpendSummary> {let settings = load_settings(state);let (month, estimated) = state.db().spend_since(month_start_ms(&chrono::Local::now())).map_err(e)?;Ok(SpendSummary {month_nanousd: month,month_estimated: estimated,budget_usd: settings.monthly_budget_usd,budget_level: budget_level(month, settings.monthly_budget_usd),})}#[tauri::command]pub fn spend_summary(state: State<'_, AppState>) -> R<SpendSummary> {spend(&state)}#[tauri::command]pub fn usage_report(state: State<'_, AppState>, days: Option<u32>) -> R<UsageReport> {let now = chrono::Local::now();let since_ms = match days {Some(d) => now.timestamp_millis() - d as i64 * DAY_MS,None => month_start_ms(&now),};let offset_minutes = now.offset().local_minus_utc() / 60;let db = state.db();Ok(UsageReport {by_model: db.usage_by_model(since_ms).map_err(e)?,by_day: db.usage_by_day(since_ms, offset_minutes).map_err(e)?,since_ms,})}// ---- presets and settings -------------------------------------------------#[tauri::command]pub fn list_presets(state: State<'_, AppState>) -> R<Vec<Preset>> {state.db().list_presets().map_err(e)}#[tauri::command]pub fn save_preset(state: State<'_, AppState>, preset: Preset) -> R<Preset> {if preset.name.trim().is_empty() {return Err("Give the preset a name.".into());}state.db().save_preset(&preset).map_err(|err| {if err.to_string().contains("UNIQUE") {"A preset with that name already exists.".to_string()} else {err.to_string()}})}#[tauri::command]pub fn delete_preset(state: State<'_, AppState>, id: String) -> R<()> {state.db().delete_preset(&id).map_err(e)}#[tauri::command]pub fn get_settings(state: State<'_, AppState>) -> Settings {load_settings(&state)}#[tauri::command]pub fn save_settings(app: AppHandle, state: State<'_, AppState>, settings: Settings) -> R<SaveSettingsResult> {let old = load_settings(&state);let mut settings = settings;settings.glass_tint = settings.glass_tint.min(100);let mut shortcut_error = None;if settings.shortcut != old.shortcut {if let Err(err) = windows::set_shortcut(&app, &old.shortcut, &settings.shortcut) {shortcut_error = Some(err);settings.shortcut = old.shortcut.clone();}}state.db().set_setting(SETTINGS_KEY, &settings).map_err(e)?;windows::apply_theme(&app, &settings.theme);if settings.material != old.material {windows::apply_material(&app, &settings.material);}windows::refresh_tray_spend(&app);let _ = app.emit("settings-changed", &settings);Ok(SaveSettingsResult { settings, shortcut_error })}// ---- attachments ----------------------------------------------------------fn ingest(state: &AppState, filename: &str, bytes: &[u8]) -> R<AttachmentMeta> {let filename = Path::new(filename).file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();if attachments::is_image_name(&filename) {let img = attachments::process_image(&filename, bytes).map_err(e)?;let path = attachments::store(&state.attachments_dir(), &img).map_err(e)?;let path = path.to_string_lossy().to_string();state.db().insert_attachment(&NewAttachment {kind: "image",filename: &filename,mime: img.mime,byte_size: img.bytes.len() as i64,sha256: &img.sha256,width: Some(img.width),height: Some(img.height),path: Some(&path),text_content: None,}).map_err(e)} else {let text = attachments::read_text(&filename, bytes).map_err(e)?;state.db().insert_attachment(&NewAttachment {kind: "text",filename: &filename,mime: "text/plain",byte_size: bytes.len() as i64,sha256: &attachments::sha256_hex(bytes),width: None,height: None,path: None,text_content: Some(&text),}).map_err(e)}}/// The native file picker, opened from Rust so no path ever comes from the page.#[tauri::command]pub async fn pick_attachments(app: AppHandle, images_allowed: bool) -> R<Vec<AttachmentMeta>> {use tauri_plugin_dialog::DialogExt;let state = app.state::<AppState>();*lock(&state.dialog_open) = true;let mut exts = vec!["txt", "md", "markdown", "csv", "json", "yaml", "yml", "toml", "log", "py", "rs", "ts", "tsx", "js", "sh","sql", "html", "css",];if images_allowed {exts.extend(attachments::IMAGE_EXTENSIONS);}let dialog = app.dialog().file().add_filter(if images_allowed { "Images and text" } else { "Text" }, &exts);let picked = tauri::async_runtime::spawn_blocking(move || dialog.blocking_pick_files()).await;// Cleared before the `?`, or a failed pick would leave it set and the quick// window would never hide on blur again.*lock(&state.dialog_open) = false;let picked = picked.map_err(e)?;let mut out = Vec::new();for f in picked.unwrap_or_default() {let path: PathBuf = f.into_path().map_err(e)?;let bytes = std::fs::read(&path).map_err(e)?;out.push(ingest(&state, &path.to_string_lossy(), &bytes)?);}Ok(out)}/// Pasted or dropped files arrive as a raw body with the name in a header.#[tauri::command]pub fn attach_bytes(state: State<'_, AppState>, request: Request<'_>) -> R<AttachmentMeta> {let InvokeBody::Raw(bytes) = request.body() else {return Err("expected raw bytes".into());};let name = request.headers().get("x-filename").and_then(|v| v.to_str().ok()).map(percent_decode).unwrap_or_else(|| "pasted.png".into());ingest(&state, &name, bytes)}fn percent_decode(s: &str) -> String {let bytes = s.as_bytes();let mut out = Vec::with_capacity(bytes.len());let mut i = 0;while i < bytes.len() {if bytes[i] == b'%' && i + 2 < bytes.len() {if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {out.push(v);i += 3;continue;}}out.push(bytes[i]);i += 1;}String::from_utf8_lossy(&out).into_owned()}/// An attachment's bytes, for previews (a Blob URL on the page).#[tauri::command]pub fn attachment_bytes(state: State<'_, AppState>, id: String) -> R<Response> {let a = state.db().attachment(&id).map_err(e)?;let bytes = match (&a.path, &a.text_content) {(Some(p), _) => std::fs::read(p).map_err(e)?,(None, Some(t)) => t.as_bytes().to_vec(),_ => vec![],};Ok(Response::new(bytes))}#[tauri::command]pub fn discard_attachment(state: State<'_, AppState>, id: String) -> R<()> {let path = state.db().delete_draft_attachment(&id).map_err(e)?;if let Some(p) = path {remove_unused_files(&state, &[p]);}Ok(())}// ---- export ---------------------------------------------------------------#[tauri::command]pub async fn choose_vault_folder(app: AppHandle) -> R<Option<String>> {use tauri_plugin_dialog::DialogExt;let state = app.state::<AppState>();let dialog = app.dialog().file().set_title("Choose the folder chats are exported to");let picked = tauri::async_runtime::spawn_blocking(move || dialog.blocking_pick_folder()).await.map_err(e)?;let Some(dir) = picked else { return Ok(None) };let dir = dir.into_path().map_err(e)?.to_string_lossy().to_string();let mut s = load_settings(&state);s.vault_dir = Some(dir.clone());state.db().set_setting(SETTINGS_KEY, &s).map_err(e)?;Ok(Some(dir))}fn unique_path(dir: &Path, filename: &str) -> PathBuf {let path = dir.join(filename);if !path.exists() {return path;}let (stem, ext) = filename.rsplit_once('.').unwrap_or((filename, "md"));(2..).map(|n| dir.join(format!("{stem} {n}.{ext}"))).find(|p| !p.exists()).unwrap()}#[tauri::command]pub async fn export_conversation(app: AppHandle, id: String) -> R<ExportResult> {let state = app.state::<AppState>();let mut dir = load_settings(&state).vault_dir;if dir.is_none() {dir = choose_vault_folder(app.clone()).await?;}let Some(dir) = dir else { return Err("No export folder chosen.".into()) };let dir = PathBuf::from(dir);let settings = load_settings(&state);let (conv, messages, paths) = {let db = state.db();let conv = db.get_conversation(&id).map_err(e)?;let messages = db.list_messages(&id).map_err(e)?;let mut paths = std::collections::HashMap::new();for m in &messages {for a in db.attachment_data_for(&m.id).map_err(e)? {if let Some(p) = a.path {paths.insert(a.meta.id.clone(), p);}}}(conv, messages, paths)};let links = if settings.image_links == "markdown" { ImageLinks::Markdown } else { ImageLinks::Wiki };let doc = to_markdown(&conv, &messages, &|aid| paths.get(aid).cloned(), links, &chrono::Local::now().timezone());std::fs::create_dir_all(&dir).map_err(e)?;let note = unique_path(&dir, &doc.filename);let mut copied = 0;for f in &doc.files {let target = dir.join(&f.relative_path);if let Some(parent) = target.parent() {std::fs::create_dir_all(parent).map_err(e)?;}if !target.exists() {std::fs::copy(&f.source_path, &target).map_err(e)?;copied += 1;}}// Write beside it, then rename: a vault watcher (or Obsidian) never sees// a half-written note, because the rename is atomic.let tmp = dir.join(format!(".{}.cortex-tmp", note.file_name().and_then(|n| n.to_str()).unwrap_or("note.md")));std::fs::write(&tmp, doc.markdown).map_err(e)?;std::fs::rename(&tmp, ¬e).map_err(|err| {let _ = std::fs::remove_file(&tmp);err.to_string()})?;Ok(ExportResult { path: note.to_string_lossy().to_string(), files_copied: copied })}#[cfg(test)]mod tests {use super::*;#[test]fn decodes_filenames() {assert_eq!(percent_decode("caf%C3%A9%20menu.png"), "caf\u{e9} menu.png");assert_eq!(percent_decode("100%"), "100%");assert_eq!(percent_decode("a%2"), "a%2");}#[test]fn unique_names() {let d = std::env::temp_dir().join(format!("cortex-u-{}", std::process::id()));std::fs::create_dir_all(&d).unwrap();std::fs::write(d.join("Chat.md"), "").unwrap();assert_eq!(unique_path(&d, "Chat.md").file_name().unwrap(), "Chat 2.md");std::fs::remove_dir_all(&d).unwrap();}}