use std::path::{Path, PathBuf};
use async_openai::types::chat::{
ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
ChatCompletionRequestToolMessage, ChatCompletionRequestUserMessage,
ChatCompletionRequestUserMessageContent,
};
use rusqlite::{params, Connection, Result as SqliteResult};
use serde::{Deserialize, Serialize};
use crate::datetime::current_timestamp;
use crate::error::Result;
const ROBIT_DIR: &str = ".robit";
const MEMORY_DIR: &str = "memory";
const DB_FILE: &str = "robit.db";
pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf> {
if global_storage {
let home = dirs::home_dir().ok_or_else(|| {
crate::error::AgentError::InternalError("Cannot determine home directory".to_string())
})?;
Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
} else {
Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub id: String,
pub chat_id: Option<String>,
pub title: String,
pub model: String,
pub source: String,
pub status: String, pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageData {
pub id: i64,
pub role: String,
pub content: String,
pub tool_name: Option<String>,
pub tool_call_id: Option<String>,
pub tool_info: Option<serde_json::Value>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallInfoData {
pub tool_call_id: String,
pub name: String,
pub arguments: String,
pub status: String,
pub output: Option<String>,
pub requires_confirm: bool,
}
const CURRENT_SCHEMA_VERSION: i32 = 2;
pub fn init_db(conn: &Connection) -> SqliteResult<()> {
ensure_meta_table(conn)?;
let version = read_schema_version(conn)?;
if version == 0 {
create_all_tables(conn)?;
write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
tracing::info!(
"Database initialized at schema v{}",
CURRENT_SCHEMA_VERSION
);
return Ok(());
}
migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
Ok(())
}
fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
match conn.query_row(
"SELECT value FROM _schema_meta WHERE key = 'version'",
[],
|row| row.get::<_, String>(0),
) {
Ok(v) => v.parse().map_err(|_| {
rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
}),
Err(rusqlite::Error::QueryReturnedNoRows) => {
if sessions_table_exists(conn)? {
Ok(1) } else {
Ok(0) }
}
Err(e) => Err(e),
}
}
fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
[],
|row| row.get(0),
)?;
Ok(count > 0)
}
fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
chat_id TEXT,
title TEXT NOT NULL,
model TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'gui',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_active INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_name TEXT,
tool_call_id TEXT,
tool_info TEXT,
tokens INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created
ON messages(session_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
)?;
Ok(())
}
fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
let mut current = from;
while current < to {
tracing::info!("Migrating database: v{} → v{}", current, current + 1);
match current {
1 => migrate_v1_to_v2(conn)?,
other => {
return Err(rusqlite::Error::InvalidParameterName(format!(
"Unknown schema version: {}",
other
)))
}
}
current += 1;
write_schema_version(conn, current)?;
tracing::info!("Database migrated to v{}", current);
}
Ok(())
}
fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", []);
let _ = conn.execute(
"ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
[],
);
let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", []);
conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
)?;
Ok(())
}
fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS _schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
)
}
fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
conn.execute(
"INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
rusqlite::params![version.to_string()],
)?;
Ok(())
}
pub fn insert_session(
conn: &Connection,
id: &str,
chat_id: Option<&str>,
title: &str,
model: &str,
source: &str,
) -> SqliteResult<()> {
let now = current_timestamp();
conn.execute(
"INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, chat_id, title, model, source, now, now],
)?;
Ok(())
}
pub fn list_sessions(
conn: &Connection,
source_filter: Option<&str>,
) -> SqliteResult<Vec<SessionInfo>> {
let sql = if source_filter.is_some() {
"SELECT id, chat_id, title, model, source, created_at, updated_at \
FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
} else {
"SELECT id, chat_id, title, model, source, created_at, updated_at \
FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
};
let mut stmt = conn.prepare(sql)?;
let rows = if let Some(source) = source_filter {
stmt.query_map(params![source], map_session_row)?
} else {
stmt.query_map([], map_session_row)?
};
rows.collect()
}
pub fn find_session_by_chat_id(
conn: &Connection,
chat_id: &str,
) -> SqliteResult<Option<SessionInfo>> {
let mut stmt = conn.prepare(
"SELECT id, chat_id, title, model, source, created_at, updated_at \
FROM sessions WHERE chat_id = ?1 AND is_active = 1",
)?;
let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
match rows.next() {
Some(Ok(session)) => Ok(Some(session)),
_ => Ok(None),
}
}
pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
let mut stmt = conn.prepare(
"SELECT id, chat_id, title, model, source, created_at, updated_at \
FROM sessions WHERE id = ?1 AND is_active = 1",
)?;
let mut rows = stmt.query_map(params![id], map_session_row)?;
match rows.next() {
Some(Ok(session)) => Ok(Some(session)),
_ => Ok(None),
}
}
fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
Ok(SessionInfo {
id: row.get(0)?,
chat_id: row.get(1)?,
title: row.get(2)?,
model: row.get(3)?,
source: row.get(4)?,
status: "idle".to_string(),
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
}
pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
let now = current_timestamp();
conn.execute(
"UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
params![title, now, id],
)?;
Ok(())
}
pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
let now = current_timestamp();
conn.execute(
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
params![now, id],
)?;
Ok(())
}
pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
conn.execute(
"UPDATE sessions SET is_active = 0 WHERE id = ?1",
params![id],
)?;
Ok(())
}
pub fn insert_message(
conn: &Connection,
session_id: &str,
role: &str,
content: &str,
tool_name: Option<&str>,
tool_call_id: Option<&str>,
tool_info: Option<&str>,
) -> SqliteResult<i64> {
let now = current_timestamp();
conn.execute(
"INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
)?;
Ok(conn.last_insert_rowid())
}
pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
let mut stmt = conn.prepare(
"SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
)?;
let rows = stmt.query_map(params![session_id], |row| {
let tool_info_str: Option<String> = row.get(5)?;
let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
Ok(MessageData {
id: row.get(0)?,
role: row.get(1)?,
content: row.get(2)?,
tool_name: row.get(3)?,
tool_call_id: row.get(4)?,
tool_info,
created_at: row.get(6)?,
})
})?;
rows.collect()
}
pub fn update_tool_message(
conn: &Connection,
session_id: &str,
tool_call_id: &str,
tool_info: &str,
) -> SqliteResult<()> {
conn.execute(
"UPDATE messages SET tool_info = ?1 WHERE session_id = ?2 AND tool_call_id = ?3",
params![tool_info, session_id, tool_call_id],
)?;
Ok(())
}
pub fn message_to_chat_message(data: &MessageData) -> Result<ChatCompletionRequestMessage> {
match data.role.as_str() {
"user" => Ok(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(data.content.clone()),
name: None,
}
.into(),
)),
"assistant" => {
let tool_calls = if let Some(tool_info) = &data.tool_info {
if let serde_json::Value::Object(obj) = tool_info {
if let Some(serde_json::Value::Array(arr)) = obj.get("tool_calls") {
use async_openai::types::chat::{
ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
};
let mut calls = Vec::new();
for call_val in arr {
if let Ok(call) =
serde_json::from_value::<ChatCompletionMessageToolCall>(
call_val.clone(),
)
{
calls.push(ChatCompletionMessageToolCalls::Function(call));
}
}
if !calls.is_empty() {
Some(calls)
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
};
#[allow(deprecated)]
Ok(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: if data.content.is_empty() {
None
} else {
Some(data.content.clone().into())
},
name: None,
tool_calls,
refusal: None,
audio: None,
function_call: None,
}
.into(),
))
}
"tool" => {
let tool_call_id = data.tool_call_id.clone().unwrap_or_default();
Ok(ChatCompletionRequestMessage::Tool(
ChatCompletionRequestToolMessage {
content: data.content.clone().into(),
tool_call_id,
}
.into(),
))
}
_ => Err(crate::error::AgentError::InternalError(format!(
"Unknown role: {}",
data.role
))),
}
}
pub fn load_chat_messages(
conn: &Connection,
session_id: &str,
) -> Result<Vec<ChatCompletionRequestMessage>> {
let messages = get_messages(conn, session_id)?;
tracing::debug!(
"load_chat_messages: session_id={}, loaded {} messages from DB",
session_id,
messages.len()
);
let mut result = Vec::with_capacity(messages.len());
for (idx, msg) in messages.iter().enumerate() {
match message_to_chat_message(&msg) {
Ok(chat_msg) => {
tracing::debug!(
" Message {}: role={}, content_len={}",
idx,
msg.role,
msg.content.len()
);
result.push(chat_msg);
}
Err(e) => {
tracing::warn!("Failed to convert message {}: {}", idx, e);
}
}
}
tracing::debug!("load_chat_messages: successfully converted {} messages", result.len());
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_local_db_path() {
let working_dir = PathBuf::from("project");
let path = resolve_db_path(&working_dir, false).unwrap();
assert_eq!(
path,
working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
);
}
#[test]
fn session_crud() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
insert_session(
&conn,
"test-123",
None,
"Test Session",
"deepseek/deepseek-chat",
"gui",
)
.unwrap();
let sessions = list_sessions(&conn, None).unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "test-123");
assert_eq!(sessions[0].title, "Test Session");
assert_eq!(sessions[0].source, "gui");
assert_eq!(sessions[0].chat_id, None);
assert_eq!(sessions[0].status, "idle");
let session = get_session(&conn, "test-123").unwrap().unwrap();
assert_eq!(session.title, "Test Session");
assert_eq!(session.source, "gui");
update_session_title(&conn, "test-123", "Updated Title").unwrap();
let updated = get_session(&conn, "test-123").unwrap().unwrap();
assert_eq!(updated.title, "Updated Title");
delete_session(&conn, "test-123").unwrap();
assert!(get_session(&conn, "test-123").unwrap().is_none());
assert!(list_sessions(&conn, None).unwrap().is_empty());
}
#[test]
fn message_operations() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
let user_id = insert_message(
&conn,
"session-msg",
"user",
"Hello Robit",
None,
None,
None,
)
.unwrap();
let assistant_id = insert_message(
&conn,
"session-msg",
"assistant",
"Hello! How can I help?",
None,
None,
None,
)
.unwrap();
let messages = get_messages(&conn, "session-msg").unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].id, user_id);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "Hello Robit");
assert_eq!(messages[1].id, assistant_id);
assert_eq!(messages[1].role, "assistant");
assert_eq!(messages[1].content, "Hello! How can I help?");
}
#[test]
fn empty_sessions() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
let sessions = list_sessions(&conn, None).unwrap();
assert_eq!(sessions.len(), 0);
}
#[test]
fn get_nonexistent_session() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
let session = get_session(&conn, "nonexistent").unwrap();
assert!(session.is_none());
}
#[test]
fn tool_message_update() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
let initial = serde_json::json!({
"tool_call_id": "tool-1",
"name": "bash",
"arguments": "{}",
"status": "pending",
"requires_confirm": true
})
.to_string();
insert_message(
&conn,
"session-tool",
"tool",
"{}",
Some("bash"),
Some("tool-1"),
Some(&initial),
)
.unwrap();
let updated = serde_json::json!({
"tool_call_id": "tool-1",
"status": "success",
"output": "done"
})
.to_string();
update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
let messages = get_messages(&conn, "session-tool").unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
}
#[test]
fn chat_id_lookup_and_source_filter() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
insert_session(
&conn,
"qq-1",
Some("group:abc"),
"技术讨论群",
"model",
"qq",
)
.unwrap();
insert_session(
&conn,
"qq-2",
Some("private:xyz"),
"私聊",
"model",
"qq",
)
.unwrap();
let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
assert_eq!(found.id, "qq-1");
assert_eq!(found.source, "qq");
assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
assert!(find_session_by_chat_id(&conn, "does-not-exist")
.unwrap()
.is_none());
let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
assert_eq!(qq_sessions.len(), 2);
assert!(qq_sessions.iter().all(|s| s.source == "qq"));
let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
assert_eq!(gui_sessions.len(), 1);
assert_eq!(gui_sessions[0].id, "gui-1");
assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
}
#[test]
fn chat_id_unique_per_chat() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
insert_session(
&conn,
"qq-1",
Some("group:abc"),
"First",
"model",
"qq",
)
.unwrap();
let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
assert!(err.is_err());
}
#[test]
fn migrates_legacy_v1_database() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_active INTEGER DEFAULT 1
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_name TEXT,
tool_call_id TEXT,
tokens INTEGER,
created_at TEXT NOT NULL
);",
)
.unwrap();
conn.execute(
"INSERT INTO sessions (id, title, model, created_at, updated_at) \
VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
[],
)
.unwrap();
init_db(&conn).unwrap();
let v: i32 = read_schema_version(&conn).unwrap();
assert_eq!(v, CURRENT_SCHEMA_VERSION);
let session = get_session(&conn, "legacy-1").unwrap().unwrap();
assert_eq!(session.title, "Legacy");
assert_eq!(session.source, "gui");
assert_eq!(session.chat_id, None);
}
#[test]
fn init_db_is_idempotent() {
let conn = Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
init_db(&conn).unwrap();
assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
}
}