From c8660df14b89e0f8dae061d70bf74444d5fa519c Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 7 Jul 2026 18:36:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20storage/mod.rs?= =?UTF-8?q?=20=E4=B8=8E=20ConfigPage.tsx=20(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storage/mod.rs (2957 -> 1834 行): - 抽出 tests.rs: 17 个测试函数 - 抽出 row_mapping.rs: 8 个 row<->record 映射与单记录查询函数 - 抽出 migrations.rs: 7 个 schema 迁移函数 (ensure_*_schema, has_column, add_column_if_missing) ConfigPage.tsx (1568 -> 1235 行): - 抽出 types.ts: 所有接口/类型定义 - 抽出 constants.ts: TABS, inputCls, selectCls, TIMEZONE_OPTIONS - 抽出 ui.tsx: Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader - 抽出 api/expert.ts: getSelectedExpert, selectExpert (经 ConfigPage 再导出保持兼容) 验证: cargo build, cargo test --lib storage:: 17/17, npm run build 对抗性检查: 8/8 通过 (无丢失/重复函数, 无循环依赖, 无未使用导入) --- src/storage/migrations.rs | 270 +++++ src/storage/mod.rs | 1139 +------------------- src/storage/row_mapping.rs | 220 ++++ src/storage/tests.rs | 667 ++++++++++++ web/src/components/Settings/ConfigPage.tsx | 365 +------ web/src/components/Settings/api/expert.ts | 20 + web/src/components/Settings/constants.ts | 50 + web/src/components/Settings/types.ts | 120 +++ web/src/components/Settings/ui.tsx | 169 +++ 9 files changed, 1540 insertions(+), 1480 deletions(-) create mode 100644 src/storage/migrations.rs create mode 100644 src/storage/row_mapping.rs create mode 100644 src/storage/tests.rs create mode 100644 web/src/components/Settings/api/expert.ts create mode 100644 web/src/components/Settings/constants.ts create mode 100644 web/src/components/Settings/types.ts create mode 100644 web/src/components/Settings/ui.tsx diff --git a/src/storage/migrations.rs b/src/storage/migrations.rs new file mode 100644 index 0000000..109f8c6 --- /dev/null +++ b/src/storage/migrations.rs @@ -0,0 +1,270 @@ +//! Schema migration helpers. +//! +//! Each `ensure_*_schema` function brings a table up to the current shape, +//! either by adding missing columns or by rebuilding the table. They run on +//! every [`super::SessionStore`] construction and are idempotent. + +use rusqlite::Connection; + +use super::StorageError; + +pub(super) fn ensure_sessions_schema(conn: &Connection) -> Result<(), StorageError> { + if !has_column(conn, "sessions", "user_turn_count")? { + add_column_if_missing( + conn, + "ALTER TABLE sessions ADD COLUMN user_turn_count INTEGER NOT NULL DEFAULT 0", + )?; + } + + if !has_column(conn, "sessions", "agent_prompt_reinjection_count")? { + add_column_if_missing( + conn, + "ALTER TABLE sessions ADD COLUMN agent_prompt_reinjection_count INTEGER NOT NULL DEFAULT 0", + )?; + } + + Ok(()) +} + +pub(super) fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageError> { + if !has_column(conn, "messages", "system_context")? { + add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN system_context TEXT")?; + } + + if !has_column(conn, "messages", "reasoning_content")? { + add_column_if_missing( + conn, + "ALTER TABLE messages ADD COLUMN reasoning_content TEXT", + )?; + } + + if !has_column(conn, "messages", "topic_id")? { + add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN topic_id TEXT")?; + // 添加外键约束(SQLite 不支持 ALTER TABLE ADD FOREIGN KEY,需要重建表) + // 这里只添加列,外键约束由应用层保证 + } + + if !has_column(conn, "messages", "tool_duration_ms")? { + add_column_if_missing( + conn, + "ALTER TABLE messages ADD COLUMN tool_duration_ms INTEGER", + )?; + } + + // 创建 topic_id 索引(如果不存在) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_messages_topic_seq ON messages(topic_id, seq) WHERE topic_id IS NOT NULL", + [], + )?; + + Ok(()) +} + +pub(super) fn ensure_scheduler_schema(conn: &Connection) -> Result<(), StorageError> { + if !has_column(conn, "scheduler_jobs", "schedule_json")? { + conn.execute( + "ALTER TABLE scheduler_jobs ADD COLUMN schedule_json TEXT NOT NULL DEFAULT '{}'", + [], + )?; + } + + if !has_column(conn, "scheduler_jobs", "state")? { + conn.execute( + "ALTER TABLE scheduler_jobs ADD COLUMN state TEXT NOT NULL DEFAULT 'scheduled'", + [], + )?; + } + + if !has_column(conn, "scheduler_jobs", "last_status")? { + conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN last_status TEXT", [])?; + } + + if !has_column(conn, "scheduler_jobs", "last_error")? { + conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN last_error TEXT", [])?; + } + + if !has_column(conn, "scheduler_jobs", "run_count")? { + conn.execute( + "ALTER TABLE scheduler_jobs ADD COLUMN run_count INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + + if !has_column(conn, "scheduler_jobs", "max_runs")? { + conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN max_runs INTEGER", [])?; + } + + if !has_column(conn, "scheduler_jobs", "paused_at")? { + conn.execute( + "ALTER TABLE scheduler_jobs ADD COLUMN paused_at INTEGER", + [], + )?; + } + + if !has_column(conn, "scheduler_jobs", "completed_at")? { + conn.execute( + "ALTER TABLE scheduler_jobs ADD COLUMN completed_at INTEGER", + [], + )?; + } + + Ok(()) +} + +pub(super) fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(), StorageError> { + // 步骤1:去重。多条记录 scope_key 不同,改为 "default" 后会违反唯一约束。 + // 对每个 (scope_kind, namespace, memory_key) 组合保留 updated_at 最新的一条。 + conn.execute( + " + DELETE FROM memories + WHERE rowid NOT IN ( + SELECT rowid FROM ( + SELECT rowid, ROW_NUMBER() OVER ( + PARTITION BY scope_kind, namespace, memory_key + ORDER BY updated_at DESC + ) AS rn + FROM memories + ) + WHERE rn = 1 + ) + ", + [], + )?; + + // 步骤2:统一 scope_key + conn.execute( + "UPDATE memories SET scope_key = 'default' WHERE scope_key != 'default'", + [], + )?; + Ok(()) +} + +pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> { + let table_exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='todos'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|count| count > 0)?; + + if !table_exists { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS todos ( + id TEXT NOT NULL, + scope_key TEXT NOT NULL, + session_id TEXT NOT NULL, + topic_id TEXT, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT NOT NULL DEFAULT 'medium', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + created_by_message_id TEXT, + PRIMARY KEY (id, scope_key) + ); + + CREATE INDEX IF NOT EXISTS idx_todos_scope + ON todos(scope_key, created_at ASC); + + CREATE INDEX IF NOT EXISTS idx_todos_session + ON todos(session_id); + ", + )?; + return Ok(()); + } + + // Migration: check if old schema has single-column PRIMARY KEY on `id` + // If so, migrate to composite PRIMARY KEY (id, scope_key) + let sql: String = conn + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='todos'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap_or_default(); + + let needs_migration = sql.contains("id TEXT PRIMARY KEY") + || (sql.contains("PRIMARY KEY") && !sql.contains("PRIMARY KEY (id, scope_key)")); + + if needs_migration { + tracing::info!("Migrating todos table to composite PRIMARY KEY (id, scope_key)"); + conn.execute_batch( + " + CREATE TABLE todos_new ( + id TEXT NOT NULL, + scope_key TEXT NOT NULL, + session_id TEXT NOT NULL, + topic_id TEXT, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT NOT NULL DEFAULT 'medium', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + created_by_message_id TEXT, + PRIMARY KEY (id, scope_key) + ); + + INSERT OR IGNORE INTO todos_new + SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at + FROM todos; + + DROP TABLE todos; + + ALTER TABLE todos_new RENAME TO todos; + + CREATE INDEX IF NOT EXISTS idx_todos_scope + ON todos(scope_key, created_at ASC); + + CREATE INDEX IF NOT EXISTS idx_todos_session + ON todos(session_id); + ", + )?; + tracing::info!("Todos table migration complete"); + } + + // Column migration: add created_by_message_id if it doesn't exist + let has_column = has_column(&conn, "todos", "created_by_message_id")?; + if !has_column { + tracing::info!("Adding created_by_message_id column to todos table"); + conn.execute( + "ALTER TABLE todos ADD COLUMN created_by_message_id TEXT", + [], + )?; + tracing::info!("Todos table column migration complete"); + } + + Ok(()) +} + +pub(super) fn has_column( + conn: &Connection, + table_name: &str, + column_name: &str, +) -> Result { + let pragma = format!("PRAGMA table_info({})", table_name); + let mut stmt = conn.prepare(&pragma)?; + let mut rows = stmt.query([])?; + + while let Some(row) = rows.next()? { + let existing_name: String = row.get(1)?; + if existing_name == column_name { + return Ok(true); + } + } + + Ok(false) +} + +pub(super) fn add_column_if_missing(conn: &Connection, sql: &str) -> Result<(), StorageError> { + match conn.execute(sql, []) { + Ok(_) => Ok(()), + Err(rusqlite::Error::SqliteFailure(_, Some(message))) + if message.contains("duplicate column name") => + { + Ok(()) + } + Err(error) => Err(StorageError::Database(error)), + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index c210e35..ffa22fb 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -11,6 +11,13 @@ pub mod error; pub mod ports; pub mod records; +mod migrations; +mod row_mapping; + +// Bring extracted helpers into scope for use by SessionStore methods below. +use migrations::*; +use row_mapping::*; + pub use error::StorageError; pub use ports::{ ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, @@ -1622,468 +1629,6 @@ fn default_session_db_path() -> Result { Ok(home.join(".picobot").join("storage").join("sessions.db")) } -/// 使用已有连接查询 session(避免从池中重复借用) -fn get_session_with_conn(conn: &Connection, session_id: &str) -> Result, StorageError> { - let mut stmt = conn.prepare( - " - SELECT id, title, channel_name, chat_id, summary, - created_at, updated_at, last_active_at, - archived_at, deleted_at, message_count, - user_turn_count, agent_prompt_reinjection_count - FROM sessions - WHERE id = ?1 AND deleted_at IS NULL - ", - )?; - - stmt.query_row(params![session_id], map_session_record) - .optional() - .map_err(StorageError::from) -} - -fn get_memory_with_conn( - conn: &Connection, - scope_kind: &str, - scope_key: &str, - namespace: &str, - memory_key: &str, -) -> Result, StorageError> { - let mut stmt = conn.prepare( - " - SELECT id, scope_kind, scope_key, namespace, memory_key, content, - source_type, source_session_id, source_message_id, source_message_seq, - source_channel_name, source_chat_id, created_at, updated_at - FROM memories - WHERE scope_kind = ?1 AND scope_key = ?2 AND namespace = ?3 AND memory_key = ?4 - ", - )?; - - stmt.query_row( - params![scope_kind, scope_key, namespace, memory_key], - map_memory_record, - ) - .optional() - .map_err(StorageError::from) -} - -fn get_scheduler_job_with_conn( - conn: &Connection, - job_id: &str, -) -> Result, StorageError> { - let mut stmt = conn.prepare( - " - SELECT id, kind, schedule_json, interval_secs, startup_delay_secs, - target_json, payload_json, enabled, state, last_status, last_error, - run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at, - created_at, updated_at - FROM scheduler_jobs - WHERE id = ?1 - ", - )?; - - stmt.query_row(params![job_id], map_scheduler_job_record) - .optional() - .map_err(StorageError::from) -} - -fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(SessionRecord { - id: row.get(0)?, - title: row.get(1)?, - channel_name: row.get(2)?, - chat_id: row.get(3)?, - summary: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, - last_active_at: row.get(7)?, - archived_at: row.get(8)?, - deleted_at: row.get(9)?, - message_count: row.get(10)?, - user_turn_count: row.get(11)?, - agent_prompt_reinjection_count: row.get(12)?, - }) -} - -fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let payload_json: String = row.get(4)?; - let payload = serde_json::from_str(&payload_json).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) - })?; - - Ok(SkillEventRecord { - id: row.get(0)?, - session_id: row.get(1)?, - event_type: row.get(2)?, - skill_name: row.get(3)?, - payload, - created_at: row.get(5)?, - }) -} - -fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let media_refs_json: String = row.get(5)?; - let media_refs: Vec = serde_json::from_str(&media_refs_json).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure( - media_refs_json.len(), - rusqlite::types::Type::Text, - Box::new(err), - ) - })?; - - let tool_calls_json: Option = row.get(9)?; - let tool_calls = tool_calls_json - .as_deref() - .map(serde_json::from_str) - .transpose() - .map_err(|err| { - rusqlite::Error::FromSqlConversionFailure( - 9, - rusqlite::types::Type::Text, - Box::new(err), - ) - })?; - - Ok(ChatMessage { - id: row.get(0)?, - role: row.get(1)?, - content: row.get(2)?, - system_context: row.get(3)?, - reasoning_content: row.get(4)?, - media_refs, - timestamp: row.get(6)?, - tool_call_id: row.get(7)?, - tool_name: row.get(8)?, - tool_state: None, - tool_duration_ms: row.get::<_, Option>(10)?.map(|v| v as u64), - tool_calls, - }) -} - -fn map_memory_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(MemoryRecord { - id: row.get(0)?, - scope_kind: row.get(1)?, - scope_key: row.get(2)?, - namespace: row.get(3)?, - memory_key: row.get(4)?, - content: row.get(5)?, - source_type: row.get(6)?, - source_session_id: row.get(7)?, - source_message_id: row.get(8)?, - source_message_seq: row.get(9)?, - source_channel_name: row.get(10)?, - source_chat_id: row.get(11)?, - created_at: row.get(12)?, - updated_at: row.get(13)?, - }) -} - -fn map_scheduler_job_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let schedule_json: String = row.get(2)?; - let target_json: String = row.get(5)?; - let payload_json: String = row.get(6)?; - let state: String = row.get(8)?; - let last_status: Option = row.get(9)?; - - let schedule = serde_json::from_str(&schedule_json).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(err)) - })?; - let target = serde_json::from_str(&target_json).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(err)) - })?; - let payload = serde_json::from_str(&payload_json).map_err(|err| { - rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(err)) - })?; - - Ok(SchedulerJobRecord { - id: row.get(0)?, - kind: row.get(1)?, - schedule, - interval_secs: row.get(3)?, - startup_delay_secs: row.get(4)?, - target, - payload, - enabled: row.get::<_, i64>(7)? != 0, - state: SchedulerJobState::from_str(&state).ok_or_else(|| { - rusqlite::Error::FromSqlConversionFailure( - 8, - rusqlite::types::Type::Text, - format!("invalid scheduler job state: {}", state).into(), - ) - })?, - last_status: last_status.and_then(|value| SchedulerJobStatus::from_str(&value)), - last_error: row.get(10)?, - run_count: row.get(11)?, - max_runs: row.get(12)?, - last_fired_at: row.get(13)?, - next_fire_at: row.get(14)?, - paused_at: row.get(15)?, - completed_at: row.get(16)?, - created_at: row.get(17)?, - updated_at: row.get(18)?, - }) -} - -fn ensure_sessions_schema(conn: &Connection) -> Result<(), StorageError> { - if !has_column(conn, "sessions", "user_turn_count")? { - add_column_if_missing( - conn, - "ALTER TABLE sessions ADD COLUMN user_turn_count INTEGER NOT NULL DEFAULT 0", - )?; - } - - if !has_column(conn, "sessions", "agent_prompt_reinjection_count")? { - add_column_if_missing( - conn, - "ALTER TABLE sessions ADD COLUMN agent_prompt_reinjection_count INTEGER NOT NULL DEFAULT 0", - )?; - } - - Ok(()) -} - -fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageError> { - if !has_column(conn, "messages", "system_context")? { - add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN system_context TEXT")?; - } - - if !has_column(conn, "messages", "reasoning_content")? { - add_column_if_missing( - conn, - "ALTER TABLE messages ADD COLUMN reasoning_content TEXT", - )?; - } - - if !has_column(conn, "messages", "topic_id")? { - add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN topic_id TEXT")?; - // 添加外键约束(SQLite 不支持 ALTER TABLE ADD FOREIGN KEY,需要重建表) - // 这里只添加列,外键约束由应用层保证 - } - - if !has_column(conn, "messages", "tool_duration_ms")? { - add_column_if_missing( - conn, - "ALTER TABLE messages ADD COLUMN tool_duration_ms INTEGER", - )?; - } - - // 创建 topic_id 索引(如果不存在) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_messages_topic_seq ON messages(topic_id, seq) WHERE topic_id IS NOT NULL", - [], - )?; - - Ok(()) -} - -fn ensure_scheduler_schema(conn: &Connection) -> Result<(), StorageError> { - if !has_column(conn, "scheduler_jobs", "schedule_json")? { - conn.execute( - "ALTER TABLE scheduler_jobs ADD COLUMN schedule_json TEXT NOT NULL DEFAULT '{}'", - [], - )?; - } - - if !has_column(conn, "scheduler_jobs", "state")? { - conn.execute( - "ALTER TABLE scheduler_jobs ADD COLUMN state TEXT NOT NULL DEFAULT 'scheduled'", - [], - )?; - } - - if !has_column(conn, "scheduler_jobs", "last_status")? { - conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN last_status TEXT", [])?; - } - - if !has_column(conn, "scheduler_jobs", "last_error")? { - conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN last_error TEXT", [])?; - } - - if !has_column(conn, "scheduler_jobs", "run_count")? { - conn.execute( - "ALTER TABLE scheduler_jobs ADD COLUMN run_count INTEGER NOT NULL DEFAULT 0", - [], - )?; - } - - if !has_column(conn, "scheduler_jobs", "max_runs")? { - conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN max_runs INTEGER", [])?; - } - - if !has_column(conn, "scheduler_jobs", "paused_at")? { - conn.execute( - "ALTER TABLE scheduler_jobs ADD COLUMN paused_at INTEGER", - [], - )?; - } - - if !has_column(conn, "scheduler_jobs", "completed_at")? { - conn.execute( - "ALTER TABLE scheduler_jobs ADD COLUMN completed_at INTEGER", - [], - )?; - } - - Ok(()) -} - -fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(), StorageError> { - // 步骤1:去重。多条记录 scope_key 不同,改为 "default" 后会违反唯一约束。 - // 对每个 (scope_kind, namespace, memory_key) 组合保留 updated_at 最新的一条。 - conn.execute( - " - DELETE FROM memories - WHERE rowid NOT IN ( - SELECT rowid FROM ( - SELECT rowid, ROW_NUMBER() OVER ( - PARTITION BY scope_kind, namespace, memory_key - ORDER BY updated_at DESC - ) AS rn - FROM memories - ) - WHERE rn = 1 - ) - ", - [], - )?; - - // 步骤2:统一 scope_key - conn.execute( - "UPDATE memories SET scope_key = 'default' WHERE scope_key != 'default'", - [], - )?; - Ok(()) -} - -fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> { - let table_exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='todos'", - [], - |row| row.get::<_, i64>(0), - ) - .map(|count| count > 0)?; - - if !table_exists { - conn.execute_batch( - " - CREATE TABLE IF NOT EXISTS todos ( - id TEXT NOT NULL, - scope_key TEXT NOT NULL, - session_id TEXT NOT NULL, - topic_id TEXT, - content TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - priority TEXT NOT NULL DEFAULT 'medium', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - created_by_message_id TEXT, - PRIMARY KEY (id, scope_key) - ); - - CREATE INDEX IF NOT EXISTS idx_todos_scope - ON todos(scope_key, created_at ASC); - - CREATE INDEX IF NOT EXISTS idx_todos_session - ON todos(session_id); - ", - )?; - return Ok(()); - } - - // Migration: check if old schema has single-column PRIMARY KEY on `id` - // If so, migrate to composite PRIMARY KEY (id, scope_key) - let sql: String = conn - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='todos'", - [], - |row| row.get::<_, String>(0), - ) - .unwrap_or_default(); - - let needs_migration = sql.contains("id TEXT PRIMARY KEY") - || (sql.contains("PRIMARY KEY") && !sql.contains("PRIMARY KEY (id, scope_key)")); - - if needs_migration { - tracing::info!("Migrating todos table to composite PRIMARY KEY (id, scope_key)"); - conn.execute_batch( - " - CREATE TABLE todos_new ( - id TEXT NOT NULL, - scope_key TEXT NOT NULL, - session_id TEXT NOT NULL, - topic_id TEXT, - content TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - priority TEXT NOT NULL DEFAULT 'medium', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - created_by_message_id TEXT, - PRIMARY KEY (id, scope_key) - ); - - INSERT OR IGNORE INTO todos_new - SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at - FROM todos; - - DROP TABLE todos; - - ALTER TABLE todos_new RENAME TO todos; - - CREATE INDEX IF NOT EXISTS idx_todos_scope - ON todos(scope_key, created_at ASC); - - CREATE INDEX IF NOT EXISTS idx_todos_session - ON todos(session_id); - ", - )?; - tracing::info!("Todos table migration complete"); - } - - // Column migration: add created_by_message_id if it doesn't exist - let has_column = has_column(&conn, "todos", "created_by_message_id")?; - if !has_column { - tracing::info!("Adding created_by_message_id column to todos table"); - conn.execute( - "ALTER TABLE todos ADD COLUMN created_by_message_id TEXT", - [], - )?; - tracing::info!("Todos table column migration complete"); - } - - Ok(()) -} - -fn has_column( - conn: &Connection, - table_name: &str, - column_name: &str, -) -> Result { - let pragma = format!("PRAGMA table_info({})", table_name); - let mut stmt = conn.prepare(&pragma)?; - let mut rows = stmt.query([])?; - - while let Some(row) = rows.next()? { - let existing_name: String = row.get(1)?; - if existing_name == column_name { - return Ok(true); - } - } - - Ok(false) -} - -fn add_column_if_missing(conn: &Connection, sql: &str) -> Result<(), StorageError> { - match conn.execute(sql, []) { - Ok(_) => Ok(()), - Err(rusqlite::Error::SqliteFailure(_, Some(message))) - if message.contains("duplicate column name") => - { - Ok(()) - } - Err(error) => Err(StorageError::Database(error)), - } -} - fn insert_message_with_seq( conn: &rusqlite::Transaction<'_>, session_id: &str, @@ -2286,672 +1831,4 @@ fn quote_fts_or_query(queries: &[String]) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT; - use crate::domain::messages::ToolCall; - - const TEST_CHANNEL: &str = "test-channel"; - - #[test] - fn test_persistent_session_id_for_cli_and_channel() { - assert_eq!(persistent_session_id("cli", "abc"), "abc"); - // 幂等:已带前缀的 chat_id 会被清理,不会累积前缀 - assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc"); - assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc"); - assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc"); - // 其他通道也幂等 - assert_eq!(persistent_session_id(TEST_CHANNEL, "test-channel:abc"), "test-channel:abc"); - } - - #[test] - fn test_session_store_roundtrip_and_lifecycle() { - let store = SessionStore::in_memory().unwrap(); - - let session = store.create_cli_session(Some("demo")).unwrap(); - assert_eq!(session.title, "demo"); - assert_eq!(session.channel_name, "cli"); - assert_eq!(session.chat_id, session.id); - assert_eq!(session.message_count, 0); - assert_eq!(session.user_turn_count, 0); - assert_eq!(session.agent_prompt_reinjection_count, 0); - - let first = ChatMessage::user("hello"); - let second = ChatMessage::assistant("world"); - store.append_message(&session.id, &first).unwrap(); - store.append_message(&session.id, &second).unwrap(); - - let stored = store.get_session(&session.id).unwrap().unwrap(); - assert_eq!(stored.message_count, 2); - assert!(stored.archived_at.is_none()); - assert_eq!(stored.user_turn_count, 1); - assert_eq!(stored.agent_prompt_reinjection_count, 0); - - let messages = store.load_messages(&session.id).unwrap(); - assert_eq!(messages.len(), 2); - assert_eq!(messages[0].role, "user"); - assert_eq!(messages[0].content, "hello"); - assert_eq!(messages[1].role, "assistant"); - assert_eq!(messages[1].content, "world"); - - store.rename_session(&session.id, "renamed").unwrap(); - let renamed = store.get_session(&session.id).unwrap().unwrap(); - assert_eq!(renamed.title, "renamed"); - - store.archive_session(&session.id).unwrap(); - let archived = store.get_session(&session.id).unwrap().unwrap(); - assert!(archived.archived_at.is_some()); - - let active_only = store.list_sessions("cli", false).unwrap(); - assert!(active_only.is_empty()); - - let including_archived = store.list_sessions("cli", true).unwrap(); - assert_eq!(including_archived.len(), 1); - - store.clear_messages(&session.id).unwrap(); - let cleared = store.load_messages(&session.id).unwrap(); - assert!(cleared.is_empty()); - let cleared_session = store.get_session(&session.id).unwrap().unwrap(); - assert_eq!(cleared_session.message_count, 0); - assert_eq!(cleared_session.user_turn_count, 0); - assert_eq!(cleared_session.agent_prompt_reinjection_count, 0); - - store.delete_session(&session.id).unwrap(); - assert!(store.get_session(&session.id).unwrap().is_none()); - } - - #[test] - fn test_ensure_channel_session_is_stable() { - let store = SessionStore::in_memory().unwrap(); - - let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); - let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); - - assert_eq!(first.id, second.id); - assert_eq!(first.chat_id, "chat-1"); - assert_eq!(second.channel_name, TEST_CHANNEL); - } - - #[test] - fn test_assistant_tool_calls_roundtrip() { - let store = SessionStore::in_memory().unwrap(); - let session = store.create_cli_session(Some("tools")).unwrap(); - - let assistant = ChatMessage::assistant_with_tool_calls( - "calling tool", - vec![ToolCall { - id: "call_1".to_string(), - name: "calculator".to_string(), - arguments: serde_json::json!({ "expression": "3*7" }), - }], - ); - - store.append_message(&session.id, &assistant).unwrap(); - - let messages = store.load_messages(&session.id).unwrap(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, "assistant"); - assert_eq!(messages[0].tool_calls.as_ref().unwrap().len(), 1); - assert_eq!(messages[0].tool_calls.as_ref().unwrap()[0].id, "call_1"); - assert_eq!( - messages[0].tool_calls.as_ref().unwrap()[0].name, - "calculator" - ); - } - - #[test] - fn test_assistant_reasoning_content_roundtrip() { - let store = SessionStore::in_memory().unwrap(); - let session = store.create_cli_session(Some("reasoning")).unwrap(); - - let assistant = ChatMessage::assistant_with_reasoning("final answer", "hidden reasoning"); - - store.append_message(&session.id, &assistant).unwrap(); - - let messages = store.load_messages(&session.id).unwrap(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].content, "final answer"); - assert_eq!( - messages[0].reasoning_content.as_deref(), - Some("hidden reasoning") - ); - } - - #[test] - fn test_schema_migration_adds_user_turn_and_reinjection_columns() { - let tmp = std::env::temp_dir().join(format!("picobot_test_mig2_{}.db", uuid::Uuid::new_v4())); - let conn = Connection::open(&tmp).unwrap(); - conn.execute_batch( - " - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - channel_name TEXT NOT NULL, - chat_id TEXT NOT NULL, - summary TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL, - archived_at INTEGER, - deleted_at INTEGER, - message_count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - media_refs_json TEXT NOT NULL, - tool_call_id TEXT, - tool_name TEXT, - tool_calls_json TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE, - UNIQUE(session_id, seq) - ); - ", - ) - .unwrap(); - - let path_str = tmp.to_string_lossy().to_string(); - let store = SessionStore::from_connection(conn, &path_str).unwrap(); - let session = store.create_cli_session(Some("migrated")).unwrap(); - assert_eq!(session.user_turn_count, 0); - assert_eq!(session.agent_prompt_reinjection_count, 0); - } - - #[test] - fn test_schema_migration_adds_reasoning_content_column_to_messages() { - let tmp = std::env::temp_dir() - .join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4())); - let conn = Connection::open(&tmp).unwrap(); - conn.execute_batch( - " - CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - channel_name TEXT NOT NULL, - chat_id TEXT NOT NULL, - summary TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL, - archived_at INTEGER, - deleted_at INTEGER, - message_count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE messages ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - media_refs_json TEXT NOT NULL, - tool_call_id TEXT, - tool_name TEXT, - tool_calls_json TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE, - UNIQUE(session_id, seq) - ); - ", - ) - .unwrap(); - - let path_str = tmp.to_string_lossy().to_string(); - let _store = SessionStore::from_connection(conn, &path_str).unwrap(); - let conn = _store.pool.get().unwrap(); - - assert!(has_column(&conn, "messages", "reasoning_content").unwrap()); - } - - #[test] - fn test_compact_active_history_rebuilds_active_segment_with_delta_messages() { - let store = SessionStore::in_memory().unwrap(); - let session = store.create_cli_session(Some("compact-history")).unwrap(); - - let agent_prompt = ChatMessage::system_with_context( - "agent", - Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), - ); - let seed_messages = vec![ - agent_prompt.clone(), - ChatMessage::user("u1"), - ChatMessage::assistant("a1"), - ChatMessage::user("u2"), - ChatMessage::assistant("a2"), - ChatMessage::user("u3"), - ChatMessage::assistant("a3"), - ChatMessage::user("u4"), - ChatMessage::assistant("a4"), - ]; - - for message in &seed_messages { - store.append_message(&session.id, message).unwrap(); - } - - let snapshot_end_seq = store - .get_session(&session.id) - .unwrap() - .unwrap() - .message_count; - let preserved_messages = store.load_messages(&session.id).unwrap()[3..].to_vec(); - let preserved_system_messages = vec![agent_prompt]; - - store - .append_message(&session.id, &ChatMessage::user("u5")) - .unwrap(); - store - .append_message(&session.id, &ChatMessage::assistant("a5")) - .unwrap(); - - let summary_message = ChatMessage::system("[Compressed History]\n\nsummary"); - let compacted = store - .compact_active_history( - &session.id, - snapshot_end_seq, - &preserved_system_messages, - &summary_message, - &preserved_messages, - ) - .unwrap(); - - assert!(compacted); - - let active_messages = store.load_messages(&session.id).unwrap(); - assert_eq!(active_messages.len(), 10); - assert_eq!(active_messages[0].role, "system"); - assert_eq!(active_messages[0].content, "agent"); - assert_eq!( - active_messages[0].system_context.as_deref(), - Some(SYSTEM_CONTEXT_AGENT_PROMPT) - ); - assert_eq!(active_messages[1].role, "system"); - assert_eq!( - active_messages[1].content, - "[Compressed History]\n\nsummary" - ); - assert_eq!(active_messages[2].content, "u2"); - assert_eq!(active_messages[3].content, "a2"); - assert_eq!(active_messages[8].content, "u5"); - assert_eq!(active_messages[9].content, "a5"); - - let stored = store.get_session(&session.id).unwrap().unwrap(); - assert_eq!(stored.user_turn_count, 4); - - let all_messages = store.load_all_messages(&session.id).unwrap(); - assert_eq!(all_messages.len(), 10); - } - - #[test] - fn test_mark_agent_prompt_reinjected_increments_counter() { - let store = SessionStore::in_memory().unwrap(); - let session = store.create_cli_session(Some("prompt")).unwrap(); - - store.mark_agent_prompt_reinjected(&session.id).unwrap(); - store.mark_agent_prompt_reinjected(&session.id).unwrap(); - - let stored = store.get_session(&session.id).unwrap().unwrap(); - assert_eq!(stored.agent_prompt_reinjection_count, 2); - } - - #[test] - fn test_tool_result_roundtrip() { - let store = SessionStore::in_memory().unwrap(); - let session = store.create_cli_session(Some("tool-result")).unwrap(); - - let tool_message = ChatMessage::tool("call_9", "write", "saved to /tmp/output.txt"); - store.append_message(&session.id, &tool_message).unwrap(); - - let messages = store.load_messages(&session.id).unwrap(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, "tool"); - assert_eq!(messages[0].content, "saved to /tmp/output.txt"); - assert_eq!(messages[0].tool_call_id.as_deref(), Some("call_9")); - assert_eq!(messages[0].tool_name.as_deref(), Some("write")); - assert!(messages[0].tool_calls.is_none()); - } - - #[test] - fn test_skill_events_roundtrip() { - let store = SessionStore::in_memory().unwrap(); - let session = store.create_cli_session(Some("skill-events")).unwrap(); - - store - .append_skill_event(None, "discovered", None, &serde_json::json!({"count": 2})) - .unwrap(); - store - .append_skill_event( - Some(&session.id), - "activated", - Some("code-review"), - &serde_json::json!({"source": "project"}), - ) - .unwrap(); - - let global_events = store.list_skill_events(None).unwrap(); - assert_eq!(global_events.len(), 1); - assert_eq!(global_events[0].event_type, "discovered"); - assert_eq!(global_events[0].payload["count"], 2); - - let session_events = store.list_skill_events(Some(&session.id)).unwrap(); - assert_eq!(session_events.len(), 1); - assert_eq!(session_events[0].event_type, "activated"); - assert_eq!(session_events[0].skill_name.as_deref(), Some("code-review")); - assert_eq!(session_events[0].payload["source"], "project"); - } - - #[test] - fn test_memory_roundtrip_with_source_fields() { - let store = SessionStore::in_memory().unwrap(); - - let saved = store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "language".to_string(), - content: "Rust".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), - source_message_id: Some("msg-1".to_string()), - source_message_seq: Some(7), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-1".to_string()), - }) - .unwrap(); - - assert_eq!(saved.content, "Rust"); - assert_eq!(saved.source_type, "message"); - assert_eq!(saved.source_session_id.as_deref(), Some("test-channel:chat-1")); - assert_eq!(saved.source_message_id.as_deref(), Some("msg-1")); - assert_eq!(saved.source_message_seq, Some(7)); - - let fetched = store - .get_memory("user", "test-channel:user-1", "user", "language") - .unwrap() - .unwrap(); - assert_eq!(fetched.id, saved.id); - assert_eq!(fetched.source_chat_id.as_deref(), Some("chat-1")); - } - - #[test] - fn test_memory_fts_tracks_upsert_and_delete() { - let store = SessionStore::in_memory().unwrap(); - - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "editor".to_string(), - content: "Prefers rust-analyzer and cargo test output".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)), - source_message_id: Some("msg-2".to_string()), - source_message_seq: Some(3), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-2".to_string()), - }) - .unwrap(); - - let hits = store - .search_memories("user", "test-channel:user-1", "rust-analyzer", None, 10) - .unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].memory_key, "editor"); - - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "editor".to_string(), - content: "Prefers clippy diagnostics".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-3", TEST_CHANNEL)), - source_message_id: Some("msg-3".to_string()), - source_message_seq: Some(4), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-3".to_string()), - }) - .unwrap(); - - let old_hits = store - .search_memories("user", "test-channel:user-1", "rust-analyzer", None, 10) - .unwrap(); - assert!(old_hits.is_empty()); - - let new_hits = store - .search_memories("user", "test-channel:user-1", "clippy", None, 10) - .unwrap(); - assert_eq!(new_hits.len(), 1); - - let deleted = store - .delete_memory("user", "test-channel:user-1", "user", "editor") - .unwrap(); - assert!(deleted); - - let hits_after_delete = store - .search_memories("user", "test-channel:user-1", "clippy", None, 10) - .unwrap(); - assert!(hits_after_delete.is_empty()); - } - - #[test] - fn test_memory_search_matches_memory_key_field() { - let store = SessionStore::in_memory().unwrap(); - - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "email_folder_preference".to_string(), - content: "用户提到邮件时默认查看代收邮箱。".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-8", TEST_CHANNEL)), - source_message_id: Some("msg-8".to_string()), - source_message_seq: Some(8), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-8".to_string()), - }) - .unwrap(); - - let hits = store - .search_memories("user", "test-channel:user-1", "email_folder_preference", None, 10) - .unwrap(); - - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].memory_key, "email_folder_preference"); - } - - #[test] - fn test_search_memories_any_matches_multiple_keywords_once() { - let store = SessionStore::in_memory().unwrap(); - - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "editor".to_string(), - content: "Prefers rust-analyzer and cargo test output".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)), - source_message_id: Some("msg-2".to_string()), - source_message_seq: Some(3), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-2".to_string()), - }) - .unwrap(); - - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "episodic".to_string(), - memory_key: "quality".to_string(), - content: "Tracks clippy warnings before release".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-3", TEST_CHANNEL)), - source_message_id: Some("msg-3".to_string()), - source_message_seq: Some(4), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-3".to_string()), - }) - .unwrap(); - - let hits = store - .search_memories_any( - "user", - "test-channel:user-1", - &["rust-analyzer".to_string(), "clippy".to_string()], - None, - 10, - ) - .unwrap(); - - assert_eq!(hits.len(), 2); - assert!(hits.iter().any(|memory| memory.memory_key == "editor")); - assert!(hits.iter().any(|memory| memory.memory_key == "quality")); - } - - #[test] - fn test_memory_scope_listing_and_full_scope_read() { - let store = SessionStore::in_memory().unwrap(); - - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-2", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "style".to_string(), - content: "偏好简洁表达".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)), - source_message_id: Some("msg-2".to_string()), - source_message_seq: Some(2), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-2".to_string()), - }) - .unwrap(); - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "user".to_string(), - memory_key: "work".to_string(), - content: "用户在做AI产品".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), - source_message_id: Some("msg-1".to_string()), - source_message_seq: Some(1), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-1".to_string()), - }) - .unwrap(); - store - .put_memory(&MemoryUpsert { - scope_kind: "user".to_string(), - scope_key: format!("{}:user-1", TEST_CHANNEL), - namespace: "patterns".to_string(), - memory_key: "workflow".to_string(), - content: "习惯先问方案再要代码".to_string(), - source_type: "message".to_string(), - source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), - source_message_id: Some("msg-3".to_string()), - source_message_seq: Some(3), - source_channel_name: Some(TEST_CHANNEL.to_string()), - source_chat_id: Some("chat-1".to_string()), - }) - .unwrap(); - - let scope_keys = store.list_memory_scope_keys("user").unwrap(); - assert_eq!( - scope_keys, - vec!["test-channel:user-1".to_string(), "test-channel:user-2".to_string()] - ); - - let full_scope = store - .list_memories_for_scope("user", "test-channel:user-1") - .unwrap(); - assert_eq!(full_scope.len(), 2); - assert!( - full_scope - .iter() - .all(|memory| memory.scope_key == "test-channel:user-1") - ); - assert!(full_scope.iter().any(|memory| memory.memory_key == "work")); - assert!( - full_scope - .iter() - .any(|memory| memory.memory_key == "workflow") - ); - } - - #[test] - fn test_scheduler_job_roundtrip_and_runtime_update() { - let store = SessionStore::in_memory().unwrap(); - - let saved = store - .upsert_scheduler_job(&SchedulerJobUpsert { - id: "heartbeat".to_string(), - kind: "outbound_message".to_string(), - schedule: serde_json::json!({ - "type": "interval", - "seconds": 300, - "startup_delay_secs": 10, - }), - interval_secs: 300, - startup_delay_secs: 10, - target: serde_json::json!({ - "channel": "test-channel", - "chat_id": "oc_demo", - }), - payload: serde_json::json!({ - "content": "heartbeat", - }), - enabled: true, - state: SchedulerJobState::Scheduled, - last_status: None, - last_error: None, - run_count: 0, - max_runs: Some(3), - last_fired_at: None, - next_fire_at: Some(1_700_000_000_000), - paused_at: None, - completed_at: None, - }) - .unwrap(); - - assert_eq!(saved.id, "heartbeat"); - assert_eq!(saved.kind, "outbound_message"); - assert_eq!(saved.state, SchedulerJobState::Scheduled); - assert_eq!(saved.max_runs, Some(3)); - - store - .update_scheduler_job_runtime( - "heartbeat", - SchedulerJobState::Completed, - Some(SchedulerJobStatus::Ok), - None, - 1, - Some(1_700_000_000_000), - None, - None, - Some(1_700_000_000_100), - ) - .unwrap(); - - let fetched = store.get_scheduler_job("heartbeat").unwrap().unwrap(); - assert_eq!(fetched.state, SchedulerJobState::Completed); - assert_eq!(fetched.last_status, Some(SchedulerJobStatus::Ok)); - assert_eq!(fetched.run_count, 1); - assert_eq!(fetched.completed_at, Some(1_700_000_000_100)); - } - -} +mod tests; diff --git a/src/storage/row_mapping.rs b/src/storage/row_mapping.rs new file mode 100644 index 0000000..3f86357 --- /dev/null +++ b/src/storage/row_mapping.rs @@ -0,0 +1,220 @@ +//! Row <-> record mapping helpers and single-record lookups by connection. +//! +//! These free functions operate on a borrowed [`rusqlite::Connection`] (or +//! [`rusqlite::Row`]) and have no access to the [`super::SessionStore`] pool. +//! They are extracted from `mod.rs` to keep the main module focused on +//! `SessionStore` methods and repository implementations. + +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::bus::ChatMessage; + +use super::{ + MemoryRecord, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SessionRecord, + SkillEventRecord, StorageError, +}; + +pub(super) fn get_session_with_conn( + conn: &Connection, + session_id: &str, +) -> Result, StorageError> { + let mut stmt = conn.prepare( + " + SELECT id, title, channel_name, chat_id, summary, + created_at, updated_at, last_active_at, + archived_at, deleted_at, message_count, + user_turn_count, agent_prompt_reinjection_count + FROM sessions + WHERE id = ?1 AND deleted_at IS NULL + ", + )?; + + stmt.query_row(params![session_id], map_session_record) + .optional() + .map_err(StorageError::from) +} + +pub(super) fn get_memory_with_conn( + conn: &Connection, + scope_kind: &str, + scope_key: &str, + namespace: &str, + memory_key: &str, +) -> Result, StorageError> { + let mut stmt = conn.prepare( + " + SELECT id, scope_kind, scope_key, namespace, memory_key, content, + source_type, source_session_id, source_message_id, source_message_seq, + source_channel_name, source_chat_id, created_at, updated_at + FROM memories + WHERE scope_kind = ?1 AND scope_key = ?2 AND namespace = ?3 AND memory_key = ?4 + ", + )?; + + stmt.query_row( + params![scope_kind, scope_key, namespace, memory_key], + map_memory_record, + ) + .optional() + .map_err(StorageError::from) +} + +pub(super) fn get_scheduler_job_with_conn( + conn: &Connection, + job_id: &str, +) -> Result, StorageError> { + let mut stmt = conn.prepare( + " + SELECT id, kind, schedule_json, interval_secs, startup_delay_secs, + target_json, payload_json, enabled, state, last_status, last_error, + run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at, + created_at, updated_at + FROM scheduler_jobs + WHERE id = ?1 + ", + )?; + + stmt.query_row(params![job_id], map_scheduler_job_record) + .optional() + .map_err(StorageError::from) +} + +pub(super) fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SessionRecord { + id: row.get(0)?, + title: row.get(1)?, + channel_name: row.get(2)?, + chat_id: row.get(3)?, + summary: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + last_active_at: row.get(7)?, + archived_at: row.get(8)?, + deleted_at: row.get(9)?, + message_count: row.get(10)?, + user_turn_count: row.get(11)?, + agent_prompt_reinjection_count: row.get(12)?, + }) +} + +pub(super) fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let payload_json: String = row.get(4)?; + let payload = serde_json::from_str(&payload_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) + })?; + + Ok(SkillEventRecord { + id: row.get(0)?, + session_id: row.get(1)?, + event_type: row.get(2)?, + skill_name: row.get(3)?, + payload, + created_at: row.get(5)?, + }) +} + +pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let media_refs_json: String = row.get(5)?; + let media_refs: Vec = serde_json::from_str(&media_refs_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + media_refs_json.len(), + rusqlite::types::Type::Text, + Box::new(err), + ) + })?; + + let tool_calls_json: Option = row.get(9)?; + let tool_calls = tool_calls_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 9, + rusqlite::types::Type::Text, + Box::new(err), + ) + })?; + + Ok(ChatMessage { + id: row.get(0)?, + role: row.get(1)?, + content: row.get(2)?, + system_context: row.get(3)?, + reasoning_content: row.get(4)?, + media_refs, + timestamp: row.get(6)?, + tool_call_id: row.get(7)?, + tool_name: row.get(8)?, + tool_state: None, + tool_duration_ms: row.get::<_, Option>(10)?.map(|v| v as u64), + tool_calls, + }) +} + +pub(super) fn map_memory_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(MemoryRecord { + id: row.get(0)?, + scope_kind: row.get(1)?, + scope_key: row.get(2)?, + namespace: row.get(3)?, + memory_key: row.get(4)?, + content: row.get(5)?, + source_type: row.get(6)?, + source_session_id: row.get(7)?, + source_message_id: row.get(8)?, + source_message_seq: row.get(9)?, + source_channel_name: row.get(10)?, + source_chat_id: row.get(11)?, + created_at: row.get(12)?, + updated_at: row.get(13)?, + }) +} + +pub(super) fn map_scheduler_job_record( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let schedule_json: String = row.get(2)?; + let target_json: String = row.get(5)?; + let payload_json: String = row.get(6)?; + let state: String = row.get(8)?; + let last_status: Option = row.get(9)?; + + let schedule = serde_json::from_str(&schedule_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(err)) + })?; + let target = serde_json::from_str(&target_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(err)) + })?; + let payload = serde_json::from_str(&payload_json).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(err)) + })?; + + Ok(SchedulerJobRecord { + id: row.get(0)?, + kind: row.get(1)?, + schedule, + interval_secs: row.get(3)?, + startup_delay_secs: row.get(4)?, + target, + payload, + enabled: row.get::<_, i64>(7)? != 0, + state: SchedulerJobState::from_str(&state).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + 8, + rusqlite::types::Type::Text, + format!("invalid scheduler job state: {}", state).into(), + ) + })?, + last_status: last_status.and_then(|value| SchedulerJobStatus::from_str(&value)), + last_error: row.get(10)?, + run_count: row.get(11)?, + max_runs: row.get(12)?, + last_fired_at: row.get(13)?, + next_fire_at: row.get(14)?, + paused_at: row.get(15)?, + completed_at: row.get(16)?, + created_at: row.get(17)?, + updated_at: row.get(18)?, + }) +} diff --git a/src/storage/tests.rs b/src/storage/tests.rs new file mode 100644 index 0000000..cf4ee50 --- /dev/null +++ b/src/storage/tests.rs @@ -0,0 +1,667 @@ +use super::*; +use super::migrations::has_column; +use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT; +use crate::domain::messages::ToolCall; + +const TEST_CHANNEL: &str = "test-channel"; + +#[test] +fn test_persistent_session_id_for_cli_and_channel() { + assert_eq!(persistent_session_id("cli", "abc"), "abc"); + // 幂等:已带前缀的 chat_id 会被清理,不会累积前缀 + assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc"); + assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc"); + assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc"); + // 其他通道也幂等 + assert_eq!(persistent_session_id(TEST_CHANNEL, "test-channel:abc"), "test-channel:abc"); +} + +#[test] +fn test_session_store_roundtrip_and_lifecycle() { + let store = SessionStore::in_memory().unwrap(); + + let session = store.create_cli_session(Some("demo")).unwrap(); + assert_eq!(session.title, "demo"); + assert_eq!(session.channel_name, "cli"); + assert_eq!(session.chat_id, session.id); + assert_eq!(session.message_count, 0); + assert_eq!(session.user_turn_count, 0); + assert_eq!(session.agent_prompt_reinjection_count, 0); + + let first = ChatMessage::user("hello"); + let second = ChatMessage::assistant("world"); + store.append_message(&session.id, &first).unwrap(); + store.append_message(&session.id, &second).unwrap(); + + let stored = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(stored.message_count, 2); + assert!(stored.archived_at.is_none()); + assert_eq!(stored.user_turn_count, 1); + assert_eq!(stored.agent_prompt_reinjection_count, 0); + + let messages = store.load_messages(&session.id).unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].role, "user"); + assert_eq!(messages[0].content, "hello"); + assert_eq!(messages[1].role, "assistant"); + assert_eq!(messages[1].content, "world"); + + store.rename_session(&session.id, "renamed").unwrap(); + let renamed = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(renamed.title, "renamed"); + + store.archive_session(&session.id).unwrap(); + let archived = store.get_session(&session.id).unwrap().unwrap(); + assert!(archived.archived_at.is_some()); + + let active_only = store.list_sessions("cli", false).unwrap(); + assert!(active_only.is_empty()); + + let including_archived = store.list_sessions("cli", true).unwrap(); + assert_eq!(including_archived.len(), 1); + + store.clear_messages(&session.id).unwrap(); + let cleared = store.load_messages(&session.id).unwrap(); + assert!(cleared.is_empty()); + let cleared_session = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(cleared_session.message_count, 0); + assert_eq!(cleared_session.user_turn_count, 0); + assert_eq!(cleared_session.agent_prompt_reinjection_count, 0); + + store.delete_session(&session.id).unwrap(); + assert!(store.get_session(&session.id).unwrap().is_none()); +} + +#[test] +fn test_ensure_channel_session_is_stable() { + let store = SessionStore::in_memory().unwrap(); + + let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); + let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); + + assert_eq!(first.id, second.id); + assert_eq!(first.chat_id, "chat-1"); + assert_eq!(second.channel_name, TEST_CHANNEL); +} + +#[test] +fn test_assistant_tool_calls_roundtrip() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("tools")).unwrap(); + + let assistant = ChatMessage::assistant_with_tool_calls( + "calling tool", + vec![ToolCall { + id: "call_1".to_string(), + name: "calculator".to_string(), + arguments: serde_json::json!({ "expression": "3*7" }), + }], + ); + + store.append_message(&session.id, &assistant).unwrap(); + + let messages = store.load_messages(&session.id).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "assistant"); + assert_eq!(messages[0].tool_calls.as_ref().unwrap().len(), 1); + assert_eq!(messages[0].tool_calls.as_ref().unwrap()[0].id, "call_1"); + assert_eq!( + messages[0].tool_calls.as_ref().unwrap()[0].name, + "calculator" + ); +} + +#[test] +fn test_assistant_reasoning_content_roundtrip() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("reasoning")).unwrap(); + + let assistant = ChatMessage::assistant_with_reasoning("final answer", "hidden reasoning"); + + store.append_message(&session.id, &assistant).unwrap(); + + let messages = store.load_messages(&session.id).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "final answer"); + assert_eq!( + messages[0].reasoning_content.as_deref(), + Some("hidden reasoning") + ); +} + +#[test] +fn test_schema_migration_adds_user_turn_and_reinjection_columns() { + let tmp = std::env::temp_dir().join(format!("picobot_test_mig2_{}.db", uuid::Uuid::new_v4())); + let conn = Connection::open(&tmp).unwrap(); + conn.execute_batch( + " + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + channel_name TEXT NOT NULL, + chat_id TEXT NOT NULL, + summary TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + archived_at INTEGER, + deleted_at INTEGER, + message_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + media_refs_json TEXT NOT NULL, + tool_call_id TEXT, + tool_name TEXT, + tool_calls_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE, + UNIQUE(session_id, seq) + ); + ", + ) + .unwrap(); + + let path_str = tmp.to_string_lossy().to_string(); + let store = SessionStore::from_connection(conn, &path_str).unwrap(); + let session = store.create_cli_session(Some("migrated")).unwrap(); + assert_eq!(session.user_turn_count, 0); + assert_eq!(session.agent_prompt_reinjection_count, 0); +} + +#[test] +fn test_schema_migration_adds_reasoning_content_column_to_messages() { + let tmp = std::env::temp_dir() + .join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4())); + let conn = Connection::open(&tmp).unwrap(); + conn.execute_batch( + " + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + channel_name TEXT NOT NULL, + chat_id TEXT NOT NULL, + summary TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + archived_at INTEGER, + deleted_at INTEGER, + message_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + media_refs_json TEXT NOT NULL, + tool_call_id TEXT, + tool_name TEXT, + tool_calls_json TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE, + UNIQUE(session_id, seq) + ); + ", + ) + .unwrap(); + + let path_str = tmp.to_string_lossy().to_string(); + let _store = SessionStore::from_connection(conn, &path_str).unwrap(); + let conn = _store.pool.get().unwrap(); + + assert!(has_column(&conn, "messages", "reasoning_content").unwrap()); +} + +#[test] +fn test_compact_active_history_rebuilds_active_segment_with_delta_messages() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("compact-history")).unwrap(); + + let agent_prompt = ChatMessage::system_with_context( + "agent", + Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()), + ); + let seed_messages = vec![ + agent_prompt.clone(), + ChatMessage::user("u1"), + ChatMessage::assistant("a1"), + ChatMessage::user("u2"), + ChatMessage::assistant("a2"), + ChatMessage::user("u3"), + ChatMessage::assistant("a3"), + ChatMessage::user("u4"), + ChatMessage::assistant("a4"), + ]; + + for message in &seed_messages { + store.append_message(&session.id, message).unwrap(); + } + + let snapshot_end_seq = store + .get_session(&session.id) + .unwrap() + .unwrap() + .message_count; + let preserved_messages = store.load_messages(&session.id).unwrap()[3..].to_vec(); + let preserved_system_messages = vec![agent_prompt]; + + store + .append_message(&session.id, &ChatMessage::user("u5")) + .unwrap(); + store + .append_message(&session.id, &ChatMessage::assistant("a5")) + .unwrap(); + + let summary_message = ChatMessage::system("[Compressed History]\n\nsummary"); + let compacted = store + .compact_active_history( + &session.id, + snapshot_end_seq, + &preserved_system_messages, + &summary_message, + &preserved_messages, + ) + .unwrap(); + + assert!(compacted); + + let active_messages = store.load_messages(&session.id).unwrap(); + assert_eq!(active_messages.len(), 10); + assert_eq!(active_messages[0].role, "system"); + assert_eq!(active_messages[0].content, "agent"); + assert_eq!( + active_messages[0].system_context.as_deref(), + Some(SYSTEM_CONTEXT_AGENT_PROMPT) + ); + assert_eq!(active_messages[1].role, "system"); + assert_eq!( + active_messages[1].content, + "[Compressed History]\n\nsummary" + ); + assert_eq!(active_messages[2].content, "u2"); + assert_eq!(active_messages[3].content, "a2"); + assert_eq!(active_messages[8].content, "u5"); + assert_eq!(active_messages[9].content, "a5"); + + let stored = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(stored.user_turn_count, 4); + + let all_messages = store.load_all_messages(&session.id).unwrap(); + assert_eq!(all_messages.len(), 10); +} + +#[test] +fn test_mark_agent_prompt_reinjected_increments_counter() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("prompt")).unwrap(); + + store.mark_agent_prompt_reinjected(&session.id).unwrap(); + store.mark_agent_prompt_reinjected(&session.id).unwrap(); + + let stored = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(stored.agent_prompt_reinjection_count, 2); +} + +#[test] +fn test_tool_result_roundtrip() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("tool-result")).unwrap(); + + let tool_message = ChatMessage::tool("call_9", "write", "saved to /tmp/output.txt"); + store.append_message(&session.id, &tool_message).unwrap(); + + let messages = store.load_messages(&session.id).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "tool"); + assert_eq!(messages[0].content, "saved to /tmp/output.txt"); + assert_eq!(messages[0].tool_call_id.as_deref(), Some("call_9")); + assert_eq!(messages[0].tool_name.as_deref(), Some("write")); + assert!(messages[0].tool_calls.is_none()); +} + +#[test] +fn test_skill_events_roundtrip() { + let store = SessionStore::in_memory().unwrap(); + let session = store.create_cli_session(Some("skill-events")).unwrap(); + + store + .append_skill_event(None, "discovered", None, &serde_json::json!({"count": 2})) + .unwrap(); + store + .append_skill_event( + Some(&session.id), + "activated", + Some("code-review"), + &serde_json::json!({"source": "project"}), + ) + .unwrap(); + + let global_events = store.list_skill_events(None).unwrap(); + assert_eq!(global_events.len(), 1); + assert_eq!(global_events[0].event_type, "discovered"); + assert_eq!(global_events[0].payload["count"], 2); + + let session_events = store.list_skill_events(Some(&session.id)).unwrap(); + assert_eq!(session_events.len(), 1); + assert_eq!(session_events[0].event_type, "activated"); + assert_eq!(session_events[0].skill_name.as_deref(), Some("code-review")); + assert_eq!(session_events[0].payload["source"], "project"); +} + +#[test] +fn test_memory_roundtrip_with_source_fields() { + let store = SessionStore::in_memory().unwrap(); + + let saved = store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "language".to_string(), + content: "Rust".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), + source_message_id: Some("msg-1".to_string()), + source_message_seq: Some(7), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-1".to_string()), + }) + .unwrap(); + + assert_eq!(saved.content, "Rust"); + assert_eq!(saved.source_type, "message"); + assert_eq!(saved.source_session_id.as_deref(), Some("test-channel:chat-1")); + assert_eq!(saved.source_message_id.as_deref(), Some("msg-1")); + assert_eq!(saved.source_message_seq, Some(7)); + + let fetched = store + .get_memory("user", "test-channel:user-1", "user", "language") + .unwrap() + .unwrap(); + assert_eq!(fetched.id, saved.id); + assert_eq!(fetched.source_chat_id.as_deref(), Some("chat-1")); +} + +#[test] +fn test_memory_fts_tracks_upsert_and_delete() { + let store = SessionStore::in_memory().unwrap(); + + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "editor".to_string(), + content: "Prefers rust-analyzer and cargo test output".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)), + source_message_id: Some("msg-2".to_string()), + source_message_seq: Some(3), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-2".to_string()), + }) + .unwrap(); + + let hits = store + .search_memories("user", "test-channel:user-1", "rust-analyzer", None, 10) + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].memory_key, "editor"); + + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "editor".to_string(), + content: "Prefers clippy diagnostics".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-3", TEST_CHANNEL)), + source_message_id: Some("msg-3".to_string()), + source_message_seq: Some(4), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-3".to_string()), + }) + .unwrap(); + + let old_hits = store + .search_memories("user", "test-channel:user-1", "rust-analyzer", None, 10) + .unwrap(); + assert!(old_hits.is_empty()); + + let new_hits = store + .search_memories("user", "test-channel:user-1", "clippy", None, 10) + .unwrap(); + assert_eq!(new_hits.len(), 1); + + let deleted = store + .delete_memory("user", "test-channel:user-1", "user", "editor") + .unwrap(); + assert!(deleted); + + let hits_after_delete = store + .search_memories("user", "test-channel:user-1", "clippy", None, 10) + .unwrap(); + assert!(hits_after_delete.is_empty()); +} + +#[test] +fn test_memory_search_matches_memory_key_field() { + let store = SessionStore::in_memory().unwrap(); + + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "email_folder_preference".to_string(), + content: "用户提到邮件时默认查看代收邮箱。".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-8", TEST_CHANNEL)), + source_message_id: Some("msg-8".to_string()), + source_message_seq: Some(8), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-8".to_string()), + }) + .unwrap(); + + let hits = store + .search_memories("user", "test-channel:user-1", "email_folder_preference", None, 10) + .unwrap(); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].memory_key, "email_folder_preference"); +} + +#[test] +fn test_search_memories_any_matches_multiple_keywords_once() { + let store = SessionStore::in_memory().unwrap(); + + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "editor".to_string(), + content: "Prefers rust-analyzer and cargo test output".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)), + source_message_id: Some("msg-2".to_string()), + source_message_seq: Some(3), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-2".to_string()), + }) + .unwrap(); + + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "episodic".to_string(), + memory_key: "quality".to_string(), + content: "Tracks clippy warnings before release".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-3", TEST_CHANNEL)), + source_message_id: Some("msg-3".to_string()), + source_message_seq: Some(4), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-3".to_string()), + }) + .unwrap(); + + let hits = store + .search_memories_any( + "user", + "test-channel:user-1", + &["rust-analyzer".to_string(), "clippy".to_string()], + None, + 10, + ) + .unwrap(); + + assert_eq!(hits.len(), 2); + assert!(hits.iter().any(|memory| memory.memory_key == "editor")); + assert!(hits.iter().any(|memory| memory.memory_key == "quality")); +} + +#[test] +fn test_memory_scope_listing_and_full_scope_read() { + let store = SessionStore::in_memory().unwrap(); + + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-2", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "style".to_string(), + content: "偏好简洁表达".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)), + source_message_id: Some("msg-2".to_string()), + source_message_seq: Some(2), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-2".to_string()), + }) + .unwrap(); + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "user".to_string(), + memory_key: "work".to_string(), + content: "用户在做AI产品".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), + source_message_id: Some("msg-1".to_string()), + source_message_seq: Some(1), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-1".to_string()), + }) + .unwrap(); + store + .put_memory(&MemoryUpsert { + scope_kind: "user".to_string(), + scope_key: format!("{}:user-1", TEST_CHANNEL), + namespace: "patterns".to_string(), + memory_key: "workflow".to_string(), + content: "习惯先问方案再要代码".to_string(), + source_type: "message".to_string(), + source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), + source_message_id: Some("msg-3".to_string()), + source_message_seq: Some(3), + source_channel_name: Some(TEST_CHANNEL.to_string()), + source_chat_id: Some("chat-1".to_string()), + }) + .unwrap(); + + let scope_keys = store.list_memory_scope_keys("user").unwrap(); + assert_eq!( + scope_keys, + vec!["test-channel:user-1".to_string(), "test-channel:user-2".to_string()] + ); + + let full_scope = store + .list_memories_for_scope("user", "test-channel:user-1") + .unwrap(); + assert_eq!(full_scope.len(), 2); + assert!( + full_scope + .iter() + .all(|memory| memory.scope_key == "test-channel:user-1") + ); + assert!(full_scope.iter().any(|memory| memory.memory_key == "work")); + assert!( + full_scope + .iter() + .any(|memory| memory.memory_key == "workflow") + ); +} + +#[test] +fn test_scheduler_job_roundtrip_and_runtime_update() { + let store = SessionStore::in_memory().unwrap(); + + let saved = store + .upsert_scheduler_job(&SchedulerJobUpsert { + id: "heartbeat".to_string(), + kind: "outbound_message".to_string(), + schedule: serde_json::json!({ + "type": "interval", + "seconds": 300, + "startup_delay_secs": 10, + }), + interval_secs: 300, + startup_delay_secs: 10, + target: serde_json::json!({ + "channel": "test-channel", + "chat_id": "oc_demo", + }), + payload: serde_json::json!({ + "content": "heartbeat", + }), + enabled: true, + state: SchedulerJobState::Scheduled, + last_status: None, + last_error: None, + run_count: 0, + max_runs: Some(3), + last_fired_at: None, + next_fire_at: Some(1_700_000_000_000), + paused_at: None, + completed_at: None, + }) + .unwrap(); + + assert_eq!(saved.id, "heartbeat"); + assert_eq!(saved.kind, "outbound_message"); + assert_eq!(saved.state, SchedulerJobState::Scheduled); + assert_eq!(saved.max_runs, Some(3)); + + store + .update_scheduler_job_runtime( + "heartbeat", + SchedulerJobState::Completed, + Some(SchedulerJobStatus::Ok), + None, + 1, + Some(1_700_000_000_000), + None, + None, + Some(1_700_000_000_100), + ) + .unwrap(); + + let fetched = store.get_scheduler_job("heartbeat").unwrap().unwrap(); + assert_eq!(fetched.state, SchedulerJobState::Completed); + assert_eq!(fetched.last_status, Some(SchedulerJobStatus::Ok)); + assert_eq!(fetched.run_count, 1); + assert_eq!(fetched.completed_at, Some(1_700_000_000_100)); +} diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index e69f703..6198826 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -1,355 +1,22 @@ -import { useState, useEffect, useCallback, type ReactNode } from 'react' +import { useState, useEffect, useCallback } from 'react' import { - Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, - Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, - CheckCircle, Plug, Radio, RefreshCw, UserCheck, Pencil, + Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, + CheckCircle, RefreshCw, UserCheck, Pencil, } from 'lucide-react' -// ── Types ────────────────────────────────────────────── -interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record; llm_timeout_secs: number; memory_maintenance_timeout_secs: number } -interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number } -interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number } -interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number } -interface TimeConfig { timezone: string } -interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: any[] } -interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } -interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } -interface ToolsConfig { disabled: string[]; task: TaskConfig } -interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } -interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } -interface SubagentsConfig { enabled: boolean; sources: string[] } -interface ClientConfig { gateway_url: string } -interface McpServerConfig { - name?: string - type: 'stdio' | 'streamableHttp' | 'http' - is_active: boolean - command?: string - args?: string[] - env?: Record - cwd?: string - base_url?: string - headers?: Record - description?: string -} - -interface SkillItem { - name: string - description: string - source: string - path: string - disabled_in_scopes: string[] -} - -interface SkillListResponse { - skills_system_enabled: boolean - total: number - skills: SkillItem[] -} - -interface SubagentItem { - name: string - description: string - source: string - disabled_in_scopes: string[] -} - -interface SubagentListResponse { - subagents_system_enabled: boolean - total: number - subagents: SubagentItem[] -} - -interface ExpertsConfig { enabled: boolean; sources: string[] } -interface ExpertItem { - name: string - description: string - source: string - path?: string - body?: string - disabled_in_scopes: string[] -} -interface ExpertListResponse { - experts_system_enabled: boolean - total: number - experts: ExpertItem[] -} - -interface McpServerStatus { - key: string - name: string - transport_type: string - is_active: boolean - connected: boolean - tool_count: number - error?: string -} - -interface McpStatusResponse { - enabled: boolean - total_servers: number - connected_servers: number - failed_servers: number - total_tools: number - servers: McpServerStatus[] -} -interface AppConfig { - providers: Record - models: Record - agents: Record - time: TimeConfig - gateway: GatewayConfig - scheduler: SchedulerConfig - skills: SkillsConfig - tools: ToolsConfig - memory_maintenance: MemoryMaintenanceConfig - image_context: ImageContextConfig - subagents: SubagentsConfig - experts: ExpertsConfig - client: ClientConfig - channels: Record - mcpServers: Record -} - -interface ConfigPageProps { - onClose: () => void - onSaveConnection?: (host: string, port: number) => void - initialTab?: TabId -} - -type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'experts' | 'mcp' | 'channels' - -const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ - { id: 'providers', label: '服务商', icon: Cpu }, - { id: 'models', label: '模型', icon: Brain }, - { id: 'agents', label: '代理', icon: Bot }, - { id: 'mcp', label: 'MCP 服务器', icon: Plug }, - { id: 'skills', label: '技能', icon: Wrench }, - { id: 'subagents', label: '子代理', icon: Bot }, - { id: 'experts', label: '专家', icon: UserCheck }, - { id: 'channels', label: '渠道', icon: Radio }, - { id: 'tools', label: '工具', icon: Settings }, - { id: 'memory', label: '记忆维护', icon: Users }, - { id: 'scheduler', label: '调度器', icon: Calendar }, - { id: 'image', label: '图片上下文', icon: Image }, - { id: 'time', label: '时间', icon: Clock }, - { id: 'connection', label: '连接', icon: Wifi }, - { id: 'gateway', label: '网关', icon: Server }, -] - -// ── Shared UI primitives ─────────────────────────────── -function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ) -} - -const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors" -const selectCls = inputCls - -const TIMEZONE_OPTIONS: { value: string; label: string }[] = [ - { value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' }, - { value: 'Asia/Tokyo', label: 'Asia/Tokyo (日本标准时间, UTC+9)' }, - { value: 'Asia/Seoul', label: 'Asia/Seoul (韩国标准时间, UTC+9)' }, - { value: 'Asia/Singapore', label: 'Asia/Singapore (新加坡时间, UTC+8)' }, - { value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (香港时间, UTC+8)' }, - { value: 'Asia/Taipei', label: 'Asia/Taipei (台北时间, UTC+8)' }, - { value: 'Asia/Bangkok', label: 'Asia/Bangkok (曼谷时间, UTC+7)' }, - { value: 'Asia/Kolkata', label: 'Asia/Kolkata (印度标准时间, UTC+5:30)' }, - { value: 'Asia/Dubai', label: 'Asia/Dubai (海湾标准时间, UTC+4)' }, - { value: 'Europe/London', label: 'Europe/London (格林威治时间, UTC+0)' }, - { value: 'Europe/Paris', label: 'Europe/Paris (中欧时间, UTC+1)' }, - { value: 'Europe/Berlin', label: 'Europe/Berlin (中欧时间, UTC+1)' }, - { value: 'Europe/Moscow', label: 'Europe/Moscow (莫斯科时间, UTC+3)' }, - { value: 'America/New_York', label: 'America/New_York (美东时间, UTC-5)' }, - { value: 'America/Chicago', label: 'America/Chicago (美中时间, UTC-6)' }, - { value: 'America/Denver', label: 'America/Denver (美山地时间, UTC-7)' }, - { value: 'America/Los_Angeles', label: 'America/Los_Angeles (美太平洋时间, UTC-8)' }, - { value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' }, - { value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' }, - { value: 'UTC', label: 'UTC (协调世界时)' }, -] - -function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - - ) -} - -function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) { - const [input, setInput] = useState('') - const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } } - return ( -
-
- {tags.map((t, i) => ( - - {t} - - - ))} -
-
- setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} /> - -
-
- ) -} - -function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) { - return ( -
-
-
-

{title}

- {subtitle && {subtitle}} -
-
-
{children}
-
- ) -} - -interface KnownSource { - key: string - label: string - description: string -} - -function SourceEditor({ - sources, - onChange, - knownSources, - examplePaths, - showCustom = true, -}: { - sources: string[] - onChange: (s: string[]) => void - knownSources: KnownSource[] - examplePaths?: string[] - showCustom?: boolean -}) { - const [customInput, setCustomInput] = useState('') - const knownKeys = new Set(knownSources.map(k => k.key)) - const customPaths = sources.filter(s => !knownKeys.has(s)) - - const toggleKnown = (key: string) => { - if (sources.includes(key)) { - onChange(sources.filter(s => s !== key)) - } else { - onChange([...sources, key]) - } - } - - const addCustom = () => { - const v = customInput.trim() - if (v && !sources.includes(v)) { - onChange([...sources, v]) - setCustomInput('') - } - } - - const removeCustom = (path: string) => { - onChange(sources.filter(s => s !== path)) - } - - return ( -
- {/* Known sources as toggles */} -
- {knownSources.map(src => ( -
-
-
{src.label}
-
{src.description}
-
- toggleKnown(src.key)} /> -
- ))} -
- - {/* Custom paths (only shown when showCustom is true) */} - {showCustom && ( -
-
自定义路径
- {customPaths.length > 0 && ( -
- {customPaths.map((p) => ( - - {p} - - - ))} -
- )} -
- setCustomInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())} - placeholder="输入绝对路径,如 D:\my-skills" - className={inputCls + ' !text-xs font-mono'} - /> - -
- {examplePaths && ( -

- 示例: {examplePaths.join('、')} -

- )} -
- )} -
- ) -} - -function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) { - const [editing, setEditing] = useState(false) - const [val, setVal] = useState(name) - return ( -
- {editing ? ( - setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus /> - ) : ( - onRename && setEditing(true)}>{name} - )} -
- -
- ) -} - -// ── Expert API helpers (reusable, also used by ExpertSelector) ─────── -export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { - const resp = await fetch(`/api/experts/selected?session_id=${encodeURIComponent(sessionId)}`) - if (!resp.ok) return { expert_name: null, expert: null } - return resp.json() -} - -export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { - const resp = await fetch('/api/experts/select', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), - }) - const data = await resp.json().catch(() => ({})) - if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } - return { success: true } -} +// ── Extracted modules ───────────────────────────────── +import type { + AppConfig, ConfigPageProps, TabId, + ProviderConfig, ModelConfig, AgentConfig, + McpServerConfig, McpStatusResponse, + SkillListResponse, + SubagentListResponse, + ExpertItem, ExpertListResponse, + KnownSource, +} from './types' +import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants' +import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui' +export { getSelectedExpert, selectExpert } from './api/expert' // ── Main Component ───────────────────────────────────── export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { diff --git a/web/src/components/Settings/api/expert.ts b/web/src/components/Settings/api/expert.ts new file mode 100644 index 0000000..4b87997 --- /dev/null +++ b/web/src/components/Settings/api/expert.ts @@ -0,0 +1,20 @@ +// Expert API helpers extracted from ConfigPage.tsx +// Used by both ConfigPage and ExpertSelector +import type { ExpertItem } from '../types' + +export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { + const resp = await fetch(`/api/experts/selected?session_id=${encodeURIComponent(sessionId)}`) + if (!resp.ok) return { expert_name: null, expert: null } + return resp.json() +} + +export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { + const resp = await fetch('/api/experts/select', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), + }) + const data = await resp.json().catch(() => ({})) + if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } + return { success: true } +} diff --git a/web/src/components/Settings/constants.ts b/web/src/components/Settings/constants.ts new file mode 100644 index 0000000..db1ed77 --- /dev/null +++ b/web/src/components/Settings/constants.ts @@ -0,0 +1,50 @@ +// Config-related constants extracted from ConfigPage.tsx +import { + Settings, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, + Plug, Radio, Wifi, Server, Users, UserCheck, +} from 'lucide-react' +import type { TabId } from './types' + +export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ + { id: 'providers', label: '服务商', icon: Cpu }, + { id: 'models', label: '模型', icon: Brain }, + { id: 'agents', label: '代理', icon: Bot }, + { id: 'mcp', label: 'MCP 服务器', icon: Plug }, + { id: 'skills', label: '技能', icon: Wrench }, + { id: 'subagents', label: '子代理', icon: Bot }, + { id: 'experts', label: '专家', icon: UserCheck }, + { id: 'channels', label: '渠道', icon: Radio }, + { id: 'tools', label: '工具', icon: Settings }, + { id: 'memory', label: '记忆维护', icon: Users }, + { id: 'scheduler', label: '调度器', icon: Calendar }, + { id: 'image', label: '图片上下文', icon: Image }, + { id: 'time', label: '时间', icon: Clock }, + { id: 'connection', label: '连接', icon: Wifi }, + { id: 'gateway', label: '网关', icon: Server }, +] + +export const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors" +export const selectCls = inputCls + +export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [ + { value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' }, + { value: 'Asia/Tokyo', label: 'Asia/Tokyo (日本标准时间, UTC+9)' }, + { value: 'Asia/Seoul', label: 'Asia/Seoul (韩国标准时间, UTC+9)' }, + { value: 'Asia/Singapore', label: 'Asia/Singapore (新加坡时间, UTC+8)' }, + { value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (香港时间, UTC+8)' }, + { value: 'Asia/Taipei', label: 'Asia/Taipei (台北时间, UTC+8)' }, + { value: 'Asia/Bangkok', label: 'Asia/Bangkok (曼谷时间, UTC+7)' }, + { value: 'Asia/Kolkata', label: 'Asia/Kolkata (印度标准时间, UTC+5:30)' }, + { value: 'Asia/Dubai', label: 'Asia/Dubai (海湾标准时间, UTC+4)' }, + { value: 'Europe/London', label: 'Europe/London (格林威治时间, UTC+0)' }, + { value: 'Europe/Paris', label: 'Europe/Paris (中欧时间, UTC+1)' }, + { value: 'Europe/Berlin', label: 'Europe/Berlin (中欧时间, UTC+1)' }, + { value: 'Europe/Moscow', label: 'Europe/Moscow (莫斯科时间, UTC+3)' }, + { value: 'America/New_York', label: 'America/New_York (美东时间, UTC-5)' }, + { value: 'America/Chicago', label: 'America/Chicago (美中时间, UTC-6)' }, + { value: 'America/Denver', label: 'America/Denver (美山地时间, UTC-7)' }, + { value: 'America/Los_Angeles', label: 'America/Los_Angeles (美太平洋时间, UTC-8)' }, + { value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' }, + { value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' }, + { value: 'UTC', label: 'UTC (协调世界时)' }, +] diff --git a/web/src/components/Settings/types.ts b/web/src/components/Settings/types.ts new file mode 100644 index 0000000..737de38 --- /dev/null +++ b/web/src/components/Settings/types.ts @@ -0,0 +1,120 @@ +// Config-related type definitions extracted from ConfigPage.tsx + +export interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record; llm_timeout_secs: number; memory_maintenance_timeout_secs: number } +export interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number } +export interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number } +export interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number } +export interface TimeConfig { timezone: string } +export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: any[] } +export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } +export interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } +export interface ToolsConfig { disabled: string[]; task: TaskConfig } +export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } +export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } +export interface SubagentsConfig { enabled: boolean; sources: string[] } +export interface ClientConfig { gateway_url: string } +export interface McpServerConfig { + name?: string + type: 'stdio' | 'streamableHttp' | 'http' + is_active: boolean + command?: string + args?: string[] + env?: Record + cwd?: string + base_url?: string + headers?: Record + description?: string +} + +export interface SkillItem { + name: string + description: string + source: string + path: string + disabled_in_scopes: string[] +} + +export interface SkillListResponse { + skills_system_enabled: boolean + total: number + skills: SkillItem[] +} + +export interface SubagentItem { + name: string + description: string + source: string + disabled_in_scopes: string[] +} + +export interface SubagentListResponse { + subagents_system_enabled: boolean + total: number + subagents: SubagentItem[] +} + +export interface ExpertsConfig { enabled: boolean; sources: string[] } +export interface ExpertItem { + name: string + description: string + source: string + path?: string + body?: string + disabled_in_scopes: string[] +} +export interface ExpertListResponse { + experts_system_enabled: boolean + total: number + experts: ExpertItem[] +} + +export interface McpServerStatus { + key: string + name: string + transport_type: string + is_active: boolean + connected: boolean + tool_count: number + error?: string +} + +export interface McpStatusResponse { + enabled: boolean + total_servers: number + connected_servers: number + failed_servers: number + total_tools: number + servers: McpServerStatus[] +} + +export interface AppConfig { + providers: Record + models: Record + agents: Record + time: TimeConfig + gateway: GatewayConfig + scheduler: SchedulerConfig + skills: SkillsConfig + tools: ToolsConfig + memory_maintenance: MemoryMaintenanceConfig + image_context: ImageContextConfig + subagents: SubagentsConfig + experts: ExpertsConfig + client: ClientConfig + channels: Record + mcpServers: Record +} + +export type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'experts' | 'mcp' | 'channels' + +export interface ConfigPageProps { + onClose: () => void + onSaveConnection?: (host: string, port: number) => void + initialTab?: TabId +} + +export interface KnownSource { + key: string + label: string + description: string +} diff --git a/web/src/components/Settings/ui.tsx b/web/src/components/Settings/ui.tsx new file mode 100644 index 0000000..d7f6558 --- /dev/null +++ b/web/src/components/Settings/ui.tsx @@ -0,0 +1,169 @@ +// Shared UI primitives extracted from ConfigPage.tsx +import { useState, type ReactNode } from 'react' +import { X, Plus, Trash2 } from 'lucide-react' +import { inputCls } from './constants' +import type { KnownSource } from './types' + +export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) { + const [input, setInput] = useState('') + const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } } + return ( +
+
+ {tags.map((t, i) => ( + + {t} + + + ))} +
+
+ setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} /> + +
+
+ ) +} + +export function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) { + return ( +
+
+
+

{title}

+ {subtitle && {subtitle}} +
+
+
{children}
+
+ ) +} + +export function SourceEditor({ + sources, + onChange, + knownSources, + examplePaths, + showCustom = true, +}: { + sources: string[] + onChange: (s: string[]) => void + knownSources: KnownSource[] + examplePaths?: string[] + showCustom?: boolean +}) { + const [customInput, setCustomInput] = useState('') + const knownKeys = new Set(knownSources.map(k => k.key)) + const customPaths = sources.filter(s => !knownKeys.has(s)) + + const toggleKnown = (key: string) => { + if (sources.includes(key)) { + onChange(sources.filter(s => s !== key)) + } else { + onChange([...sources, key]) + } + } + + const addCustom = () => { + const v = customInput.trim() + if (v && !sources.includes(v)) { + onChange([...sources, v]) + setCustomInput('') + } + } + + const removeCustom = (path: string) => { + onChange(sources.filter(s => s !== path)) + } + + return ( +
+ {/* Known sources as toggles */} +
+ {knownSources.map(src => ( +
+
+
{src.label}
+
{src.description}
+
+ toggleKnown(src.key)} /> +
+ ))} +
+ + {/* Custom paths (only shown when showCustom is true) */} + {showCustom && ( +
+
自定义路径
+ {customPaths.length > 0 && ( +
+ {customPaths.map((p) => ( + + {p} + + + ))} +
+ )} +
+ setCustomInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())} + placeholder="输入绝对路径,如 D:\my-skills" + className={inputCls + ' !text-xs font-mono'} + /> + +
+ {examplePaths && ( +

+ 示例: {examplePaths.join('、')} +

+ )} +
+ )} +
+ ) +} + +export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) { + const [editing, setEditing] = useState(false) + const [val, setVal] = useState(name) + return ( +
+ {editing ? ( + setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus /> + ) : ( + onRename && setEditing(true)}>{name} + )} +
+ +
+ ) +}