mattrguerra/cortexmainPrivate on GitHub, published here in full

mod.rs

Language Rust9.8 KB274 lines

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

  1. //! A conversation as a Markdown note with YAML frontmatter, for an Obsidian
  2. //! vault: title, dates, models, tokens and cost up top, then the exchange.
  3. use chrono::TimeZone;
  4. use crate::cost::to_usd;
  5. use crate::storage::{Conversation, Message};
  6. use crate::types::UsageSource;
  7. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  8. pub enum ImageLinks {
  9. /// `![[file.png]]`, Obsidian's embed.
  10. Wiki,
  11. /// `![file.png](attachments/file.png)`, plain Markdown.
  12. Markdown,
  13. }
  14. pub struct ExportFile {
  15. /// Where to copy it, relative to the note's folder.
  16. pub relative_path: String,
  17. pub source_path: String,
  18. }
  19. pub struct Export {
  20. pub filename: String,
  21. pub markdown: String,
  22. pub files: Vec<ExportFile>,
  23. }
  24. /// A YAML double-quoted scalar, escaped.
  25. pub fn yaml_str(s: &str) -> String {
  26. let mut out = String::with_capacity(s.len() + 2);
  27. out.push('"');
  28. for c in s.chars() {
  29. match c {
  30. '"' => out.push_str("\\\""),
  31. '\\' => out.push_str("\\\\"),
  32. '\n' => out.push_str("\\n"),
  33. '\r' => out.push_str("\\r"),
  34. '\t' => out.push_str("\\t"),
  35. c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)),
  36. c => out.push(c),
  37. }
  38. }
  39. out.push('"');
  40. out
  41. }
  42. /// A filename Obsidian and every filesystem accept.
  43. pub fn safe_filename(title: &str) -> String {
  44. let cleaned: String =
  45. title.chars().map(|c| if "[]#^|\\/:*?\"<>".contains(c) || c.is_control() { ' ' } else { c }).collect();
  46. let squashed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
  47. let trimmed = squashed.trim_matches(|c: char| c == '.' || c == ' ');
  48. let mut name: String = trimmed.chars().take(80).collect();
  49. if name.is_empty() {
  50. name = "Cortex chat".into();
  51. }
  52. name
  53. }
  54. fn iso<Tz: TimeZone>(ms: i64, tz: &Tz) -> String
  55. where
  56. Tz::Offset: std::fmt::Display,
  57. {
  58. tz.timestamp_millis_opt(ms).single().map(|d| d.format("%Y-%m-%dT%H:%M:%S%:z").to_string()).unwrap_or_default()
  59. }
  60. pub fn to_markdown<Tz: TimeZone>(
  61. conv: &Conversation,
  62. messages: &[Message],
  63. attachment_paths: &dyn Fn(&str) -> Option<String>,
  64. links: ImageLinks,
  65. tz: &Tz,
  66. ) -> Export
  67. where
  68. Tz::Offset: std::fmt::Display,
  69. {
  70. let mut unique: Vec<&str> = Vec::new();
  71. for m in messages.iter().filter(|m| m.role == "assistant").filter_map(|m| m.model_id.as_deref()) {
  72. if !unique.contains(&m) {
  73. unique.push(m);
  74. }
  75. }
  76. let assistant = || messages.iter().filter(|m| m.role == "assistant");
  77. let tokens_in: i64 = assistant().map(|m| m.usage.prompt_tokens).sum();
  78. let tokens_out: i64 = assistant().map(|m| m.usage.completion_tokens).sum();
  79. let cost: i64 = assistant().map(|m| m.cost_nanousd).sum();
  80. let estimated = assistant().any(|m| m.usage_source == UsageSource::Estimated);
  81. let mut fm = vec!["---".to_string(), format!("title: {}", yaml_str(&conv.title))];
  82. fm.push(format!("created: {}", iso(conv.created_at, tz)));
  83. fm.push(format!("updated: {}", iso(conv.updated_at, tz)));
  84. fm.push("models:".into());
  85. for m in &unique {
  86. fm.push(format!(" - {}", yaml_str(m)));
  87. }
  88. if unique.is_empty() {
  89. fm.pop();
  90. fm.push("models: []".into());
  91. }
  92. fm.push(format!("tokens_in: {tokens_in}"));
  93. fm.push(format!("tokens_out: {tokens_out}"));
  94. fm.push(format!("cost_usd: {:.6}", to_usd(cost)));
  95. fm.push(format!("cost_estimated: {estimated}"));
  96. fm.push(format!("cortex_id: {}", yaml_str(&conv.id)));
  97. fm.push("tags:".into());
  98. fm.push(" - cortex".into());
  99. fm.push("---".into());
  100. let mut body = vec![String::new(), format!("# {}", conv.title), String::new()];
  101. if !conv.system_prompt.trim().is_empty() {
  102. body.push("> [!note] System prompt".into());
  103. for line in conv.system_prompt.trim().lines() {
  104. body.push(format!("> {line}"));
  105. }
  106. body.push(String::new());
  107. }
  108. let mut files = Vec::new();
  109. let mut used_names: Vec<String> = Vec::new();
  110. for m in messages.iter().filter(|m| m.role != "system") {
  111. let who =
  112. if m.role == "user" { "You".to_string() } else { m.model_id.clone().unwrap_or_else(|| "Assistant".into()) };
  113. body.push(format!("## {who}"));
  114. body.push(String::new());
  115. if !m.content.trim().is_empty() {
  116. body.push(m.content.trim_end().to_string());
  117. body.push(String::new());
  118. }
  119. for a in &m.attachments {
  120. match a.kind.as_str() {
  121. "image" => {
  122. let Some(src) = attachment_paths(&a.id) else { continue };
  123. let mut name = safe_filename(&a.filename);
  124. let mut n = 2;
  125. while used_names.contains(&name) {
  126. name = format!("{n}-{}", safe_filename(&a.filename));
  127. n += 1;
  128. }
  129. used_names.push(name.clone());
  130. body.push(match links {
  131. ImageLinks::Wiki => format!("![[{name}]]"),
  132. ImageLinks::Markdown => format!("![{name}](attachments/{})", name.replace(' ', "%20")),
  133. });
  134. body.push(String::new());
  135. files.push(ExportFile { relative_path: format!("attachments/{name}"), source_path: src });
  136. }
  137. _ => {
  138. body.push(format!("*Attached: {}*", a.filename));
  139. body.push(String::new());
  140. }
  141. }
  142. }
  143. match m.status.as_str() {
  144. "cancelled" => body.push("*Stopped before it finished.*\n".into()),
  145. "error" => body.push(format!("*Error: {}*\n", m.error_message.clone().unwrap_or_default())),
  146. _ => {}
  147. }
  148. }
  149. Export {
  150. filename: format!("{}.md", safe_filename(&conv.title)),
  151. markdown: format!("{}\n{}\n", fm.join("\n"), body.join("\n").trim_end()),
  152. files,
  153. }
  154. }
  155. #[cfg(test)]
  156. mod tests {
  157. use super::*;
  158. use crate::storage::{AttachmentMeta, Conversation, Message};
  159. use crate::types::{Usage, UsageSource};
  160. fn msg(role: &str, content: &str, model: Option<&str>, cost: i64, src: UsageSource) -> Message {
  161. Message {
  162. id: format!("m-{role}-{cost}"),
  163. conversation_id: "c1".into(),
  164. role: role.into(),
  165. content: content.into(),
  166. reasoning: None,
  167. status: "complete".into(),
  168. error_kind: None,
  169. error_message: None,
  170. provider_id: None,
  171. model_id: model.map(str::to_string),
  172. usage: if role == "assistant" {
  173. Usage { prompt_tokens: 120, completion_tokens: 80, cached_tokens: 0 }
  174. } else {
  175. Usage::default()
  176. },
  177. usage_source: src,
  178. prices: None,
  179. cost_nanousd: cost,
  180. finish_reason: None,
  181. ttft_ms: None,
  182. duration_ms: None,
  183. created_at: 0,
  184. attachments: vec![],
  185. }
  186. }
  187. #[test]
  188. fn a_whole_note() {
  189. let conv = Conversation {
  190. id: "0199-abc".into(),
  191. title: "Compare two \"pricing\" tiers: a/b".into(),
  192. provider_id: "deepinfra".into(),
  193. model_id: "m".into(),
  194. preset_id: None,
  195. system_prompt: "Be concise.\nUse tables.".into(),
  196. origin: "main".into(),
  197. pinned: false,
  198. archived: false,
  199. created_at: 1_790_000_000_000,
  200. updated_at: 1_790_000_600_000,
  201. };
  202. let mut user = msg("user", "Which tier is cheaper per seat?", None, 0, UsageSource::None);
  203. user.attachments.push(AttachmentMeta {
  204. id: "a1".into(),
  205. kind: "image".into(),
  206. filename: "chart: q3.png".into(),
  207. mime: "image/png".into(),
  208. byte_size: 10,
  209. width: Some(800),
  210. height: Some(600),
  211. });
  212. user.attachments.push(AttachmentMeta {
  213. id: "a2".into(),
  214. kind: "text".into(),
  215. filename: "notes.txt".into(),
  216. mime: "text/plain".into(),
  217. byte_size: 10,
  218. width: None,
  219. height: None,
  220. });
  221. let mut cut =
  222. msg("assistant", "Tier B, by about", Some("google/gemma-3-27b-it"), 1_000, UsageSource::Estimated);
  223. cut.status = "cancelled".into();
  224. let msgs = vec![
  225. user,
  226. msg(
  227. "assistant",
  228. "| Tier | Per seat |\n|---|---|\n| A | $12 |\n| B | $9 |",
  229. Some("Qwen/Qwen3-VL-30B-A3B-Instruct"),
  230. 180_000,
  231. UsageSource::Reported,
  232. ),
  233. msg("user", "And yearly?", None, 0, UsageSource::None),
  234. cut,
  235. ];
  236. let paths = |id: &str| (id == "a1").then(|| "/data/attachments/abc.png".to_string());
  237. let tz = chrono::FixedOffset::west_opt(5 * 3600).unwrap();
  238. let wiki = to_markdown(&conv, &msgs, &paths, ImageLinks::Wiki, &tz);
  239. assert_eq!(wiki.filename, "Compare two pricing tiers a b.md");
  240. assert_eq!(wiki.files.len(), 1);
  241. assert_eq!(wiki.files[0].relative_path, "attachments/chart q3.png");
  242. insta::assert_snapshot!("export_wiki", wiki.markdown);
  243. let md = to_markdown(&conv, &msgs, &paths, ImageLinks::Markdown, &tz);
  244. assert!(md.markdown.contains("![chart q3.png](attachments/chart%20q3.png)"));
  245. }
  246. #[test]
  247. fn yaml_escaping() {
  248. assert_eq!(yaml_str("a \"b\" \\ c\nd"), r#""a \"b\" \\ c\nd""#);
  249. assert_eq!(yaml_str("tab\there"), r#""tab\there""#);
  250. assert_eq!(yaml_str("\u{7}"), r#""\x07""#);
  251. }
  252. #[test]
  253. fn filenames() {
  254. assert_eq!(safe_filename(" ..weird [[name]] #tag ^x | y "), "weird name tag x y");
  255. assert_eq!(safe_filename("///"), "Cortex chat");
  256. assert_eq!(safe_filename(&"x".repeat(200)).len(), 80);
  257. }
  258. }