mattrguerra/cortexmainPrivate on GitHub, published here in full

mod.rs

Language Rust31.8 KB882 lines

crates/cortex-core/src/storage/mod.rs

  1. //! Storage: one SQLite file with full-text search over messages and titles.
  2. use std::path::Path;
  3. use chrono::{Datelike, TimeZone};
  4. use rusqlite::{params, Connection, OptionalExtension, Row};
  5. use serde::{Deserialize, Serialize};
  6. use ts_rs::TS;
  7. use crate::pricing::ModelInfo;
  8. use crate::types::{Prices, Usage, UsageSource};
  9. mod search;
  10. pub use search::fts_query;
  11. const MIGRATIONS: &[&str] = &[include_str!("migrations/0001_init.sql"), include_str!("migrations/0002_fts.sql")];
  12. #[derive(Debug, thiserror::Error)]
  13. pub enum DbError {
  14. #[error(transparent)]
  15. Sqlite(#[from] rusqlite::Error),
  16. #[error(transparent)]
  17. Json(#[from] serde_json::Error),
  18. #[error("not found: {0}")]
  19. NotFound(String),
  20. }
  21. pub type Result<T> = std::result::Result<T, DbError>;
  22. pub fn now_ms() -> i64 {
  23. chrono::Utc::now().timestamp_millis()
  24. }
  25. pub fn new_id() -> String {
  26. uuid::Uuid::now_v7().to_string()
  27. }
  28. /// Start of the calendar month containing `now`, in `now`'s own time zone, as UTC ms.
  29. pub fn month_start_ms<Tz: TimeZone>(now: &chrono::DateTime<Tz>) -> i64 {
  30. let tz = now.timezone();
  31. tz.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
  32. .earliest()
  33. .map(|d| d.timestamp_millis())
  34. .unwrap_or_else(|| now.timestamp_millis())
  35. }
  36. // ---- types shared with the frontend ---------------------------------------
  37. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  38. #[ts(export)]
  39. pub struct Provider {
  40. pub id: String,
  41. /// `deepinfra` or `openai_compatible`
  42. pub kind: String,
  43. pub name: String,
  44. pub base_url: String,
  45. pub requires_key: bool,
  46. }
  47. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  48. #[ts(export)]
  49. pub struct Conversation {
  50. pub id: String,
  51. pub title: String,
  52. pub provider_id: String,
  53. pub model_id: String,
  54. pub preset_id: Option<String>,
  55. pub system_prompt: String,
  56. pub origin: String,
  57. pub pinned: bool,
  58. pub archived: bool,
  59. #[ts(type = "number")]
  60. pub created_at: i64,
  61. #[ts(type = "number")]
  62. pub updated_at: i64,
  63. }
  64. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  65. #[ts(export)]
  66. pub struct ConversationSummary {
  67. pub id: String,
  68. pub title: String,
  69. pub model_id: String,
  70. pub pinned: bool,
  71. pub origin: String,
  72. #[ts(type = "number")]
  73. pub updated_at: i64,
  74. #[ts(type = "number")]
  75. pub cost_nanousd: i64,
  76. pub cost_estimated: bool,
  77. #[ts(type = "number")]
  78. pub message_count: i64,
  79. /// Some reply was answered (tokens counted), so $0 means free, not failed.
  80. pub has_usage: bool,
  81. }
  82. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  83. #[ts(export)]
  84. pub struct AttachmentMeta {
  85. pub id: String,
  86. /// `image` or `text`
  87. pub kind: String,
  88. pub filename: String,
  89. pub mime: String,
  90. #[ts(type = "number")]
  91. pub byte_size: i64,
  92. pub width: Option<u32>,
  93. pub height: Option<u32>,
  94. }
  95. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  96. #[ts(export)]
  97. pub struct Message {
  98. pub id: String,
  99. pub conversation_id: String,
  100. pub role: String,
  101. pub content: String,
  102. pub reasoning: Option<String>,
  103. /// `complete`, `streaming`, `cancelled` or `error`
  104. pub status: String,
  105. pub error_kind: Option<String>,
  106. pub error_message: Option<String>,
  107. pub provider_id: Option<String>,
  108. pub model_id: Option<String>,
  109. pub usage: Usage,
  110. pub usage_source: UsageSource,
  111. pub prices: Option<Prices>,
  112. #[ts(type = "number")]
  113. pub cost_nanousd: i64,
  114. pub finish_reason: Option<String>,
  115. #[ts(type = "number | null")]
  116. pub ttft_ms: Option<i64>,
  117. #[ts(type = "number | null")]
  118. pub duration_ms: Option<i64>,
  119. #[ts(type = "number")]
  120. pub created_at: i64,
  121. pub attachments: Vec<AttachmentMeta>,
  122. }
  123. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  124. #[ts(export)]
  125. pub struct Preset {
  126. pub id: String,
  127. pub name: String,
  128. pub system_prompt: String,
  129. pub provider_id: Option<String>,
  130. pub model_id: Option<String>,
  131. pub temperature: Option<f32>,
  132. pub max_tokens: Option<u32>,
  133. }
  134. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  135. #[ts(export)]
  136. pub struct SearchHit {
  137. pub conversation_id: String,
  138. pub title: String,
  139. pub message_id: Option<String>,
  140. /// With `\u{1}` and `\u{2}` around matched terms, for the UI to highlight.
  141. pub snippet: String,
  142. #[ts(type = "number")]
  143. pub created_at: i64,
  144. }
  145. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
  146. #[ts(export)]
  147. pub struct UsageRow {
  148. /// A model id, or a day as YYYY-MM-DD.
  149. pub key: String,
  150. #[ts(type = "number")]
  151. pub cost_nanousd: i64,
  152. #[ts(type = "number")]
  153. pub prompt_tokens: i64,
  154. #[ts(type = "number")]
  155. pub completion_tokens: i64,
  156. #[ts(type = "number")]
  157. pub messages: i64,
  158. pub estimated: bool,
  159. }
  160. /// What the stream produced, written onto the assistant row in one go.
  161. #[derive(Debug, Clone, Default)]
  162. pub struct Finalize {
  163. pub content: String,
  164. pub reasoning: Option<String>,
  165. pub status: &'static str,
  166. pub error_kind: Option<String>,
  167. pub error_message: Option<String>,
  168. pub usage: Usage,
  169. pub usage_source: Option<UsageSource>,
  170. pub prices: Option<Prices>,
  171. pub cost_nanousd: i64,
  172. pub provider_cost_nanousd: Option<i64>,
  173. pub finish_reason: Option<String>,
  174. pub ttft_ms: Option<i64>,
  175. pub duration_ms: Option<i64>,
  176. }
  177. pub struct NewAttachment<'a> {
  178. pub kind: &'a str,
  179. pub filename: &'a str,
  180. pub mime: &'a str,
  181. pub byte_size: i64,
  182. pub sha256: &'a str,
  183. pub width: Option<u32>,
  184. pub height: Option<u32>,
  185. pub path: Option<&'a str>,
  186. pub text_content: Option<&'a str>,
  187. }
  188. /// An attachment as the provider request needs it.
  189. #[derive(Debug, Clone)]
  190. pub struct AttachmentData {
  191. pub meta: AttachmentMeta,
  192. pub path: Option<String>,
  193. pub text_content: Option<String>,
  194. }
  195. // ---- the database ----------------------------------------------------------
  196. pub struct Db {
  197. conn: Connection,
  198. }
  199. const MESSAGE_COLS: &str = "id, conversation_id, role, content, reasoning, status, error_kind, error_message, \
  200. provider_id, model_id, prompt_tokens, completion_tokens, cached_tokens, usage_source, \
  201. price_in_per_m, price_out_per_m, price_cache_per_m, cost_nanousd, finish_reason, ttft_ms, duration_ms, created_at";
  202. const CONVERSATION_COLS: &str =
  203. "id, title, provider_id, model_id, preset_id, system_prompt, origin, pinned, archived, created_at, updated_at";
  204. fn conversation_row(r: &Row) -> rusqlite::Result<Conversation> {
  205. Ok(Conversation {
  206. id: r.get(0)?,
  207. title: r.get(1)?,
  208. provider_id: r.get(2)?,
  209. model_id: r.get(3)?,
  210. preset_id: r.get(4)?,
  211. system_prompt: r.get(5)?,
  212. origin: r.get(6)?,
  213. pinned: r.get(7)?,
  214. archived: r.get(8)?,
  215. created_at: r.get(9)?,
  216. updated_at: r.get(10)?,
  217. })
  218. }
  219. fn message_row(r: &Row) -> rusqlite::Result<Message> {
  220. let pin: Option<f64> = r.get(14)?;
  221. let pout: Option<f64> = r.get(15)?;
  222. let pcache: Option<f64> = r.get(16)?;
  223. Ok(Message {
  224. id: r.get(0)?,
  225. conversation_id: r.get(1)?,
  226. role: r.get(2)?,
  227. content: r.get(3)?,
  228. reasoning: r.get(4)?,
  229. status: r.get(5)?,
  230. error_kind: r.get(6)?,
  231. error_message: r.get(7)?,
  232. provider_id: r.get(8)?,
  233. model_id: r.get(9)?,
  234. usage: Usage { prompt_tokens: r.get(10)?, completion_tokens: r.get(11)?, cached_tokens: r.get(12)? },
  235. usage_source: UsageSource::parse(&r.get::<_, String>(13)?),
  236. prices: match (pin, pout) {
  237. (Some(i), Some(o)) => Some(Prices { input_per_m: i, output_per_m: o, cache_read_per_m: pcache }),
  238. _ => None,
  239. },
  240. cost_nanousd: r.get(17)?,
  241. finish_reason: r.get(18)?,
  242. ttft_ms: r.get(19)?,
  243. duration_ms: r.get(20)?,
  244. created_at: r.get(21)?,
  245. attachments: vec![],
  246. })
  247. }
  248. fn attachment_meta_row(r: &Row) -> rusqlite::Result<AttachmentMeta> {
  249. Ok(AttachmentMeta {
  250. id: r.get(0)?,
  251. kind: r.get(1)?,
  252. filename: r.get(2)?,
  253. mime: r.get(3)?,
  254. byte_size: r.get(4)?,
  255. width: r.get(5)?,
  256. height: r.get(6)?,
  257. })
  258. }
  259. pub const DEEPINFRA_ID: &str = "deepinfra";
  260. impl Db {
  261. pub fn open(path: &Path) -> Result<Self> {
  262. Self::init(Connection::open(path)?)
  263. }
  264. pub fn open_in_memory() -> Result<Self> {
  265. Self::init(Connection::open_in_memory()?)
  266. }
  267. fn init(conn: Connection) -> Result<Self> {
  268. conn.pragma_update(None, "journal_mode", "WAL")?;
  269. conn.pragma_update(None, "foreign_keys", "ON")?;
  270. conn.busy_timeout(std::time::Duration::from_secs(5))?;
  271. let mut db = Self { conn };
  272. db.migrate()?;
  273. db.seed()?;
  274. db.recover_interrupted()?;
  275. Ok(db)
  276. }
  277. fn migrate(&mut self) -> Result<()> {
  278. let version: i64 = self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
  279. for (i, sql) in MIGRATIONS.iter().enumerate().skip(version as usize) {
  280. let tx = self.conn.transaction()?;
  281. tx.execute_batch(sql)?;
  282. tx.pragma_update(None, "user_version", (i + 1) as i64)?;
  283. tx.commit()?;
  284. }
  285. Ok(())
  286. }
  287. #[cfg(test)]
  288. pub(crate) fn schema_version(&self) -> Result<i64> {
  289. Ok(self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?)
  290. }
  291. fn seed(&self) -> Result<()> {
  292. self.conn.execute(
  293. "INSERT OR IGNORE INTO providers (id, kind, name, base_url, requires_key, created_at)
  294. VALUES (?1, 'deepinfra', 'DeepInfra', ?2, 1, ?3)",
  295. params![DEEPINFRA_ID, crate::provider::DEEPINFRA_BASE, now_ms()],
  296. )?;
  297. Ok(())
  298. }
  299. /// A crash or quit mid-stream leaves a row marked streaming; close it off.
  300. fn recover_interrupted(&self) -> Result<usize> {
  301. Ok(self.conn.execute(
  302. "UPDATE messages SET status = 'error', error_kind = 'interrupted',
  303. error_message = 'Cortex closed before this reply finished.'
  304. WHERE status = 'streaming'",
  305. [],
  306. )?)
  307. }
  308. // ---- providers ----
  309. pub fn list_providers(&self) -> Result<Vec<Provider>> {
  310. let mut st = self.conn.prepare(
  311. "SELECT id, kind, name, base_url, requires_key FROM providers ORDER BY kind = 'deepinfra' DESC, created_at",
  312. )?;
  313. let rows = st.query_map([], |r| {
  314. Ok(Provider {
  315. id: r.get(0)?,
  316. kind: r.get(1)?,
  317. name: r.get(2)?,
  318. base_url: r.get(3)?,
  319. requires_key: r.get(4)?,
  320. })
  321. })?;
  322. Ok(rows.collect::<rusqlite::Result<_>>()?)
  323. }
  324. pub fn get_provider(&self, id: &str) -> Result<Provider> {
  325. self.list_providers()?
  326. .into_iter()
  327. .find(|p| p.id == id)
  328. .ok_or_else(|| DbError::NotFound(format!("provider {id}")))
  329. }
  330. pub fn upsert_provider(&self, p: &Provider) -> Result<()> {
  331. self.conn.execute(
  332. "INSERT INTO providers (id, kind, name, base_url, requires_key, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
  333. ON CONFLICT (id) DO UPDATE SET name = excluded.name, base_url = excluded.base_url,
  334. requires_key = excluded.requires_key",
  335. params![p.id, p.kind, p.name, p.base_url.trim_end_matches('/'), p.requires_key, now_ms()],
  336. )?;
  337. Ok(())
  338. }
  339. pub fn delete_provider(&self, id: &str) -> Result<()> {
  340. if id == DEEPINFRA_ID {
  341. return Ok(()); // the built-in provider stays
  342. }
  343. self.conn.execute("DELETE FROM providers WHERE id = ?1", [id])?;
  344. self.conn.execute("DELETE FROM model_pricing WHERE provider_id = ?1", [id])?;
  345. Ok(())
  346. }
  347. // ---- conversations ----
  348. pub fn create_conversation(
  349. &self,
  350. provider_id: &str,
  351. model_id: &str,
  352. system_prompt: &str,
  353. preset_id: Option<&str>,
  354. origin: &str,
  355. ) -> Result<Conversation> {
  356. let id = new_id();
  357. let now = now_ms();
  358. self.conn.execute(
  359. "INSERT INTO conversations (id, title, provider_id, model_id, preset_id, system_prompt, origin, created_at, updated_at)
  360. VALUES (?1, 'New chat', ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
  361. params![id, provider_id, model_id, preset_id, system_prompt, origin, now],
  362. )?;
  363. self.get_conversation(&id)
  364. }
  365. pub fn get_conversation(&self, id: &str) -> Result<Conversation> {
  366. self.conn
  367. .query_row(&format!("SELECT {CONVERSATION_COLS} FROM conversations WHERE id = ?1"), [id], conversation_row)
  368. .optional()?
  369. .ok_or_else(|| DbError::NotFound(format!("conversation {id}")))
  370. }
  371. pub fn list_conversations(&self, include_archived: bool, limit: i64) -> Result<Vec<ConversationSummary>> {
  372. let mut st = self.conn.prepare(
  373. "SELECT c.id, c.title, c.model_id, c.pinned, c.origin, c.updated_at,
  374. COALESCE(SUM(m.cost_nanousd), 0),
  375. COALESCE(MAX(m.usage_source = 'estimated'), 0),
  376. COUNT(m.id),
  377. COALESCE(MAX(m.role = 'assistant' AND m.usage_source != 'none'), 0)
  378. FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id
  379. WHERE (?1 OR c.archived = 0)
  380. GROUP BY c.id ORDER BY c.pinned DESC, c.updated_at DESC LIMIT ?2",
  381. )?;
  382. let rows = st.query_map(params![include_archived, limit], |r| {
  383. Ok(ConversationSummary {
  384. id: r.get(0)?,
  385. title: r.get(1)?,
  386. model_id: r.get(2)?,
  387. pinned: r.get(3)?,
  388. origin: r.get(4)?,
  389. updated_at: r.get(5)?,
  390. cost_nanousd: r.get(6)?,
  391. cost_estimated: r.get(7)?,
  392. message_count: r.get(8)?,
  393. has_usage: r.get(9)?,
  394. })
  395. })?;
  396. Ok(rows.collect::<rusqlite::Result<_>>()?)
  397. }
  398. pub fn rename_conversation(&self, id: &str, title: &str) -> Result<()> {
  399. let title = title.trim();
  400. let title = if title.is_empty() { "New chat" } else { title };
  401. self.conn.execute("UPDATE conversations SET title = ?2 WHERE id = ?1", params![id, title])?;
  402. Ok(())
  403. }
  404. pub fn set_pinned(&self, id: &str, pinned: bool) -> Result<()> {
  405. self.conn.execute("UPDATE conversations SET pinned = ?2 WHERE id = ?1", params![id, pinned])?;
  406. Ok(())
  407. }
  408. pub fn set_archived(&self, id: &str, archived: bool) -> Result<()> {
  409. self.conn.execute("UPDATE conversations SET archived = ?2 WHERE id = ?1", params![id, archived])?;
  410. Ok(())
  411. }
  412. pub fn set_conversation_model(&self, id: &str, provider_id: &str, model_id: &str) -> Result<()> {
  413. self.conn.execute(
  414. "UPDATE conversations SET provider_id = ?2, model_id = ?3 WHERE id = ?1",
  415. params![id, provider_id, model_id],
  416. )?;
  417. Ok(())
  418. }
  419. pub fn set_system_prompt(&self, id: &str, system_prompt: &str, preset_id: Option<&str>) -> Result<()> {
  420. self.conn.execute(
  421. "UPDATE conversations SET system_prompt = ?2, preset_id = ?3 WHERE id = ?1",
  422. params![id, system_prompt, preset_id],
  423. )?;
  424. Ok(())
  425. }
  426. pub fn delete_conversation(&self, id: &str) -> Result<()> {
  427. self.conn.execute("DELETE FROM conversations WHERE id = ?1", [id])?;
  428. Ok(())
  429. }
  430. fn touch(&self, conversation_id: &str) -> Result<()> {
  431. self.conn
  432. .execute("UPDATE conversations SET updated_at = ?2 WHERE id = ?1", params![conversation_id, now_ms()])?;
  433. Ok(())
  434. }
  435. // ---- messages ----
  436. pub fn insert_user_message(
  437. &self,
  438. conversation_id: &str,
  439. content: &str,
  440. attachment_ids: &[String],
  441. ) -> Result<Message> {
  442. let id = new_id();
  443. let tx = self.conn.unchecked_transaction()?;
  444. tx.execute(
  445. "INSERT INTO messages (id, conversation_id, role, content, status, created_at) VALUES (?1, ?2, 'user', ?3, 'complete', ?4)",
  446. params![id, conversation_id, content, now_ms()],
  447. )?;
  448. for a in attachment_ids {
  449. tx.execute("UPDATE attachments SET message_id = ?1 WHERE id = ?2 AND message_id IS NULL", params![id, a])?;
  450. }
  451. // The first message names the chat; no model call is spent on a title.
  452. tx.execute(
  453. "UPDATE conversations SET title = ?2 WHERE id = ?1 AND title = 'New chat'",
  454. params![conversation_id, title_from(content)],
  455. )?;
  456. tx.commit()?;
  457. self.touch(conversation_id)?;
  458. self.get_message(&id)
  459. }
  460. pub fn insert_assistant_placeholder(
  461. &self,
  462. conversation_id: &str,
  463. provider_id: &str,
  464. model_id: &str,
  465. ) -> Result<Message> {
  466. let id = new_id();
  467. self.conn.execute(
  468. "INSERT INTO messages (id, conversation_id, role, content, status, provider_id, model_id, created_at)
  469. VALUES (?1, ?2, 'assistant', '', 'streaming', ?3, ?4, ?5)",
  470. params![id, conversation_id, provider_id, model_id, now_ms()],
  471. )?;
  472. self.get_message(&id)
  473. }
  474. pub fn finalize_assistant(&self, id: &str, f: &Finalize) -> Result<Message> {
  475. let (pin, pout, pcache) = match f.prices {
  476. Some(p) => (Some(p.input_per_m), Some(p.output_per_m), p.cache_read_per_m),
  477. None => (None, None, None),
  478. };
  479. self.conn.execute(
  480. "UPDATE messages SET content = ?2, reasoning = ?3, status = ?4, error_kind = ?5, error_message = ?6,
  481. prompt_tokens = ?7, completion_tokens = ?8, cached_tokens = ?9, usage_source = ?10,
  482. price_in_per_m = ?11, price_out_per_m = ?12, price_cache_per_m = ?13, cost_nanousd = ?14,
  483. provider_cost_nanousd = ?15, finish_reason = ?16, ttft_ms = ?17, duration_ms = ?18
  484. WHERE id = ?1",
  485. params![
  486. id,
  487. f.content,
  488. f.reasoning.as_deref().filter(|s| !s.is_empty()),
  489. f.status,
  490. f.error_kind,
  491. f.error_message,
  492. f.usage.prompt_tokens,
  493. f.usage.completion_tokens,
  494. f.usage.cached_tokens,
  495. f.usage_source.unwrap_or(UsageSource::None).as_str(),
  496. pin,
  497. pout,
  498. pcache,
  499. f.cost_nanousd,
  500. f.provider_cost_nanousd,
  501. f.finish_reason,
  502. f.ttft_ms,
  503. f.duration_ms
  504. ],
  505. )?;
  506. let m = self.get_message(id)?;
  507. self.touch(&m.conversation_id)?;
  508. Ok(m)
  509. }
  510. pub fn get_message(&self, id: &str) -> Result<Message> {
  511. let mut m = self
  512. .conn
  513. .query_row(&format!("SELECT {MESSAGE_COLS} FROM messages WHERE id = ?1"), [id], message_row)
  514. .optional()?
  515. .ok_or_else(|| DbError::NotFound(format!("message {id}")))?;
  516. m.attachments = self.attachments_for(&m.id)?;
  517. Ok(m)
  518. }
  519. pub fn list_messages(&self, conversation_id: &str) -> Result<Vec<Message>> {
  520. let mut st = self.conn.prepare(&format!(
  521. "SELECT {MESSAGE_COLS} FROM messages WHERE conversation_id = ?1 ORDER BY created_at, rowid"
  522. ))?;
  523. let mut msgs: Vec<Message> = st.query_map([conversation_id], message_row)?.collect::<rusqlite::Result<_>>()?;
  524. for m in &mut msgs {
  525. m.attachments = self.attachments_for(&m.id)?;
  526. }
  527. Ok(msgs)
  528. }
  529. /// Delete a message and everything after it (edit and regenerate start here).
  530. /// The message's own attachments go back to being drafts, so an edited
  531. /// message can be sent again with them.
  532. pub fn truncate_from(&self, conversation_id: &str, message_id: &str) -> Result<usize> {
  533. let (created, rowid): (i64, i64) = self.conn.query_row(
  534. "SELECT created_at, rowid FROM messages WHERE id = ?1 AND conversation_id = ?2",
  535. params![message_id, conversation_id],
  536. |r| Ok((r.get(0)?, r.get(1)?)),
  537. )?;
  538. self.conn.execute(
  539. "UPDATE attachments SET message_id = NULL, created_at = ?2 WHERE message_id = ?1",
  540. params![message_id, now_ms()],
  541. )?;
  542. Ok(self.conn.execute(
  543. "DELETE FROM messages WHERE conversation_id = ?1 AND (created_at > ?2 OR (created_at = ?2 AND rowid >= ?3))",
  544. params![conversation_id, created, rowid],
  545. )?)
  546. }
  547. // ---- attachments ----
  548. pub fn insert_attachment(&self, a: &NewAttachment) -> Result<AttachmentMeta> {
  549. let id = new_id();
  550. self.conn.execute(
  551. "INSERT INTO attachments (id, kind, filename, mime, byte_size, sha256, width, height, path, text_content, created_at)
  552. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
  553. params![id, a.kind, a.filename, a.mime, a.byte_size, a.sha256, a.width, a.height, a.path, a.text_content, now_ms()],
  554. )?;
  555. Ok(self.attachment(&id)?.meta)
  556. }
  557. pub fn attachment(&self, id: &str) -> Result<AttachmentData> {
  558. self.conn
  559. .query_row(
  560. "SELECT id, kind, filename, mime, byte_size, width, height, path, text_content FROM attachments WHERE id = ?1",
  561. [id],
  562. |r| Ok(AttachmentData { meta: attachment_meta_row(r)?, path: r.get(7)?, text_content: r.get(8)? }),
  563. )
  564. .optional()?
  565. .ok_or_else(|| DbError::NotFound(format!("attachment {id}")))
  566. }
  567. pub fn attachments_for(&self, message_id: &str) -> Result<Vec<AttachmentMeta>> {
  568. let mut st = self.conn.prepare(
  569. "SELECT id, kind, filename, mime, byte_size, width, height FROM attachments WHERE message_id = ?1 ORDER BY created_at, rowid",
  570. )?;
  571. let rows = st.query_map([message_id], attachment_meta_row)?;
  572. Ok(rows.collect::<rusqlite::Result<_>>()?)
  573. }
  574. pub fn attachment_data_for(&self, message_id: &str) -> Result<Vec<AttachmentData>> {
  575. self.attachments_for(message_id)?.into_iter().map(|a| self.attachment(&a.id)).collect()
  576. }
  577. /// Paths still referenced by any attachment row (a file may be shared by hash).
  578. pub fn attachment_path_in_use(&self, path: &str) -> Result<bool> {
  579. Ok(self.conn.query_row("SELECT EXISTS (SELECT 1 FROM attachments WHERE path = ?1)", [path], |r| r.get(0))?)
  580. }
  581. /// Remove an attachment that was never sent; returns its file path, if any.
  582. pub fn delete_draft_attachment(&self, id: &str) -> Result<Option<String>> {
  583. let path: Option<Option<String>> = self
  584. .conn
  585. .query_row("SELECT path FROM attachments WHERE id = ?1 AND message_id IS NULL", [id], |r| r.get(0))
  586. .optional()?;
  587. self.conn.execute("DELETE FROM attachments WHERE id = ?1 AND message_id IS NULL", [id])?;
  588. Ok(path.flatten())
  589. }
  590. /// Drafts never sent (the composer was cleared or the app closed).
  591. pub fn delete_stale_drafts(&self, older_than_ms: i64) -> Result<Vec<String>> {
  592. let cutoff = now_ms() - older_than_ms;
  593. let mut st = self.conn.prepare(
  594. "SELECT path FROM attachments WHERE message_id IS NULL AND created_at < ?1 AND path IS NOT NULL",
  595. )?;
  596. let paths: Vec<String> = st.query_map([cutoff], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
  597. self.conn.execute("DELETE FROM attachments WHERE message_id IS NULL AND created_at < ?1", [cutoff])?;
  598. Ok(paths)
  599. }
  600. // ---- presets ----
  601. pub fn list_presets(&self) -> Result<Vec<Preset>> {
  602. let mut st = self.conn.prepare(
  603. "SELECT id, name, system_prompt, provider_id, model_id, temperature, max_tokens FROM presets ORDER BY sort_order, name",
  604. )?;
  605. let rows = st.query_map([], |r| {
  606. Ok(Preset {
  607. id: r.get(0)?,
  608. name: r.get(1)?,
  609. system_prompt: r.get(2)?,
  610. provider_id: r.get(3)?,
  611. model_id: r.get(4)?,
  612. temperature: r.get(5)?,
  613. max_tokens: r.get(6)?,
  614. })
  615. })?;
  616. Ok(rows.collect::<rusqlite::Result<_>>()?)
  617. }
  618. pub fn save_preset(&self, p: &Preset) -> Result<Preset> {
  619. let id = if p.id.is_empty() { new_id() } else { p.id.clone() };
  620. let now = now_ms();
  621. self.conn.execute(
  622. "INSERT INTO presets (id, name, system_prompt, provider_id, model_id, temperature, max_tokens, created_at, updated_at)
  623. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)
  624. ON CONFLICT (id) DO UPDATE SET name = excluded.name, system_prompt = excluded.system_prompt,
  625. provider_id = excluded.provider_id, model_id = excluded.model_id, temperature = excluded.temperature,
  626. max_tokens = excluded.max_tokens, updated_at = excluded.updated_at",
  627. params![id, p.name.trim(), p.system_prompt, p.provider_id, p.model_id, p.temperature, p.max_tokens, now],
  628. )?;
  629. Ok(Preset { id, ..p.clone() })
  630. }
  631. pub fn delete_preset(&self, id: &str) -> Result<()> {
  632. self.conn.execute("DELETE FROM presets WHERE id = ?1", [id])?;
  633. Ok(())
  634. }
  635. // ---- settings ----
  636. pub fn get_setting<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
  637. let raw: Option<String> =
  638. self.conn.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| r.get(0)).optional()?;
  639. Ok(match raw {
  640. Some(s) => Some(serde_json::from_str(&s)?),
  641. None => None,
  642. })
  643. }
  644. pub fn set_setting<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {
  645. self.conn.execute(
  646. "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT (key) DO UPDATE SET value = excluded.value",
  647. params![key, serde_json::to_string(value)?],
  648. )?;
  649. Ok(())
  650. }
  651. // ---- models and prices ----
  652. pub fn replace_models(&self, provider_id: &str, models: &[ModelInfo], source: &str, fetched_at: i64) -> Result<()> {
  653. let tx = self.conn.unchecked_transaction()?;
  654. tx.execute("DELETE FROM model_pricing WHERE provider_id = ?1", [provider_id])?;
  655. for m in models {
  656. tx.execute(
  657. "INSERT INTO model_pricing (provider_id, model_id, input_per_m, output_per_m, cache_read_per_m,
  658. context_length, max_tokens, tags, source, fetched_at)
  659. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
  660. params![
  661. provider_id,
  662. m.id,
  663. m.prices.input_per_m,
  664. m.prices.output_per_m,
  665. m.prices.cache_read_per_m,
  666. m.context_length,
  667. m.max_tokens,
  668. serde_json::to_string(&m.tags)?,
  669. source,
  670. fetched_at
  671. ],
  672. )?;
  673. }
  674. tx.commit()?;
  675. Ok(())
  676. }
  677. pub fn list_models(&self, provider_id: &str) -> Result<Vec<ModelInfo>> {
  678. let mut st = self.conn.prepare(
  679. "SELECT model_id, input_per_m, output_per_m, cache_read_per_m, context_length, max_tokens, tags
  680. FROM model_pricing WHERE provider_id = ?1 ORDER BY lower(model_id)",
  681. )?;
  682. let rows = st.query_map([provider_id], |r| {
  683. let tags: String = r.get(6)?;
  684. Ok(ModelInfo {
  685. provider_id: provider_id.to_string(),
  686. id: r.get(0)?,
  687. prices: Prices { input_per_m: r.get(1)?, output_per_m: r.get(2)?, cache_read_per_m: r.get(3)? },
  688. context_length: r.get(4)?,
  689. max_tokens: r.get(5)?,
  690. tags: serde_json::from_str(&tags).unwrap_or_default(),
  691. })
  692. })?;
  693. Ok(rows.collect::<rusqlite::Result<_>>()?)
  694. }
  695. pub fn model(&self, provider_id: &str, model_id: &str) -> Result<Option<ModelInfo>> {
  696. Ok(self.list_models(provider_id)?.into_iter().find(|m| m.id == model_id))
  697. }
  698. /// `(source, fetched_at)` of a provider's current list, if any.
  699. pub fn models_source(&self, provider_id: &str) -> Result<Option<(String, i64)>> {
  700. Ok(self
  701. .conn
  702. .query_row(
  703. "SELECT source, MAX(fetched_at) FROM model_pricing WHERE provider_id = ?1 GROUP BY source ORDER BY 2 DESC LIMIT 1",
  704. [provider_id],
  705. |r| Ok((r.get(0)?, r.get(1)?)),
  706. )
  707. .optional()?)
  708. }
  709. // ---- spend ----
  710. pub fn spend_since(&self, since_ms: i64) -> Result<(i64, bool)> {
  711. Ok(self.conn.query_row(
  712. "SELECT COALESCE(SUM(cost_nanousd), 0), COALESCE(MAX(usage_source = 'estimated'), 0)
  713. FROM messages WHERE role = 'assistant' AND created_at >= ?1",
  714. [since_ms],
  715. |r| Ok((r.get(0)?, r.get(1)?)),
  716. )?)
  717. }
  718. pub fn usage_by_model(&self, since_ms: i64) -> Result<Vec<UsageRow>> {
  719. self.usage_grouped("model_id", since_ms)
  720. }
  721. /// Per local day: `utc_offset_minutes` is the viewer's offset from UTC.
  722. pub fn usage_by_day(&self, since_ms: i64, utc_offset_minutes: i32) -> Result<Vec<UsageRow>> {
  723. let expr = format!("strftime('%Y-%m-%d', (created_at / 1000) + {} * 60, 'unixepoch')", utc_offset_minutes);
  724. self.usage_grouped(&expr, since_ms)
  725. }
  726. fn usage_grouped(&self, key_expr: &str, since_ms: i64) -> Result<Vec<UsageRow>> {
  727. let mut st = self.conn.prepare(&format!(
  728. "SELECT {key_expr} AS k, SUM(cost_nanousd), SUM(prompt_tokens), SUM(completion_tokens), COUNT(*),
  729. MAX(usage_source = 'estimated')
  730. FROM messages WHERE role = 'assistant' AND created_at >= ?1 AND k IS NOT NULL
  731. GROUP BY k ORDER BY 2 DESC, k"
  732. ))?;
  733. let rows = st.query_map([since_ms], |r| {
  734. Ok(UsageRow {
  735. key: r.get(0)?,
  736. cost_nanousd: r.get(1)?,
  737. prompt_tokens: r.get(2)?,
  738. completion_tokens: r.get(3)?,
  739. messages: r.get(4)?,
  740. estimated: r.get(5)?,
  741. })
  742. })?;
  743. Ok(rows.collect::<rusqlite::Result<_>>()?)
  744. }
  745. // ---- search ----
  746. pub fn search(&self, input: &str, limit: i64) -> Result<Vec<SearchHit>> {
  747. let Some(q) = fts_query(input) else { return Ok(vec![]) };
  748. let mut st = self.conn.prepare(
  749. "SELECT conversation_id, title, message_id, snippet, created_at FROM (
  750. SELECT c.id AS conversation_id, c.title AS title, m.id AS message_id,
  751. snippet(messages_fts, 0, char(1), char(2), '...', 12) AS snippet,
  752. m.created_at AS created_at, bm25(messages_fts) AS rank
  753. FROM messages_fts JOIN messages m ON m.rowid = messages_fts.rowid
  754. JOIN conversations c ON c.id = m.conversation_id
  755. WHERE messages_fts MATCH ?1 AND c.archived = 0
  756. UNION ALL
  757. SELECT c.id, c.title, NULL, snippet(conversations_fts, 0, char(1), char(2), '...', 12),
  758. c.updated_at, bm25(conversations_fts) - 5.0
  759. FROM conversations_fts JOIN conversations c ON c.rowid = conversations_fts.rowid
  760. WHERE conversations_fts MATCH ?1 AND c.archived = 0
  761. ) ORDER BY rank LIMIT ?2",
  762. )?;
  763. let rows = st.query_map(params![q, limit], |r| {
  764. Ok(SearchHit {
  765. conversation_id: r.get(0)?,
  766. title: r.get(1)?,
  767. message_id: r.get(2)?,
  768. snippet: r.get(3)?,
  769. created_at: r.get(4)?,
  770. })
  771. })?;
  772. Ok(rows.collect::<rusqlite::Result<_>>()?)
  773. }
  774. #[cfg(test)]
  775. pub(crate) fn conn(&self) -> &Connection {
  776. &self.conn
  777. }
  778. }
  779. /// The first line of the first message, trimmed to a sensible length.
  780. pub fn title_from(content: &str) -> String {
  781. let line = content.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("New chat");
  782. let mut t: String = line.chars().take(60).collect();
  783. if line.chars().count() > 60 {
  784. if let Some(cut) = t.rfind(' ').filter(|&i| i > 30) {
  785. t.truncate(cut);
  786. }
  787. t.push_str("...");
  788. }
  789. t
  790. }
  791. #[cfg(test)]
  792. mod tests;