mattrguerra/cortexmainPrivate on GitHub, published here in full

commands.rs

Language Rust25.5 KB717 lines

src-tauri/src/commands.rs

  1. //! The commands the interface can call. Each one delegates to cortex-core.
  2. use std::path::{Path, PathBuf};
  3. use cortex_core::api::*;
  4. use cortex_core::attachments;
  5. use cortex_core::chat::{build_request, request_tokens, run_turn};
  6. use cortex_core::cost::cost_nanousd;
  7. use cortex_core::estimate;
  8. use cortex_core::export::{to_markdown, ImageLinks};
  9. use cortex_core::pricing;
  10. use cortex_core::provider::{Client, ProviderError};
  11. use cortex_core::storage::{
  12. self, month_start_ms, AttachmentMeta, Conversation, ConversationSummary, NewAttachment, Preset, Provider,
  13. SearchHit, DEEPINFRA_ID,
  14. };
  15. use cortex_core::types::Usage;
  16. use tauri::ipc::{Channel, InvokeBody, Request, Response};
  17. use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow};
  18. use tokio_util::sync::CancellationToken;
  19. use crate::state::{lock, AppState};
  20. use crate::windows;
  21. type R<T> = Result<T, String>;
  22. fn e<E: ToString>(err: E) -> String {
  23. err.to_string()
  24. }
  25. pub const SETTINGS_KEY: &str = "settings";
  26. pub const DAY_MS: i64 = 24 * 3600 * 1000;
  27. pub fn load_settings(state: &AppState) -> Settings {
  28. state.db().get_setting::<Settings>(SETTINGS_KEY).ok().flatten().unwrap_or_default()
  29. }
  30. // ---- app and windows ------------------------------------------------------
  31. #[tauri::command]
  32. pub fn app_info(state: State<'_, AppState>) -> AppInfo {
  33. AppInfo {
  34. version: env!("CARGO_PKG_VERSION").into(),
  35. platform: std::env::consts::OS.into(),
  36. vibrancy: cfg!(target_os = "macos"),
  37. data_dir: state.data_dir.display().to_string(),
  38. }
  39. }
  40. /// The page has painted: show the window now, so it never flashes blank.
  41. #[tauri::command]
  42. pub fn window_ready(window: WebviewWindow) {
  43. if window.label() == "main" {
  44. let _ = window.show();
  45. let _ = window.set_focus();
  46. }
  47. }
  48. #[tauri::command]
  49. pub fn show_main(app: AppHandle) {
  50. windows::show_main(&app);
  51. }
  52. #[tauri::command]
  53. pub fn quick_open_in_main(app: AppHandle, conversation_id: String) {
  54. windows::hide_quick(&app);
  55. windows::show_main(&app);
  56. let _ = app.emit_to("main", "open-conversation", conversation_id);
  57. }
  58. #[tauri::command]
  59. pub fn hide_quick(app: AppHandle) {
  60. windows::hide_quick(&app);
  61. }
  62. #[tauri::command]
  63. pub fn resize_quick(app: AppHandle, height: f64) {
  64. if let Some(w) = app.get_webview_window("quick") {
  65. let _ = w.set_size(tauri::LogicalSize::new(windows::QUICK_WIDTH, height.clamp(64.0, windows::QUICK_MAX)));
  66. }
  67. }
  68. // ---- providers and keys ---------------------------------------------------
  69. #[tauri::command]
  70. pub fn list_providers(state: State<'_, AppState>) -> R<Vec<Provider>> {
  71. state.db().list_providers().map_err(e)
  72. }
  73. #[tauri::command]
  74. pub fn save_provider(state: State<'_, AppState>, mut provider: Provider) -> R<Provider> {
  75. if provider.id.trim().is_empty() {
  76. provider.id = storage::new_id();
  77. }
  78. if provider.id != DEEPINFRA_ID {
  79. provider.kind = "openai_compatible".into();
  80. }
  81. let url = provider.base_url.trim();
  82. if !(url.starts_with("http://") || url.starts_with("https://")) {
  83. return Err("The address must start with http:// or https://".into());
  84. }
  85. state.db().upsert_provider(&provider).map_err(e)?;
  86. state.db().get_provider(&provider.id).map_err(e)
  87. }
  88. #[tauri::command]
  89. pub fn delete_provider(state: State<'_, AppState>, id: String) -> R<()> {
  90. let _ = state.keys.clear(&id);
  91. state.db().delete_provider(&id).map_err(e)
  92. }
  93. /// Check a key with the provider, then store it. A key the provider refuses is
  94. /// not stored; one that could not be checked (offline, say) is, and says so.
  95. #[tauri::command]
  96. pub async fn set_api_key(app: AppHandle, provider_id: String, key: String) -> R<KeySaved> {
  97. let key = cortex_core::secrets::clean_key(&key);
  98. if key.is_empty() {
  99. return Err("The key is empty.".into());
  100. }
  101. let state = app.state::<AppState>();
  102. let provider = state.db().get_provider(&provider_id).map_err(e)?;
  103. // Listing models with a bad key gets a 401, so it doubles as a free check.
  104. let detail = match Client::new(&provider.base_url, Some(key.clone())).list_models().await {
  105. Ok(_) => None,
  106. Err(ProviderError::InvalidKey) => {
  107. return Err(format!(
  108. "{} rejected this key, so it was not saved. Copy the whole key again and paste it.",
  109. provider.name
  110. ))
  111. }
  112. Err(err) => Some(err.to_string()),
  113. };
  114. state.keys.set(&provider_id, &key).map_err(|err| format!("Could not store the key in the keychain: {err}"))?;
  115. Ok(KeySaved { accepted: detail.is_none(), detail })
  116. }
  117. #[tauri::command]
  118. pub fn clear_api_key(state: State<'_, AppState>, provider_id: String) -> R<()> {
  119. state.keys.clear(&provider_id)
  120. }
  121. #[tauri::command]
  122. pub fn has_api_key(state: State<'_, AppState>, provider_id: String) -> R<bool> {
  123. state.keys.has(&provider_id)
  124. }
  125. // ---- models ---------------------------------------------------------------
  126. fn models_response(state: &AppState, provider_id: &str, warning: Option<String>) -> R<ModelsResponse> {
  127. let db = state.db();
  128. let mut models = db.list_models(provider_id).map_err(e)?;
  129. let mut source = db.models_source(provider_id).map_err(e)?;
  130. let mut snapshot_date = None;
  131. if models.is_empty() && provider_id == DEEPINFRA_ID {
  132. // First run offline: the bundled prices.
  133. let snap = pricing::snapshot(DEEPINFRA_ID);
  134. db.replace_models(DEEPINFRA_ID, &snap.models, "snapshot", 0).map_err(e)?;
  135. models = snap.models;
  136. snapshot_date = Some(snap.fetched_at);
  137. source = Some(("snapshot".into(), 0));
  138. }
  139. let (source, fetched_at) = source.unwrap_or(("local".into(), 0));
  140. Ok(ModelsResponse {
  141. models,
  142. snapshot_date: (source == "snapshot")
  143. .then(|| snapshot_date.unwrap_or_else(|| pricing::snapshot(DEEPINFRA_ID).fetched_at)),
  144. fetched_at: (fetched_at > 0).then_some(fetched_at),
  145. source,
  146. warning,
  147. })
  148. }
  149. #[tauri::command]
  150. pub fn list_models(state: State<'_, AppState>, provider_id: String) -> R<ModelsResponse> {
  151. models_response(&state, &provider_id, None)
  152. }
  153. /// Fetch a provider's model list (DeepInfra's carries prices). On failure the
  154. /// stored list stays, and the response says why.
  155. pub async fn refresh_models_for(app: &AppHandle, provider_id: &str) -> R<ModelsResponse> {
  156. let state = app.state::<AppState>();
  157. let provider = state.db().get_provider(provider_id).map_err(e)?;
  158. let key = state.keys.get(provider_id).ok().flatten();
  159. let client = Client::new(&provider.base_url, key);
  160. let warning = match client.list_models().await {
  161. Ok(v) => {
  162. let (models, source) = if provider.kind == "deepinfra" {
  163. (pricing::parse_priced_models(provider_id, &v), "live")
  164. } else {
  165. (pricing::parse_local_models(provider_id, &v), "local")
  166. };
  167. if models.is_empty() {
  168. Some("The provider listed no chat models.".to_string())
  169. } else {
  170. state.db().replace_models(provider_id, &models, source, storage::now_ms()).map_err(e)?;
  171. None
  172. }
  173. }
  174. Err(err) => Some(err.to_string()),
  175. };
  176. models_response(&state, provider_id, warning)
  177. }
  178. #[tauri::command]
  179. pub async fn refresh_models(app: AppHandle, provider_id: String) -> R<ModelsResponse> {
  180. refresh_models_for(&app, &provider_id).await
  181. }
  182. /// At startup: refresh any list older than a day, quietly.
  183. pub async fn refresh_stale(app: AppHandle) {
  184. let providers = app.state::<AppState>().db().list_providers().unwrap_or_default();
  185. for p in providers {
  186. let age = app.state::<AppState>().db().models_source(&p.id).ok().flatten();
  187. let stale = match age {
  188. Some((src, at)) => src == "snapshot" || storage::now_ms() - at > DAY_MS,
  189. None => true,
  190. };
  191. if stale {
  192. let _ = refresh_models_for(&app, &p.id).await;
  193. }
  194. }
  195. let _ = app.emit("models-updated", ());
  196. }
  197. // ---- conversations --------------------------------------------------------
  198. #[tauri::command]
  199. pub fn list_conversations(state: State<'_, AppState>, include_archived: bool) -> R<Vec<ConversationSummary>> {
  200. state.db().list_conversations(include_archived, 500).map_err(e)
  201. }
  202. #[tauri::command]
  203. pub fn get_conversation(state: State<'_, AppState>, id: String) -> R<ConversationView> {
  204. let db = state.db();
  205. Ok(ConversationView {
  206. conversation: db.get_conversation(&id).map_err(e)?,
  207. messages: db.list_messages(&id).map_err(e)?,
  208. })
  209. }
  210. #[tauri::command]
  211. pub fn create_conversation(
  212. state: State<'_, AppState>,
  213. provider_id: String,
  214. model_id: String,
  215. preset_id: Option<String>,
  216. origin: Option<String>,
  217. ) -> R<Conversation> {
  218. let db = state.db();
  219. let preset = match &preset_id {
  220. Some(id) => db.list_presets().map_err(e)?.into_iter().find(|p| &p.id == id),
  221. None => None,
  222. };
  223. let (provider_id, model_id) = match &preset {
  224. Some(Preset { provider_id: Some(p), model_id: Some(m), .. }) => (p.clone(), m.clone()),
  225. _ => (provider_id, model_id),
  226. };
  227. let system = preset.as_ref().map(|p| p.system_prompt.as_str()).unwrap_or("");
  228. let origin = if origin.as_deref() == Some("quick") { "quick" } else { "main" };
  229. db.create_conversation(&provider_id, &model_id, system, preset_id.as_deref(), origin).map_err(e)
  230. }
  231. #[tauri::command]
  232. pub fn rename_conversation(state: State<'_, AppState>, id: String, title: String) -> R<()> {
  233. state.db().rename_conversation(&id, &title).map_err(e)
  234. }
  235. #[tauri::command]
  236. pub fn set_pinned(state: State<'_, AppState>, id: String, pinned: bool) -> R<()> {
  237. state.db().set_pinned(&id, pinned).map_err(e)
  238. }
  239. #[tauri::command]
  240. pub fn set_archived(state: State<'_, AppState>, id: String, archived: bool) -> R<()> {
  241. state.db().set_archived(&id, archived).map_err(e)
  242. }
  243. #[tauri::command]
  244. pub fn delete_conversation(state: State<'_, AppState>, id: String) -> R<()> {
  245. let paths: Vec<String> = {
  246. let db = state.db();
  247. let msgs = db.list_messages(&id).map_err(e)?;
  248. let mut paths = Vec::new();
  249. for m in &msgs {
  250. for a in db.attachment_data_for(&m.id).map_err(e)? {
  251. paths.extend(a.path);
  252. }
  253. }
  254. db.delete_conversation(&id).map_err(e)?;
  255. paths
  256. };
  257. remove_unused_files(&state, &paths);
  258. Ok(())
  259. }
  260. pub fn remove_unused_files(state: &AppState, paths: &[String]) {
  261. for p in paths {
  262. if !state.db().attachment_path_in_use(p).unwrap_or(true) {
  263. let _ = std::fs::remove_file(p);
  264. }
  265. }
  266. }
  267. #[tauri::command]
  268. pub fn set_conversation_model(state: State<'_, AppState>, id: String, provider_id: String, model_id: String) -> R<()> {
  269. state.db().set_conversation_model(&id, &provider_id, &model_id).map_err(e)
  270. }
  271. #[tauri::command]
  272. pub fn set_system_prompt(
  273. state: State<'_, AppState>,
  274. id: String,
  275. system_prompt: String,
  276. preset_id: Option<String>,
  277. ) -> R<()> {
  278. state.db().set_system_prompt(&id, &system_prompt, preset_id.as_deref()).map_err(e)
  279. }
  280. #[tauri::command]
  281. pub fn truncate_from(state: State<'_, AppState>, conversation_id: String, message_id: String) -> R<()> {
  282. state.db().truncate_from(&conversation_id, &message_id).map(|_| ()).map_err(e)
  283. }
  284. // ---- sending --------------------------------------------------------------
  285. #[tauri::command]
  286. pub fn send_message(
  287. app: AppHandle,
  288. state: State<'_, AppState>,
  289. req: SendRequest,
  290. on_event: Channel<StreamEvent>,
  291. ) -> R<SendAck> {
  292. let (conv, provider) = {
  293. let db = state.db();
  294. let conv = db.get_conversation(&req.conversation_id).map_err(e)?;
  295. let provider = db.get_provider(&conv.provider_id).map_err(e)?;
  296. (conv, provider)
  297. };
  298. let key = state.keys.get(&provider.id)?;
  299. if provider.requires_key && key.as_deref().unwrap_or("").is_empty() {
  300. return Err(format!("Add your {} API key in Settings first.", provider.name));
  301. }
  302. let (user_message, request, assistant, prices) = {
  303. let db = state.db();
  304. let user_message = if !req.text.trim().is_empty() || !req.attachment_ids.is_empty() {
  305. Some(db.insert_user_message(&conv.id, &req.text, &req.attachment_ids).map_err(e)?)
  306. } else {
  307. None
  308. };
  309. let request = build_request(&db, &conv.id).map_err(e)?;
  310. let assistant = db.insert_assistant_placeholder(&conv.id, &provider.id, &conv.model_id).map_err(e)?;
  311. let prices = db.model(&provider.id, &conv.model_id).map_err(e)?.map(|m| m.prices);
  312. (user_message, request, assistant, prices)
  313. };
  314. let stream_id = storage::new_id();
  315. let token = CancellationToken::new();
  316. lock(&state.streams).insert(stream_id.clone(), token.clone());
  317. let client = Client::new(&provider.base_url, key);
  318. let (app2, sid, placeholder) = (app.clone(), stream_id.clone(), assistant.clone());
  319. tauri::async_runtime::spawn(async move {
  320. let state = app2.state::<AppState>();
  321. let _ = run_turn(&state.db, &client, &request, &placeholder, prices, &token, |ev| {
  322. let _ = on_event.send(ev);
  323. })
  324. .await;
  325. lock(&state.streams).remove(&sid);
  326. windows::refresh_tray_spend(&app2);
  327. let _ = app2.emit("conversations-changed", ());
  328. });
  329. Ok(SendAck { stream_id, user_message, assistant_message: assistant })
  330. }
  331. #[tauri::command]
  332. pub fn cancel_stream(state: State<'_, AppState>, stream_id: String) {
  333. if let Some(t) = lock(&state.streams).get(&stream_id) {
  334. t.cancel();
  335. }
  336. }
  337. /// What sending `text` (plus images) would cost at the input price.
  338. #[tauri::command]
  339. pub fn estimate_prompt(
  340. state: State<'_, AppState>,
  341. conversation_id: Option<String>,
  342. provider_id: String,
  343. model_id: String,
  344. text: String,
  345. image_count: u32,
  346. extra_text: Option<String>,
  347. ) -> R<Estimate> {
  348. let db = state.db();
  349. let history = match &conversation_id {
  350. Some(id) => build_request(&db, id).map(|r| request_tokens(&r)).unwrap_or(0),
  351. None => 0,
  352. };
  353. let new = estimate::estimate_prompt([(text.as_str(), image_count as usize)])
  354. + estimate::estimate_tokens(extra_text.as_deref().unwrap_or(""));
  355. let prompt_tokens = history + new;
  356. let model = db.model(&provider_id, &model_id).map_err(e)?;
  357. let prompt_cost_nanousd = model
  358. .as_ref()
  359. .map(|m| cost_nanousd(&Usage { prompt_tokens, completion_tokens: 0, cached_tokens: 0 }, &m.prices))
  360. .unwrap_or(0);
  361. Ok(Estimate { prompt_tokens, prompt_cost_nanousd, context_length: model.and_then(|m| m.context_length) })
  362. }
  363. // ---- search, spend, usage -------------------------------------------------
  364. #[tauri::command]
  365. pub fn search(state: State<'_, AppState>, query: String) -> R<Vec<SearchHit>> {
  366. state.db().search(&query, 40).map_err(e)
  367. }
  368. pub fn spend(state: &AppState) -> R<SpendSummary> {
  369. let settings = load_settings(state);
  370. let (month, estimated) = state.db().spend_since(month_start_ms(&chrono::Local::now())).map_err(e)?;
  371. Ok(SpendSummary {
  372. month_nanousd: month,
  373. month_estimated: estimated,
  374. budget_usd: settings.monthly_budget_usd,
  375. budget_level: budget_level(month, settings.monthly_budget_usd),
  376. })
  377. }
  378. #[tauri::command]
  379. pub fn spend_summary(state: State<'_, AppState>) -> R<SpendSummary> {
  380. spend(&state)
  381. }
  382. #[tauri::command]
  383. pub fn usage_report(state: State<'_, AppState>, days: Option<u32>) -> R<UsageReport> {
  384. let now = chrono::Local::now();
  385. let since_ms = match days {
  386. Some(d) => now.timestamp_millis() - d as i64 * DAY_MS,
  387. None => month_start_ms(&now),
  388. };
  389. let offset_minutes = now.offset().local_minus_utc() / 60;
  390. let db = state.db();
  391. Ok(UsageReport {
  392. by_model: db.usage_by_model(since_ms).map_err(e)?,
  393. by_day: db.usage_by_day(since_ms, offset_minutes).map_err(e)?,
  394. since_ms,
  395. })
  396. }
  397. // ---- presets and settings -------------------------------------------------
  398. #[tauri::command]
  399. pub fn list_presets(state: State<'_, AppState>) -> R<Vec<Preset>> {
  400. state.db().list_presets().map_err(e)
  401. }
  402. #[tauri::command]
  403. pub fn save_preset(state: State<'_, AppState>, preset: Preset) -> R<Preset> {
  404. if preset.name.trim().is_empty() {
  405. return Err("Give the preset a name.".into());
  406. }
  407. state.db().save_preset(&preset).map_err(|err| {
  408. if err.to_string().contains("UNIQUE") {
  409. "A preset with that name already exists.".to_string()
  410. } else {
  411. err.to_string()
  412. }
  413. })
  414. }
  415. #[tauri::command]
  416. pub fn delete_preset(state: State<'_, AppState>, id: String) -> R<()> {
  417. state.db().delete_preset(&id).map_err(e)
  418. }
  419. #[tauri::command]
  420. pub fn get_settings(state: State<'_, AppState>) -> Settings {
  421. load_settings(&state)
  422. }
  423. #[tauri::command]
  424. pub fn save_settings(app: AppHandle, state: State<'_, AppState>, settings: Settings) -> R<SaveSettingsResult> {
  425. let old = load_settings(&state);
  426. let mut settings = settings;
  427. settings.glass_tint = settings.glass_tint.min(100);
  428. let mut shortcut_error = None;
  429. if settings.shortcut != old.shortcut {
  430. if let Err(err) = windows::set_shortcut(&app, &old.shortcut, &settings.shortcut) {
  431. shortcut_error = Some(err);
  432. settings.shortcut = old.shortcut.clone();
  433. }
  434. }
  435. state.db().set_setting(SETTINGS_KEY, &settings).map_err(e)?;
  436. windows::apply_theme(&app, &settings.theme);
  437. if settings.material != old.material {
  438. windows::apply_material(&app, &settings.material);
  439. }
  440. windows::refresh_tray_spend(&app);
  441. let _ = app.emit("settings-changed", &settings);
  442. Ok(SaveSettingsResult { settings, shortcut_error })
  443. }
  444. // ---- attachments ----------------------------------------------------------
  445. fn ingest(state: &AppState, filename: &str, bytes: &[u8]) -> R<AttachmentMeta> {
  446. let filename = Path::new(filename).file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
  447. if attachments::is_image_name(&filename) {
  448. let img = attachments::process_image(&filename, bytes).map_err(e)?;
  449. let path = attachments::store(&state.attachments_dir(), &img).map_err(e)?;
  450. let path = path.to_string_lossy().to_string();
  451. state
  452. .db()
  453. .insert_attachment(&NewAttachment {
  454. kind: "image",
  455. filename: &filename,
  456. mime: img.mime,
  457. byte_size: img.bytes.len() as i64,
  458. sha256: &img.sha256,
  459. width: Some(img.width),
  460. height: Some(img.height),
  461. path: Some(&path),
  462. text_content: None,
  463. })
  464. .map_err(e)
  465. } else {
  466. let text = attachments::read_text(&filename, bytes).map_err(e)?;
  467. state
  468. .db()
  469. .insert_attachment(&NewAttachment {
  470. kind: "text",
  471. filename: &filename,
  472. mime: "text/plain",
  473. byte_size: bytes.len() as i64,
  474. sha256: &attachments::sha256_hex(bytes),
  475. width: None,
  476. height: None,
  477. path: None,
  478. text_content: Some(&text),
  479. })
  480. .map_err(e)
  481. }
  482. }
  483. /// The native file picker, opened from Rust so no path ever comes from the page.
  484. #[tauri::command]
  485. pub async fn pick_attachments(app: AppHandle, images_allowed: bool) -> R<Vec<AttachmentMeta>> {
  486. use tauri_plugin_dialog::DialogExt;
  487. let state = app.state::<AppState>();
  488. *lock(&state.dialog_open) = true;
  489. let mut exts = vec![
  490. "txt", "md", "markdown", "csv", "json", "yaml", "yml", "toml", "log", "py", "rs", "ts", "tsx", "js", "sh",
  491. "sql", "html", "css",
  492. ];
  493. if images_allowed {
  494. exts.extend(attachments::IMAGE_EXTENSIONS);
  495. }
  496. let dialog = app.dialog().file().add_filter(if images_allowed { "Images and text" } else { "Text" }, &exts);
  497. let picked = tauri::async_runtime::spawn_blocking(move || dialog.blocking_pick_files()).await;
  498. // Cleared before the `?`, or a failed pick would leave it set and the quick
  499. // window would never hide on blur again.
  500. *lock(&state.dialog_open) = false;
  501. let picked = picked.map_err(e)?;
  502. let mut out = Vec::new();
  503. for f in picked.unwrap_or_default() {
  504. let path: PathBuf = f.into_path().map_err(e)?;
  505. let bytes = std::fs::read(&path).map_err(e)?;
  506. out.push(ingest(&state, &path.to_string_lossy(), &bytes)?);
  507. }
  508. Ok(out)
  509. }
  510. /// Pasted or dropped files arrive as a raw body with the name in a header.
  511. #[tauri::command]
  512. pub fn attach_bytes(state: State<'_, AppState>, request: Request<'_>) -> R<AttachmentMeta> {
  513. let InvokeBody::Raw(bytes) = request.body() else {
  514. return Err("expected raw bytes".into());
  515. };
  516. let name = request
  517. .headers()
  518. .get("x-filename")
  519. .and_then(|v| v.to_str().ok())
  520. .map(percent_decode)
  521. .unwrap_or_else(|| "pasted.png".into());
  522. ingest(&state, &name, bytes)
  523. }
  524. fn percent_decode(s: &str) -> String {
  525. let bytes = s.as_bytes();
  526. let mut out = Vec::with_capacity(bytes.len());
  527. let mut i = 0;
  528. while i < bytes.len() {
  529. if bytes[i] == b'%' && i + 2 < bytes.len() {
  530. if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
  531. out.push(v);
  532. i += 3;
  533. continue;
  534. }
  535. }
  536. out.push(bytes[i]);
  537. i += 1;
  538. }
  539. String::from_utf8_lossy(&out).into_owned()
  540. }
  541. /// An attachment's bytes, for previews (a Blob URL on the page).
  542. #[tauri::command]
  543. pub fn attachment_bytes(state: State<'_, AppState>, id: String) -> R<Response> {
  544. let a = state.db().attachment(&id).map_err(e)?;
  545. let bytes = match (&a.path, &a.text_content) {
  546. (Some(p), _) => std::fs::read(p).map_err(e)?,
  547. (None, Some(t)) => t.as_bytes().to_vec(),
  548. _ => vec![],
  549. };
  550. Ok(Response::new(bytes))
  551. }
  552. #[tauri::command]
  553. pub fn discard_attachment(state: State<'_, AppState>, id: String) -> R<()> {
  554. let path = state.db().delete_draft_attachment(&id).map_err(e)?;
  555. if let Some(p) = path {
  556. remove_unused_files(&state, &[p]);
  557. }
  558. Ok(())
  559. }
  560. // ---- export ---------------------------------------------------------------
  561. #[tauri::command]
  562. pub async fn choose_vault_folder(app: AppHandle) -> R<Option<String>> {
  563. use tauri_plugin_dialog::DialogExt;
  564. let state = app.state::<AppState>();
  565. let dialog = app.dialog().file().set_title("Choose the folder chats are exported to");
  566. let picked = tauri::async_runtime::spawn_blocking(move || dialog.blocking_pick_folder()).await.map_err(e)?;
  567. let Some(dir) = picked else { return Ok(None) };
  568. let dir = dir.into_path().map_err(e)?.to_string_lossy().to_string();
  569. let mut s = load_settings(&state);
  570. s.vault_dir = Some(dir.clone());
  571. state.db().set_setting(SETTINGS_KEY, &s).map_err(e)?;
  572. Ok(Some(dir))
  573. }
  574. fn unique_path(dir: &Path, filename: &str) -> PathBuf {
  575. let path = dir.join(filename);
  576. if !path.exists() {
  577. return path;
  578. }
  579. let (stem, ext) = filename.rsplit_once('.').unwrap_or((filename, "md"));
  580. (2..).map(|n| dir.join(format!("{stem} {n}.{ext}"))).find(|p| !p.exists()).unwrap()
  581. }
  582. #[tauri::command]
  583. pub async fn export_conversation(app: AppHandle, id: String) -> R<ExportResult> {
  584. let state = app.state::<AppState>();
  585. let mut dir = load_settings(&state).vault_dir;
  586. if dir.is_none() {
  587. dir = choose_vault_folder(app.clone()).await?;
  588. }
  589. let Some(dir) = dir else { return Err("No export folder chosen.".into()) };
  590. let dir = PathBuf::from(dir);
  591. let settings = load_settings(&state);
  592. let (conv, messages, paths) = {
  593. let db = state.db();
  594. let conv = db.get_conversation(&id).map_err(e)?;
  595. let messages = db.list_messages(&id).map_err(e)?;
  596. let mut paths = std::collections::HashMap::new();
  597. for m in &messages {
  598. for a in db.attachment_data_for(&m.id).map_err(e)? {
  599. if let Some(p) = a.path {
  600. paths.insert(a.meta.id.clone(), p);
  601. }
  602. }
  603. }
  604. (conv, messages, paths)
  605. };
  606. let links = if settings.image_links == "markdown" { ImageLinks::Markdown } else { ImageLinks::Wiki };
  607. let doc = to_markdown(&conv, &messages, &|aid| paths.get(aid).cloned(), links, &chrono::Local::now().timezone());
  608. std::fs::create_dir_all(&dir).map_err(e)?;
  609. let note = unique_path(&dir, &doc.filename);
  610. let mut copied = 0;
  611. for f in &doc.files {
  612. let target = dir.join(&f.relative_path);
  613. if let Some(parent) = target.parent() {
  614. std::fs::create_dir_all(parent).map_err(e)?;
  615. }
  616. if !target.exists() {
  617. std::fs::copy(&f.source_path, &target).map_err(e)?;
  618. copied += 1;
  619. }
  620. }
  621. // Write beside it, then rename: a vault watcher (or Obsidian) never sees
  622. // a half-written note, because the rename is atomic.
  623. let tmp = dir.join(format!(".{}.cortex-tmp", note.file_name().and_then(|n| n.to_str()).unwrap_or("note.md")));
  624. std::fs::write(&tmp, doc.markdown).map_err(e)?;
  625. std::fs::rename(&tmp, &note).map_err(|err| {
  626. let _ = std::fs::remove_file(&tmp);
  627. err.to_string()
  628. })?;
  629. Ok(ExportResult { path: note.to_string_lossy().to_string(), files_copied: copied })
  630. }
  631. #[cfg(test)]
  632. mod tests {
  633. use super::*;
  634. #[test]
  635. fn decodes_filenames() {
  636. assert_eq!(percent_decode("caf%C3%A9%20menu.png"), "caf\u{e9} menu.png");
  637. assert_eq!(percent_decode("100%"), "100%");
  638. assert_eq!(percent_decode("a%2"), "a%2");
  639. }
  640. #[test]
  641. fn unique_names() {
  642. let d = std::env::temp_dir().join(format!("cortex-u-{}", std::process::id()));
  643. std::fs::create_dir_all(&d).unwrap();
  644. std::fs::write(d.join("Chat.md"), "").unwrap();
  645. assert_eq!(unique_path(&d, "Chat.md").file_name().unwrap(), "Chat 2.md");
  646. std::fs::remove_dir_all(&d).unwrap();
  647. }
  648. }