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:
parent
f8d5f0253a
commit
c8660df14b
270
src/storage/migrations.rs
Normal file
270
src/storage/migrations.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
1139
src/storage/mod.rs
1139
src/storage/mod.rs
File diff suppressed because it is too large
Load Diff
220
src/storage/row_mapping.rs
Normal file
220
src/storage/row_mapping.rs
Normal 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
667
src/storage/tests.rs
Normal 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));
|
||||
}
|
||||
@ -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<string, string>; 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<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 }
|
||||
}
|
||||
// ── 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) {
|
||||
|
||||
20
web/src/components/Settings/api/expert.ts
Normal file
20
web/src/components/Settings/api/expert.ts
Normal 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 }
|
||||
}
|
||||
50
web/src/components/Settings/constants.ts
Normal file
50
web/src/components/Settings/constants.ts
Normal 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 (协调世界时)' },
|
||||
]
|
||||
120
web/src/components/Settings/types.ts
Normal file
120
web/src/components/Settings/types.ts
Normal 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
|
||||
}
|
||||
169
web/src/components/Settings/ui.tsx
Normal file
169
web/src/components/Settings/ui.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user