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!(
485 "Unknown role: {}",
486 data.role
487 ))),
488 }
489}
490
491pub fn load_chat_messages(
493 conn: &Connection,
494 session_id: &str,
495) -> Result<Vec<ChatCompletionRequestMessage>> {
496 let messages = get_messages(conn, session_id)?;
497 let mut result = Vec::with_capacity(messages.len());
498 for msg in messages {
499 match message_to_chat_message(&msg) {
500 Ok(chat_msg) => result.push(chat_msg),
501 Err(e) => {
502 tracing::warn!("Failed to convert message: {}", e);
503 }
504 }
505 }
506 Ok(result)
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn resolves_local_db_path() {
515 let working_dir = PathBuf::from("project");
516 let path = resolve_db_path(&working_dir, false).unwrap();
517 assert_eq!(
518 path,
519 working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
520 );
521 }
522
523 #[test]
524 fn session_crud() {
525 let conn = Connection::open_in_memory().unwrap();
526 init_db(&conn).unwrap();
527
528 insert_session(
529 &conn,
530 "test-123",
531 None,
532 "Test Session",
533 "deepseek/deepseek-chat",
534 "gui",
535 )
536 .unwrap();
537
538 let sessions = list_sessions(&conn, None).unwrap();
539 assert_eq!(sessions.len(), 1);
540 assert_eq!(sessions[0].id, "test-123");
541 assert_eq!(sessions[0].title, "Test Session");
542 assert_eq!(sessions[0].source, "gui");
543 assert_eq!(sessions[0].chat_id, None);
544 assert_eq!(sessions[0].status, "idle");
545
546 let session = get_session(&conn, "test-123").unwrap().unwrap();
547 assert_eq!(session.title, "Test Session");
548 assert_eq!(session.source, "gui");
549
550 update_session_title(&conn, "test-123", "Updated Title").unwrap();
551 let updated = get_session(&conn, "test-123").unwrap().unwrap();
552 assert_eq!(updated.title, "Updated Title");
553
554 delete_session(&conn, "test-123").unwrap();
555 assert!(get_session(&conn, "test-123").unwrap().is_none());
556 assert!(list_sessions(&conn, None).unwrap().is_empty());
557 }
558
559 #[test]
560 fn message_operations() {
561 let conn = Connection::open_in_memory().unwrap();
562 init_db(&conn).unwrap();
563
564 insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
565 let user_id = insert_message(
566 &conn,
567 "session-msg",
568 "user",
569 "Hello Robit",
570 None,
571 None,
572 None,
573 )
574 .unwrap();
575 let assistant_id = insert_message(
576 &conn,
577 "session-msg",
578 "assistant",
579 "Hello! How can I help?",
580 None,
581 None,
582 None,
583 )
584 .unwrap();
585
586 let messages = get_messages(&conn, "session-msg").unwrap();
587 assert_eq!(messages.len(), 2);
588 assert_eq!(messages[0].id, user_id);
589 assert_eq!(messages[0].role, "user");
590 assert_eq!(messages[0].content, "Hello Robit");
591 assert_eq!(messages[1].id, assistant_id);
592 assert_eq!(messages[1].role, "assistant");
593 assert_eq!(messages[1].content, "Hello! How can I help?");
594 }
595
596 #[test]
597 fn empty_sessions() {
598 let conn = Connection::open_in_memory().unwrap();
599 init_db(&conn).unwrap();
600
601 let sessions = list_sessions(&conn, None).unwrap();
602 assert_eq!(sessions.len(), 0);
603 }
604
605 #[test]
606 fn get_nonexistent_session() {
607 let conn = Connection::open_in_memory().unwrap();
608 init_db(&conn).unwrap();
609
610 let session = get_session(&conn, "nonexistent").unwrap();
611 assert!(session.is_none());
612 }
613
614 #[test]
615 fn tool_message_update() {
616 let conn = Connection::open_in_memory().unwrap();
617 init_db(&conn).unwrap();
618
619 insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
620 let initial = serde_json::json!({
621 "tool_call_id": "tool-1",
622 "name": "bash",
623 "arguments": "{}",
624 "status": "pending",
625 "requires_confirm": true
626 })
627 .to_string();
628 insert_message(
629 &conn,
630 "session-tool",
631 "tool",
632 "{}",
633 Some("bash"),
634 Some("tool-1"),
635 Some(&initial),
636 )
637 .unwrap();
638
639 let updated = serde_json::json!({
640 "tool_call_id": "tool-1",
641 "status": "success",
642 "output": "done"
643 })
644 .to_string();
645 update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
646
647 let messages = get_messages(&conn, "session-tool").unwrap();
648 assert_eq!(messages.len(), 1);
649 assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
650 assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
651 assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
652 assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
653 }
654
655 #[test]
656 fn chat_id_lookup_and_source_filter() {
657 let conn = Connection::open_in_memory().unwrap();
658 init_db(&conn).unwrap();
659
660 insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
661 insert_session(
662 &conn,
663 "qq-1",
664 Some("group:abc"),
665 "技术讨论群",
666 "model",
667 "qq",
668 )
669 .unwrap();
670 insert_session(
671 &conn,
672 "qq-2",
673 Some("private:xyz"),
674 "私聊",
675 "model",
676 "qq",
677 )
678 .unwrap();
679
680 let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
682 assert_eq!(found.id, "qq-1");
683 assert_eq!(found.source, "qq");
684 assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
685
686 assert!(find_session_by_chat_id(&conn, "does-not-exist")
688 .unwrap()
689 .is_none());
690
691 let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
693 assert_eq!(qq_sessions.len(), 2);
694 assert!(qq_sessions.iter().all(|s| s.source == "qq"));
695
696 let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
697 assert_eq!(gui_sessions.len(), 1);
698 assert_eq!(gui_sessions[0].id, "gui-1");
699
700 assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
702 }
703
704 #[test]
705 fn chat_id_unique_per_chat() {
706 let conn = Connection::open_in_memory().unwrap();
707 init_db(&conn).unwrap();
708
709 insert_session(
710 &conn,
711 "qq-1",
712 Some("group:abc"),
713 "First",
714 "model",
715 "qq",
716 )
717 .unwrap();
718 let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
720 assert!(err.is_err());
721 }
722
723 #[test]
724 fn migrates_legacy_v1_database() {
725 let conn = Connection::open_in_memory().unwrap();
726 conn.execute_batch(
728 "CREATE TABLE sessions (
729 id TEXT PRIMARY KEY,
730 title TEXT NOT NULL,
731 model TEXT NOT NULL,
732 created_at TEXT NOT NULL,
733 updated_at TEXT NOT NULL,
734 is_active INTEGER DEFAULT 1
735 );
736 CREATE TABLE messages (
737 id INTEGER PRIMARY KEY AUTOINCREMENT,
738 session_id TEXT NOT NULL REFERENCES sessions(id),
739 role TEXT NOT NULL,
740 content TEXT NOT NULL,
741 tool_name TEXT,
742 tool_call_id TEXT,
743 tokens INTEGER,
744 created_at TEXT NOT NULL
745 );",
746 )
747 .unwrap();
748 conn.execute(
749 "INSERT INTO sessions (id, title, model, created_at, updated_at) \
750 VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
751 [],
752 )
753 .unwrap();
754
755 init_db(&conn).unwrap();
759
760 let v: i32 = read_schema_version(&conn).unwrap();
762 assert_eq!(v, CURRENT_SCHEMA_VERSION);
763
764 let session = get_session(&conn, "legacy-1").unwrap().unwrap();
766 assert_eq!(session.title, "Legacy");
767 assert_eq!(session.source, "gui");
768 assert_eq!(session.chat_id, None);
769 }
770
771 #[test]
772 fn init_db_is_idempotent() {
773 let conn = Connection::open_in_memory().unwrap();
774 init_db(&conn).unwrap();
775 init_db(&conn).unwrap();
777 assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
778 }
779}