1use std::path::{Path, PathBuf};
4
5use async_openai::types::chat::{
6 ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
7 ChatCompletionRequestToolMessage, ChatCompletionRequestUserMessage,
8 ChatCompletionRequestUserMessageContent,
9};
10use rusqlite::{params, Connection, Result as SqliteResult};
11use serde::{Deserialize, Serialize};
12
13use crate::datetime::current_timestamp;
14use crate::error::Result;
15
16const ROBIT_DIR: &str = ".robit";
17const MEMORY_DIR: &str = "memory";
18const DB_FILE: &str = "robit.db";
19
20pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf> {
22 if global_storage {
23 let home = dirs::home_dir().ok_or_else(|| {
24 crate::error::AgentError::InternalError("Cannot determine home directory".to_string())
25 })?;
26 Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
27 } else {
28 Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
29 }
30}
31
32#[derive(Debug, Clone, Serialize)]
34pub struct SessionInfo {
35 pub id: String,
36 pub chat_id: Option<String>,
38 pub title: String,
39 pub model: String,
40 pub source: String,
42 pub status: String, pub created_at: String,
44 pub updated_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MessageData {
50 pub id: i64,
51 pub role: String,
52 pub content: String,
53 pub tool_name: Option<String>,
54 pub tool_call_id: Option<String>,
55 pub tool_info: Option<serde_json::Value>,
56 pub created_at: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolCallInfoData {
62 pub tool_call_id: String,
63 pub name: String,
64 pub arguments: String,
65 pub status: String,
66 pub output: Option<String>,
67 pub requires_confirm: bool,
68}
69
70const CURRENT_SCHEMA_VERSION: i32 = 2;
72
73pub fn init_db(conn: &Connection) -> SqliteResult<()> {
79 ensure_meta_table(conn)?;
80
81 let version = read_schema_version(conn)?;
82
83 if version == 0 {
84 create_all_tables(conn)?;
86 write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
87 tracing::info!(
88 "Database initialized at schema v{}",
89 CURRENT_SCHEMA_VERSION
90 );
91 return Ok(());
92 }
93
94 migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
95 Ok(())
96}
97
98fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
106 match conn.query_row(
107 "SELECT value FROM _schema_meta WHERE key = 'version'",
108 [],
109 |row| row.get::<_, String>(0),
110 ) {
111 Ok(v) => v.parse().map_err(|_| {
112 rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
113 }),
114 Err(rusqlite::Error::QueryReturnedNoRows) => {
115 if sessions_table_exists(conn)? {
117 Ok(1) } else {
119 Ok(0) }
121 }
122 Err(e) => Err(e),
123 }
124}
125
126fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
128 let count: i64 = conn.query_row(
129 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
130 [],
131 |row| row.get(0),
132 )?;
133 Ok(count > 0)
134}
135
136fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
138 conn.execute_batch(
139 "CREATE TABLE IF NOT EXISTS sessions (
140 id TEXT PRIMARY KEY,
141 chat_id TEXT,
142 title TEXT NOT NULL,
143 model TEXT NOT NULL,
144 source TEXT NOT NULL DEFAULT 'gui',
145 created_at TEXT NOT NULL,
146 updated_at TEXT NOT NULL,
147 is_active INTEGER DEFAULT 1
148 );
149
150 CREATE TABLE IF NOT EXISTS messages (
151 id INTEGER PRIMARY KEY AUTOINCREMENT,
152 session_id TEXT NOT NULL REFERENCES sessions(id),
153 role TEXT NOT NULL,
154 content TEXT NOT NULL,
155 tool_name TEXT,
156 tool_call_id TEXT,
157 tool_info TEXT,
158 tokens INTEGER,
159 created_at TEXT NOT NULL
160 );
161
162 CREATE INDEX IF NOT EXISTS idx_messages_session
163 ON messages(session_id);
164 CREATE INDEX IF NOT EXISTS idx_messages_created
165 ON messages(session_id, created_at);
166 CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
167 ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
168 )?;
169 Ok(())
170}
171
172fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
174 let mut current = from;
175 while current < to {
176 tracing::info!("Migrating database: v{} → v{}", current, current + 1);
177 match current {
178 1 => migrate_v1_to_v2(conn)?,
179 other => {
180 return Err(rusqlite::Error::InvalidParameterName(format!(
181 "Unknown schema version: {}",
182 other
183 )))
184 }
185 }
186 current += 1;
187 write_schema_version(conn, current)?;
188 tracing::info!("Database migrated to v{}", current);
189 }
190 Ok(())
191}
192
193fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
199 let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", []);
200 let _ = conn.execute(
201 "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
202 [],
203 );
204 let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", []);
205 conn.execute_batch(
206 "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
207 ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
208 )?;
209 Ok(())
210}
211
212fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
217 conn.execute_batch(
218 "CREATE TABLE IF NOT EXISTS _schema_meta (
219 key TEXT PRIMARY KEY,
220 value TEXT NOT NULL
221 )",
222 )
223}
224
225fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
226 conn.execute(
227 "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
228 rusqlite::params![version.to_string()],
229 )?;
230 Ok(())
231}
232
233pub fn insert_session(
243 conn: &Connection,
244 id: &str,
245 chat_id: Option<&str>,
246 title: &str,
247 model: &str,
248 source: &str,
249) -> SqliteResult<()> {
250 let now = current_timestamp();
251 conn.execute(
252 "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
253 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
254 params![id, chat_id, title, model, source, now, now],
255 )?;
256 Ok(())
257}
258
259pub fn list_sessions(
264 conn: &Connection,
265 source_filter: Option<&str>,
266) -> SqliteResult<Vec<SessionInfo>> {
267 let sql = if source_filter.is_some() {
268 "SELECT id, chat_id, title, model, source, created_at, updated_at \
269 FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
270 } else {
271 "SELECT id, chat_id, title, model, source, created_at, updated_at \
272 FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
273 };
274 let mut stmt = conn.prepare(sql)?;
275 let rows = if let Some(source) = source_filter {
276 stmt.query_map(params![source], map_session_row)?
277 } else {
278 stmt.query_map([], map_session_row)?
279 };
280 rows.collect()
281}
282
283pub fn find_session_by_chat_id(
287 conn: &Connection,
288 chat_id: &str,
289) -> SqliteResult<Option<SessionInfo>> {
290 let mut stmt = conn.prepare(
291 "SELECT id, chat_id, title, model, source, created_at, updated_at \
292 FROM sessions WHERE chat_id = ?1 AND is_active = 1",
293 )?;
294 let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
295 match rows.next() {
296 Some(Ok(session)) => Ok(Some(session)),
297 _ => Ok(None),
298 }
299}
300
301pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
303 let mut stmt = conn.prepare(
304 "SELECT id, chat_id, title, model, source, created_at, updated_at \
305 FROM sessions WHERE id = ?1 AND is_active = 1",
306 )?;
307 let mut rows = stmt.query_map(params![id], map_session_row)?;
308 match rows.next() {
309 Some(Ok(session)) => Ok(Some(session)),
310 _ => Ok(None),
311 }
312}
313
314fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
316 Ok(SessionInfo {
317 id: row.get(0)?,
318 chat_id: row.get(1)?,
319 title: row.get(2)?,
320 model: row.get(3)?,
321 source: row.get(4)?,
322 status: "idle".to_string(),
323 created_at: row.get(5)?,
324 updated_at: row.get(6)?,
325 })
326}
327
328pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
330 let now = current_timestamp();
331 conn.execute(
332 "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
333 params![title, now, id],
334 )?;
335 Ok(())
336}
337
338pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
340 let now = current_timestamp();
341 conn.execute(
342 "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
343 params![now, id],
344 )?;
345 Ok(())
346}
347
348pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
350 conn.execute(
351 "UPDATE sessions SET is_active = 0 WHERE id = ?1",
352 params![id],
353 )?;
354 Ok(())
355}
356
357pub fn insert_message(
359 conn: &Connection,
360 session_id: &str,
361 role: &str,
362 content: &str,
363 tool_name: Option<&str>,
364 tool_call_id: Option<&str>,
365 tool_info: Option<&str>,
366) -> SqliteResult<i64> {
367 let now = current_timestamp();
368 conn.execute(
369 "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
370 params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
371 )?;
372 Ok(conn.last_insert_rowid())
373}
374
375pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
377 let mut stmt = conn.prepare(
378 "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
379 )?;
380 let rows = stmt.query_map(params![session_id], |row| {
381 let tool_info_str: Option<String> = row.get(5)?;
382 let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
383 Ok(MessageData {
384 id: row.get(0)?,
385 role: row.get(1)?,
386 content: row.get(2)?,
387 tool_name: row.get(3)?,
388 tool_call_id: row.get(4)?,
389 tool_info,
390 created_at: row.get(6)?,
391 })
392 })?;
393 rows.collect()
394}
395
396pub fn update_tool_message(
398 conn: &Connection,
399 session_id: &str,
400 tool_call_id: &str,
401 tool_info: &str,
402) -> SqliteResult<()> {
403 conn.execute(
404 "UPDATE messages SET tool_info = ?1 WHERE session_id = ?2 AND tool_call_id = ?3",
405 params![tool_info, session_id, tool_call_id],
406 )?;
407 Ok(())
408}
409
410pub fn message_to_chat_message(data: &MessageData) -> Result<ChatCompletionRequestMessage> {
416 match data.role.as_str() {
417 "user" => Ok(ChatCompletionRequestMessage::User(
418 ChatCompletionRequestUserMessage {
419 content: ChatCompletionRequestUserMessageContent::Text(data.content.clone()),
420 name: None,
421 }
422 .into(),
423 )),
424 "assistant" => {
425 let tool_calls = if let Some(tool_info) = &data.tool_info {
427 if let serde_json::Value::Object(obj) = tool_info {
428 if let Some(serde_json::Value::Array(arr)) = obj.get("tool_calls") {
429 use async_openai::types::chat::{
430 ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
431 };
432 let mut calls = Vec::new();
433 for call_val in arr {
434 if let Ok(call) =
435 serde_json::from_value::<ChatCompletionMessageToolCall>(
436 call_val.clone(),
437 )
438 {
439 calls.push(ChatCompletionMessageToolCalls::Function(call));
440 }
441 }
442 if !calls.is_empty() {
443 Some(calls)
444 } else {
445 None
446 }
447 } else {
448 None
449 }
450 } else {
451 None
452 }
453 } else {
454 None
455 };
456
457 #[allow(deprecated)]
458 Ok(ChatCompletionRequestMessage::Assistant(
459 ChatCompletionRequestAssistantMessage {
460 content: if data.content.is_empty() {
461 None
462 } else {
463 Some(data.content.clone().into())
464 },
465 name: None,
466 tool_calls,
467 refusal: None,
468 audio: None,
469 function_call: None,
470 }
471 .into(),
472 ))
473 }
474 "tool" => {
475 let tool_call_id = data.tool_call_id.clone().unwrap_or_default();
476 Ok(ChatCompletionRequestMessage::Tool(
477 ChatCompletionRequestToolMessage {
478 content: data.content.clone().into(),
479 tool_call_id,
480 }
481 .into(),
482 ))
483 }
484 _ => Err(crate::error::AgentError::InternalError(format!(
486 "Unknown role: {}",
487 data.role
488 ))),
489 }
490}
491
492pub fn load_chat_messages(
494 conn: &Connection,
495 session_id: &str,
496) -> Result<Vec<ChatCompletionRequestMessage>> {
497 let messages = get_messages(conn, session_id)?;
498 tracing::debug!(
499 "load_chat_messages: session_id={}, loaded {} messages from DB",
500 session_id,
501 messages.len()
502 );
503
504 let mut result = Vec::with_capacity(messages.len());
505 for (idx, msg) in messages.iter().enumerate() {
506 match message_to_chat_message(&msg) {
507 Ok(chat_msg) => {
508 tracing::debug!(
509 " Message {}: role={}, content_len={}",
510 idx,
511 msg.role,
512 msg.content.len()
513 );
514 result.push(chat_msg);
515 }
516 Err(e) => {
517 tracing::warn!("Failed to convert message {}: {}", idx, e);
518 }
519 }
520 }
521 tracing::debug!("load_chat_messages: successfully converted {} messages", result.len());
522 Ok(result)
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn resolves_local_db_path() {
531 let working_dir = PathBuf::from("project");
532 let path = resolve_db_path(&working_dir, false).unwrap();
533 assert_eq!(
534 path,
535 working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
536 );
537 }
538
539 #[test]
540 fn session_crud() {
541 let conn = Connection::open_in_memory().unwrap();
542 init_db(&conn).unwrap();
543
544 insert_session(
545 &conn,
546 "test-123",
547 None,
548 "Test Session",
549 "deepseek/deepseek-chat",
550 "gui",
551 )
552 .unwrap();
553
554 let sessions = list_sessions(&conn, None).unwrap();
555 assert_eq!(sessions.len(), 1);
556 assert_eq!(sessions[0].id, "test-123");
557 assert_eq!(sessions[0].title, "Test Session");
558 assert_eq!(sessions[0].source, "gui");
559 assert_eq!(sessions[0].chat_id, None);
560 assert_eq!(sessions[0].status, "idle");
561
562 let session = get_session(&conn, "test-123").unwrap().unwrap();
563 assert_eq!(session.title, "Test Session");
564 assert_eq!(session.source, "gui");
565
566 update_session_title(&conn, "test-123", "Updated Title").unwrap();
567 let updated = get_session(&conn, "test-123").unwrap().unwrap();
568 assert_eq!(updated.title, "Updated Title");
569
570 delete_session(&conn, "test-123").unwrap();
571 assert!(get_session(&conn, "test-123").unwrap().is_none());
572 assert!(list_sessions(&conn, None).unwrap().is_empty());
573 }
574
575 #[test]
576 fn message_operations() {
577 let conn = Connection::open_in_memory().unwrap();
578 init_db(&conn).unwrap();
579
580 insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
581 let user_id = insert_message(
582 &conn,
583 "session-msg",
584 "user",
585 "Hello Robit",
586 None,
587 None,
588 None,
589 )
590 .unwrap();
591 let assistant_id = insert_message(
592 &conn,
593 "session-msg",
594 "assistant",
595 "Hello! How can I help?",
596 None,
597 None,
598 None,
599 )
600 .unwrap();
601
602 let messages = get_messages(&conn, "session-msg").unwrap();
603 assert_eq!(messages.len(), 2);
604 assert_eq!(messages[0].id, user_id);
605 assert_eq!(messages[0].role, "user");
606 assert_eq!(messages[0].content, "Hello Robit");
607 assert_eq!(messages[1].id, assistant_id);
608 assert_eq!(messages[1].role, "assistant");
609 assert_eq!(messages[1].content, "Hello! How can I help?");
610 }
611
612 #[test]
613 fn empty_sessions() {
614 let conn = Connection::open_in_memory().unwrap();
615 init_db(&conn).unwrap();
616
617 let sessions = list_sessions(&conn, None).unwrap();
618 assert_eq!(sessions.len(), 0);
619 }
620
621 #[test]
622 fn get_nonexistent_session() {
623 let conn = Connection::open_in_memory().unwrap();
624 init_db(&conn).unwrap();
625
626 let session = get_session(&conn, "nonexistent").unwrap();
627 assert!(session.is_none());
628 }
629
630 #[test]
631 fn tool_message_update() {
632 let conn = Connection::open_in_memory().unwrap();
633 init_db(&conn).unwrap();
634
635 insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
636 let initial = serde_json::json!({
637 "tool_call_id": "tool-1",
638 "name": "bash",
639 "arguments": "{}",
640 "status": "pending",
641 "requires_confirm": true
642 })
643 .to_string();
644 insert_message(
645 &conn,
646 "session-tool",
647 "tool",
648 "{}",
649 Some("bash"),
650 Some("tool-1"),
651 Some(&initial),
652 )
653 .unwrap();
654
655 let updated = serde_json::json!({
656 "tool_call_id": "tool-1",
657 "status": "success",
658 "output": "done"
659 })
660 .to_string();
661 update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
662
663 let messages = get_messages(&conn, "session-tool").unwrap();
664 assert_eq!(messages.len(), 1);
665 assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
666 assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
667 assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
668 assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
669 }
670
671 #[test]
672 fn chat_id_lookup_and_source_filter() {
673 let conn = Connection::open_in_memory().unwrap();
674 init_db(&conn).unwrap();
675
676 insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
677 insert_session(
678 &conn,
679 "qq-1",
680 Some("group:abc"),
681 "技术讨论群",
682 "model",
683 "qq",
684 )
685 .unwrap();
686 insert_session(
687 &conn,
688 "qq-2",
689 Some("private:xyz"),
690 "私聊",
691 "model",
692 "qq",
693 )
694 .unwrap();
695
696 let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
698 assert_eq!(found.id, "qq-1");
699 assert_eq!(found.source, "qq");
700 assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
701
702 assert!(find_session_by_chat_id(&conn, "does-not-exist")
704 .unwrap()
705 .is_none());
706
707 let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
709 assert_eq!(qq_sessions.len(), 2);
710 assert!(qq_sessions.iter().all(|s| s.source == "qq"));
711
712 let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
713 assert_eq!(gui_sessions.len(), 1);
714 assert_eq!(gui_sessions[0].id, "gui-1");
715
716 assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
718 }
719
720 #[test]
721 fn chat_id_unique_per_chat() {
722 let conn = Connection::open_in_memory().unwrap();
723 init_db(&conn).unwrap();
724
725 insert_session(
726 &conn,
727 "qq-1",
728 Some("group:abc"),
729 "First",
730 "model",
731 "qq",
732 )
733 .unwrap();
734 let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
736 assert!(err.is_err());
737 }
738
739 #[test]
740 fn migrates_legacy_v1_database() {
741 let conn = Connection::open_in_memory().unwrap();
742 conn.execute_batch(
744 "CREATE TABLE sessions (
745 id TEXT PRIMARY KEY,
746 title TEXT NOT NULL,
747 model TEXT NOT NULL,
748 created_at TEXT NOT NULL,
749 updated_at TEXT NOT NULL,
750 is_active INTEGER DEFAULT 1
751 );
752 CREATE TABLE messages (
753 id INTEGER PRIMARY KEY AUTOINCREMENT,
754 session_id TEXT NOT NULL REFERENCES sessions(id),
755 role TEXT NOT NULL,
756 content TEXT NOT NULL,
757 tool_name TEXT,
758 tool_call_id TEXT,
759 tokens INTEGER,
760 created_at TEXT NOT NULL
761 );",
762 )
763 .unwrap();
764 conn.execute(
765 "INSERT INTO sessions (id, title, model, created_at, updated_at) \
766 VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
767 [],
768 )
769 .unwrap();
770
771 init_db(&conn).unwrap();
775
776 let v: i32 = read_schema_version(&conn).unwrap();
778 assert_eq!(v, CURRENT_SCHEMA_VERSION);
779
780 let session = get_session(&conn, "legacy-1").unwrap().unwrap();
782 assert_eq!(session.title, "Legacy");
783 assert_eq!(session.source, "gui");
784 assert_eq!(session.chat_id, None);
785 }
786
787 #[test]
788 fn init_db_is_idempotent() {
789 let conn = Connection::open_in_memory().unwrap();
790 init_db(&conn).unwrap();
791 init_db(&conn).unwrap();
793 assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
794 }
795}