refactor: 拆分 storage/mod.rs 与 ConfigPage.tsx (P2)

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 通过 (无丢失/重复函数, 无循环依赖, 无未使用导入)
This commit is contained in:
oudecheng 2026-07-07 18:36:23 +08:00
parent f8d5f0253a
commit c8660df14b
9 changed files with 1540 additions and 1480 deletions

270
src/storage/migrations.rs Normal file
View File

@ -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<bool, StorageError> {
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)),
}
}

File diff suppressed because it is too large Load Diff

220
src/storage/row_mapping.rs Normal file
View File

@ -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<Option<SessionRecord>, 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<Option<MemoryRecord>, 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<Option<SchedulerJobRecord>, 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<SessionRecord> {
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<SkillEventRecord> {
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<ChatMessage> {
let media_refs_json: String = row.get(5)?;
let media_refs: Vec<String> = 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<String> = 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<i64>>(10)?.map(|v| v as u64),
tool_calls,
})
}
pub(super) fn map_memory_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryRecord> {
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<SchedulerJobRecord> {
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<String> = 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)?,
})
}

667
src/storage/tests.rs Normal file
View File

@ -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));
}

View File

@ -1,355 +1,22 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useState, useEffect, useCallback } from 'react'
import { import {
Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi, CheckCircle, RefreshCw, UserCheck, Pencil,
CheckCircle, Plug, Radio, RefreshCw, UserCheck, Pencil,
} from 'lucide-react' } from 'lucide-react'
// ── Types ────────────────────────────────────────────── // ── Extracted modules ─────────────────────────────────
interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record<string, string>; llm_timeout_secs: number; memory_maintenance_timeout_secs: number } import type {
interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number } AppConfig, ConfigPageProps, TabId,
interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number } ProviderConfig, ModelConfig, AgentConfig,
interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number } McpServerConfig, McpStatusResponse,
interface TimeConfig { timezone: string } SkillListResponse,
interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: any[] } SubagentListResponse,
interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } ExpertItem, ExpertListResponse,
interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } KnownSource,
interface ToolsConfig { disabled: string[]; task: TaskConfig } } from './types'
interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'
interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui'
interface SubagentsConfig { enabled: boolean; sources: string[] } export { getSelectedExpert, selectExpert } from './api/expert'
interface ClientConfig { gateway_url: string }
interface McpServerConfig {
name?: string
type: 'stdio' | 'streamableHttp' | 'http'
is_active: boolean
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
base_url?: string
headers?: Record<string, string>
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<string, ProviderConfig>
models: Record<string, ModelConfig>
agents: Record<string, AgentConfig>
time: TimeConfig
gateway: GatewayConfig
scheduler: SchedulerConfig
skills: SkillsConfig
tools: ToolsConfig
memory_maintenance: MemoryMaintenanceConfig
image_context: ImageContextConfig
subagents: SubagentsConfig
experts: ExpertsConfig
client: ClientConfig
channels: Record<string, any>
mcpServers: Record<string, McpServerConfig>
}
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 (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
)
}
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 (
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
)
}
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 (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span key={t} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
{t}
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
<div className="flex gap-2">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
</div>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
)
}
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 (
<div className="space-y-4">
{/* Known sources as toggles */}
<div className="space-y-2">
{knownSources.map(src => (
<div key={src.key} className="flex items-center justify-between py-1.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
</div>
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
</div>
))}
</div>
{/* Custom paths (only shown when showCustom is true) */}
{showCustom && (
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider"></div>
{customPaths.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{customPaths.map((p) => (
<span key={p} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
{p}
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
value={customInput}
onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills"
className={inputCls + ' !text-xs font-mono'}
/>
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{examplePaths && (
<p className="text-xs text-[var(--text-muted)]">
: {examplePaths.join('、')}
</p>
)}
</div>
)}
</div>
)
}
function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(name)
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? (
<input value={val} onChange={e => 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 />
) : (
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
)}
<div className="flex-1" />
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
)
}
// ── 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 }
}
// ── Main Component ───────────────────────────────────── // ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) { export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) {

View File

@ -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 }
}

View File

@ -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 (协调世界时)' },
]

View File

@ -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<string, string>; 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<string, string>
cwd?: string
base_url?: string
headers?: Record<string, string>
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<string, ProviderConfig>
models: Record<string, ModelConfig>
agents: Record<string, AgentConfig>
time: TimeConfig
gateway: GatewayConfig
scheduler: SchedulerConfig
skills: SkillsConfig
tools: ToolsConfig
memory_maintenance: MemoryMaintenanceConfig
image_context: ImageContextConfig
subagents: SubagentsConfig
experts: ExpertsConfig
client: ClientConfig
channels: Record<string, any>
mcpServers: Record<string, McpServerConfig>
}
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
}

View File

@ -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 (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
)
}
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
>
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
)
}
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 (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => (
<span key={t} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
{t}
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
<div className="flex gap-2">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
export function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
</div>
</div>
<div className="p-4 space-y-4">{children}</div>
</div>
)
}
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 (
<div className="space-y-4">
{/* Known sources as toggles */}
<div className="space-y-2">
{knownSources.map(src => (
<div key={src.key} className="flex items-center justify-between py-1.5">
<div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
</div>
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
</div>
))}
</div>
{/* Custom paths (only shown when showCustom is true) */}
{showCustom && (
<div className="space-y-2">
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider"></div>
{customPaths.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{customPaths.map((p) => (
<span key={p} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
{p}
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
value={customInput}
onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills"
className={inputCls + ' !text-xs font-mono'}
/>
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{examplePaths && (
<p className="text-xs text-[var(--text-muted)]">
: {examplePaths.join('、')}
</p>
)}
</div>
)}
</div>
)
}
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 (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? (
<input value={val} onChange={e => 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 />
) : (
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
)}
<div className="flex-1" />
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
)
}