Skip to main content

chasm/providers/
session_format.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Session format conversion utilities
4//!
5//! Converts between different chat session formats:
6//! - VS Code Copilot Chat format
7//! - OpenAI API format
8//! - Ollama format
9//! - Generic markdown format
10
11use crate::models::{extract_response_text, ChatMessage, ChatRequest, ChatSession};
12use serde::{Deserialize, Serialize};
13
14/// Generic message format for import/export
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GenericMessage {
17    pub role: String,
18    pub content: String,
19    #[serde(default)]
20    pub timestamp: Option<i64>,
21    #[serde(default)]
22    pub model: Option<String>,
23}
24
25/// Generic session format for import/export
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct GenericSession {
28    pub id: String,
29    pub title: Option<String>,
30    pub messages: Vec<GenericMessage>,
31    #[serde(default)]
32    pub created_at: Option<i64>,
33    #[serde(default)]
34    pub updated_at: Option<i64>,
35    #[serde(default)]
36    pub provider: Option<String>,
37    #[serde(default)]
38    pub model: Option<String>,
39}
40
41impl From<ChatSession> for GenericSession {
42    fn from(session: ChatSession) -> Self {
43        let mut messages = Vec::new();
44
45        for request in session.requests {
46            // Add user message
47            if let Some(msg) = &request.message {
48                if let Some(text) = &msg.text {
49                    messages.push(GenericMessage {
50                        role: "user".to_string(),
51                        content: text.clone(),
52                        timestamp: request.timestamp,
53                        model: request.model_id.clone(),
54                    });
55                }
56            }
57
58            // Add assistant response
59            if let Some(response) = &request.response {
60                if let Some(text) = extract_response_text(response) {
61                    messages.push(GenericMessage {
62                        role: "assistant".to_string(),
63                        content: text,
64                        timestamp: request.timestamp,
65                        model: request.model_id.clone(),
66                    });
67                }
68            }
69        }
70
71        GenericSession {
72            id: session
73                .session_id
74                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
75            title: session.custom_title,
76            messages,
77            created_at: Some(session.creation_date),
78            updated_at: Some(session.last_message_date),
79            provider: session.responder_username,
80            model: None,
81        }
82    }
83}
84
85impl From<GenericSession> for ChatSession {
86    fn from(generic: GenericSession) -> Self {
87        let now = chrono::Utc::now().timestamp_millis();
88
89        let mut requests = Vec::new();
90        let mut user_msg: Option<(String, Option<i64>, Option<String>)> = None;
91
92        for msg in generic.messages {
93            match msg.role.as_str() {
94                "user" => {
95                    user_msg = Some((msg.content, msg.timestamp, msg.model));
96                }
97                "assistant" => {
98                    if let Some((user_text, timestamp, model)) = user_msg.take() {
99                        requests.push(ChatRequest {
100                            timestamp: timestamp.or(Some(now)),
101                            message: Some(ChatMessage {
102                                text: Some(user_text),
103                                parts: None,
104                            }),
105                            response: Some(serde_json::json!(
106                                [{"value": msg.content}]
107                            )),
108                            variable_data: None,
109                            request_id: Some(uuid::Uuid::new_v4().to_string()),
110                            response_id: Some(uuid::Uuid::new_v4().to_string()),
111                            model_id: model.or(msg.model),
112                            agent: None,
113                            result: None,
114                            followups: None,
115                            is_canceled: Some(false),
116                            content_references: None,
117                            code_citations: None,
118                            response_markdown_info: None,
119                            source_session: None,
120                            model_state: None,
121                            time_spent_waiting: None,
122                        });
123                    }
124                }
125                _ => {}
126            }
127        }
128
129        ChatSession {
130            version: 3,
131            session_id: Some(generic.id),
132            creation_date: generic.created_at.unwrap_or(now),
133            last_message_date: generic.updated_at.unwrap_or(now),
134            is_imported: true,
135            initial_location: "imported".to_string(),
136            custom_title: generic.title,
137            requester_username: Some("user".to_string()),
138            requester_avatar_icon_uri: None,
139            responder_username: generic.provider,
140            responder_avatar_icon_uri: None,
141            requests,
142        }
143    }
144}
145
146/// Convert a session to markdown format
147pub fn session_to_markdown(session: &ChatSession) -> String {
148    let mut md = String::new();
149
150    // Header
151    md.push_str(&format!("# {}\n\n", session.title()));
152
153    if let Some(id) = &session.session_id {
154        md.push_str(&format!("Session ID: `{}`\n\n", id));
155    }
156
157    md.push_str(&format!(
158        "Created: {}\n",
159        format_timestamp(session.creation_date)
160    ));
161    md.push_str(&format!(
162        "Last Updated: {}\n\n",
163        format_timestamp(session.last_message_date)
164    ));
165
166    md.push_str("---\n\n");
167
168    // Messages
169    for (i, request) in session.requests.iter().enumerate() {
170        // User message
171        if let Some(msg) = &request.message {
172            if let Some(text) = &msg.text {
173                md.push_str(&format!("## User ({})\n\n", i + 1));
174                md.push_str(text);
175                md.push_str("\n\n");
176            }
177        }
178
179        // Assistant response
180        if let Some(response) = &request.response {
181            if let Some(text) = extract_response_text(response) {
182                let model = request.model_id.as_deref().unwrap_or("Assistant");
183                md.push_str(&format!("## {} ({})\n\n", model, i + 1));
184                md.push_str(&text);
185                md.push_str("\n\n");
186            }
187        }
188
189        md.push_str("---\n\n");
190    }
191
192    md
193}
194
195/// Parse a markdown file into a session
196pub fn markdown_to_session(markdown: &str, title: Option<String>) -> ChatSession {
197    let now = chrono::Utc::now().timestamp_millis();
198    let session_id = uuid::Uuid::new_v4().to_string();
199
200    // Simple parsing - look for ## User and ## Assistant sections
201    let mut requests = Vec::new();
202    let mut current_user: Option<String> = None;
203    let mut current_assistant: Option<String> = None;
204    let mut in_user = false;
205    let mut in_assistant = false;
206    let mut content = String::new();
207
208    for line in markdown.lines() {
209        if line.starts_with("## User") {
210            // Save previous pair
211            if let Some(user) = current_user.take() {
212                requests.push(create_request(
213                    user,
214                    current_assistant.take().unwrap_or_default(),
215                    now,
216                    None,
217                ));
218            }
219            in_user = true;
220            in_assistant = false;
221            content.clear();
222        } else if line.starts_with("## ") && !line.starts_with("## User") {
223            // Assistant or model response
224            if in_user {
225                current_user = Some(content.trim().to_string());
226            }
227            in_user = false;
228            in_assistant = true;
229            content.clear();
230        } else if line == "---" {
231            if in_assistant {
232                current_assistant = Some(content.trim().to_string());
233            }
234            // Save pair
235            if let Some(user) = current_user.take() {
236                requests.push(create_request(
237                    user,
238                    current_assistant.take().unwrap_or_default(),
239                    now,
240                    None,
241                ));
242            }
243            in_user = false;
244            in_assistant = false;
245            content.clear();
246        } else {
247            content.push_str(line);
248            content.push('\n');
249        }
250    }
251
252    // Handle final pair
253    if in_user {
254        current_user = Some(content.trim().to_string());
255    } else if in_assistant {
256        current_assistant = Some(content.trim().to_string());
257    }
258    if let Some(user) = current_user.take() {
259        requests.push(create_request(
260            user,
261            current_assistant.take().unwrap_or_default(),
262            now,
263            None,
264        ));
265    }
266
267    ChatSession {
268        version: 3,
269        session_id: Some(session_id),
270        creation_date: now,
271        last_message_date: now,
272        is_imported: true,
273        initial_location: "markdown".to_string(),
274        custom_title: title,
275        requester_username: Some("user".to_string()),
276        requester_avatar_icon_uri: None,
277        responder_username: Some("Imported".to_string()),
278        responder_avatar_icon_uri: None,
279        requests,
280    }
281}
282
283/// Create a ChatRequest from user/assistant text
284fn create_request(
285    user_text: String,
286    assistant_text: String,
287    timestamp: i64,
288    model: Option<String>,
289) -> ChatRequest {
290    ChatRequest {
291        timestamp: Some(timestamp),
292        message: Some(ChatMessage {
293            text: Some(user_text),
294            parts: None,
295        }),
296        response: Some(serde_json::json!(
297            [{"value": assistant_text}]
298        )),
299        variable_data: None,
300        request_id: Some(uuid::Uuid::new_v4().to_string()),
301        response_id: Some(uuid::Uuid::new_v4().to_string()),
302        model_id: model,
303        agent: None,
304        result: None,
305        followups: None,
306        is_canceled: Some(false),
307        content_references: None,
308        code_citations: None,
309        response_markdown_info: None,
310        source_session: None,
311        model_state: None,
312        time_spent_waiting: None,
313    }
314}
315
316/// Extract text from various response formats
317/// NOTE: This is now delegated to crate::models::extract_response_text.
318/// This wrapper is kept for backward compatibility with in-module callers.
319fn _extract_response_text_legacy(response: &serde_json::Value) -> Option<String> {
320    extract_response_text(response)
321}
322
323/// Format a timestamp for display
324fn format_timestamp(timestamp: i64) -> String {
325    use chrono::{TimeZone, Utc};
326
327    if timestamp == 0 {
328        return "Unknown".to_string();
329    }
330
331    let dt = Utc.timestamp_millis_opt(timestamp);
332    match dt {
333        chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
334        _ => "Invalid".to_string(),
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn test_session_to_markdown() {
344        let session = ChatSession {
345            version: 3,
346            session_id: Some("test-123".to_string()),
347            creation_date: 1700000000000,
348            last_message_date: 1700000000000,
349            is_imported: false,
350            initial_location: "panel".to_string(),
351            custom_title: Some("Test Session".to_string()),
352            requester_username: Some("user".to_string()),
353            requester_avatar_icon_uri: None,
354            responder_username: Some("assistant".to_string()),
355            responder_avatar_icon_uri: None,
356            requests: vec![ChatRequest {
357                timestamp: Some(1700000000000),
358                message: Some(ChatMessage {
359                    text: Some("Hello".to_string()),
360                    parts: None,
361                }),
362                response: Some(serde_json::json!({
363                    "value": [{"value": "Hi there!"}]
364                })),
365                variable_data: None,
366                request_id: None,
367                response_id: None,
368                model_id: Some("gpt-4".to_string()),
369                agent: None,
370                result: None,
371                followups: None,
372                is_canceled: None,
373                content_references: None,
374                code_citations: None,
375                response_markdown_info: None,
376                source_session: None,
377                model_state: None,
378                time_spent_waiting: None,
379            }],
380        };
381
382        let md = session_to_markdown(&session);
383        assert!(md.contains("# Test Session"));
384        assert!(md.contains("Hello"));
385        assert!(md.contains("Hi there!"));
386    }
387
388    #[test]
389    fn test_generic_session_conversion() {
390        let session = ChatSession {
391            version: 3,
392            session_id: Some("test-123".to_string()),
393            creation_date: 1700000000000,
394            last_message_date: 1700000000000,
395            is_imported: false,
396            initial_location: "panel".to_string(),
397            custom_title: Some("Test".to_string()),
398            requester_username: None,
399            requester_avatar_icon_uri: None,
400            responder_username: Some("Copilot".to_string()),
401            responder_avatar_icon_uri: None,
402            requests: vec![],
403        };
404
405        let generic: GenericSession = session.clone().into();
406        assert_eq!(generic.id, "test-123");
407        assert_eq!(generic.title, Some("Test".to_string()));
408
409        let back: ChatSession = generic.into();
410        assert_eq!(back.session_id, Some("test-123".to_string()));
411    }
412}