1use anyhow::{Context as _, Result};
7use serde::{Deserialize, Serialize};
8
9use super::App;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CoreSnapshot {
14 pub sessions: Vec<SessionSnapshot>,
15 pub models: Vec<ModelSnapshot>,
16 pub settings: SettingsSnapshot,
17 pub tasks: Vec<TaskSnapshot>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SessionSnapshot {
22 pub id: String,
23 pub title: String,
24 pub slug: Option<String>,
25 pub model: String,
26 pub kind: String,
27 pub web_mode: bool,
28 pub created_at: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ModelSnapshot {
33 pub id: String,
36 pub name: String,
37 pub context_length: Option<u64>,
38 pub favorite: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SettingsSnapshot {
43 pub model: Option<String>,
44 pub verbosity: String,
45 pub web_mode: bool,
46 pub incognito: bool,
47 pub searxng_url: String,
49 #[serde(default)]
52 pub langsearch_configured: bool,
53 pub search_provider: String,
54 pub temperature: Option<f32>,
55 pub top_p: Option<f32>,
56 pub max_tokens: Option<u32>,
57 pub compact_threshold: u8,
58 pub memory_model: String,
59 pub transcriber_model: String,
60 pub ocr_model: String,
61 pub ocr_engine: String,
62 pub embedding_model: String,
63 pub image_gen_model: String,
64 pub video_gen_model: String,
65 pub blocked_domains: Vec<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TaskSnapshot {
70 pub id: u64,
71 pub session_id: String,
72 pub session_title: String,
73 pub model: String,
74 pub backend: String,
75 pub status: String,
78 pub buffer_chars: usize,
79}
80
81fn sanitized_endpoint(value: &str) -> String {
85 let value = value.trim();
86 let value = value.split_once('#').map_or(value, |(prefix, _)| prefix);
87 let value = value.split_once('?').map_or(value, |(prefix, _)| prefix);
88 if let Some((scheme, authority)) = value.split_once("://")
89 && let Some((_, host)) = authority.rsplit_once('@')
90 {
91 return format!("{scheme}://{host}");
92 }
93 value.to_string()
94}
95
96impl App {
97 pub fn snapshot(&self) -> Result<CoreSnapshot> {
101 let sessions = if self.sessions_cache.is_empty() {
102 self.db
103 .list_sessions(&self.active_space.id)
104 .context("reading sessions for snapshot")?
105 } else {
106 self.sessions_cache.clone()
107 };
108 let favorite_ids = &self.favorites;
109 Ok(CoreSnapshot {
110 sessions: sessions
111 .into_iter()
112 .map(|s| SessionSnapshot {
113 id: s.id.clone(),
114 title: s.title.clone(),
115 slug: s.slug.clone(),
116 model: s.model.clone(),
117 kind: s.kind.clone(),
118 web_mode: s.web_mode,
119 created_at: s.created_at.clone(),
120 })
121 .collect(),
122 models: self
123 .models
124 .iter()
125 .map(|m| ModelSnapshot {
126 id: super::composite_id(m),
127 name: m.name.clone(),
128 context_length: m.context_length,
129 favorite: favorite_ids.contains(&super::composite_id(m)),
130 })
131 .collect(),
132 settings: SettingsSnapshot {
133 model: self.current_model.clone(),
134 verbosity: self.verbosity.clone(),
135 web_mode: self.web_mode,
136 incognito: self.incognito,
137 searxng_url: sanitized_endpoint(&self.searxng_url),
138 langsearch_configured: !self.langsearch_key.trim().is_empty(),
139 search_provider: self.search_provider.clone(),
140 temperature: self.settings.temperature,
141 top_p: self.settings.top_p,
142 max_tokens: self.settings.max_tokens,
143 compact_threshold: self.settings.compact_threshold,
144 memory_model: self.memory_model.clone(),
145 transcriber_model: self.transcriber_model.clone(),
146 ocr_model: self.ocr_model.clone(),
147 ocr_engine: self.ocr_engine.clone(),
148 embedding_model: self.embedding_model.clone(),
149 image_gen_model: self.image_gen_model.clone(),
150 video_gen_model: self.video_gen_model.clone(),
151 blocked_domains: self.blocked_domains(),
152 },
153 tasks: self
154 .chat_tasks
155 .values()
156 .map(|t| TaskSnapshot {
157 id: t.id,
158 session_id: t.session_id.clone(),
159 session_title: t.session_title.clone(),
160 model: t.model.clone(),
161 backend: t.backend.name().to_string(),
162 status: if t.tool_status.is_some() {
163 "tool".to_string()
164 } else {
165 "streaming".to_string()
166 },
167 buffer_chars: t.buffer.chars().count(),
168 })
169 .collect(),
170 })
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
183 fn golden_json_locks_wire_shape() {
184 let snap = CoreSnapshot {
185 sessions: vec![SessionSnapshot {
186 id: "s1".into(),
187 title: "hello".into(),
188 slug: Some("hello".into()),
189 model: "openrouter:anthropic/claude-sonnet-4".into(),
190 kind: "chat".into(),
191 web_mode: false,
192 created_at: "2025-01-01T00:00:00Z".into(),
193 }],
194 models: vec![ModelSnapshot {
195 id: "openrouter:anthropic/claude-sonnet-4".into(),
196 name: "Claude Sonnet 4".into(),
197 context_length: Some(200_000),
198 favorite: true,
199 }],
200 settings: SettingsSnapshot {
201 model: Some("openrouter:anthropic/claude-sonnet-4".into()),
202 verbosity: "high".into(),
203 web_mode: false,
204 incognito: false,
205 searxng_url: String::new(),
206 langsearch_configured: false,
207 search_provider: "searxng".into(),
208 temperature: Some(0.7),
209 top_p: None,
210 max_tokens: None,
211 compact_threshold: 60,
212 memory_model: String::new(),
213 transcriber_model: String::new(),
214 ocr_model: String::new(),
215 ocr_engine: "router".into(),
216 embedding_model: String::new(),
217 image_gen_model: String::new(),
218 video_gen_model: String::new(),
219 blocked_domains: Vec::new(),
220 },
221 tasks: vec![TaskSnapshot {
222 id: 1,
223 session_id: "s1".into(),
224 session_title: "hello".into(),
225 model: "openrouter:anthropic/claude-sonnet-4".into(),
226 backend: "OpenRouter".into(),
227 status: "streaming".into(),
228 buffer_chars: 12,
229 }],
230 };
231 let json = serde_json::to_string(&snap).expect("snapshot serializes");
232 assert_eq!(
233 json,
234 r#"{"sessions":[{"id":"s1","title":"hello","slug":"hello","model":"openrouter:anthropic/claude-sonnet-4","kind":"chat","web_mode":false,"created_at":"2025-01-01T00:00:00Z"}],"models":[{"id":"openrouter:anthropic/claude-sonnet-4","name":"Claude Sonnet 4","context_length":200000,"favorite":true}],"settings":{"model":"openrouter:anthropic/claude-sonnet-4","verbosity":"high","web_mode":false,"incognito":false,"searxng_url":"","langsearch_configured":false,"search_provider":"searxng","temperature":0.7,"top_p":null,"max_tokens":null,"compact_threshold":60,"memory_model":"","transcriber_model":"","ocr_model":"","ocr_engine":"router","embedding_model":"","image_gen_model":"","video_gen_model":"","blocked_domains":[]},"tasks":[{"id":1,"session_id":"s1","session_title":"hello","model":"openrouter:anthropic/claude-sonnet-4","backend":"OpenRouter","status":"streaming","buffer_chars":12}]}"#
235 );
236 let back: CoreSnapshot = serde_json::from_str(&json).expect("golden parses");
238 assert_eq!(back.sessions[0].title, "hello");
239 assert_eq!(back.sessions[0].slug.as_deref(), Some("hello"));
240 assert_eq!(back.models[0].context_length, Some(200_000));
241 assert_eq!(back.settings.temperature, Some(0.7));
242 assert!(!back.settings.langsearch_configured);
243 assert_eq!(back.settings.blocked_domains, Vec::<String>::new());
244 assert_eq!(back.tasks[0].status, "streaming");
245 assert_eq!(back.tasks[0].buffer_chars, 12);
246 }
247
248 #[test]
249 fn sanitized_endpoint_drops_credentials_and_query() {
250 assert_eq!(
251 sanitized_endpoint("https://user:pass@example.test/search?token=secret#frag"),
252 "https://example.test/search"
253 );
254 assert_eq!(
255 sanitized_endpoint("http://localhost:8080"),
256 "http://localhost:8080"
257 );
258 }
259}