Compare commits

..

No commits in common. "53a45ad7c46eb143c32f49216df13421e402afdf" and "d7ff969560d2a429b3f3bc2666251003eb36ec04" have entirely different histories.

76 changed files with 3458 additions and 13073 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Rust
target
Cargo.lock
# Frontend
web/node_modules

3782
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "0.1.2"
version = "0.1.1"
edition = "2024"
[dependencies]
@ -50,7 +50,7 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Kernel",
] }
# MCP (Model Context Protocol) support
rmcp = { version = "1.7", features = [
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main", features = [
"client",
"transport-child-process",
"transport-streamable-http-client-reqwest",

View File

@ -738,6 +738,16 @@ pub trait SkillProvider: Send + Sync + 'static {
}
}
#[derive(Default)]
#[allow(dead_code)]
struct EmptySkillProvider;
impl SkillProvider for EmptySkillProvider {
fn system_index_prompt(&self) -> Option<String> {
None
}
}
impl AgentLoop {
pub fn new(config: impl Into<AgentRuntimeConfig>) -> Result<Self, AgentError> {
let runtime_config = config.into();
@ -907,7 +917,7 @@ impl AgentLoop {
"Pre-process message state before sanitize"
);
}
let removed = Self::sanitize_messages_for_llm(&mut messages);
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
if removed > 0 {
tracing::warn!(
removed_count = removed,
@ -927,11 +937,13 @@ impl AgentLoop {
// 检查取消信号
// 使用 unwrap_or(true):即使 watch channel 因异常关闭sender drop 但未 send
// 也视为取消信号。defense-in-depth —— fail safe 而非 fail silent。
if self.check_cancelled().await {
tracing::info!(iteration, "Agent execution cancelled by user");
let cancel = Self::build_cancel_result(iteration, emitted_messages);
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
return Ok(cancel);
if let Some(ref mutex) = self.cancel_token {
if mutex.lock().await.has_changed().unwrap_or(true) {
tracing::info!(iteration, "Agent execution cancelled by user");
let cancel = Self::build_cancel_result(iteration, emitted_messages);
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
return Ok(cancel);
}
}
// Build request
@ -947,7 +959,8 @@ impl AgentLoop {
// This catches edge cases where compression, persistence races,
// or delta message merging may have introduced orphaned sequences
// that survived the initial sanitization.
let mid_loop_removed = Self::sanitize_messages_for_llm(&mut messages);
let mid_loop_removed =
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
if mid_loop_removed > 0 {
tracing::warn!(
iteration = iteration,
@ -956,7 +969,50 @@ impl AgentLoop {
);
}
let request = self.build_llm_request(&messages, system_prompt_context, tools);
// 过滤超出轮次和数量限制的图片
let filtered_messages = filter_images_by_age_and_count(
&messages,
self.runtime_config.max_image_age_rounds,
self.runtime_config.max_images_in_context,
);
let image_count = count_supported_image_media_refs(&filtered_messages);
// 构建系统提示词(统一注入 Agent 和 Skill 提示词)
let system_prompt = system_prompt_context.and_then(|ctx| {
self.system_prompt_provider
.as_ref()
.and_then(|provider| provider.build(ctx))
});
let mut text_only_messages: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
if let Some(ref prompt) = system_prompt {
text_only_messages.push(Message::system(prompt.content.clone()));
}
text_only_messages.extend(filtered_messages.iter().map(chat_message_to_text_only_llm_message));
let image_tokens = image_token_budget_for_request(
&self.runtime_config,
&text_only_messages,
tools.as_ref(),
);
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
// 使用相同的系统提示词(已构建)
if let Some(ref prompt) = system_prompt {
messages_for_llm.push(Message::system(prompt.content.clone()));
}
messages_for_llm.extend(
filtered_messages
.iter()
.map(|message| chat_message_to_llm_message(message, &mut image_budget)),
);
let request = ChatCompletionRequest {
messages: messages_for_llm,
temperature: None,
max_tokens: None,
tools,
};
// Set up streaming delta consumer
// Pre-generate the message ID so stream deltas and the final assistant
@ -1060,10 +1116,23 @@ impl AgentLoop {
// If no tool calls, this is the final response
if response.tool_calls.is_empty() {
let result = self.build_final_response(
response, &streaming_message_id, had_streaming, &mut emitted_messages,
).await;
return Ok(result);
let mut assistant_message = if let Some(reasoning_content) = response.reasoning_content
{
ChatMessage::assistant_with_reasoning(response.content, reasoning_content)
} else {
ChatMessage::assistant(response.content)
};
// Use the same ID as the stream deltas so the front-end can replace
// the streamed message with this authoritative response.
if had_streaming {
assistant_message.id = streaming_message_id;
}
emitted_messages.push(assistant_message.clone());
self.emit_live_tool_call_message(assistant_message.clone()).await;
return Ok(AgentProcessResult {
final_response: assistant_message,
emitted_messages,
});
}
// Execute tool calls
@ -1132,10 +1201,57 @@ impl AgentLoop {
self.execute_tools(&response.tool_calls).await
};
self.process_tool_results(
&response.tool_calls, &tool_results, &mut loop_detector,
&mut messages, &mut emitted_messages,
).await;
for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
// Truncate tool result if too large
let truncated_output =
truncate_tool_result(&result.output, self.runtime_config.tool_result_max_chars);
// Record tool call and check for loops
let loop_result = loop_detector.record(&tool_call.name, &tool_call.arguments);
match loop_result {
LoopDetectionResult::Warning(msg) => {
// Add warning and proceed
tracing::warn!(
tool = %tool_call.name,
"Loop warning: {}",
msg
);
let tool_message = ChatMessage::tool_with_state(
tool_call.id.clone(),
tool_call.name.clone(),
format!("{}\n\n[上一条结果]\n{}", msg, truncated_output),
if result.state == ToolExecutionState::PendingUserAction {
ToolMessageState::PendingUserAction
} else {
ToolMessageState::Completed
},
)
.with_tool_duration(result.duration.as_millis() as u64);
messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone());
let duration_ms = Some(result.duration.as_millis() as u64);
self.emit_tool_result(tool_message, duration_ms).await;
}
LoopDetectionResult::Ok => {
let tool_message = ChatMessage::tool_with_state(
tool_call.id.clone(),
tool_call.name.clone(),
truncated_output,
if result.state == ToolExecutionState::PendingUserAction {
ToolMessageState::PendingUserAction
} else {
ToolMessageState::Completed
},
)
.with_tool_duration(result.duration.as_millis() as u64);
messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone());
let duration_ms = Some(result.duration.as_millis() as u64);
self.emit_tool_result(tool_message, duration_ms).await;
}
}
}
// Loop continues to next iteration with updated messages
// PendingUserAction 工具的结果已在上方加入 messages
@ -1148,168 +1264,11 @@ impl AgentLoop {
);
}
// Max iterations reached - request final summary from LLM
Ok(self.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages).await)
}
/// 等待取消信号。若未配置 cancel_token永远不返回。
///
/// 封装了与 watch channel 的交互changed() 返回 Ok 表示收到信号,
/// 返回 Err(Closed) 表示 sender 已 drop两种情况都视为取消。
async fn cancel_signal(&self) {
if let Some(ref mutex) = self.cancel_token {
let mut token = mutex.lock().await;
let _ = token.changed().await;
} else {
std::future::pending::<()>().await;
}
}
/// 净化消息中的不完整 tool_call 序列,返回移除数量。
/// 日志由调用方负责,因为不同调用点需要不同的结构化字段。
fn sanitize_messages_for_llm(messages: &mut Vec<ChatMessage>) -> usize {
crate::bus::message::sanitize_incomplete_tool_call_sequences(messages)
}
/// 检查取消信号。返回 true 表示已取消。
async fn check_cancelled(&self) -> bool {
if let Some(ref mutex) = self.cancel_token {
mutex.lock().await.has_changed().unwrap_or(true)
} else {
false
}
}
/// 构建 LLM 请求:过滤图片、构建系统提示、转换消息格式。
/// 统一了循环内调用和最终 summary 调用的请求构建逻辑。
fn build_llm_request(
&self,
messages: &[ChatMessage],
system_prompt_context: Option<&SystemPromptContext>,
tools: Option<Vec<crate::domain::tools::Tool>>,
) -> ChatCompletionRequest {
let filtered_messages = filter_images_by_age_and_count(
messages,
self.runtime_config.max_image_age_rounds,
self.runtime_config.max_images_in_context,
);
let image_count = count_supported_image_media_refs(&filtered_messages);
let system_prompt = system_prompt_context.and_then(|ctx| {
self.system_prompt_provider
.as_ref()
.and_then(|provider| provider.build(ctx))
});
let mut text_only_messages: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
if let Some(ref prompt) = system_prompt {
text_only_messages.push(Message::system(prompt.content.clone()));
}
text_only_messages.extend(filtered_messages.iter().map(chat_message_to_text_only_llm_message));
let image_tokens = image_token_budget_for_request(
&self.runtime_config,
&text_only_messages,
tools.as_ref(),
);
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
if let Some(ref prompt) = system_prompt {
messages_for_llm.push(Message::system(prompt.content.clone()));
}
messages_for_llm.extend(
filtered_messages
.iter()
.map(|message| chat_message_to_llm_message(message, &mut image_budget)),
);
ChatCompletionRequest {
messages: messages_for_llm,
temperature: None,
max_tokens: None,
tools,
}
}
/// 构建 LLM 最终响应(无工具调用时)。调用方应先检查 `response.tool_calls.is_empty()`。
async fn build_final_response(
&self,
response: crate::providers::ChatCompletionResponse,
streaming_message_id: &str,
had_streaming: bool,
emitted_messages: &mut Vec<ChatMessage>,
) -> AgentProcessResult {
let mut assistant_message = if let Some(reasoning_content) = response.reasoning_content {
ChatMessage::assistant_with_reasoning(response.content, reasoning_content)
} else {
ChatMessage::assistant(response.content)
};
// Use the same ID as the stream deltas so the front-end can replace
// the streamed message with this authoritative response.
if had_streaming {
assistant_message.id = streaming_message_id.to_string();
}
emitted_messages.push(assistant_message.clone());
self.emit_live_tool_call_message(assistant_message.clone()).await;
AgentProcessResult {
final_response: assistant_message,
emitted_messages: std::mem::take(emitted_messages),
}
}
/// 处理工具执行结果,追加到 messages 和 emitted_messages发出 tool_result 事件。
/// 合并了 LoopDetectionResult::Warning 和 ::Ok 两个分支的逻辑。
async fn process_tool_results(
&self,
tool_calls: &[ToolCall],
tool_results: &[ToolExecutionOutcome],
loop_detector: &mut LoopDetector,
messages: &mut Vec<ChatMessage>,
emitted_messages: &mut Vec<ChatMessage>,
) {
for (tool_call, result) in tool_calls.iter().zip(tool_results.iter()) {
let truncated_output =
truncate_tool_result(&result.output, self.runtime_config.tool_result_max_chars);
let loop_result = loop_detector.record(&tool_call.name, &tool_call.arguments);
let prefix = match loop_result {
LoopDetectionResult::Warning(msg) => {
tracing::warn!(tool = %tool_call.name, "Loop warning: {}", msg);
format!("{}\n\n[上一条结果]\n", msg)
}
LoopDetectionResult::Ok => String::new(),
};
let state = if result.state == ToolExecutionState::PendingUserAction {
ToolMessageState::PendingUserAction
} else {
ToolMessageState::Completed
};
let tool_message = ChatMessage::tool_with_state(
tool_call.id.clone(),
tool_call.name.clone(),
format!("{}{}", prefix, truncated_output),
state,
)
.with_tool_duration(result.duration.as_millis() as u64);
messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone());
let duration_ms = Some(result.duration.as_millis() as u64);
self.emit_tool_result(tool_message, duration_ms).await;
}
}
/// 达到最大迭代次数后,请求 LLM 生成总结。
async fn run_final_summary(
&self,
messages: &mut Vec<ChatMessage>,
system_prompt_context: Option<&SystemPromptContext>,
emitted_messages: &mut Vec<ChatMessage>,
) -> AgentProcessResult {
// Max iterations reached - ask LLM for a summary based on completed work
tracing::warn!("Max iterations reached, requesting final summary from LLM");
// Defense: sanitize before final summary request
let removed = Self::sanitize_messages_for_llm(messages);
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
if removed > 0 {
tracing::warn!(removed_count = removed, "Sanitized before max-iterations summary");
}
@ -1321,7 +1280,47 @@ impl AgentLoop {
);
messages.push(summary_request);
let request = self.build_llm_request(messages, system_prompt_context, None);
// 过滤超出轮次和数量限制的图片
let filtered_messages = filter_images_by_age_and_count(
&messages,
self.runtime_config.max_image_age_rounds,
self.runtime_config.max_images_in_context,
);
// Convert messages to LLM format (使用系统提示词提供者)
let image_count = count_supported_image_media_refs(&filtered_messages);
let mut text_only_messages: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 1);
if let Some(ref provider) = self.system_prompt_provider {
if let Some(ctx) = system_prompt_context {
if let Some(prompt) = provider.build(ctx) {
text_only_messages.push(Message::system(prompt.content.clone()));
}
}
}
text_only_messages.extend(filtered_messages.iter().map(chat_message_to_text_only_llm_message));
let image_tokens =
image_token_budget_for_request(&self.runtime_config, &text_only_messages, None);
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 1);
if let Some(ref provider) = self.system_prompt_provider {
if let Some(ctx) = system_prompt_context {
if let Some(prompt) = provider.build(ctx) {
messages_for_llm.push(Message::system(prompt.content.clone()));
}
}
}
messages_for_llm.extend(
filtered_messages
.iter()
.map(|message| chat_message_to_llm_message(message, &mut image_budget)),
);
let request = ChatCompletionRequest {
messages: messages_for_llm,
temperature: None,
max_tokens: None,
tools: None, // No tools in final summary call
};
// 最终 summary 调用也与取消信号竞速
let final_result: Result<
@ -1331,9 +1330,9 @@ impl AgentLoop {
if self.cancel_token.is_some() {
tokio::select! {
_ = self.cancel_signal() => {
let cancel = Self::build_cancel_result(self.max_iterations, std::mem::take(emitted_messages));
let cancel = Self::build_cancel_result(self.max_iterations, emitted_messages);
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
return cancel;
return Ok(cancel);
}
result = self.provider.chat(request) => {
final_result = result;
@ -1345,17 +1344,18 @@ impl AgentLoop {
match final_result {
Ok(response) => {
let assistant_message = if let Some(reasoning_content) = response.reasoning_content {
let assistant_message = if let Some(reasoning_content) = response.reasoning_content
{
ChatMessage::assistant_with_reasoning(response.content, reasoning_content)
} else {
ChatMessage::assistant(response.content)
};
emitted_messages.push(assistant_message.clone());
self.emit_live_tool_call_message(assistant_message.clone()).await;
AgentProcessResult {
Ok(AgentProcessResult {
final_response: assistant_message,
emitted_messages: std::mem::take(emitted_messages),
}
emitted_messages,
})
}
Err(e) => {
tracing::error!(
@ -1368,14 +1368,27 @@ impl AgentLoop {
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
emitted_messages.push(final_message.clone());
self.emit_live_tool_call_message(final_message.clone()).await;
AgentProcessResult {
Ok(AgentProcessResult {
final_response: final_message,
emitted_messages: std::mem::take(emitted_messages),
}
emitted_messages,
})
}
}
}
/// 等待取消信号。若未配置 cancel_token永远不返回。
///
/// 封装了与 watch channel 的交互changed() 返回 Ok 表示收到信号,
/// 返回 Err(Closed) 表示 sender 已 drop两种情况都视为取消。
async fn cancel_signal(&self) {
if let Some(ref mutex) = self.cancel_token {
let mut token = mutex.lock().await;
let _ = token.changed().await;
} else {
std::future::pending::<()>().await;
}
}
/// 构建取消响应,包含已完成的迭代次数和已生成的消息数量。
/// 构建取消响应,将取消通知追加到 `emitted_messages` 末尾后一并返回。
/// 这样 finalize_result 会把中间消息加入内存历史,确保下一个 LLM 调用有完整上下文。

View File

@ -60,6 +60,27 @@ impl HistoryUnit {
}
}
}
/// Expand this unit back into a flat list of ChatMessages.
#[allow(dead_code)]
fn into_messages(self) -> Vec<ChatMessage> {
match self {
HistoryUnit::SystemGuard(msg)
| HistoryUnit::UserMessage(msg)
| HistoryUnit::AssistantText(msg) => vec![msg],
HistoryUnit::ToolRound { assistant, results } => {
let mut msgs = vec![assistant];
msgs.extend(results);
msgs
}
}
}
/// Returns true if this unit is a ToolRound.
#[allow(dead_code)]
fn is_tool_round(&self) -> bool {
matches!(self, HistoryUnit::ToolRound { .. })
}
}
// ============================================================================

View File

@ -5,8 +5,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::config::{
AgentConfig, ChannelConfig, Config, FeishuChannelConfig, GatewayConfig, ModelConfig,
ProviderConfig, SchedulerConfig, TaggedChannelConfig, WECHAT_DEFAULT_BASE_URL,
WechatChannelConfig,
ProviderConfig, SchedulerConfig, TaggedChannelConfig, WechatChannelConfig,
};
/// Interactive configuration wizard for PicoBot
@ -80,7 +79,6 @@ impl InitWizard {
mcp_servers: HashMap::new(),
image_context: crate::config::ImageContextConfig::default(),
subagents: crate::config::SubagentsConfig::default(),
experts: crate::config::ExpertsConfig::default(),
}
}
@ -743,7 +741,7 @@ impl InitWizard {
// Use default values directly
let channel_name = "wechat";
let base_url = WECHAT_DEFAULT_BASE_URL;
let base_url = "https://ilinkai.weixin.qq.com";
let cred_path = Self::default_wechat_cred_path();
let force_login = false;
@ -834,7 +832,6 @@ impl InitWizard {
mcp_servers: existing.mcp_servers.clone(),
image_context: existing.image_context.clone(),
subagents: existing.subagents.clone(),
experts: existing.experts.clone(),
}
}

View File

@ -67,7 +67,6 @@ impl OutputAdapter for WebSocketOutputAdapter {
code: error.code,
message: error.message,
timestamp: Some(crate::protocol::now_timestamp()),
subagent_task_id: None,
});
return outbounds;
}
@ -198,7 +197,6 @@ impl OutputAdapter for WebSocketOutputAdapter {
code: "RESPONSE_ERROR".to_string(),
message: msg.content.clone(),
timestamp: Some(crate::protocol::now_timestamp()),
subagent_task_id: None,
},
_ => WsOutbound::AssistantResponse {
id: response.request_id.to_string(),

View File

@ -2,8 +2,10 @@ use crate::agent::context_compressor::estimate_tokens;
use crate::agent::{SystemPromptContext, SystemPromptProvider};
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::get_messages_from_session;
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
@ -11,6 +13,7 @@ use std::sync::Arc;
/// 获取当前话题命令处理器
pub struct GetCurrentSessionCommandHandler {
store: Arc<SessionStore>,
session_manager: Option<SessionManager>,
system_prompt_provider: Option<Arc<dyn SystemPromptProvider>>,
}
@ -18,10 +21,16 @@ impl GetCurrentSessionCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self {
store,
session_manager: None,
system_prompt_provider: None,
}
}
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
self.session_manager = Some(session_manager);
self
}
pub fn with_system_prompt_provider(mut self, provider: Arc<dyn SystemPromptProvider>) -> Self {
self.system_prompt_provider = Some(provider);
self
@ -70,11 +79,12 @@ async fn handle_get_current_session(
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
// 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史)
let messages = handler
.store
.load_messages_for_topic(topic_id, Some(&topic.session_id))
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
// Load messages from session memory
let messages = get_messages_from_session(
&handler.session_manager,
&ctx.channel_name,
chat_id,
).await?;
let actual_message_count = messages.len();
let message_tokens = estimate_tokens(&messages);

View File

@ -149,15 +149,6 @@ fn reconstruct_task_from_db(
let now = record.updated_at;
// DB 未持久化 task 状态字段,无法可靠区分 Running/Completed/Failed/Timeout。
// 用 Unknown 表示"重启后从 DB 重建,真实状态不可知",避免把 failed/timeout
// 误报为 Completed 误导用户。前端会把 Unknown 显示为"未知"。
tracing::warn!(
task_id = %task_id,
session_id = %session_id,
"Reconstructing task from DB after restart; true state unknown, marking as Unknown"
);
Ok(Some(TaskSession {
id: task_id.to_string(),
session_id,
@ -167,7 +158,7 @@ fn reconstruct_task_from_db(
parent_channel_name: record.channel_name.clone(),
description,
subagent_type,
state: TaskSessionState::Unknown,
state: TaskSessionState::Completed,
created_at: record.created_at,
updated_at: now,
summary: None,

View File

@ -26,3 +26,38 @@ pub use save_session::{
generate_subagent_tasks_markdown, load_subagent_data, SubagentTaskData,
};
use crate::bus::ChatMessage;
use crate::command::response::CommandError;
use crate::gateway::session::SessionManager;
/// 从 Session 内存获取消息历史(供命令使用)
pub async fn get_messages_from_session(
session_manager: &Option<SessionManager>,
channel_name: &str,
chat_id: &str,
) -> Result<Vec<ChatMessage>, CommandError> {
let session_manager = session_manager.as_ref().ok_or_else(|| {
CommandError::new(
"SESSION_MANAGER_NOT_SET",
"Session manager not configured".to_string(),
)
})?;
match session_manager.get(channel_name).await {
Some(session) => {
let guard = session.lock().await;
Ok(guard
.get_history(chat_id)
.map(|m| m.clone())
.unwrap_or_default())
}
None => {
tracing::warn!(
channel = %channel_name,
chat_id = %chat_id,
"No in-memory session, returning empty message list"
);
Ok(Vec::new())
}
}
}

View File

@ -213,11 +213,9 @@ async fn handle_save_session(
Ok(CommandResponse::success(ctx.request_id)
.with_message(
MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")),
&format!("Session saved to: {}", output_path.display()),
)
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
.with_metadata("filepath", output_path.to_string_lossy().as_ref())
.with_metadata("message_count", &message_count.to_string()))
}
@ -585,11 +583,9 @@ pub fn generate_system_prompt_markdown(system_prompt: &Option<SystemPrompt>) ->
output.push_str("# System Prompt\n\n");
if let Some(prompt) = system_prompt {
// 直接输出 system prompt 内容,不加 ``` 围栏。
// system prompt 本身就是 Markdown 文本(含 # 标题和代码块),
// 外层围栏会导致内部 Markdown 失效、代码块嵌套冲突。
output.push_str("```\n");
output.push_str(&prompt.content);
output.push_str("\n\n");
output.push_str("\n```\n\n");
} else {
output.push_str("*No system prompt available*\n\n");
}
@ -707,7 +703,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
// 返回成功或失败消息
match result {
Ok(output_path) => {
let msg = format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/"));
let msg = format!("Session saved to: {}", output_path.display());
tracing::info!("{}", msg);
Ok(Some(msg))
}

View File

@ -5,10 +5,11 @@ use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::{
escape_yaml_string, format_timestamp, generate_messages_markdown,
generate_subagent_tasks_markdown, generate_system_prompt_markdown,
load_subagent_data, SubagentTaskData,
get_messages_from_session, load_subagent_data, SubagentTaskData,
};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager;
use crate::storage::{SessionStore, TopicRecord};
use crate::tools::task::repository::TaskRepository;
use async_trait::async_trait;
@ -174,6 +175,7 @@ pub struct SaveTopicCommandHandler {
store: Arc<SessionStore>,
task_repository: Arc<dyn TaskRepository>,
system_prompt_provider: Arc<dyn SystemPromptProvider>,
session_manager: Option<SessionManager>,
}
impl SaveTopicCommandHandler {
@ -186,8 +188,14 @@ impl SaveTopicCommandHandler {
store,
task_repository,
system_prompt_provider,
session_manager: None,
}
}
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
self.session_manager = Some(session_manager);
self
}
}
#[async_trait]
@ -244,19 +252,14 @@ async fn handle_save_topic(
tracing::debug!(topic_id = %topic_id, chat_id = %chat_id, "Attempting to save topic");
// 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史
let topic_record = handler
.store
.get_topic(topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
// 从 Session 获取当前 history包含已压缩的消息
let messages = get_messages_from_session(
&handler.session_manager,
&ctx.channel_name,
chat_id,
).await?;
let messages = handler
.store
.load_messages_for_topic(topic_id, Some(&topic_record.session_id))
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
tracing::debug!(message_count = messages.len(), "Loaded messages from DB for topic");
tracing::debug!(message_count = messages.len(), "Got messages from session");
// 调用保存函数
let output_path = save_topic_to_file(
@ -276,10 +279,8 @@ async fn handle_save_topic(
Ok(CommandResponse::success(ctx.request_id)
.with_message(
MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!("Topic saved to: {}", output_path.display().to_string().replace('\\', "/")),
&format!("Topic saved to: {}", output_path.display()),
)
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
.with_metadata("filepath", output_path.to_string_lossy().as_ref())
.with_metadata("message_count", &message_count.to_string()))
}

View File

@ -38,8 +38,6 @@ pub struct Config {
pub image_context: ImageContextConfig,
#[serde(default)]
pub subagents: SubagentsConfig,
#[serde(default)]
pub experts: ExpertsConfig,
}
/// 图片上下文限制配置
@ -171,34 +169,6 @@ impl Default for SubagentsConfig {
}
}
/// 专家提示词配置
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExpertsConfig {
/// 是否启用专家发现与注入
#[serde(default = "default_experts_enabled")]
pub enabled: bool,
/// 定义来源优先级
#[serde(default = "default_experts_sources")]
pub sources: Vec<String>,
}
fn default_experts_enabled() -> bool {
true
}
fn default_experts_sources() -> Vec<String> {
vec!["user".to_string(), "project".to_string()]
}
impl Default for ExpertsConfig {
fn default() -> Self {
Self {
enabled: default_experts_enabled(),
sources: default_experts_sources(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ToolsConfig {
#[serde(default)]
@ -420,11 +390,8 @@ fn default_media_dir() -> String {
.to_string()
}
/// WeChat 渠道默认 base URL
pub const WECHAT_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
fn default_wechat_base_url() -> String {
WECHAT_DEFAULT_BASE_URL.to_string()
"https://ilinkai.weixin.qq.com".to_string()
}
fn default_wechat_cred_path() -> String {
@ -918,16 +885,19 @@ impl Config {
tracing::info!(path = %path.display(), "Config loaded");
fs::read_to_string(path)?
} else {
// 主目录配置不存在时直接自动创建,不再 fallback 到 cwd 下的 config.json。
// 之前的 fallback 逻辑会在 ~/.picobot/config.json 因任何原因未被找到时
// 静默加载 cwd 下的 config.json可能导致 experts/skills 等字段缺失
// 而退化为默认值,造成用户配置丢失的困惑。
// 开发者若需用项目目录配置,可通过 CONFIG_PATH 环境变量显式指定。
tracing::info!(
path = %path.display(),
"Config not found, auto-creating minimal config"
);
Self::create_default_config(path)?
// Fallback to current directory
let fallback = Path::new("config.json");
if fallback.exists() {
tracing::info!(path = %fallback.display(), "Config loaded from fallback path");
fs::read_to_string(fallback)?
} else {
// Auto-create a minimal config on first startup
tracing::info!(
path = %path.display(),
"Config not found, auto-creating minimal config"
);
Self::create_default_config(path)?
}
};
let content = resolve_env_placeholders(&content);
let config: Config = serde_json::from_str(&content)?;

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +1,18 @@
use std::sync::Arc;
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider};
use crate::config::LLMProviderConfig;
use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::gateway::todo_prompt_provider::TodoPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::persistent_session_id;
use crate::storage::PromptInjectionRepository;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry};
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
///
/// 单一来源AgentFactory::create 与命令侧(/save、/save-session、/current
/// 都调用此函数,确保保存到文件的系统提示词与 LLM 实际接收的提示词一致。
///
/// Provider 顺序AgentPrompt → SkillPrompt → ExpertPrompt → SubagentPrompt → TodoPrompt
pub(crate) fn build_system_prompt_provider(
reinject_every: usize,
provider_config: LLMProviderConfig,
prompt_repository: Arc<dyn PromptInjectionRepository>,
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
) -> Arc<dyn SystemPromptProvider> {
Arc::new(CompositeSystemPromptProvider::new(vec![
Box::new(AgentPromptProvider::new(
reinject_every,
provider_config,
prompt_repository,
)),
Box::new(SkillPromptProvider::new(skills)),
Box::new(ExpertPromptProvider::new(experts)),
Box::new(SubagentPromptProvider::new(subagent_runtime)),
Box::new(ToolPromptProvider::new()),
]))
}
#[derive(Clone)]
pub(crate) struct AgentFactory {
tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
@ -68,8 +36,6 @@ impl AgentFactory {
pub(crate) fn new(
tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>,
) -> Self {
@ -83,8 +49,6 @@ impl AgentFactory {
Self {
tools,
skills,
experts,
subagent_runtime,
reinject_every,
prompt_repository,
instance_id,
@ -105,15 +69,16 @@ impl AgentFactory {
"AgentFactory: creating agent with config"
);
// 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑)
let system_prompt_provider = build_system_prompt_provider(
self.reinject_every,
request.provider_config.clone(),
self.prompt_repository.clone(),
self.skills.clone(),
self.experts.clone(),
self.subagent_runtime.clone(),
);
// 创建组合的系统提示词提供者
let system_prompt_provider = Arc::new(CompositeSystemPromptProvider::new(vec![
Box::new(AgentPromptProvider::new(
self.reinject_every,
request.provider_config.clone(),
self.prompt_repository.clone(),
)),
Box::new(SkillPromptProvider::new(self.skills.clone())),
Box::new(TodoPromptProvider::new()),
]));
AgentLoop::with_tools_and_system_prompt_provider(
request.provider_config,

View File

@ -43,13 +43,7 @@ impl AgentPromptProvider {
/// 记录注入事件
fn record_injection(&self, context: &SystemPromptContext) {
if let Some(session_id) = &context.session_id {
if let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id) {
tracing::warn!(
session_id = ?session_id,
error = %e,
"Failed to mark agent prompt reinjected; injection counter may be inaccurate"
);
}
let _ = self.repository.mark_agent_prompt_reinjected(session_id);
}
}
}

View File

@ -13,7 +13,15 @@ impl CliSessionService {
Self { store }
}
#[allow(dead_code)]
pub(crate) fn create(&self, title: Option<&str>) -> Result<SessionRecord, AgentError> {
self.store
.create_cli_session(title)
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))
}
/// 创建指定通道的会话
#[allow(dead_code)]
pub(crate) fn create_with_channel(
&self,
channel_name: &str,
@ -23,4 +31,58 @@ impl CliSessionService {
.create_session(channel_name, title)
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))
}
#[allow(dead_code)]
pub(crate) fn get(&self, session_id: &str) -> Result<Option<SessionRecord>, AgentError> {
self.store
.get_session(session_id)
.map_err(|err| AgentError::Other(format!("get session error: {}", err)))
}
#[allow(dead_code)]
pub(crate) fn list(&self, include_archived: bool) -> Result<Vec<SessionRecord>, AgentError> {
self.store
.list_sessions("cli", include_archived)
.map_err(|err| AgentError::Other(format!("list sessions error: {}", err)))
}
/// 列出指定通道的会话
#[allow(dead_code)]
pub(crate) fn list_by_channel(
&self,
channel_name: &str,
include_archived: bool,
) -> Result<Vec<SessionRecord>, AgentError> {
self.store
.list_sessions(channel_name, include_archived)
.map_err(|err| AgentError::Other(format!("list sessions error: {}", err)))
}
#[allow(dead_code)]
pub(crate) fn rename(&self, session_id: &str, title: &str) -> Result<(), AgentError> {
self.store
.rename_session(session_id, title)
.map_err(|err| AgentError::Other(format!("rename session error: {}", err)))
}
#[allow(dead_code)]
pub(crate) fn archive(&self, session_id: &str) -> Result<(), AgentError> {
self.store
.archive_session(session_id)
.map_err(|err| AgentError::Other(format!("archive session error: {}", err)))
}
#[allow(dead_code)]
pub(crate) fn delete(&self, session_id: &str) -> Result<(), AgentError> {
self.store
.delete_session(session_id)
.map_err(|err| AgentError::Other(format!("delete session error: {}", err)))
}
#[allow(dead_code)]
pub(crate) fn clear_messages(&self, session_id: &str) -> Result<(), AgentError> {
self.store
.clear_messages(session_id)
.map_err(|err| AgentError::Other(format!("clear session error: {}", err)))
}
}

View File

@ -13,6 +13,58 @@
- 当现有工具是完成任务的最直接方式时,优先使用工具。
- 除非用户明确要求改变方向,否则保持用户原本目标不变。
## 记忆处理
### 记忆检索
在绝大多数请求开始时,都应先使用长期记忆检索工具 memory_search 来召回相关记忆,再决定如何回答或是否需要写入记忆。先检索通常能帮助识别用户长期偏好、稳定事实、历史决策、持续任务和上下文约束。
#### 默认流程
- 先使用长期记忆检索工具 memory_search优先调用 memory_search(action='search')。
- 只有在你已经明确知道 namespace 和 key 时,才改用 get。
- 只有在需要浏览最近几条记忆时,才用 list。
- 即使用户没有明确提到「记忆」或「偏好」,也应该先搜记忆,不要因为你自认为已经能直接回答就省略检索。
#### 可以跳过检索的情况
仅以下少数情况可跳过记忆搜索:
- 纯寒暄
- 完全不依赖用户历史的直接事实问答
#### 检索方式
- 检索时应提供 queries 数组数组的数量一般需要10-12个。
- 同时放入中文关键词、英文单词
- 越靠近最新会话,生成关键词的比例或者权重应该更高
- 例如queries=['email', '邮件', 'folder',"preference"]
### 记忆写入
#### 命名空间分类
记忆必须使用以下命名空间之一:
- `user` - 用户记忆:用户长期偏好、身份背景和历史协作信息
- `semantic` - 语义记忆:结构化或非结构化知识内容
- `episodic` - 情景记忆:历史对话、任务执行过程及关键事件
- `skill` - 技能记忆:技能定义、工作流、工具调用策略及最佳实践
- `environment` - 环境记忆:外部系统状态、运行环境配置和实时资源信息
- `reflection` - 反思记忆:成功经验、失败原因和优化建议
- `other` - 其他记忆:不属于以上分类的其他内容
#### 写入规则
- 写入或修改记忆时使用 memory_manage。
- 遇到未来仍有用的信息时写入记忆:用户长期偏好、稳定事实、用户对你的纠正、持续任务或项目上下文、明确决策等。
#### 【重要注意!】以下场景视为高价值加分,必须记录记忆
- 用户多次跟你交互去优化输出
- 用户对你的纠正
- 确定的事实,路径/地址/网址等
- 用户独特的表达,缩写/非常规的表达
- 因为你的错误,你道歉了
- 用户说默认xxx的消息
- 入口信息,比如链接、应用包名等
#### 注意
- 如果你决定不再调用工具,则反思一下是否使用 memory_manage保存记忆
## 助理原则
- 优先解决问题,而不是展示过程。
@ -28,14 +80,48 @@
- 默认短而清楚,按信息密度组织内容。
- 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。
## PICO配置
### 技能系统
- **技能存储路径**:
- 项目级: `{project-root}/.picobot/skills/{skill-name}/SKILL.md`
- 用户级: `~/.picobot/skills/{skill-name}/SKILL.md`
- **创建/修改技能**:
- 必须使用 `skill_manage` 工具的 `create``update` action
- 不要使用 `write` 工具直接写入技能文件
- `skill_manage` 会自动创建正确的目录结构
- **使用技能**:
- Skill 不是工具名,不能直接调用
- 必须先调用 `skill_activate` 工具激活技能,再按指令执行
## 补充要求
- 回答应以帮助用户完成当前目标为中心。
- 在信息不足时先补关键前提,在信息充分时直接执行。
- Skill 不是工具名。看到可用 Skill 时,不能直接调用 Skill 名称;必须先调用 skill_activate并传入对应的 name。
- 调用工具的时候必须同时用简短的话告诉用户你调用工具是做什么
- 无需担心创建子智能体过多的问题请按用户或者skill的要求创建对应数量的子智能体这样可以隔离上下文更好完成工作
- 思考的时候建议用中文思考
- 涉及到时间的都用get_time工具获取避免时间不准确
## 用户附件
用户发过来了一些附件先判断文件后缀名能不能直接读取如果不能直接read的比如xlsx,就要通过代码等其他方式读取里面的内容
## 定时任务
- 默认创建静默任务silent_agent_task在独立后台会话中执行不干扰主对话
- 静默模式下如需发送消息给用户prompt中需显式使用 send_session_message 工具
## Shell 交互终端
- 当 shell 工具返回包含 `__PICOBOT_PENDING_USER_ACTION__``[session_id: xxx]` 的结果时,表示进程正在等待输入
- 阅读已输出的内容,理解提示含义(如确认提示 Y/N、输入密码、选择选项等
- 使用 `session_id``stdin_input` 参数回复交互内容,例如:`{"command": "echo test", "session_id": "xxx", "stdin_input": "Y"}`
- 常见场景:确认提示输入 Y/N、输入密码/验证码、选择选项、Read-Host 等
## todo工具使用规范
- 复杂任务执行前进行todo规划
- 严格按照既定的未完成的todo工作项执行任务如果工作项不在适用就更新不得随意遗漏工作项
- 完成一项工作就标记一项已完成,不建议批量标记已完成,这样用户不能把握任务执行进度
- 禁止将未完成的工作项标记为已完成

View File

@ -1,40 +1,10 @@
use axum::{Json, extract::{Query, State}};
use axum::{Json, extract::State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use super::GatewayState;
use crate::config::{Config, get_default_config_path};
use crate::experts::{Expert, ExpertScope, ExpertWithStatus};
use crate::skills::SkillWithStatus;
use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus};
#[derive(Deserialize)]
pub struct SkillToggleRequest {
pub name: String,
pub scope: String,
pub enabled: bool,
}
#[derive(Serialize)]
pub struct SkillToggleResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct SkillListResponse {
skills_system_enabled: bool,
total: usize,
skills: Vec<SkillWithStatus>,
}
#[derive(Serialize)]
pub struct HealthResponse {
@ -149,16 +119,11 @@ pub async fn save_config(
*cfg = new_config.clone();
}
// 同步更新 ExpertRuntime 的 config让 sources 等变更即时生效(无需重启)
if let Err(e) = state.experts.update_config(new_config.experts.clone()) {
tracing::warn!(error = %e, "Failed to sync experts config after save_config");
}
tracing::info!(path = %config_path.display(), "Config saved via API");
Ok(Json(SaveConfigResponse {
success: true,
message: "配置已保存".to_string(),
message: "配置已保存,需要重启服务才能生效".to_string(),
config_path: config_path.to_string_lossy().to_string(),
}))
}
@ -187,13 +152,7 @@ pub async fn restart(
let restart_tx = state.restart_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if let Err(e) = restart_tx.send(true) {
tracing::warn!(
error = %e,
"Failed to send restart signal; receiver may have already exited. \
HTTP response already returned success, but restart may not occur."
);
}
let _ = restart_tx.send(true);
});
Ok(Json(RestartResponse {
@ -223,539 +182,3 @@ pub async fn mcp_status(
};
Json(status)
}
/// GET /api/skills — Return all discovered skills with their disabled status
pub async fn skills_list(
State(state): State<Arc<GatewayState>>,
) -> Json<SkillListResponse> {
let skills_enabled = state.config.read().await.skills.enabled;
if !skills_enabled {
return Json(SkillListResponse {
skills_system_enabled: false,
total: 0,
skills: vec![],
});
}
let skills = state.skills.list_skills_with_status();
let total = skills.len();
Json(SkillListResponse {
skills_system_enabled: true,
total,
skills,
})
}
/// POST /api/skills/toggle — Enable or disable a specific skill
pub async fn skills_toggle(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SkillToggleRequest>,
) -> (StatusCode, Json<SkillToggleResponse>) {
let scope = match crate::skills::SkillScope::parse(&req.scope) {
Some(s) => s,
None => {
return (
StatusCode::BAD_REQUEST,
Json(SkillToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(format!("invalid scope: {}", req.scope)),
}),
);
}
};
let result = if req.enabled {
state.skills.enable_skill(scope, &req.name, true)
} else {
state.skills.disable_skill(scope, &req.name, true)
};
match result {
Ok(change) => (
StatusCode::OK,
Json(SkillToggleResponse {
success: true,
changed: Some(change.changed),
available: Some(change.available),
disabled_in_scopes: Some(
change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(),
),
error: None,
}),
),
Err(msg) => {
let status = if msg.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(SkillToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(msg),
}),
)
}
}
}
#[derive(Deserialize)]
pub struct SubagentToggleRequest {
pub name: String,
pub scope: String,
pub enabled: bool,
}
#[derive(Serialize)]
pub struct SubagentToggleResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct SubagentListResponse {
subagents_system_enabled: bool,
total: usize,
subagents: Vec<SubagentWithStatus>,
}
/// GET /api/subagents — Return all discovered subagents with their disabled status
pub async fn subagents_list(
State(state): State<Arc<GatewayState>>,
) -> Json<SubagentListResponse> {
let subagents_enabled = state.config.read().await.subagents.enabled;
if !subagents_enabled {
return Json(SubagentListResponse {
subagents_system_enabled: false,
total: 0,
subagents: vec![],
});
}
let subagents = state.subagent_runtime.list_with_status();
let total = subagents.len();
Json(SubagentListResponse {
subagents_system_enabled: true,
total,
subagents,
})
}
/// POST /api/subagents/toggle — Enable or disable a specific subagent
pub async fn subagents_toggle(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SubagentToggleRequest>,
) -> (StatusCode, Json<SubagentToggleResponse>) {
let scope = match SubagentScope::parse(&req.scope) {
Some(s) => s,
None => {
return (
StatusCode::BAD_REQUEST,
Json(SubagentToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(format!("invalid scope: {}", req.scope)),
}),
);
}
};
let result = if req.enabled {
state.subagent_runtime.enable_subagent(scope, &req.name)
} else {
state.subagent_runtime.disable_subagent(scope, &req.name)
};
match result {
Ok(change) => (
StatusCode::OK,
Json(SubagentToggleResponse {
success: true,
changed: Some(change.changed),
available: Some(change.available),
disabled_in_scopes: Some(
change
.disabled_in_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
),
error: None,
}),
),
Err(msg) => {
let status = if msg.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(SubagentToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(msg),
}),
)
}
}
}
// ===================== Experts =====================
#[derive(Deserialize)]
pub struct ExpertToggleRequest {
pub name: String,
pub scope: String,
pub enabled: bool,
}
#[derive(Serialize)]
pub struct ExpertToggleResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertListResponse {
experts_system_enabled: bool,
total: usize,
experts: Vec<ExpertWithStatus>,
}
#[derive(Deserialize)]
pub struct ExpertCreateRequest {
pub name: String,
pub description: String,
pub body: String,
pub scope: String,
}
#[derive(Deserialize)]
pub struct ExpertUpdateRequest {
pub name: String,
pub scope: String,
pub description: Option<String>,
pub body: Option<String>,
}
#[derive(Deserialize)]
pub struct ExpertDeleteRequest {
pub name: String,
pub scope: String,
}
#[derive(Deserialize)]
pub struct ExpertSelectedQuery {
pub session_id: String,
}
#[derive(Serialize)]
pub struct ExpertSelectedResponse {
pub expert_name: Option<String>,
pub expert: Option<ExpertWithStatus>,
}
#[derive(Deserialize)]
pub struct ExpertSelectRequest {
pub session_id: String,
pub expert_name: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertSelectResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertResponse {
pub name: String,
pub description: String,
pub body: String,
pub source: String,
pub path: String,
}
impl From<Expert> for ExpertResponse {
fn from(expert: Expert) -> Self {
Self {
name: expert.name,
description: expert.description,
body: expert.body,
source: expert.source.as_str().to_string(),
path: expert.path.display().to_string(),
}
}
}
#[derive(Serialize)]
pub struct ExpertDeleteResponse {
pub success: bool,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// GET /api/experts — Return all discovered experts with their disabled status
pub async fn experts_list(
State(state): State<Arc<GatewayState>>,
) -> Json<ExpertListResponse> {
let experts_enabled = state.config.read().await.experts.enabled;
if !experts_enabled {
return Json(ExpertListResponse {
experts_system_enabled: false,
total: 0,
experts: vec![],
});
}
let experts = state.experts.list_experts_with_status();
let total = experts.len();
Json(ExpertListResponse {
experts_system_enabled: true,
total,
experts,
})
}
/// POST /api/experts/toggle — Enable or disable a specific expert
pub async fn experts_toggle(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertToggleRequest>,
) -> (StatusCode, Json<ExpertToggleResponse>) {
let scope = match ExpertScope::parse(&req.scope) {
Some(s) => s,
None => {
return (
StatusCode::BAD_REQUEST,
Json(ExpertToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(format!("invalid scope: {}", req.scope)),
}),
);
}
};
let result = if req.enabled {
state.experts.enable_expert(scope, &req.name)
} else {
state.experts.disable_expert(scope, &req.name)
};
match result {
Ok(change) => (
StatusCode::OK,
Json(ExpertToggleResponse {
success: true,
changed: Some(change.changed),
available: Some(change.available),
disabled_in_scopes: Some(
change
.disabled_in_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
),
error: None,
}),
),
Err(msg) => {
let status = if msg.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(ExpertToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(msg),
}),
)
}
}
}
/// POST /api/experts/create — Create a new expert
pub async fn experts_create(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertCreateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope)
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
let expert = state
.experts
.create_expert(scope, &req.name, &req.description, &req.body, true)
.map_err(|err| {
let status = if err.contains("already exists") {
StatusCode::CONFLICT
} else {
StatusCode::BAD_REQUEST
};
(status, err)
})?;
Ok(Json(ExpertResponse::from(expert)))
}
/// PUT /api/experts/update — Update an existing expert
pub async fn experts_update(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertUpdateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope)
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
let expert = state
.experts
.update_expert(
scope,
&req.name,
req.description.as_deref(),
req.body.as_deref(),
true,
)
.map_err(|err| {
let status = if err.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_REQUEST
};
(status, err)
})?;
Ok(Json(ExpertResponse::from(expert)))
}
/// DELETE /api/experts/delete?name=&scope= — Delete an expert
pub async fn experts_delete(
State(state): State<Arc<GatewayState>>,
Query(req): Query<ExpertDeleteRequest>,
) -> Result<Json<ExpertDeleteResponse>, (StatusCode, Json<ExpertDeleteResponse>)> {
let scope = match ExpertScope::parse(&req.scope) {
Some(s) => s,
None => {
return Err((
StatusCode::BAD_REQUEST,
Json(ExpertDeleteResponse {
success: false,
path: String::new(),
error: Some(format!("invalid scope: {}", req.scope)),
}),
));
}
};
match state.experts.delete_expert(scope, &req.name, true) {
Ok(path) => Ok(Json(ExpertDeleteResponse {
success: true,
path: path.display().to_string(),
error: None,
})),
Err(msg) => {
let status = if msg.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Err((
status,
Json(ExpertDeleteResponse {
success: false,
path: String::new(),
error: Some(msg),
}),
))
}
}
}
/// GET /api/experts/selected?session_id=... — Return the currently selected expert for a session
pub async fn experts_selected(
State(state): State<Arc<GatewayState>>,
Query(q): Query<ExpertSelectedQuery>,
) -> Json<ExpertSelectedResponse> {
let expert_name = state.experts.selected_expert_name_for(&q.session_id);
let expert = expert_name.as_ref().and_then(|name| {
// Build an ExpertWithStatus from the discovered catalog.
state
.experts
.list_experts_with_status()
.into_iter()
.find(|e| &e.name == name)
});
Json(ExpertSelectedResponse {
expert_name,
expert,
})
}
/// POST /api/experts/select — Select (or clear) the expert for a session
pub async fn experts_select(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertSelectRequest>,
) -> (StatusCode, Json<ExpertSelectResponse>) {
let result = match req.expert_name {
Some(name) => state.experts.select_expert(&req.session_id, &name),
None => state.experts.clear_expert(&req.session_id),
};
match result {
Ok(()) => (
StatusCode::OK,
Json(ExpertSelectResponse {
success: true,
error: None,
}),
),
Err(msg) => (
StatusCode::BAD_REQUEST,
Json(ExpertSelectResponse {
success: false,
error: Some(msg),
}),
),
}
}

View File

@ -270,6 +270,51 @@ impl MemoryMaintenanceService {
)))
}
#[allow(dead_code)]
pub(crate) async fn run_for_scope(
&self,
scope_key: &str,
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
let Some(plan) = self.build_plan_for_scope(scope_key)? else {
return Ok(None);
};
// 步骤1整理记忆不生成摘要
let organize_output = self.organize_plan(scope_key, &plan).await?;
// 应用整理结果merge和delete
apply_memory_maintenance_output(
self.store.as_ref(),
scope_key,
&plan,
&organize_output,
self.maintenance_config.max_merge_ratio,
self.maintenance_config.min_memories_to_keep,
self.maintenance_config.max_merge_per_group,
)?;
// 步骤2从数据库重新读取剩余的记忆
let remaining_memories = self
.store
.list_memories_for_scope("user", scope_key)
.map_err(|err| {
AgentError::Other(format!("list remaining memories error: {}", err))
})?;
// 步骤2生成摘要
let managed_markdown = if remaining_memories.is_empty() {
String::new()
} else {
self.generate_summary(scope_key, &remaining_memories).await?
};
Ok(Some(MemoryMaintenanceScopeResult {
scope_key: scope_key.to_string(),
output: organize_output,
managed_markdown,
}))
}
async fn generate_summary(
&self,
scope_key: &str,

View File

@ -25,7 +25,7 @@ pub mod session_message_service;
pub mod session_pool;
pub mod static_files;
pub mod tool_registry_factory;
pub mod tool_prompt_provider;
pub mod todo_prompt_provider;
pub mod ws;
use axum::{Router, routing};
@ -43,7 +43,6 @@ use crate::logging;
use crate::scheduler::Scheduler;
use crate::skills::SkillRuntime;
use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
use agent_task_executor::{AgentTaskExecutor, SchedulerMaintenanceService};
use cancel_manager::CancelManager;
use outbound_dispatcher::OutboundDispatcher;
@ -64,9 +63,6 @@ pub struct GatewayState {
pub cancel_manager: CancelManager,
pub restart_tx: watch::Sender<bool>,
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
pub skills: Arc<SkillRuntime>,
pub experts: Arc<crate::experts::ExpertRuntime>,
pub subagent_runtime: Arc<SubagentRuntime>,
}
impl GatewayState {
@ -84,7 +80,6 @@ impl GatewayState {
let session_ttl_hours = config.gateway.session_ttl_hours;
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(config.experts.clone()));
let channel_manager = ChannelManager::new();
let bus = channel_manager.bus();
@ -92,14 +87,13 @@ impl GatewayState {
mcp_servers: config.mcp_servers.clone(),
};
let (session_manager, task_repository, mcp_manager, subagent_runtime) = build_session_manager_with_sender(
let (session_manager, task_repository, mcp_manager) = build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
config.time.timezone.clone(),
provider_config,
provider_configs,
skills.clone(),
experts.clone(),
skills,
Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(),
config.tools.task.clone(),
@ -127,9 +121,6 @@ impl GatewayState {
cancel_manager,
restart_tx,
mcp_manager,
skills,
experts,
subagent_runtime,
})
}
@ -239,17 +230,6 @@ pub async fn run(
.route("/api/config", routing::get(http::get_config).put(http::save_config))
.route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
.route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected))
.route("/api/experts/select", routing::post(http::experts_select))
.route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler)
.with_state(state.clone())
@ -260,17 +240,6 @@ pub async fn run(
.route("/api/config", routing::get(http::get_config).put(http::save_config))
.route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
.route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected))
.route("/api/experts/select", routing::post(http::experts_select))
.route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone())

View File

@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore;
use crate::agent::{AgentError, PersistingEmittedMessageHandler};
use crate::agent::{AgentError, CompositeSystemPromptProvider, PersistingEmittedMessageHandler};
use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
use crate::command::adapter::InputAdapter;
use crate::command::adapters::channel::ChannelInputAdapter;
@ -19,9 +19,10 @@ use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::config::LLMProviderConfig;
use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::cancel_manager::CancelManager;
use crate::providers::{create_provider, ProviderRuntimeConfig};
use crate::skills::SkillPromptProvider;
use crate::storage::persistent_session_id;
use crate::topic_description::generate_topic_description;
@ -64,20 +65,21 @@ impl InboundProcessor {
command_router.register(Box::new(switch_handler));
// 创建 system_prompt_provider用于 save_session, save_topic, get_current
// 与 AgentFactory::create 共享同一构建逻辑,确保保存到文件的系统提示词
// 与 LLM 实际接收的提示词完全一致(含 Expert/Subagent/Todo
let system_prompt_provider = build_system_prompt_provider(
0, // 命令侧不需要 reinject 逻辑
provider_config.clone(),
session_manager.store().clone(),
session_manager.skills(),
session_manager.experts(),
session_manager.subagent_runtime(),
);
let skills = session_manager.skills();
let prompt_repository = session_manager.store().clone();
let system_prompt_provider: Arc<dyn crate::agent::SystemPromptProvider> = Arc::new(CompositeSystemPromptProvider::new(vec![
Box::new(AgentPromptProvider::new(
0, // 不需要 reinject 逻辑
provider_config.clone(),
prompt_repository,
)),
Box::new(SkillPromptProvider::new(skills)),
]));
// 注册 get_current 处理器
command_router.register(Box::new(
GetCurrentSessionCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone())
.with_system_prompt_provider(system_prompt_provider.clone())
));
@ -96,7 +98,7 @@ impl InboundProcessor {
store.clone(),
session_manager.task_repository(),
system_prompt_provider,
)));
).with_session_manager(session_manager.clone())));
// 注册 delete_topic 处理器
command_router.register(Box::new(

View File

@ -1,7 +1,6 @@
//! Gateway Runtime - builds SessionManager with decoupled MCP integration
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
@ -17,7 +16,6 @@ use crate::storage::{
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
SessionStore, SkillEventRepository, TodoRepository,
};
use crate::tools::task::runtime::SubagentRuntime;
use crate::tools::{
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
@ -45,7 +43,6 @@ pub(crate) fn build_session_manager(
provider_config: LLMProviderConfig,
provider_configs: HashMap<String, LLMProviderConfig>,
skills: Arc<SkillRuntime>,
experts: Arc<crate::experts::ExpertRuntime>,
disabled_tools: HashSet<String>,
task_config: TaskConfig,
subagents_config: SubagentsConfig,
@ -53,7 +50,7 @@ pub(crate) fn build_session_manager(
session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>), AgentError> {
build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
@ -61,7 +58,6 @@ pub(crate) fn build_session_manager(
provider_config,
provider_configs,
skills,
experts,
Arc::new(NoopSessionMessageSender),
disabled_tools,
task_config,
@ -81,7 +77,6 @@ pub(crate) fn build_session_manager_with_sender(
provider_config: LLMProviderConfig,
provider_configs: HashMap<String, LLMProviderConfig>,
skills: Arc<SkillRuntime>,
experts: Arc<crate::experts::ExpertRuntime>,
session_message_sender: Arc<dyn SessionMessageSender>,
disabled_tools: HashSet<String>,
task_config: TaskConfig,
@ -90,7 +85,7 @@ pub(crate) fn build_session_manager_with_sender(
session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>), AgentError> {
let store = Arc::new(
SessionStore::new()
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
@ -177,7 +172,7 @@ pub(crate) fn build_session_manager_with_sender(
}
// Create SubAgentRuntime (if task tool is enabled)
let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = if task_config.enabled {
let (factory, task_repository): (_, Arc<dyn TaskRepository>) = if task_config.enabled {
let task_repository = Arc::new(InMemoryTaskRepository::new());
// Build subagent tools with MCP tools (task tool registered separately below)
let subagent_tools = Arc::new(
@ -190,13 +185,8 @@ pub(crate) fn build_session_manager_with_sender(
)
);
// Create subagent catalog with discovery, wrap in SubagentRuntime
// Create subagent catalog with discovery
let catalog = Arc::new(SubagentCatalog::discover(&subagents_config));
let subagent_runtime = Arc::new(SubagentRuntime::new(
subagents_config.clone(),
catalog,
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
));
let runtime_config = SubAgentRuntimeConfig {
default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(),
@ -207,13 +197,13 @@ pub(crate) fn build_session_manager_with_sender(
max_nesting_depth: task_config.max_nesting_depth,
};
let default_subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
let subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
runtime_config,
task_repository.clone(),
conversations.clone(),
subagent_tools.clone(),
provider_config.clone(),
subagent_runtime.clone(),
catalog,
bus.clone(),
store.clone(),
));
@ -221,16 +211,14 @@ pub(crate) fn build_session_manager_with_sender(
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)
if factory.is_enabled("task") {
subagent_tools.register(TaskTool::new(
default_subagent_runtime.clone(),
subagent_runtime.clone(),
Some(task_config.max_nesting_depth),
));
}
(factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime)
(factory.with_subagent_runtime(subagent_runtime), task_repository)
} else {
// task_config 未启用时仍创建 subagent_runtime供 API 使用)
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
(factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime)
(factory, Arc::new(InMemoryTaskRepository::new()))
};
// Build base tools
@ -272,8 +260,6 @@ pub(crate) fn build_session_manager_with_sender(
let agent_factory = AgentFactory::new(
tools.clone(),
skills.clone(),
experts.clone(),
subagent_runtime.clone(),
agent_prompt_reinject_every as usize,
prompt_repository.clone(),
);
@ -302,8 +288,6 @@ pub(crate) fn build_session_manager_with_sender(
Ok((SessionManager::from_services(SessionManagerServices {
tools: tools as Arc<ToolRegistry>,
skills,
experts,
subagent_runtime: subagent_runtime.clone(),
store,
show_tool_results,
lifecycle,
@ -312,5 +296,5 @@ pub(crate) fn build_session_manager_with_sender(
scheduled_tasks,
memory_maintenance,
task_repository: task_repository.clone(),
}), task_repository, mcp_manager, subagent_runtime))
}), task_repository, mcp_manager))
}

View File

@ -10,7 +10,6 @@ use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository};
use crate::tools::ToolRegistry;
use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
@ -250,19 +249,13 @@ impl Session {
skills: Arc<SkillRuntime>,
store: Arc<SessionStore>,
agent_prompt_reinject_every: u64,
subagent_runtime: Arc<SubagentRuntime>,
) -> Result<Self, AgentError> {
let conversations: Arc<dyn ConversationRepository> = store.clone();
let skill_events: Arc<dyn SkillEventRepository> = store.clone();
let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone();
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
crate::config::ExpertsConfig::default(),
));
let agent_factory = AgentFactory::new(
tools,
skills.clone(),
experts,
subagent_runtime,
agent_prompt_reinject_every as usize,
prompt_repository.clone(),
);
@ -533,6 +526,16 @@ impl Session {
&self.compressor
}
#[allow(dead_code)]
pub(crate) fn try_start_background_compaction(&mut self, chat_id: &str) -> bool {
self.history.try_start_background_compaction(chat_id)
}
#[allow(dead_code)]
pub(crate) fn finish_background_compaction(&mut self, chat_id: &str) {
self.history.finish_background_compaction(chat_id);
}
pub(crate) fn reload_chat_history(&mut self, chat_id: &str) -> Result<(), AgentError> {
// 如果当前有 topic加载该 topic 的消息(按 session_id 过滤,排除子智能体消息)
if let Some(topic_id) = self.history.chat_topic(chat_id) {
@ -611,8 +614,6 @@ impl Session {
pub struct SessionManager {
tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>,
experts: Arc<crate::experts::ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
store: Arc<SessionStore>,
show_tool_results: bool,
lifecycle: SessionLifecycleService,
@ -626,8 +627,6 @@ pub struct SessionManager {
pub(crate) struct SessionManagerServices {
pub(crate) tools: Arc<ToolRegistry>,
pub(crate) skills: Arc<SkillRuntime>,
pub(crate) experts: Arc<crate::experts::ExpertRuntime>,
pub(crate) subagent_runtime: Arc<SubagentRuntime>,
pub(crate) store: Arc<SessionStore>,
pub(crate) show_tool_results: bool,
pub(crate) lifecycle: SessionLifecycleService,
@ -643,8 +642,6 @@ impl SessionManager {
Self {
tools: services.tools,
skills: services.skills,
experts: services.experts,
subagent_runtime: services.subagent_runtime,
store: services.store,
show_tool_results: services.show_tool_results,
lifecycle: services.lifecycle,
@ -670,9 +667,6 @@ impl SessionManager {
session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig,
) -> Result<Self, AgentError> {
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
crate::config::ExpertsConfig::default(),
));
super::runtime::build_session_manager(
agent_prompt_reinject_every,
show_tool_results,
@ -680,7 +674,6 @@ impl SessionManager {
provider_config,
provider_configs,
skills,
experts,
disabled_tools,
task_config,
subagents_config,
@ -689,7 +682,7 @@ impl SessionManager {
mcp_config,
None,
)
.map(|(session_manager, _, _, _)| session_manager)
.map(|(session_manager, _, _)| session_manager)
}
pub fn tools(&self) -> Arc<ToolRegistry> {
@ -712,16 +705,6 @@ impl SessionManager {
self.skills.clone()
}
/// 获取专家运行时实例(与 AgentFactory、HTTP API 共享同一 Arc 实例)
pub fn experts(&self) -> Arc<crate::experts::ExpertRuntime> {
self.experts.clone()
}
/// 获取子代理运行时实例(与 AgentFactory 共享同一 Arc 实例)
pub fn subagent_runtime(&self) -> Arc<SubagentRuntime> {
self.subagent_runtime.clone()
}
pub(crate) fn cli_sessions(&self) -> CliSessionService {
self.cli_sessions.clone()
}
@ -955,7 +938,6 @@ mod tests {
skills,
store,
100,
Arc::new(SubagentRuntime::from_config(Default::default())),
)
.await
.unwrap();
@ -1004,7 +986,6 @@ mod tests {
skills,
store.clone(),
100,
Arc::new(SubagentRuntime::from_config(Default::default())),
)
.await
.unwrap();
@ -2031,7 +2012,6 @@ mod tests {
skills,
store.clone(),
100,
Arc::new(SubagentRuntime::from_config(Default::default())),
)
.await
.unwrap();
@ -2072,7 +2052,6 @@ mod tests {
skills,
store.clone(),
100,
Arc::new(SubagentRuntime::from_config(Default::default())),
)
.await
.unwrap();
@ -2146,7 +2125,6 @@ mod tests {
skills,
store.clone(),
0,
Arc::new(SubagentRuntime::from_config(Default::default())),
)
.await
.unwrap();

View File

@ -259,6 +259,16 @@ impl SessionHistory {
Ok(())
}
#[allow(dead_code)]
pub(crate) fn try_start_background_compaction(&mut self, chat_id: &str) -> bool {
self.compression_in_flight.insert(chat_id.to_string())
}
#[allow(dead_code)]
pub(crate) fn finish_background_compaction(&mut self, chat_id: &str) {
self.compression_in_flight.remove(chat_id);
}
pub(crate) fn reload_chat_history(&mut self, chat_id: &str) -> Result<(), AgentError> {
let history = self
.conversations

View File

@ -0,0 +1,84 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
pub struct TodoPromptProvider;
impl TodoPromptProvider {
pub fn new() -> Self {
Self
}
}
impl SystemPromptProvider for TodoPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
Some(SystemPrompt {
content: TODO_WRITE_INSTRUCTIONS.to_string(),
context: Some("todo_write".to_string()),
})
}
}
const TODO_WRITE_INSTRUCTIONS: &str = r#"
## TodoWrite
使 `todo_write`
### 使
- 3 使 todo_write
- todo
### merge
- `merge: true` **使**
- `merge: false` todo
###
- `pending`
- `in_progress`
- `completed`
- `cancelled`
###
1. `in_progress`
2. `in_progress`
3. `completed` `cancelled` `in_progress` `pending`
4. `in_progress` 退 `pending` `completed` `cancelled`
5. completed
6. `content`
7. ** `id`** id `"r9Tg8Kq2"`使 idid todo_write `current_todos`
### 使
id
```json
{"merge": true, "todos": [{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "in_progress"}]}
```
```json
{"merge": true, "todos": [{"id": "pQ7nWy2z", "content": "补充测试", "status": "pending"}]}
```
使 id
```json
{"merge": true, "todos": [{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "completed"}]}
```
```json
{"merge": true, "todos": [
{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "completed"},
{"id": "pQ7nWy2z", "content": "补充测试", "status": "in_progress"}
]}
```
###
使 `todo_read`
```json
{}
```
`todo_read`
-
-
-
"#;

View File

@ -1,198 +0,0 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
/// 工具使用说明 Provider
///
/// 统一收拢所有工具的使用说明memory/skill/todo/shell/scheduler
/// 与 AgentPromptProvider代理身份与行为准则职责分离。
///
/// 设计决策:为什么不合并进 AgentPrompt
/// - AgentPrompt 专注于"你是谁、怎么工作"(身份/原则/回复风格)
/// - ToolPrompt 专注于"工具怎么用"(具体工具的调用流程/参数/规则)
/// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置
pub struct ToolPromptProvider;
impl ToolPromptProvider {
pub fn new() -> Self {
Self
}
}
impl SystemPromptProvider for ToolPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
Some(SystemPrompt {
content: format!(
"{}\n\n{}\n\n{}\n\n{}\n\n{}",
MEMORY_TOOLS_INSTRUCTIONS,
SKILL_TOOLS_INSTRUCTIONS,
TODO_WRITE_INSTRUCTIONS,
SHELL_TOOLS_INSTRUCTIONS,
SCHEDULER_TOOLS_INSTRUCTIONS,
),
context: Some("tools".to_string()),
})
}
}
/// memory_search / memory_manage 工具使用说明
const MEMORY_TOOLS_INSTRUCTIONS: &str = r#"# 记忆工具
##
使 memory_search
###
- 使 memory_search memory_search(action='search')
- namespace key get
- list
- 使
###
-
-
###
- queries 10-12
-
-
- queries=['email', '', 'folder',"preference"]
##
###
使
- `user` -
- `semantic` -
- `episodic` -
- `skill` -
- `environment` -
- `reflection` -
- `other` -
###
- 使 memory_manage
-
###
-
-
- //
- /
-
- xxx的消息
-
###
- 使 memory_manage "#;
/// skill_activate / skill_manage 工具使用说明
const SKILL_TOOLS_INSTRUCTIONS: &str = r#"# 技能工具
##
- : `{project-root}/.picobot/skills/{skill-name}/SKILL.md`
- : `~/.picobot/skills/{skill-name}/SKILL.md`
## /
- 使 `skill_manage` `create` `update` action
- 使 `write`
- `skill_manage`
## 使
-
- `skill_activate`
-
## 使
使
-
-
-
## 使
1. ****: <available_skills>
2. ****:
3. ****: `skill_activate` name
4. ****: skill_activate "#;
/// todo_write / todo_read 工具使用说明
const TODO_WRITE_INSTRUCTIONS: &str = r#"# TodoWrite 工具
使 `todo_write`
## 使
- 3 使 todo_write
- todo
- todo
- todo
-
-
## merge
- `merge: true` **使**
- `merge: false` todo
##
- `pending`
- `in_progress`
- `completed`
- `cancelled`
##
1. `in_progress`
2. `in_progress`
3. `completed` `cancelled` `in_progress` `pending`
4. `in_progress` 退 `pending` `completed` `cancelled`
5. completed
6. `content`
7. ** `id`** id `"r9Tg8Kq2"`使 idid todo_write `current_todos`
## 使
id
```json
{"merge": true, "todos": [{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "in_progress"}]}
```
```json
{"merge": true, "todos": [{"id": "pQ7nWy2z", "content": "补充测试", "status": "pending"}]}
```
使 id
```json
{"merge": true, "todos": [{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "completed"}]}
```
```json
{"merge": true, "todos": [
{"id": "aB3kLm9x", "content": "修复登录 bug", "status": "completed"},
{"id": "pQ7nWy2z", "content": "补充测试", "status": "in_progress"}
]}
```
##
使 `todo_read`
```json
{}
```
`todo_read`
-
-
- "#;
/// shell / bash 工具使用说明
const SHELL_TOOLS_INSTRUCTIONS: &str = r#"# Shell 交互终端
- shell `__PICOBOT_PENDING_USER_ACTION__` `[session_id: xxx]`
- Y/N
- 使 `session_id` `stdin_input` `{"command": "echo test", "session_id": "xxx", "stdin_input": "Y"}`
- Y/N/Read-Host "#;
/// silent_agent_task 工具使用说明
const SCHEDULER_TOOLS_INSTRUCTIONS: &str = r#"# 定时任务
- silent_agent_task
- prompt中需显式使用 send_session_message "#;

View File

@ -92,6 +92,12 @@ impl ToolRegistryFactory {
!self.disabled_tools.contains(tool_name)
}
/// Get a reference to the shell session manager for lifecycle control.
#[allow(dead_code)]
pub(crate) fn shell_session_manager(&self) -> Arc<ShellSessionManager> {
self.shell_session_manager.clone()
}
pub(crate) fn build(&self) -> ToolRegistry {
let registry = ToolRegistry::new();
@ -118,7 +124,7 @@ impl ToolRegistryFactory {
}
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
registry.register(TodoWriteTool::new(state.clone()));
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
}
}
@ -226,7 +232,7 @@ impl ToolRegistryFactory {
// Todo 追踪工具
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
registry.register(TodoWriteTool::new(state.clone()));
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
}
}

View File

@ -1,5 +1,5 @@
use super::GatewayState;
use crate::agent::AgentError;
use crate::agent::{AgentError, CompositeSystemPromptProvider};
use crate::bus::{InboundMessage, MediaItem};
use crate::command::adapter::{InputAdapter, OutputAdapter};
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
@ -25,8 +25,9 @@ use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::protocol::{WsInbound, WsOutbound, MediaSummary, parse_inbound, serialize_outbound};
use crate::skills::SkillPromptProvider;
use crate::storage::persistent_session_id;
use crate::tools::task::repository::TaskRepository;
use crate::tools::task::types::TaskSessionState;
@ -266,7 +267,6 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
timestamp: Some(crate::protocol::now_timestamp()),
code:"SESSION_ERROR".to_string(),
message: e.to_string(),
subagent_task_id: None,
})
.await;
}
@ -278,7 +278,6 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
timestamp: Some(crate::protocol::now_timestamp()),
code:"PARSE_ERROR".to_string(),
message: e.to_string(),
subagent_task_id: None,
})
.await;
}
@ -371,7 +370,6 @@ async fn handle_inbound(
timestamp: Some(crate::protocol::now_timestamp()),
code: "INVALID_COMMAND".to_string(),
message: "Invalid command payload".to_string(),
subagent_task_id: None,
})
.await;
return Ok(());
@ -382,7 +380,6 @@ async fn handle_inbound(
timestamp: Some(crate::protocol::now_timestamp()),
code: "PARSE_ERROR".to_string(),
message: e.to_string(),
subagent_task_id: None,
})
.await;
return Ok(());
@ -390,6 +387,7 @@ async fn handle_inbound(
};
// 创建命令路由器
let _cli_sessions = state.session_manager.cli_sessions();
let store = state.session_manager.store();
let skills = state.session_manager.skills();
let skills_for_handler = skills.clone();
@ -397,16 +395,14 @@ async fn handle_inbound(
.map_err(|e| AgentError::Other(e.to_string()))?;
let prompt_repository = state.session_manager.store().clone();
// 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、
// /current 保存/展示的系统提示词与 LLM 实际接收的完全一致
let system_prompt_provider = build_system_prompt_provider(
0, // 命令侧不需要 reinject 逻辑
provider_config.clone(),
prompt_repository,
skills,
state.session_manager.experts(),
state.session_manager.subagent_runtime(),
);
let system_prompt_provider: Arc<dyn crate::agent::SystemPromptProvider> = Arc::new(CompositeSystemPromptProvider::new(vec![
Box::new(AgentPromptProvider::new(
0,
provider_config.clone(),
prompt_repository.clone(),
)),
Box::new(SkillPromptProvider::new(skills)),
]));
let mut router = CommandRouter::new();
// 注册 Session 处理器
@ -426,10 +422,7 @@ async fn handle_inbound(
.with_session_manager(state.session_manager.clone());
router.register(Box::new(switch_handler));
// 注册 get_current 处理器
router.register(Box::new(
GetCurrentSessionCommandHandler::new(store.clone())
.with_system_prompt_provider(system_prompt_provider.clone()),
));
router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone())));
// 注册 load_topic 处理器
router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
// 注册 load_task_messages 处理器
@ -447,7 +440,7 @@ async fn handle_inbound(
store.clone(),
state.task_repository.clone(),
system_prompt_provider.clone(),
)));
).with_session_manager(state.session_manager.clone())));
// 注册 delete_topic 处理器
router.register(Box::new(
DeleteTopicCommandHandler::new(store.clone())
@ -803,12 +796,6 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
}
| WsOutbound::ToolPending {
subagent_task_id, ..
}
| WsOutbound::StreamDelta {
subagent_task_id, ..
}
| WsOutbound::StreamEnd {
subagent_task_id, ..
} => {
*subagent_task_id = Some(task_id.to_string());
}

View File

@ -7,7 +7,6 @@ pub mod client;
pub mod command;
pub mod config;
pub mod domain;
pub mod experts;
pub mod gateway;
pub mod logging;
pub mod mcp;

View File

@ -252,8 +252,8 @@ impl McpClientManager {
}
};
// Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+)
let info = client.peer_info().map(|arc| arc.as_ref().clone());
// Get server info (returns Option<&ServerInfo>)
let info = client.peer_info().cloned();
// List available tools
let tools = client.list_all_tools().await?;
@ -364,25 +364,11 @@ impl McpClientManager {
let reader = BufReader::new(child_stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
// Escalate real warnings/errors; demote normal diagnostics to debug
let lower = line.to_lowercase();
let is_warning = lower.contains("error")
|| lower.contains("warn")
|| lower.contains("panic")
|| lower.contains("fatal");
if is_warning {
tracing::warn!(
server_key = %server_key_owned,
stderr = %line,
"MCP child process stderr"
);
} else {
tracing::debug!(
server_key = %server_key_owned,
stderr = %line,
"MCP child process stderr"
);
}
tracing::warn!(
server_key = %server_key_owned,
stderr = %line,
"MCP child process stderr"
);
// Also collect into the shared buffer (cap at 50 lines)
if let Ok(mut buf) = stderr_lines_for_task.lock() {
if buf.len() < 50 {

View File

@ -209,8 +209,6 @@ pub enum WsOutbound {
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_task_id: Option<String>,
},
#[serde(rename = "task_started")]
TaskStarted {
@ -303,8 +301,6 @@ pub enum WsOutbound {
topic_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_task_id: Option<String>,
},
#[serde(rename = "todo_list")]
TodoList {

View File

@ -172,7 +172,6 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
code: "AGENT_ERROR".to_string(),
message: message.content.clone(),
timestamp: Some(crate::protocol::now_timestamp()),
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
}],
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
@ -198,7 +197,6 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
OutboundEventKind::ExecutionCompleted => vec![WsOutbound::ExecutionCompleted {
topic_id: message.metadata.get("topic_id").cloned(),
timestamp: Some(crate::protocol::now_timestamp()),
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
}],
}
}

View File

@ -159,9 +159,6 @@ impl AnthropicProvider {
.build()
.unwrap_or_else(|_| Client::new());
// 兼容带末尾斜杠的 base_url避免 format!("{}/v1/messages", base_url) 产生双斜杠
let base_url = base_url.trim_end_matches('/').to_string();
Self {
client,
name,

View File

@ -263,10 +263,6 @@ impl OpenAIProvider {
.build()
.unwrap_or_else(|_| Client::new());
// 兼容带末尾斜杠的 base_url如 https://opencode.ai/zen/go/v1/
// 否则 format!("{}/chat/completions", base_url) 会产生双斜杠导致 404
let base_url = base_url.trim_end_matches('/').to_string();
Self {
client,
name,

View File

@ -25,17 +25,6 @@ pub struct Skill {
pub path: PathBuf,
}
/// A skill entry with its disabled status across scopes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillWithStatus {
pub name: String,
pub description: String,
pub source: String,
pub path: String,
/// Which scopes have this skill disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSource {
User,
@ -174,24 +163,6 @@ impl SkillRuntime {
.clone()
}
/// List all discovered skills including disabled ones, with their disabled scopes.
pub fn list_skills_with_status(&self) -> Vec<SkillWithStatus> {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
let disable_state = load_skill_disable_state(&cwd);
catalog.skills.iter().map(|skill| {
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
SkillWithStatus {
name: skill.name.clone(),
description: skill.description.clone(),
source: skill.source.as_str().to_string(),
path: skill.path.display().to_string(),
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
}
}).collect()
}
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog
.read()
@ -466,9 +437,25 @@ impl SkillCatalog {
return None;
}
// 仅输出技能索引列表。
// skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。
let mut prompt = String::from("# 可用技能Skills\n\n<available_skills>\n");
let mut prompt = String::from(
"# 技能系统Skills\n\n\
使\n\n\
## 使\n\n\
使\n\
- \n\
- \n\
- \n\n\
## 使\n\n\
1. ****: <available_skills> \n\
2. ****: \n\
3. ****: `skill_activate` name \n\
4. ****: skill_activate \n\n\
## \n\n\
- \n\
- skill_activate \n\
- \n\n\
<available_skills>\n",
);
for skill in &self.skills {
let entry = format!(
@ -1062,7 +1049,7 @@ mod tests {
let prompt = catalog.system_index_prompt().unwrap();
assert!(prompt.contains("<available_skills>"));
assert!(prompt.contains("# 可用技能"));
assert!(prompt.contains("技能是预定义的工作流和指令集合,用于处理特定类型的任务。"));
assert!(prompt.contains("<name>demo-skill</name>"));
assert!(prompt.contains("<description>demo &lt;skill&gt; &amp; usage</description>"));
@ -1448,65 +1435,4 @@ mod tests {
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
assert_eq!(payload["source"], "user_openclaw");
}
#[test]
fn test_list_skills_with_status_includes_disabled() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let skill_dir = project_dir.join(".picobot").join("skills").join("demo");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: A demo skill\n---\nBody here",
)
.unwrap();
save_skill_state_file(
&project_dir.join(".picobot").join("skill-state.json"),
&SkillStateFile {
disabled_skills: vec!["demo".to_string()],
},
)
.unwrap();
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
// list_skills_with_status should include the disabled skill with its disabled scope
let skills = runtime.list_skills_with_status();
assert_eq!(
skills.len(),
1,
"list_skills_with_status should include disabled skills"
);
assert_eq!(skills[0].name, "demo");
assert_eq!(skills[0].description, "A demo skill");
assert_eq!(skills[0].source, "project");
assert_eq!(skills[0].disabled_in_scopes, vec!["project".to_string()]);
// list_skills (normal) should filter out disabled skills
let active = runtime.list_skills();
assert_eq!(
active.len(),
0,
"list_skills should filter out disabled skills"
);
// After enabling, list_skills_with_status should report no disabled scopes
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
let skills_after = runtime.list_skills_with_status();
assert_eq!(skills_after.len(), 1);
assert!(skills_after[0].disabled_in_scopes.is_empty());
}
}

View File

@ -1,270 +0,0 @@
//! 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

View File

@ -1,220 +0,0 @@
//! 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)?,
})
}

View File

@ -1,667 +0,0 @@
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,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
@ -279,63 +279,6 @@ impl SubAgentEmitter {
}
}
/// 构建子智能体事件的基础 metadata与 SubAgentEmitter 注入的字段保持一致。
fn build_subagent_event_metadata(session: &TaskSession) -> HashMap<String, String> {
let mut metadata = HashMap::new();
metadata.insert("subagent_task_id".to_string(), session.id.clone());
metadata.insert("is_subagent_event".to_string(), "true".to_string());
metadata.insert(
"topic_id".to_string(),
session.parent_topic_id.clone().unwrap_or_default(),
);
metadata
}
/// 发布子智能体执行完成事件ExecutionCompletedmetadata 含 subagent_task_id。
async fn publish_subagent_completion(
bus: &Option<Arc<MessageBus>>,
session: &TaskSession,
) {
if let Some(bus) = bus {
let metadata = build_subagent_event_metadata(session);
if let Err(e) = bus
.publish_outbound(OutboundMessage::execution_completed(
session.parent_channel_name.clone(),
session.parent_chat_id.clone(),
Some(session.parent_session_id.clone()),
metadata,
))
.await
{
tracing::warn!(error = %e, task_id = %session.id, "Failed to publish subagent execution_completed");
}
}
}
/// 发布子智能体执行错误事件ErrorNotificationmetadata 含 subagent_task_id。
async fn publish_subagent_error(
bus: &Option<Arc<MessageBus>>,
session: &TaskSession,
error_msg: &str,
) {
if let Some(bus) = bus {
let metadata = build_subagent_event_metadata(session);
if let Err(e) = bus
.publish_outbound(OutboundMessage::error_notification(
session.parent_channel_name.clone(),
session.parent_chat_id.clone(),
Some(session.parent_session_id.clone()),
error_msg.to_string(),
None,
metadata,
))
.await
{
tracing::warn!(error = %e, task_id = %session.id, "Failed to publish subagent error notification");
}
}
}
impl SystemPromptProvider for StaticSystemPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
Some(SystemPrompt {
@ -352,8 +295,8 @@ pub struct DefaultSubAgentRuntime {
conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig,
/// 子代理运行时协调层(管理禁用状态
subagent_runtime: Arc<SubagentRuntime>,
/// 子代理定义目录(内置 + 自定义
catalog: Arc<SubagentCatalog>,
bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>,
}
@ -365,7 +308,7 @@ impl DefaultSubAgentRuntime {
conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig,
subagent_runtime: Arc<SubagentRuntime>,
catalog: Arc<SubagentCatalog>,
bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>,
) -> Self {
@ -375,17 +318,27 @@ impl DefaultSubAgentRuntime {
conversation_repository,
subagent_tools,
provider_config,
subagent_runtime,
catalog,
bus,
store,
}
}
/// 查找子代理定义(过滤禁用项),找不到或被禁用时返回 Err
fn find_subagent_def(&self, type_name: &str) -> Result<SubagentDef, String> {
self.subagent_runtime
.find_available(type_name)
.ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name))
/// 查找子代理定义,找不到时 fallback 到 general
fn find_subagent_def(&self, type_name: &str) -> SubagentDef {
self.catalog
.find(type_name)
.cloned()
.unwrap_or_else(|| self.catalog.find("general").expect("general subagent must exist").clone())
}
/// 获取实际使用的工具白名单(预留,未来可用于动态工具过滤)
#[allow(dead_code)]
fn effective_allowed_tools(&self, def: &SubagentDef) -> HashSet<String> {
def.allowed_tools
.as_ref()
.map(|tools| tools.iter().cloned().collect())
.unwrap_or_else(|| self.config.default_allowed_tools.clone())
}
/// 获取实际执行时间
@ -577,9 +530,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
.ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?;
// 2. 查找子代理定义
let def = self
.find_subagent_def(task.subagent_type.as_str())
.map_err(TaskError::InvalidArguments)?;
let def = self.find_subagent_def(task.subagent_type.as_str());
// 3. 创建任务会话
let topic_id = parent_context.topic_id.clone();
@ -681,8 +632,6 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
"Task completed, updating session"
);
self.task_repository.save_task_session(&session).await?;
// 发布子智能体 ExecutionCompletedmetadata 注入 subagent_task_id 供前端路由到对应子智能体层
publish_subagent_completion(&self.bus, &session).await;
Ok(tool_result)
}
Err(e) => {
@ -701,8 +650,6 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
session.mark_failed(e.to_string());
}
self.task_repository.save_task_session(&session).await?;
// 发布子智能体 ErrorNotificationmetadata 注入 subagent_task_id 供前端路由到对应子智能体层
publish_subagent_error(&self.bus, &session, &e.to_string()).await;
Err(e)
}
}
@ -761,16 +708,12 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
let mut session = session;
session.mark_completed(tool_result.summary.clone());
self.task_repository.save_task_session(&session).await?;
// 发布子智能体 ExecutionCompletedmetadata 注入 subagent_task_id 供前端路由到对应子智能体层
publish_subagent_completion(&self.bus, &session).await;
Ok(tool_result)
}
Err(e) => {
let mut session = session;
session.mark_failed(e.to_string());
self.task_repository.save_task_session(&session).await?;
// 发布子智能体 ErrorNotificationmetadata 注入 subagent_task_id 供前端路由到对应子智能体层
publish_subagent_error(&self.bus, &session, &e.to_string()).await;
Err(e)
}
}
@ -790,7 +733,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
}
fn available_subagent_names(&self) -> Vec<String> {
self.subagent_runtime.available_names()
self.catalog.names()
}
}
@ -927,357 +870,6 @@ fn xml_escape(s: &str) -> String {
.replace('\'', "&apos;")
}
// ========== 子代理运行时协调层(管理禁用状态) ==========
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubagentScope {
User,
Project,
}
impl SubagentScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
/// A subagent entry with its disabled status across scopes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SubagentWithStatus {
pub name: String,
pub description: String,
pub source: String,
/// Which scopes have this subagent disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SubagentAvailabilityChange {
pub name: String,
pub scope: SubagentScope,
pub changed: bool,
pub disabled_in_scopes: Vec<SubagentScope>,
pub available: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct SubagentStateFile {
#[serde(default)]
disabled_subagents: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct SubagentDisableState {
user_disabled: HashSet<String>,
project_disabled: HashSet<String>,
}
impl SubagentDisableState {
fn is_disabled(&self, name: &str) -> bool {
self.user_disabled.contains(name) || self.project_disabled.contains(name)
}
fn disabled_scopes_for(&self, name: &str) -> Vec<SubagentScope> {
let mut scopes = Vec::new();
if self.user_disabled.contains(name) {
scopes.push(SubagentScope::User);
}
if self.project_disabled.contains(name) {
scopes.push(SubagentScope::Project);
}
scopes
}
}
fn user_subagent_state_path() -> Option<PathBuf> {
crate::platform::home_dir().map(|p| p.join(".picobot").join("subagent-state.json"))
}
fn project_subagent_state_path(cwd: &Path) -> PathBuf {
cwd.join(".picobot").join("subagent-state.json")
}
fn subagent_state_path(scope: SubagentScope, cwd: &Path) -> PathBuf {
match scope {
SubagentScope::User => user_subagent_state_path()
.unwrap_or_else(|| cwd.join(".picobot").join("subagent-state.json")),
SubagentScope::Project => project_subagent_state_path(cwd),
}
}
fn load_subagent_disable_state(cwd: &Path) -> SubagentDisableState {
SubagentDisableState {
user_disabled: user_subagent_state_path()
.map(|path| load_disabled_subagent_names(&path))
.unwrap_or_default(),
project_disabled: load_disabled_subagent_names(&project_subagent_state_path(cwd)),
}
}
fn load_disabled_subagent_names(path: &Path) -> HashSet<String> {
match load_subagent_state_file(path) {
Ok(state) => state.disabled_subagents.into_iter().collect(),
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "Failed to load subagent state file");
HashSet::new()
}
}
}
fn load_subagent_state_file(path: &Path) -> Result<SubagentStateFile, String> {
if !path.exists() {
return Ok(SubagentStateFile::default());
}
let content = fs::read_to_string(path)
.map_err(|err| format!("failed to read subagent state file: {}", err))?;
serde_json::from_str(&content)
.map_err(|err| format!("failed to parse subagent state file: {}", err))
}
fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create subagent state directory: {}", err))?;
}
let content = serde_json::to_string_pretty(state)
.map_err(|err| format!("failed to render subagent state file: {}", err))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, format!("{}\n", content))
.map_err(|err| format!("failed to write temporary subagent state file: {}", err))?;
crate::platform::atomic_rename(&tmp_path, path)
.map_err(|err| format!("failed to persist subagent state file: {}", err))?;
Ok(())
}
/// 子代理运行时协调层
///
/// 在 `SubagentCatalog`(纯数据容器)之上管理禁用状态,所有过滤逻辑在此层。
/// 对齐 `SkillRuntime` 模式。
#[derive(Debug)]
pub struct SubagentRuntime {
catalog: Arc<SubagentCatalog>,
disable_state: RwLock<SubagentDisableState>,
#[allow(dead_code)]
config: SubagentsConfig,
cwd: PathBuf,
}
impl SubagentRuntime {
pub fn new(config: SubagentsConfig, catalog: Arc<SubagentCatalog>, cwd: PathBuf) -> Self {
let disable_state = load_subagent_disable_state(&cwd);
Self {
catalog,
disable_state: RwLock::new(disable_state),
config,
cwd,
}
}
/// 从配置构造discover + wrap
pub fn from_config(config: SubagentsConfig) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let catalog = Arc::new(SubagentCatalog::discover(&config));
Self::new(config, catalog, cwd)
}
/// 列出所有子代理(含禁用项),带 disabled_in_scopes
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
let mut items: Vec<SubagentWithStatus> = self
.catalog
.all()
.iter()
.map(|def| {
let scopes = state.disabled_scopes_for(&def.name);
SubagentWithStatus {
name: def.name.clone(),
description: def.description.clone(),
source: def.source.as_str().to_string(),
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
}
})
.collect();
items.sort_by(|a, b| a.name.cmp(&b.name));
items
}
/// 可用子代理名称(过滤禁用项)
pub fn available_names(&self) -> Vec<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
self.catalog
.names()
.into_iter()
.filter(|name| !state.is_disabled(name))
.collect()
}
/// 查找可用子代理(过滤禁用项)
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
if state.is_disabled(name) {
return None;
}
self.catalog.find(name).cloned()
}
/// 生成过滤后的系统索引提示词
pub fn system_index_prompt_filtered(&self) -> Option<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
let available_defs: Vec<&SubagentDef> = self
.catalog
.all()
.into_iter()
.filter(|def| !state.is_disabled(&def.name))
.collect();
if available_defs.is_empty() {
return None;
}
let mut prompt = String::from(
"# 子代理系统\n\n\
\n\
\n\n\
<available_subagents>\n",
);
for def in available_defs {
prompt.push_str(&format!(
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
xml_escape(&def.name),
xml_escape(&def.description),
));
}
prompt.push_str("</available_subagents>");
Some(prompt)
}
/// 禁用子代理
pub fn disable_subagent(
&self,
scope: SubagentScope,
name: &str,
) -> Result<SubagentAvailabilityChange, String> {
self.set_subagent_enabled(scope, name, false)
}
/// 启用子代理
pub fn enable_subagent(
&self,
scope: SubagentScope,
name: &str,
) -> Result<SubagentAvailabilityChange, String> {
self.set_subagent_enabled(scope, name, true)
}
fn set_subagent_enabled(
&self,
scope: SubagentScope,
name: &str,
enabled: bool,
) -> Result<SubagentAvailabilityChange, String> {
// 校验子代理存在
if self.catalog.find(name).is_none() {
return Err(format!("subagent '{}' not found", name));
}
// 更新对应 scope 的 state 文件
let state_path = subagent_state_path(scope, &self.cwd);
let mut state_file = load_subagent_state_file(&state_path)?;
let mut disabled: HashSet<String> = state_file.disabled_subagents.into_iter().collect();
let changed = if enabled {
disabled.remove(name)
} else {
disabled.insert(name.to_string())
};
let mut disabled_list: Vec<String> = disabled.into_iter().collect();
disabled_list.sort();
state_file.disabled_subagents = disabled_list;
save_subagent_state_file(&state_path, &state_file)?;
// 更新内存中的 disable_state
{
let mut state = self
.disable_state
.write()
.expect("subagent state rwlock poisoned");
match scope {
SubagentScope::User => {
if enabled {
state.user_disabled.remove(name);
} else {
state.user_disabled.insert(name.to_string());
}
}
SubagentScope::Project => {
if enabled {
state.project_disabled.remove(name);
} else {
state.project_disabled.insert(name.to_string());
}
}
}
}
// 计算新的 disabled_in_scopes
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(SubagentAvailabilityChange {
name: name.to_string(),
scope,
changed,
available: disabled_in_scopes.is_empty(),
disabled_in_scopes,
})
}
/// 获取 catalog 引用(用于 DefaultSubAgentRuntime 等需要直接访问的场景)
pub fn catalog(&self) -> &Arc<SubagentCatalog> {
&self.catalog
}
}
/// 为子代理系统提供索引提示词
///
/// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。
pub struct SubagentPromptProvider {
runtime: Arc<SubagentRuntime>,
}
impl SubagentPromptProvider {
pub fn new(runtime: Arc<SubagentRuntime>) -> Self {
Self { runtime }
}
}
impl SystemPromptProvider for SubagentPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
self.runtime
.system_index_prompt_filtered()
.map(|content| SystemPrompt {
content,
context: Some("subagents".to_string()),
})
}
}
// ========== 自定义子代理发现 ==========
/// 源顺序解析
@ -1464,166 +1056,3 @@ fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
Some((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SubagentsConfig;
static SUBAGENT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
SUBAGENT_TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner())
}
struct HomeDirGuard {
previous: Option<std::ffi::OsString>,
previous_userprofile: Option<std::ffi::OsString>,
}
impl HomeDirGuard {
fn enter(path: &Path) -> Self {
let home_backup = std::env::var_os("HOME");
let userprofile_backup = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", path);
std::env::set_var("USERPROFILE", path);
}
Self {
previous: home_backup,
previous_userprofile: userprofile_backup,
}
}
}
impl Drop for HomeDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.previous_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
}
}
fn make_runtime(cwd: &Path) -> SubagentRuntime {
let catalog = Arc::new(SubagentCatalog::new());
SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf())
}
#[test]
fn test_disable_subagent_filters_from_prompt() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
// general 在初始 prompt 中
let prompt = runtime.system_index_prompt_filtered().unwrap();
assert!(prompt.contains("<name>general</name>"));
// 在 project scope 禁用 general
let change = runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
assert!(change.changed);
assert!(!change.available);
// 禁用后 prompt 不应包含 generalexplore 仍可用,所以 prompt 仍为 Some
let prompt = runtime.system_index_prompt_filtered().unwrap();
assert!(!prompt.contains("<name>general</name>"));
assert!(prompt.contains("<name>explore</name>"));
}
#[test]
fn test_enable_subagent_restores() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
let prompt = runtime.system_index_prompt_filtered().unwrap();
assert!(!prompt.contains("<name>general</name>"));
let change = runtime
.enable_subagent(SubagentScope::Project, "general")
.unwrap();
assert!(change.changed);
assert!(change.available);
let prompt = runtime.system_index_prompt_filtered().unwrap();
assert!(prompt.contains("<name>general</name>"));
}
#[test]
fn test_list_with_status_includes_disabled() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
let items = runtime.list_with_status();
let general = items.iter().find(|i| i.name == "general").unwrap();
assert!(general
.disabled_in_scopes
.contains(&"project".to_string()));
// explore 应仍启用
let explore = items.iter().find(|i| i.name == "explore").unwrap();
assert!(explore.disabled_in_scopes.is_empty());
}
#[test]
fn test_find_available_filters_disabled() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
assert!(runtime.find_available("general").is_none());
assert!(runtime.find_available("explore").is_some());
// available_names 不应包含 general
let names = runtime.available_names();
assert!(!names.contains(&"general".to_string()));
assert!(names.contains(&"explore".to_string()));
}
#[test]
fn test_disable_unknown_subagent_errors() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
let err = runtime
.disable_subagent(SubagentScope::Project, "nonexistent")
.unwrap_err();
assert!(err.contains("not found"));
}
}

View File

@ -14,8 +14,6 @@ pub enum TaskSessionState {
Failed,
/// 已超时
Timeout,
/// 状态未知(如重启后从 DB 重建时无法可靠推断原状态)
Unknown,
}
impl Default for TaskSessionState {
@ -38,17 +36,6 @@ pub enum SubagentSource {
Custom(String),
}
impl SubagentSource {
pub fn as_str(&self) -> &str {
match self {
Self::Builtin => "builtin",
Self::User => "user",
Self::Project => "project",
Self::Custom(_) => "custom",
}
}
}
/// 子代理完整定义
#[derive(Debug, Clone)]
pub struct SubagentDef {

View File

@ -6,7 +6,6 @@ use serde::Serialize;
use serde_json::json;
use tokio::sync::RwLock;
use crate::storage::TodoRepository;
use crate::tools::traits::{Tool, ToolContext, ToolResult};
// ── 数据模型 ──────────────────────────────────────────────
@ -62,16 +61,11 @@ pub struct TodoWriteTool {
/// 内存状态scope_key → Vec<TodoItem>
/// scope_key = topic_id.unwrap_or(session_id)
state: Arc<RwLock<HashMap<String, Vec<TodoItem>>>>,
/// 持久化仓库:内存为空时从 DB 回填,保证 merge 模式不丢失旧项
repository: Arc<dyn TodoRepository>,
}
impl TodoWriteTool {
pub(crate) fn new(
state: Arc<RwLock<HashMap<String, Vec<TodoItem>>>>,
repository: Arc<dyn TodoRepository>,
) -> Self {
Self { state, repository }
pub(crate) fn new(state: Arc<RwLock<HashMap<String, Vec<TodoItem>>>>) -> Self {
Self { state }
}
}
@ -164,39 +158,10 @@ impl Tool for TodoWriteTool {
.and_then(|v| v.as_bool())
.unwrap_or(true);
// 3. 读锁获取旧状态;内存为空时从 DB 回填(与 TodoReadTool 一致,保证 merge 模式不丢失旧项)
// 3. 读锁获取旧状态
let old_items = {
let guard = self.state.read().await;
match guard.get(&scope_key).cloned() {
Some(items) if !items.is_empty() => items,
_ => {
drop(guard);
let db_items = match self.repository.list_todos(&scope_key) {
Ok(records) if !records.is_empty() => {
records
.into_iter()
.map(|r| TodoItem {
id: r.id,
content: r.content,
status: r.status,
created_by_message_id: r.created_by_message_id,
})
.collect::<Vec<_>>()
}
_ => Vec::new(),
};
if !db_items.is_empty() {
let mut write_guard = self.state.write().await;
write_guard.insert(scope_key.clone(), db_items.clone());
tracing::info!(
scope_key = %scope_key,
todo_count = db_items.len(),
"TodoWriteTool: backfilled memory from SQLite before merge"
);
}
db_items
}
}
guard.get(&scope_key).cloned().unwrap_or_default()
};
// 构建 id → TodoItem 的旧状态映射
@ -479,59 +444,9 @@ mod tests {
Arc::new(RwLock::new(HashMap::new()))
}
struct MockTodoRepository {
records: Vec<crate::storage::TodoRecord>,
}
impl TodoRepository for MockTodoRepository {
fn replace_todos(
&self,
_scope_key: &str,
_items: &[crate::storage::TodoRecord],
) -> Result<Vec<crate::storage::TodoRecord>, crate::storage::StorageError> {
Ok(vec![])
}
fn list_todos(
&self,
scope_key: &str,
) -> Result<Vec<crate::storage::TodoRecord>, crate::storage::StorageError> {
Ok(self
.records
.iter()
.filter(|r| r.scope_key == scope_key)
.cloned()
.collect())
}
}
fn mock_repo() -> Arc<MockTodoRepository> {
Arc::new(MockTodoRepository { records: vec![] })
}
fn mock_record(
scope_key: &str,
id: &str,
content: &str,
status: &str,
) -> crate::storage::TodoRecord {
crate::storage::TodoRecord {
id: id.to_string(),
scope_key: scope_key.to_string(),
session_id: "cli:chat-1".to_string(),
topic_id: None,
content: content.to_string(),
status: status.to_string(),
priority: "medium".to_string(),
created_at: 1000,
updated_at: 1000,
created_by_message_id: None,
}
}
#[tokio::test]
async fn test_create_initial_todos() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let context = test_context();
let result = tool
@ -558,7 +473,7 @@ mod tests {
#[tokio::test]
async fn test_single_in_progress_constraint() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -595,7 +510,7 @@ mod tests {
#[tokio::test]
async fn test_state_transition_in_progress_to_completed() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -645,7 +560,7 @@ mod tests {
#[tokio::test]
async fn test_completed_can_revert_to_in_progress() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
// 创建并完成一个任务
@ -694,7 +609,7 @@ mod tests {
#[tokio::test]
async fn test_cancelled_can_revert_to_pending() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -742,7 +657,7 @@ mod tests {
#[tokio::test]
async fn test_in_progress_cannot_revert_to_pending() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -788,7 +703,7 @@ mod tests {
#[tokio::test]
async fn test_new_item_can_be_any_status() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let context = test_context();
// 新项直接 completed — 应该允许id 必填后不再限制初始状态)
@ -809,7 +724,7 @@ mod tests {
#[tokio::test]
async fn test_new_item_can_start_as_in_progress() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let context = test_context();
let result = tool
@ -836,7 +751,7 @@ mod tests {
#[tokio::test]
async fn test_remove_items_by_omission() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -876,7 +791,7 @@ mod tests {
#[tokio::test]
async fn test_topic_isolation() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let main_context = ToolContext {
session_id: Some("cli:chat-1".to_string()),
@ -926,7 +841,7 @@ mod tests {
#[tokio::test]
async fn test_empty_list() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let context = test_context();
let result = tool
@ -944,7 +859,7 @@ mod tests {
#[tokio::test]
async fn test_missing_todos_param() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let context = test_context();
let result = tool
@ -958,7 +873,7 @@ mod tests {
#[tokio::test]
async fn test_no_context() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let result = tool.execute(json!({})).await.unwrap();
@ -969,7 +884,7 @@ mod tests {
#[tokio::test]
async fn test_subagent_isolation() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let parent_ctx = ToolContext {
session_id: Some("cli:chat-1".to_string()),
@ -1020,7 +935,7 @@ mod tests {
#[tokio::test]
async fn test_merge_mode_preserves_unreferenced_items() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -1062,7 +977,7 @@ mod tests {
#[tokio::test]
async fn test_merge_mode_add_new_item() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -1100,7 +1015,7 @@ mod tests {
#[tokio::test]
async fn test_merge_mode_never_removes() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -1137,7 +1052,7 @@ mod tests {
#[tokio::test]
async fn test_non_merge_still_removes_by_omission() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -1176,7 +1091,7 @@ mod tests {
#[tokio::test]
async fn test_merge_match_by_content_fallback() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
let _ = tool
@ -1222,7 +1137,7 @@ mod tests {
#[tokio::test]
async fn test_missing_id_validation_error() {
let tool = TodoWriteTool::new(test_state(), mock_repo());
let tool = TodoWriteTool::new(test_state());
let context = test_context();
let result = tool
@ -1244,7 +1159,7 @@ mod tests {
#[tokio::test]
async fn test_default_is_merge_mode() {
let state = test_state();
let tool = TodoWriteTool::new(state.clone(), mock_repo());
let tool = TodoWriteTool::new(state.clone());
let context = test_context();
// 先创建 2 个 todo
@ -1281,59 +1196,4 @@ mod tests {
let task_a = todos.iter().find(|t| t["id"] == "x1").unwrap();
assert_eq!(task_a["status"], "in_progress");
}
#[tokio::test]
async fn test_merge_backfills_from_db_when_memory_empty() {
// 场景进程重启后内存清空DB 保留旧项 [a(completed), b(pending), c(in_progress)]
// 子智能体用 merge 模式只传 [{a, completed}],应保留 b、c不丢失
let state = test_state(); // 空 HashMap
let scope_key = "task:sub-1".to_string();
let repo = Arc::new(MockTodoRepository {
records: vec![
mock_record(&scope_key, "a", "任务A", "completed"),
mock_record(&scope_key, "b", "任务B", "pending"),
mock_record(&scope_key, "c", "任务C", "in_progress"),
],
});
let tool = TodoWriteTool::new(state.clone(), repo);
let ctx = ToolContext {
session_id: Some("cli:chat-1".to_string()),
task_id: Some(scope_key.clone()),
nesting_depth: 1, // 模拟子智能体
..ToolContext::default()
};
// merge=true 只传 a(completed)b/c 应从 DB 回填并保留
let args = json!({
"merge": true,
"todos": [
{ "id": "a", "content": "任务A", "status": "completed" }
]
});
let result = tool.execute_with_context(&ctx, args).await.unwrap();
assert!(result.success, "merge should succeed: {:?}", result.error);
let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
let todos = output["current_todos"].as_array().unwrap();
assert_eq!(
todos.len(),
3,
"should preserve all 3 items after merge with DB backfill"
);
let ids: Vec<&str> = todos.iter().map(|t| t["id"].as_str().unwrap()).collect();
assert!(ids.contains(&"a"));
assert!(ids.contains(&"b"), "pending item b must be preserved");
assert!(
ids.contains(&"c"),
"in_progress item c must be preserved"
);
// 验证内存已被回填
let guard = state.read().await;
let memory_items = guard.get(&scope_key).unwrap();
assert_eq!(memory_items.len(), 3);
}
}

1189
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,9 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"preview": "vite preview"
},
"dependencies": {
"@types/react": "^19.2.15",
@ -22,15 +20,10 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"vite": "^8.0.14",
"vitest": "^4.1.10"
"vite": "^8.0.14"
}
}

View File

@ -166,9 +166,6 @@ function App() {
})
const [configPageOpen, setConfigPageOpen] = useState(false)
const [configInitialTab, setConfigInitialTab] = useState<'providers' | 'experts'>('providers')
// 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态
const [settingsClosedTick, setSettingsClosedTick] = useState(0)
const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings({ host, port })
@ -222,7 +219,7 @@ function App() {
}, 500)
return () => clearTimeout(timer)
}, [topicRefreshTrigger, status, handleCommand, sendMessage, requestTopicList])
}, [topicRefreshTrigger])
// Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时)
useEffect(() => {
@ -358,18 +355,8 @@ function App() {
)
const handleExitSubAgentView = useCallback(() => {
const command = exitSubAgentView()
if (command) {
sendMessage({ type: 'command', payload: JSON.stringify(command) })
}
}, [exitSubAgentView, sendMessage])
const handleNavigateToSubAgentLevel = useCallback((index: number) => {
const command = navigateToSubAgentLevel(index)
if (command) {
sendMessage({ type: 'command', payload: JSON.stringify(command) })
}
}, [navigateToSubAgentLevel, sendMessage])
exitSubAgentView()
}, [exitSubAgentView])
// 切换到定时任务 tab 时自动获取列表
useEffect(() => {
@ -437,7 +424,10 @@ function App() {
alert('该待办的完成记录无法定位,可能是历史数据')
return
}
// 仅定时任务视图需要退出(其消息源是另一个 chat_id
// 若处于子智能体视图或定时任务视图,先退出回到主会话视图
if (subAgentStack.length > 0) {
navigateToSubAgentLevel(-1)
}
if (schedulerView) {
exitSchedulerJobView()
}
@ -448,7 +438,7 @@ function App() {
setTimeout(() => {
setHighlightedMessageId(msgId)
}, 50)
}, [setHighlightedMessageId, schedulerView, exitSchedulerJobView])
}, [setHighlightedMessageId, subAgentStack.length, schedulerView, navigateToSubAgentLevel, exitSchedulerJobView])
const handleRefreshSchedulerJobs = useCallback(() => {
const cmd = requestSchedulerJobList()
@ -577,10 +567,7 @@ function App() {
<Brain className="h-4 w-4" />
</button>
<button
onClick={() => {
setConfigInitialTab('providers')
setConfigPageOpen(true)
}}
onClick={() => setConfigPageOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
title="系统配置"
aria-label="System config"
@ -739,7 +726,7 @@ function App() {
</button>
{/* Breadcrumb: 主会话 */}
<button
onClick={() => handleNavigateToSubAgentLevel(-1)}
onClick={() => navigateToSubAgentLevel(-1)}
className="flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors shrink-0"
title="返回主会话"
>
@ -755,7 +742,6 @@ function App() {
level.status === 'timeout' ? '超时' :
level.status === 'running' ? '执行中' :
level.status === 'loading' ? '加载中...' :
level.status === 'unknown' ? '未知' :
level.status
const statusColor =
level.status === 'completed' ? 'text-emerald-400' :
@ -776,7 +762,7 @@ function App() {
</div>
) : (
<button
onClick={() => handleNavigateToSubAgentLevel(idx)}
onClick={() => navigateToSubAgentLevel(idx)}
className="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors min-w-0"
>
<span className="truncate">{level.description}</span>
@ -807,12 +793,6 @@ function App() {
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
sessionId={sessionId}
settingsClosedTick={settingsClosedTick}
onOpenSettings={() => {
setConfigInitialTab('experts')
setConfigPageOpen(true)
}}
/>
</div>
</div>
@ -901,14 +881,7 @@ function App() {
{/* 系统配置页面 */}
{configPageOpen && (
<ConfigPage
onClose={() => {
setConfigPageOpen(false)
setSettingsClosedTick(t => t + 1)
}}
onSaveConnection={handleSaveConnection}
initialTab={configInitialTab}
/>
<ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} />
)}
</div>
)

View File

@ -1,56 +0,0 @@
// 统一的 API 端点常量,消除硬编码字符串
export const API = {
config: '/api/config',
restart: '/api/restart',
health: '/health',
mcpStatus: '/api/mcp/status',
skills: '/api/skills',
skillsToggle: '/api/skills/toggle',
subagents: '/api/subagents',
subagentsToggle: '/api/subagents/toggle',
experts: '/api/experts',
expertsToggle: '/api/experts/toggle',
expertsCreate: '/api/experts/create',
expertsUpdate: '/api/experts/update',
expertsDelete: '/api/experts/delete',
expertsSelected: '/api/experts/selected',
expertsSelect: '/api/experts/select',
} as const
/**
* fetch JSON headers
* [data, error]
*/
export async function apiFetch<T>(
endpoint: string,
options?: { method?: string; body?: unknown; signal?: AbortSignal }
): Promise<[T | null, { status: number; message: string } | null]> {
try {
const resp = await fetch(endpoint, {
method: options?.method ?? 'GET',
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
body: options?.body ? JSON.stringify(options.body) : undefined,
signal: options?.signal,
})
const data = await resp.json().catch(() => null)
if (!resp.ok) {
return [null, { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }]
}
return [data as T, null]
} catch (e) {
return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }]
}
}
/**
* GET null
*/
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
try {
const resp = await fetch(endpoint)
if (!resp.ok) return null
return await resp.json() as T
} catch {
return null
}
}

View File

@ -1,32 +0,0 @@
import { API, apiFetch } from './client'
import type { AppConfig } from '../components/Settings/types'
export interface RestartResponse {
success: boolean
message?: string
}
export async function getAppConfig(): Promise<[AppConfig | null, string | null]> {
const [data, err] = await apiFetch<AppConfig>(API.config)
return [data, err?.message ?? null]
}
export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> {
const [, err] = await apiFetch<{ success: boolean }>(API.config, { method: 'PUT', body: { config } })
return err ? [false, err.message] : [true, null]
}
export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> {
const resp = await fetch(API.restart, { method: 'POST' })
const data = await resp.json().catch(() => ({ success: false }))
return { status: resp.status, data }
}
export async function checkHealth(): Promise<boolean> {
try {
const resp = await fetch(API.health)
return resp.ok
} catch {
return false
}
}

View File

@ -1,53 +0,0 @@
import { API, apiGetSilent } from './client'
import type { ExpertListResponse, ExpertItem } from '../components/Settings/types'
export function listExperts(): Promise<ExpertListResponse | null> {
return apiGetSilent<ExpertListResponse>(API.experts)
}
export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.expertsToggle, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
}
export async function createExpert(payload: { name: string; description: string; body: string; scope: string }): Promise<Response> {
return fetch(API.expertsCreate, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string }): Promise<Response> {
return fetch(API.expertsUpdate, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export async function deleteExpert(name: string, scope: string): Promise<Response> {
const params = new URLSearchParams({ name, scope })
return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' })
}
export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
const params = new URLSearchParams({ session_id: sessionId })
const resp = await fetch(`${API.expertsSelected}?${params}`)
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.expertsSelect, {
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

@ -1,6 +0,0 @@
import { API, apiGetSilent } from './client'
import type { McpStatusResponse } from '../components/Settings/types'
export function getMcpStatus(): Promise<McpStatusResponse | null> {
return apiGetSilent<McpStatusResponse>(API.mcpStatus)
}

View File

@ -1,14 +0,0 @@
import { API, apiGetSilent } from './client'
import type { SkillListResponse } from '../components/Settings/types'
export function listSkills(): Promise<SkillListResponse | null> {
return apiGetSilent<SkillListResponse>(API.skills)
}
export async function toggleSkill(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.skillsToggle, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
}

View File

@ -1,14 +0,0 @@
import { API, apiGetSilent } from './client'
import type { SubagentListResponse } from '../components/Settings/types'
export function listSubagents(): Promise<SubagentListResponse | null> {
return apiGetSilent<SubagentListResponse>(API.subagents)
}
export async function toggleSubagent(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.subagentsToggle, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }),
})
}

View File

@ -1,7 +1,5 @@
import { useState } from 'react'
import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput'
import { ExpertSelector } from './ExpertSelector'
import type { ChatMessage, Attachment } from '../../types/protocol'
interface ChatContainerProps {
@ -17,12 +15,6 @@ interface ChatContainerProps {
viewKey?: string
/** 高亮的消息 ID */
highlightedMessageId?: string | null
/** 当前 session ID用于专家选择 */
sessionId?: string | null
/** 打开设置页(用于专家管理入口) */
onOpenSettings?: () => void
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
settingsClosedTick?: number
}
export function ChatContainer({
@ -36,23 +28,12 @@ export function ChatContainer({
showThinking = true,
viewKey,
highlightedMessageId,
sessionId,
onOpenSettings,
settingsClosedTick,
}: ChatContainerProps) {
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null)
return (
<div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
</div>
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<MessageInput
onSend={onSendMessage}
onStop={onStop}
@ -60,7 +41,6 @@ export function ChatContainer({
isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
selectedExpert={selectedExpert}
/>
</div>
)

View File

@ -1,259 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts'
interface ExpertItem {
name: string
description: string
source: string
path?: string
body?: string
disabled_in_scopes: string[]
}
interface SelectedExpert {
name: string
description: string
}
interface ExpertSelectorProps {
sessionId: string | null
onManageExperts?: () => void
onSelectionChange?: (expert: SelectedExpert | null) => void
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
settingsClosedTick?: number
}
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) {
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
const [expertList, setExpertList] = useState<ExpertItem[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [listLoading, setListLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// 刷新当前会话选中的专家(后端会对禁用专家返回 null
const refreshSelection = useCallback(() => {
if (!sessionId) {
setSelectedExpert(null)
onSelectionChange?.(null)
return
}
setLoading(true)
setError(null)
getSelectedExpert(sessionId)
.then(data => {
if (data?.expert) {
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
} else {
// 已选专家被禁用/删除时,后端返回 null前端同步清除
setSelectedExpert(null)
onSelectionChange?.(null)
}
})
.catch(() => {
// Silent fail: default to no expert
setSelectedExpert(null)
onSelectionChange?.(null)
})
.finally(() => setLoading(false))
}, [sessionId, onSelectionChange])
// Load current selection whenever sessionId changes
useEffect(() => {
refreshSelection()
}, [refreshSelection])
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
useEffect(() => {
if (settingsClosedTick === undefined) return
refreshSelection()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick])
// Click outside to close dropdown
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const fetchExpertList = useCallback(async () => {
setListLoading(true)
const data = await listExperts()
if (data) {
// Only show enabled experts (disabled_in_scopes.length === 0)
const enabled = (data.experts ?? []).filter(
(e: ExpertItem) => e.disabled_in_scopes.length === 0
)
setExpertList(enabled)
}
setListLoading(false)
}, [])
const handleToggleOpen = () => {
const next = !open
setOpen(next)
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
if (next) {
fetchExpertList()
refreshSelection()
}
}
const handleSelect = async (expert: SelectedExpert | null) => {
if (!sessionId) return
// Optimistic update
const prev = selectedExpert
setSelectedExpert(expert)
onSelectionChange?.(expert)
setOpen(false)
try {
const result = await selectExpert(sessionId, expert?.name ?? null)
if (!result.success) {
// Revert
setSelectedExpert(prev)
onSelectionChange?.(prev)
setError(result.error || '切换专家失败')
setTimeout(() => setError(null), 3000)
}
} catch {
setSelectedExpert(prev)
onSelectionChange?.(prev)
setError('网络错误,切换专家失败')
setTimeout(() => setError(null), 3000)
}
}
const handleManage = () => {
setOpen(false)
onManageExperts?.()
}
// If sessionId is null, render nothing
if (!sessionId) return null
return (
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
<div className="max-w-5xl mx-auto flex items-center gap-2">
<div className="relative">
<button
onClick={handleToggleOpen}
disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
) : (
<UserCheck
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
)}
{selectedExpert ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
{selectedExpert.name}
</span>
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
{selectedExpert.description}
</span>
</span>
) : (
<span className="text-[var(--text-muted)]"></span>
)}
<ChevronDown
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
>
{listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
</div>
) : (
<>
{/* 无专家 option */}
<button
onClick={() => handleSelect(null)}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
<span className="text-[var(--text-primary)]"></span>
{!selectedExpert && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
)}
</button>
{expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
return (
<button
key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
>
<UserCheck
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1.5">
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
{expert.name}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
)}
</div>
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{expert.description}
</p>
{expert.path && (
<p
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
title={expert.path}
>
{expert.path}
</p>
)}
</div>
</button>
)
})}
</div>
) : (
<div className="border-t border-[var(--border-color)]">
<button
onClick={handleManage}
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
>
<Settings className="h-4 w-4" />
<span>...</span>
</button>
</div>
)}
</>
)}
</div>
)}
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div>
</div>
)
}

View File

@ -12,11 +12,9 @@ interface MessageInputProps {
placeholder?: string
isReadOnly?: boolean
channelName?: string
selectedExpert?: { name: string; description: string } | null
}
interface FileAttachment {
id: string
file: File
attachment: Attachment
preview?: string // 用于图片预览
@ -35,13 +33,10 @@ export function MessageInput({
onStop,
disabled = false,
isLoading = false,
placeholder,
placeholder = '输入消息...按 / 查看命令',
isReadOnly = false,
channelName,
selectedExpert,
}: MessageInputProps) {
const effectivePlaceholder = placeholder
?? (selectedExpert ? `${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令')
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<FileAttachment[]>([])
const [isDragging, setIsDragging] = useState(false)
@ -93,7 +88,6 @@ export function MessageInput({
}
const fileAttachment: FileAttachment = {
id: crypto.randomUUID(),
file,
attachment,
preview: mediaType === 'image' ? base64 : undefined,
@ -172,7 +166,6 @@ export function MessageInput({
}
const fileAttachment: FileAttachment = {
id: crypto.randomUUID(),
file,
attachment,
preview: mediaType === 'image' ? base64 : undefined,
@ -298,7 +291,7 @@ export function MessageInput({
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att, index) => (
<div
key={att.id}
key={index}
className="flex items-center gap-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm"
>
{att.preview ? (
@ -367,7 +360,7 @@ export function MessageInput({
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={effectivePlaceholder}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-4 py-3 pr-12 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--focus-ring)] disabled:opacity-50 transition-all self-center scrollbar-hide"

View File

@ -1,33 +1,294 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import {
Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
CheckCircle, RefreshCw, UserCheck, Pencil,
Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
CheckCircle, Plug, Radio, RefreshCw,
} from 'lucide-react'
// ── Extracted modules ─────────────────────────────────
import type {
AppConfig, ConfigPageProps, TabId,
ProviderConfig, ModelConfig, AgentConfig,
McpServerConfig, McpStatusResponse,
SkillListResponse,
SubagentListResponse,
ExpertItem, ExpertListResponse,
KnownSource,
SchedulerConfig, ChannelConfig,
} from './types'
import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'
import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader } from './ui'
import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'
import { listSkills, toggleSkill } from '../../api/skills'
import { listSubagents, toggleSubagent } from '../../api/subagents'
import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts'
import { getMcpStatus } from '../../api/mcp'
export { getSelectedExpert, selectExpert } from '../../api/experts'
// ── 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 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
client: ClientConfig
channels: Record<string, any>
mcpServers: Record<string, McpServerConfig>
}
interface ConfigPageProps {
onClose: () => void
onSaveConnection?: (host: string, port: number) => void
}
type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'mcp' | 'channels'
const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'connection', label: '连接', icon: Wifi },
{ id: 'gateway', label: '网关', icon: Server },
{ id: 'providers', label: '服务商', icon: Cpu },
{ id: 'models', label: '模型', icon: Brain },
{ id: 'agents', label: '代理', icon: Bot },
{ id: 'time', label: '时间', icon: Clock },
{ id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'skills', label: '技能', icon: Wrench },
{ id: 'tools', label: '工具', icon: Settings },
{ id: 'memory', label: '记忆维护', icon: Users },
{ id: 'image', label: '图片上下文', icon: Image },
{ id: 'subagents', label: '子代理', icon: Bot },
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
{ id: 'channels', label: '渠道', icon: Radio },
]
// ── 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={i} 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, children }: { title: 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">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
</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, i) => (
<span key={i} 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>
)
}
// ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) {
export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [config, setConfig] = useState<AppConfig | null>(null)
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? 'providers')
const [activeTab, setActiveTab] = useState<TabId>('gateway')
const [loading, setLoading] = useState(true)
// Connection settings (localStorage-based)
const [connHost, setConnHost] = useState(() => {
@ -45,71 +306,12 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const [showRestartDialog, setShowRestartDialog] = useState(false)
const [restarting, setRestarting] = useState(false)
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
const [skillListLoading, setSkillListLoading] = useState(false)
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
const [subagentListLoading, setSubagentListLoading] = useState(false)
const [expertList, setExpertList] = useState<ExpertListResponse | null>(null)
const [expertListLoading, setExpertListLoading] = useState(false)
const [editingExpert, setEditingExpert] = useState<{
mode: 'create' | 'edit'
name?: string
scope: string
nameField: string
description: string
body: string
} | null>(null)
const [editingExpertError, setEditingExpertError] = useState('')
const [savingExpert, setSavingExpert] = useState(false)
const fetchMcpStatus = useCallback(async () => {
const data = await getMcpStatus()
if (data) setMcpStatus(data)
}, [])
const fetchSkillList = useCallback(async () => {
setSkillListLoading(true)
const data = await listSkills()
if (data) setSkillList(data)
setSkillListLoading(false)
}, [])
const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleSkill(name, scope, enabled)
}, [])
const fetchSubagentList = useCallback(async () => {
setSubagentListLoading(true)
const data = await listSubagents()
if (data) setSubagentList(data)
setSubagentListLoading(false)
}, [])
const toggleSubagentCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleSubagent(name, scope, enabled)
}, [])
const fetchExpertList = useCallback(async () => {
setExpertListLoading(true)
const data = await listExperts()
if (data) setExpertList(data)
setExpertListLoading(false)
}, [])
const toggleExpertCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
return toggleExpert(name, scope, enabled)
}, [])
const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string }) => {
return createExpert(payload)
}, [])
const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string }) => {
return updateExpert(payload)
}, [])
const deleteExpertCb = useCallback(async (name: string, scope: string) => {
return deleteExpert(name, scope)
try {
const resp = await fetch('/api/mcp/status')
if (resp.ok) setMcpStatus(await resp.json())
} catch { /* ignore fetch errors */ }
}, [])
const handleClose = useCallback(() => {
@ -119,11 +321,10 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
// Load config
useEffect(() => {
getAppConfig().then(([data, err]) => {
if (data) setConfig(data)
if (err) setError('加载配置失败: ' + err)
fetch('/api/config').then(r => r.json()).then(data => {
setConfig(data)
setLoading(false)
})
}).catch(e => { setError('加载配置失败: ' + e.message); setLoading(false) })
}, [])
// Fetch MCP status when MCP tab is selected
@ -131,21 +332,6 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
if (activeTab === 'mcp') fetchMcpStatus()
}, [activeTab, fetchMcpStatus])
// Fetch skill list when skills tab is selected
useEffect(() => {
if (activeTab === 'skills') fetchSkillList()
}, [activeTab, fetchSkillList])
// Fetch subagent list when subagents tab is selected
useEffect(() => {
if (activeTab === 'subagents') fetchSubagentList()
}, [activeTab, fetchSubagentList])
// Fetch expert list when experts tab is selected
useEffect(() => {
if (activeTab === 'experts') fetchExpertList()
}, [activeTab, fetchExpertList])
// ESC to close
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
@ -161,52 +347,63 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const handleSave = async () => {
if (!config) return
setSaving(true); setError('')
const [ok, err] = await updateAppConfig(config)
if (!ok) {
setError(err || '保存失败')
} else {
try {
const resp = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.message || data.error || '保存失败')
// Config is now synced to both disk and in-memory state,
// so the local state is already correct. No need to re-fetch.
setDirty(false)
// Show restart confirmation dialog
setShowRestartDialog(true)
} catch (e: any) {
setError(e.message || '保存失败')
} finally {
setSaving(false)
}
setSaving(false)
}
const handleRestart = async () => {
setShowRestartDialog(false)
setRestarting(true)
try {
const { status, data } = await restartGateway()
if (status === 409) {
const resp = await fetch('/api/restart', { method: 'POST' })
const data = await resp.json()
if (resp.status === 409) {
setToast(data.message || '有任务运行中,请等待完成后再试')
setRestarting(false)
setTimeout(() => setToast(''), 5000)
return
}
if (status < 200 || status >= 300) throw new Error(data.message || '重启失败')
if (!resp.ok) throw new Error(data.message || '重启失败')
setToast('服务正在重启,页面将自动重连...')
// Poll /health until gateway is back
const poll = async () => {
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 1000))
if (await checkHealth()) {
const [refreshed] = await getAppConfig()
if (refreshed) setConfig(refreshed)
setToast('服务已重启,配置已生效')
setRestarting(false)
setTimeout(() => setToast(''), 3000)
return
}
try {
const r = await fetch('/health')
if (r.ok) {
const refreshed = await fetch('/api/config').then(r => r.json())
setConfig(refreshed)
setToast('服务已重启,配置已生效')
setRestarting(false)
setTimeout(() => setToast(''), 3000)
return
}
} catch { /* gateway not ready yet */ }
}
setToast('重启超时,请手动刷新页面')
setRestarting(false)
setTimeout(() => setToast(''), 5000)
}
poll()
} catch (e: unknown) {
setError(e instanceof Error ? e.message : '重启失败')
} catch (e: any) {
setError(e.message || '重启失败')
setRestarting(false)
}
}
@ -390,7 +587,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.scheduler.enabled} onChange={v => update('scheduler', { ...config.scheduler, enabled: v })} /></div>
<Field label="Tick 分辨率 (ms)"><input type="number" value={config.scheduler.tick_resolution_ms} onChange={e => update('scheduler', { ...config.scheduler, tick_resolution_ms: +e.target.value })} className={inputCls} /></Field>
<Field label="工作队列容量"><input type="number" value={config.scheduler.worker_queue_capacity} onChange={e => update('scheduler', { ...config.scheduler, worker_queue_capacity: +e.target.value })} className={inputCls} /></Field>
<Field label="Misfire 策略"><select value={config.scheduler.misfire_policy} onChange={e => update('scheduler', { ...config.scheduler, misfire_policy: e.target.value as SchedulerConfig['misfire_policy'] })} className={selectCls}><option value="skip"> (Skip)</option><option value="catch_up"> (Catch Up)</option></select></Field>
<Field label="Misfire 策略"><select value={config.scheduler.misfire_policy} onChange={e => update('scheduler', { ...config.scheduler, misfire_policy: e.target.value as any })} className={selectCls}><option value="skip"> (Skip)</option><option value="catch_up"> (Catch Up)</option></select></Field>
</SectionCard>
</div>
)
@ -424,85 +621,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
/>
</SectionCard>
{renderDiscoveredSkills()}
</div>
)
const renderDiscoveredSkills = () => {
if (!skillList || !skillList.skills_system_enabled) return null
const skills = skillList.skills
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
// Optimistic update
const prevSkillList = skillList
setSkillList({
...skillList,
skills: skills.map(s =>
s.name === name
? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] }
: s
),
})
try {
const resp = await toggleSkillCb(name, 'project', !currentlyEnabled)
const data = await resp.json()
if (!resp.ok || !data.success) {
// Rollback
setSkillList(prevSkillList)
setToast(data.error || '切换技能状态失败')
setTimeout(() => setToast(''), 3000)
return
}
// Update with server response
setSkillList({
...prevSkillList,
skills: prevSkillList.skills.map(s =>
s.name === name
? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] }
: s
),
})
} catch {
// Rollback
setSkillList(prevSkillList)
setToast('网络错误,切换技能状态失败')
setTimeout(() => setToast(''), 3000)
}
}
return (
<SectionCard title="已发现技能" subtitle="即时生效">
{skillListLoading && skills.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : skills.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{skills.map(skill => {
const isEnabled = skill.disabled_in_scopes.length === 0
return (
<div key={skill.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{skill.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{skill.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{skill.description}</p>
</div>
<Toggle checked={isEnabled} onChange={() => handleToggle(skill.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
const TASK_KNOWN_TOOLS: KnownSource[] = [
{ key: 'read', label: 'Read', description: '读取文件' },
{ key: 'edit', label: 'Edit', description: '编辑文件' },
@ -568,332 +689,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
/>
</SectionCard>
{renderDiscoveredSubagents()}
</div>
)
const renderDiscoveredSubagents = () => {
if (!subagentList || !subagentList.subagents_system_enabled) return null
const subagents = subagentList.subagents
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
const prevList = subagentList
setSubagentList({
...subagentList,
subagents: subagents.map(s =>
s.name === name
? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] }
: s
),
})
try {
const resp = await toggleSubagentCb(name, 'project', !currentlyEnabled)
const data = await resp.json()
if (!resp.ok || !data.success) {
setSubagentList(prevList)
setToast(data.error || '切换子代理状态失败')
setTimeout(() => setToast(''), 3000)
return
}
setSubagentList({
...prevList,
subagents: prevList.subagents.map(s =>
s.name === name
? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] }
: s
),
})
} catch {
setSubagentList(prevList)
setToast('网络错误,切换子代理状态失败')
setTimeout(() => setToast(''), 3000)
}
}
return (
<SectionCard title="已发现子代理" subtitle="即时生效">
{subagentListLoading && subagents.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : subagents.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{subagents.map(subagent => {
const isEnabled = subagent.disabled_in_scopes.length === 0
return (
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{subagent.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
</div>
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
const EXPERT_KNOWN_SOURCES: KnownSource[] = [
{ key: 'user', label: '用户专家', description: '~/.picobot/experts' },
{ key: 'project', label: '项目专家', description: '.picobot/experts' },
]
const renderExperts = () => (
<div className="space-y-5">
<SectionCard title="专家系统">
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]"></span><Toggle checked={config.experts.enabled} onChange={v => update('experts', { ...config.experts, enabled: v })} /></div>
</SectionCard>
<SectionCard title="来源目录">
<SourceEditor
sources={config.experts.sources}
onChange={v => update('experts', { ...config.experts, sources: v })}
knownSources={EXPERT_KNOWN_SOURCES}
examplePaths={['D:\\my-experts', '/home/user/shared-experts']}
/>
</SectionCard>
{renderDiscoveredExperts()}
<button
onClick={() => {
setEditingExpertError('')
setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '' })
}}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center"
>
<Plus className="h-4 w-4" />
</button>
{renderExpertModal()}
</div>
)
const renderDiscoveredExperts = () => {
if (!expertList || !expertList.experts_system_enabled) return null
const experts = expertList.experts
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
const prevList = expertList
setExpertList({
...expertList,
experts: experts.map(e =>
e.name === name
? { ...e, disabled_in_scopes: currentlyEnabled ? ['project'] : [] }
: e
),
})
try {
const resp = await toggleExpertCb(name, 'project', !currentlyEnabled)
const data = await resp.json()
if (!resp.ok || !data.success) {
setExpertList(prevList)
setToast(data.error || '切换专家状态失败')
setTimeout(() => setToast(''), 3000)
return
}
setExpertList({
...prevList,
experts: prevList.experts.map(e =>
e.name === name
? { ...e, disabled_in_scopes: data.disabled_in_scopes || [] }
: e
),
})
} catch {
setExpertList(prevList)
setToast('网络错误,切换专家状态失败')
setTimeout(() => setToast(''), 3000)
}
}
const handleEdit = (expert: ExpertItem) => {
setEditingExpertError('')
setEditingExpert({
mode: 'edit',
name: expert.name,
scope: 'project',
nameField: expert.name,
description: expert.description,
body: expert.body ?? '',
})
}
const handleDelete = async (name: string) => {
if (!confirm(`确定删除专家 "${name}" 吗?此操作将删除对应文件。`)) return
try {
const resp = await deleteExpertCb(name, 'project')
const data = await resp.json()
if (!resp.ok || !data.success) {
setToast(data.error || '删除专家失败')
setTimeout(() => setToast(''), 3000)
return
}
setToast('专家已删除')
setTimeout(() => setToast(''), 3000)
fetchExpertList()
} catch {
setToast('网络错误,删除专家失败')
setTimeout(() => setToast(''), 3000)
}
}
return (
<SectionCard title="已发现专家" subtitle="即时生效">
{expertListLoading && experts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : experts.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{experts.map(expert => {
const isEnabled = expert.disabled_in_scopes.length === 0
return (
<div key={expert.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{expert.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{expert.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{expert.description}</p>
</div>
<button
onClick={() => handleEdit(expert)}
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(expert.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Toggle checked={isEnabled} onChange={() => handleToggle(expert.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
const renderExpertModal = () => {
if (!editingExpert) return null
const isEdit = editingExpert.mode === 'edit'
const canSave = editingExpert.nameField.trim() && editingExpert.description.trim()
const handleSave = async () => {
if (!canSave) return
setSavingExpert(true)
setEditingExpertError('')
try {
const resp = isEdit
? await updateExpertCb({
name: editingExpert.nameField,
scope: 'project',
description: editingExpert.description,
body: editingExpert.body,
})
: await createExpertCb({
name: editingExpert.nameField,
description: editingExpert.description,
body: editingExpert.body,
scope: 'project',
})
const data = await resp.json().catch(() => ({}))
if (!resp.ok) {
setEditingExpertError(data.error || data.message || '保存失败')
setSavingExpert(false)
return
}
setToast(isEdit ? '专家已更新' : '专家已创建')
setTimeout(() => setToast(''), 3000)
setEditingExpert(null)
fetchExpertList()
} catch (e: unknown) {
setEditingExpertError(e instanceof Error ? e.message : '网络错误')
} finally {
setSavingExpert(false)
}
}
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
<div className="flex items-center gap-2 mb-4">
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
{isEdit ? '编辑专家' : '添加专家'}
</h3>
</div>
<div className="space-y-3">
<Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入">
<input
value={editingExpert.nameField}
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
disabled={isEdit}
placeholder="如 translator"
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
autoFocus={!isEdit}
/>
</Field>
<Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入">
<input
value={editingExpert.description}
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
placeholder="如 翻译专家"
className={inputCls}
/>
</Field>
<Field label="专家提示词正文" hint="markdown 格式。非空时仅注入正文(不注入名称和描述);为空时自动用“名称+描述”生成兜底提示词">
<textarea
value={editingExpert.body}
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
placeholder="你是一名专业翻译..."
className={inputCls + ' min-h-[360px] resize-y font-mono text-xs'}
/>
</Field>
{editingExpertError && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{editingExpertError}
</div>
)}
</div>
<div className="flex gap-3 justify-end mt-5">
<button
onClick={() => setEditingExpert(null)}
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={!canSave || savingExpert}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{savingExpert ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{savingExpert ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)
}
const renderMcp = () => {
const entries = Object.entries(config.mcpServers)
const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)
@ -1005,11 +803,11 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
}
}
const delChannel = (name: string) => { if (confirm(`删除渠道 "${name}"?`)) { const { [name]: _, ...rest } = config.channels; update('channels', rest) } }
const updChannel = (name: string, patch: Partial<ChannelConfig>) => update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } })
const getChannelType = (ch: ChannelConfig): string => {
const updChannel = (name: string, patch: Record<string, any>) => update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } })
const getChannelType = (ch: any): string => {
if (ch.type) return ch.type
if ('app_id' in ch || 'app_secret' in ch) return 'feishu'
if ('cred_path' in ch) return 'wechat'
if (ch.app_id !== undefined || ch.app_secret !== undefined) return 'feishu'
if (ch.cred_path !== undefined) return 'wechat'
return 'feishu'
}
return (
@ -1079,14 +877,13 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
case 'memory': return renderMemory()
case 'image': return renderImage()
case 'subagents': return renderSubagents()
case 'experts': return renderExperts()
case 'mcp': return renderMcp()
case 'channels': return renderChannels()
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={handleClose}>
<div
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
onClick={e => e.stopPropagation()}

View File

@ -1,50 +0,0 @@
// 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

@ -1,165 +0,0 @@
// 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?: SchedulerJobConfig[] }
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 FeishuChannelConfig {
enabled: boolean
app_id: string
app_secret: string
allow_from?: string[]
agent?: string
media_dir?: string
reaction_emoji?: string
max_message_chars?: number
reply_context_max_chars?: number
}
export interface WechatChannelConfig {
enabled: boolean
base_url: string
cred_path: string
force_login?: boolean
allow_from?: string[]
agent?: string
}
export interface ChannelConfig {
type?: string
enabled?: boolean
app_id?: string
app_secret?: string
agent?: string
base_url?: string
cred_path?: string
force_login?: boolean
allow_from?: string[]
media_dir?: string
reaction_emoji?: string
max_message_chars?: number
reply_context_max_chars?: number
[key: string]: unknown
}
export interface SchedulerJobConfig {
id: string
enabled: boolean
kind: string
[key: string]: unknown
}
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, ChannelConfig>
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

@ -1,169 +0,0 @@
// 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>
)
}

View File

@ -0,0 +1,127 @@
import { useState } from 'react'
import { Monitor, Smartphone, MessageSquare, Hash, ChevronDown, Eye, Pencil } from 'lucide-react'
import type { Channel } from '../../types/protocol'
interface ChannelSelectorProps {
channels: Channel[]
selectedChannel: string | null
onSelectChannel: (channelId: string) => void
}
const CHANNEL_ICONS: Record<string, React.ReactNode> = {
cli: <Monitor className="h-4 w-4" />,
websocket: <MessageSquare className="h-4 w-4" />,
feishu: <Smartphone className="h-4 w-4" />,
weixin: <Smartphone className="h-4 w-4" />,
wechat: <Smartphone className="h-4 w-4" />,
}
export function ChannelSelector({
channels,
selectedChannel,
onSelectChannel,
}: ChannelSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = channels.find((c) => c.id === selectedChannel)
return (
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<Hash className="h-4 w-4 text-[#00f0ff]" />
</h2>
</div>
{/* Channel Dropdown */}
<div className="px-3 py-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between rounded-lg border border-white/10 bg-[#1a1a25]/80 px-3 py-2.5 text-left hover:bg-[#1a1a25] transition-all"
>
<div className="flex items-center gap-2.5">
<span className="text-zinc-400">
{selected ? CHANNEL_ICONS[selected.id] || <MessageSquare className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
{selected?.name || '选择通道'}
</span>
{selected && (
<span className={`text-xs flex items-center gap-1 ${selected.isWritable ? 'text-emerald-400' : 'text-zinc-500'}`}>
{selected.isWritable ? (
<>
<Pencil className="h-3 w-3" />
</>
) : (
<>
<Eye className="h-3 w-3" />
</>
)}
</span>
)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-white/10 bg-[#1a1a25] shadow-xl shadow-black/50 overflow-hidden">
{channels.length === 0 ? (
<div className="px-3 py-3 text-sm text-zinc-500 text-center">
</div>
) : (
channels.map((channel) => (
<button
key={channel.id}
onClick={() => {
onSelectChannel(channel.id)
setIsOpen(false)
}}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-white/5 transition-colors ${
channel.id === selectedChannel ? 'bg-[#00f0ff]/10' : ''
}`}
>
<div className="flex items-center gap-2.5">
<span className={channel.id === selectedChannel ? 'text-[#00f0ff]' : 'text-zinc-400'}>
{CHANNEL_ICONS[channel.id] || <MessageSquare className="h-4 w-4" />}
</span>
<div className="flex flex-col">
<span className={`text-sm ${channel.id === selectedChannel ? 'text-white font-medium' : 'text-zinc-300'}`}>
{channel.name}
</span>
{channel.description && (
<span className="text-xs text-zinc-500">{channel.description}</span>
)}
</div>
</div>
<span className={`text-xs px-1.5 py-0.5 rounded ${
channel.isWritable
? 'bg-emerald-400/10 text-emerald-400'
: 'bg-zinc-500/10 text-zinc-500'
}`}>
{channel.isWritable ? '可输入' : '只读'}
</span>
</button>
))
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { FolderOpen, ChevronDown, Hash } from 'lucide-react'
import type { Session } from '../../hooks/useChat'
interface SessionSelectorProps {
sessions: Session[]
selectedSession: string | null
channelId: string // 使用 channelId 而不是 channelName
onSelectSession: (sessionId: string) => void
}
export function SessionSelector({
sessions,
selectedSession,
channelId,
onSelectSession,
}: SessionSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const selected = sessions.find((s) => s.id === selectedSession)
// 按通道 ID 筛选 Session
const channelSessions = sessions.filter(
(s) => s.channel_name === channelId
)
return (
<div className="relative">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<FolderOpen className="h-4 w-4 text-[#00f0ff]" />
Session
</h2>
</div>
{/* Session Dropdown */}
<div className="px-3 py-2">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between rounded-lg border border-white/10 bg-[#1a1a25]/80 px-3 py-2.5 text-left hover:bg-[#1a1a25] transition-all"
>
<div className="flex items-center gap-2.5">
<span className="text-zinc-400">
<FolderOpen className="h-4 w-4" />
</span>
<div className="flex flex-col">
<span className="text-sm font-medium text-white truncate max-w-[160px]">
{selected?.title || '选择 Session'}
</span>
{selected && (
<span className="text-xs text-zinc-500">
{selected.message_count}
</span>
)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-white/10 bg-[#1a1a25] shadow-xl shadow-black/50 overflow-hidden">
{channelSessions.length === 0 ? (
<div className="px-3 py-3 text-sm text-zinc-500 text-center">
Session
</div>
) : (
channelSessions.map((session, index) => (
<button
key={session.id}
onClick={() => {
onSelectSession(session.id)
setIsOpen(false)
}}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left hover:bg-white/5 transition-colors ${
session.id === selectedSession ? 'bg-[#00f0ff]/10' : ''
}`}
>
<div className="flex items-center gap-2.5">
<span className={session.id === selectedSession ? 'text-[#00f0ff]' : 'text-zinc-400'}>
<Hash className="h-4 w-4" />
</span>
<div className="flex flex-col">
<span className={`text-sm truncate max-w-[140px] ${
session.id === selectedSession ? 'text-white font-medium' : 'text-zinc-300'
}`}>
{session.title}
</span>
<span className="text-xs text-zinc-500">
{session.message_count}
</span>
</div>
</div>
<span className="text-xs text-zinc-600 font-mono">
{index + 1}
</span>
</button>
))
)}
</div>
</>
)}
</div>
</div>
)
}

View File

@ -1,125 +0,0 @@
import type {
ChatMessage,
WsOutbound,
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
StreamDelta,
StreamEnd,
ExecutionCompleted,
WsError,
} from '../../types/protocol'
// 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref
let messageIdCounter = 0
export function generateMessageId(): string {
messageIdCounter += 1
return `msg_${Date.now()}_${messageIdCounter}`
}
/** 重置计数器(仅测试使用) */
export function _resetMessageIdCounterForTests(): void {
messageIdCounter = 0
}
/** 从服务端消息中提取 subagent_task_id如果该消息类型携带此字段 */
export function getSubagentTaskId(message: WsOutbound): string | undefined {
if (message.type === 'tool_call' || message.type === 'tool_result'
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id
}
if (message.type === 'stream_delta' || message.type === 'stream_end') {
return (message as StreamDelta | StreamEnd).subagent_task_id
}
if (message.type === 'execution_completed' || message.type === 'error') {
return (message as ExecutionCompleted | WsError).subagent_task_id
}
return undefined
}
/** 将服务端消息转换为 UI ChatMessage不兼容的消息类型返回 null */
export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | null {
switch (message.type) {
case 'assistant_response': {
const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
return {
id: msg.id,
role: role as ChatMessage['role'],
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
attachments: msg.attachments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}
}
case 'tool_call': {
const msg = message as ToolCall
return {
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}
}
case 'tool_result': {
const msg = message as ToolResult
return {
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
}
}
case 'tool_pending': {
const msg = message as ToolPending
return {
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
}
}
case 'stream_delta': {
const msg = message as StreamDelta
return {
id: msg.id,
role: 'assistant' as const,
content: msg.delta,
timestamp: Math.floor(Date.now() / 1000),
type: 'message' as const,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_delta,
}
}
case 'error': {
return {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${message.message}`,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
}
default:
return null
}
}

View File

@ -1,22 +0,0 @@
import type { ChatMessage } from '../../types/protocol'
/** 子智能体视图(栈中的一层) */
export interface SubAgentView {
taskId: string
description: string
subagentType: string
status: string
summary?: string
messages: ChatMessage[]
}
/** 定时任务执行对话查看视图 */
export interface SchedulerJobView {
jobId: string
description: string
channel: string
chatId: string
messages: ChatMessage[]
}
export const DEFAULT_CHAT_ID = 'default'

View File

@ -1,34 +0,0 @@
import { useState, useCallback, useMemo, useRef } from 'react'
import type { WsInbound, Command } from '../../types/protocol'
export interface UseConnectionReturn {
connectionId: string | null
isConnected: boolean
setConnectionId: (id: string | null) => void
setSendMessage: (fn: (msg: WsInbound) => boolean) => void
/** 发送命令到后端(封装 command payload 序列化) */
sendCommand: (cmd: Command) => void
}
export function useConnection(): UseConnectionReturn {
const [connectionId, setConnectionId] = useState<string | null>(null)
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
sendMessageRef.current = fn
}, [])
const sendCommand = useCallback((cmd: Command) => {
sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(cmd) })
}, [])
const isConnected = useMemo(() => connectionId !== null, [connectionId])
return {
connectionId,
isConnected,
setConnectionId,
setSendMessage,
sendCommand,
}
}

View File

@ -1,297 +0,0 @@
import { useState, useCallback, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
import type {
ChatMessage,
WsOutbound,
Topic,
StreamDelta,
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
ExecutionCompleted,
WsError,
TaskStarted,
Attachment,
Command,
} from '../../types/protocol'
import { generateMessageId, getSubagentTaskId } from './messageMappers'
interface UseMessagesOptions {
selectedTopicRef: MutableRefObject<string | null>
topicsRef: MutableRefObject<Topic[]>
bumpTopicRefreshTrigger: () => void
}
export interface UseMessagesReturn {
messages: ChatMessage[]
setMessages: Dispatch<SetStateAction<ChatMessage[]>>
isLoading: boolean
setIsLoading: Dispatch<SetStateAction<boolean>>
handleMessage: (content: string, attachments?: Attachment[]) => void
clearMessages: () => void
handleStop: () => Command
/** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */
handleMainViewMessage: (message: WsOutbound) => boolean
}
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set())
const applyUserMessageId = useCallback((userMessageId: string) => {
if (syncedUserMessageIdsRef.current.has(userMessageId)) return
syncedUserMessageIdsRef.current.add(userMessageId)
setMessages(prev => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'user') {
const updated = [...prev]
updated[i] = { ...updated[i], id: userMessageId }
return updated
}
}
return prev
})
}, [])
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'user',
content,
timestamp: Math.floor(Date.now() / 1000),
type: 'message',
attachments: attachments || [],
},
])
setIsLoading(true)
}, [])
const clearMessages = useCallback(() => {
setMessages([])
}, [])
const handleStop = useCallback((): Command => {
return { type: 'stop_execution' }
}, [])
const handleMainViewMessage = useCallback((message: WsOutbound): boolean => {
switch (message.type) {
case 'task_started': {
const msg = message as TaskStarted
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
// 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) return true
setMessages((prev) => {
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = prev.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !prev[idx].navigateToTaskId) {
const updated = [...prev]
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
return updated
}
}
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
return updated
}
}
return prev
})
return true
}
case 'stream_delta': {
const msg = message as StreamDelta
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => {
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...prev]
const existing = updated[existingIdx]
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
return updated
}
return [
...prev,
{
id: msg.id,
role: 'assistant' as const,
content: msg.delta,
timestamp: Math.floor(Date.now() / 1000),
type: 'message' as const,
reasoningContent: msg.reasoning_delta,
},
]
})
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
return true
}
case 'stream_end': {
return true
}
case 'execution_completed': {
const msg = message as ExecutionCompleted
if (getSubagentTaskId(message)) return true
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setIsLoading(false)
return true
}
case 'assistant_response': {
const msg = message as AssistantResponse
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
setMessages((prev) => {
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
const newMsg: ChatMessage = {
id: msg.id,
role,
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
attachments: msg.attachments,
reasoningContent: msg.reasoning_content,
}
if (existingIdx >= 0) {
const updated = [...prev]
updated[existingIdx] = newMsg
return updated
}
return [...prev, newMsg]
})
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)
if (currentTopic && !currentTopic.description) {
bumpTopicRefreshTrigger()
}
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
return true
}
case 'tool_call': {
const msg = message as ToolCall
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
},
])
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
return true
}
case 'tool_result': {
const msg = message as ToolResult
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
},
])
return true
}
case 'tool_pending': {
const msg = message as ToolPending
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
},
])
return true
}
case 'execution_cancelled': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: (message as { type: 'execution_cancelled'; message: string }).message,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
])
setIsLoading(false)
return true
}
case 'error': {
if (getSubagentTaskId(message)) return true
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: `Error: ${(message as WsError).message}`,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
])
setIsLoading(false)
return true
}
default:
return false
}
}, [selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId])
return {
messages,
setMessages,
isLoading,
setIsLoading,
handleMessage,
clearMessages,
handleStop,
handleMainViewMessage,
}
}

View File

@ -1,98 +0,0 @@
import { useState, useCallback, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
import type {
WsOutbound,
SchedulerJobSummary,
SchedulerJobSessionLookup,
Command,
} from '../../types/protocol'
import { serverMessageToChatMessage } from './messageMappers'
import type { SchedulerJobView } from './types'
export interface UseSchedulerViewReturn {
schedulerView: SchedulerJobView | null
setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>>
schedulerViewRef: MutableRefObject<SchedulerJobView | null>
schedulerJobs: SchedulerJobSummary[]
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>
sidebarTab: 'topics' | 'scheduler'
setSidebarTab: (tab: 'topics' | 'scheduler') => void
requestSchedulerJobList: () => Command
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
exitSchedulerJobView: () => void
/** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */
handleSchedulerMessage: (message: WsOutbound) => boolean
}
export function useSchedulerView(): UseSchedulerViewReturn {
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
useEffect(() => {
schedulerViewRef.current = schedulerView
}, [schedulerView])
const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' }
}, [])
const enterSchedulerJobView = useCallback(
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
const newView: SchedulerJobView = {
jobId,
description,
channel: lookup.channel,
chatId: lookup.chat_id,
messages: [],
}
schedulerViewRef.current = newView
setSchedulerView(newView)
return {
type: 'load_chat_messages',
channel: lookup.channel,
chat_id: lookup.chat_id,
}
},
[]
)
const exitSchedulerJobView = useCallback(() => {
schedulerViewRef.current = null
setSchedulerView(null)
}, [])
/** Tier 1 路由调度器视图激活时chat 消息追加到 schedulerView非 chat 消息 fall through */
const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => {
const currentSchedulerView = schedulerViewRef.current
if (!currentSchedulerView) return false
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSchedulerView((prev) =>
prev
? { ...prev, messages: [...prev.messages, chatMsg] }
: prev
)
return true
}
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
return false
}, [])
// scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置
return {
schedulerView,
setSchedulerView,
schedulerViewRef,
schedulerJobs,
setSchedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
enterSchedulerJobView,
exitSchedulerJobView,
handleSchedulerMessage,
}
}

View File

@ -1,56 +0,0 @@
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'
import type { SessionSummary, Command } from '../../types/protocol'
export interface UseSessionsReturn {
sessions: SessionSummary[]
setSessions: Dispatch<SetStateAction<SessionSummary[]>>
selectedSessionId: string | null
setSelectedSessionId: Dispatch<SetStateAction<string | null>>
session: SessionSummary | null
sessionId: string | null
chatId: string
selectSession: (sessionId: string) => void
requestSessionList: (selectedChannel: string) => Command
}
interface UseSessionsOptions {
/** selectSession 时额外执行的副作用 */
onSessionChange?: () => void
}
export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
const [sessions, setSessions] = useState<SessionSummary[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const selectedSession = useMemo(
() => sessions.find(s => s.session_id === selectedSessionId) ?? null,
[sessions, selectedSessionId]
)
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession])
const chatId = useMemo(() => sessionId ?? 'default', [sessionId])
const selectSession = useCallback((id: string) => {
setSelectedSessionId(id)
options?.onSessionChange?.()
}, [options])
const requestSessionList = useCallback((selectedChannel: string): Command => {
return {
type: 'list_sessions_by_channel',
channel_name: selectedChannel,
include_archived: false,
}
}, [])
return {
sessions,
setSessions,
selectedSessionId,
setSelectedSessionId,
session: selectedSession,
sessionId,
chatId,
selectSession,
requestSessionList,
}
}

View File

@ -1,104 +0,0 @@
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'
import type {
MemorySummary,
SkillSummary,
TodoItemSummary,
Channel,
Command,
} from '../../types/protocol'
export interface UseSideDataReturn {
memories: MemorySummary[]
setMemories: Dispatch<SetStateAction<MemorySummary[]>>
skills: SkillSummary[]
setSkills: Dispatch<SetStateAction<SkillSummary[]>>
todos: TodoItemSummary[]
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
channels: Channel[]
setChannels: Dispatch<SetStateAction<Channel[]>>
selectedChannel: string
setSelectedChannel: Dispatch<SetStateAction<string>>
isWritable: boolean
requestMemoryList: () => Command
createMemory: (namespace: string, key: string, content: string) => Command
updateMemory: (id: string, content: string) => Command
deleteMemory: (id: string) => Command
requestSkillList: () => Command
requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command
requestChannelList: () => Command
}
export function useSideData(): UseSideDataReturn {
const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
const isWritable = useMemo(
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false,
[channels, selectedChannel]
)
const requestMemoryList = useCallback((): Command => {
return { type: 'list_memories' }
}, [])
const createMemory = useCallback((namespace: string, key: string, content: string): Command => {
return { type: 'create_memory', namespace, key, content }
}, [])
const updateMemory = useCallback((id: string, content: string): Command => {
return { type: 'update_memory', id, content }
}, [])
const deleteMemory = useCallback((id: string): Command => {
return { type: 'delete_memory', id }
}, [])
const requestSkillList = useCallback((): Command => {
return { type: 'list_skills' }
}, [])
const requestTodoList = useCallback((): Command => {
return { type: 'list_todos' }
}, [])
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
return { type: 'list_todos', task_id: subTaskId }
}, [])
const requestChannelList = useCallback((): Command => {
return { type: 'list_channels' }
}, [])
return {
memories,
setMemories,
skills,
setSkills,
todos,
setTodos,
highlightedMessageId,
setHighlightedMessageId,
channels,
setChannels,
selectedChannel,
setSelectedChannel,
isWritable,
requestMemoryList,
createMemory,
updateMemory,
deleteMemory,
requestSkillList,
requestTodoList,
requestSubAgentTodoList,
requestChannelList,
}
}

View File

@ -1,387 +0,0 @@
import { useState, useCallback, useMemo, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
import type {
ChatMessage,
WsOutbound,
StreamDelta,
WsError,
ToolCall,
TaskStarted,
TaskMessagesLoaded,
Command,
} from '../../types/protocol'
import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers'
import type { SubAgentView } from './types'
interface UseSubAgentViewOptions {
/** 发送命令到后端(用于子代理 todo_write 后刷新待办) */
sendCommand: (cmd: Command) => void
/** 构建子代理待办刷新命令 */
requestSubAgentTodoList: (subTaskId: string) => Command
}
export interface UseSubAgentViewReturn {
subAgentStack: SubAgentView[]
setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>>
subAgentView: SubAgentView | null
subAgentViewRef: MutableRefObject<SubAgentView | null>
subAgentStackRef: MutableRefObject<SubAgentView[]>
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
exitSubAgentView: () => Command | null
navigateToSubAgentLevel: (index: number) => Command | null
/** 处理子智能体视图的消息路由Tier 2返回是否已处理 */
handleSubAgentMessage: (message: WsOutbound) => boolean
}
export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn {
const { sendCommand, requestSubAgentTodoList } = options
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([])
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
const subAgentViewRef = useRef<SubAgentView | null>(null)
const subAgentStackRef = useRef<SubAgentView[]>([])
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
// ref 同步:确保回调中读到最新值
useEffect(() => {
subAgentViewRef.current = subAgentView
}, [subAgentView])
useEffect(() => {
subAgentStackRef.current = subAgentStack
}, [subAgentStack])
// 追加消息到栈顶视图(含流式累加)
const appendToSubAgentViewMessage = useCallback((message: WsOutbound) => {
// stream_delta: accumulate into existing message by ID, or create new
if (message.type === 'stream_delta') {
const msg = message as StreamDelta
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...top.messages]
const existing = updated[existingIdx]
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
}
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
})
return
}
// stream_end: no-op, assistant_response will replace
if (message.type === 'stream_end') return
// execution_completed: 更新栈顶 status 为 completed
if (message.type === 'execution_completed') {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, status: 'completed' }
return newStack
})
return
}
// error: 更新栈顶 status 为 error并追加错误消息
if (message.type === 'error') {
const errMsg = message as WsError
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, status: 'error', messages: [...top.messages, errorChatMsg] }
return newStack
})
return
}
// Other messages: assistant_response replaces streamed message by ID
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (message.type === 'assistant_response') {
const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...top.messages]
updated[existingIdx] = chatMsg
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
}
} else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') {
// 按 id + type 去重,避免 load_task_messages 并发调用导致重复。
const exists = top.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type)
if (exists) return prev
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
})
}
}, [])
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
setSubAgentStack((prev) => {
const idx = prev.findIndex(v => v.taskId === taskId)
if (idx < 0) return prev
const layer = prev[idx]
if (message.type === 'execution_completed') {
const newStack = [...prev]
newStack[idx] = { ...layer, status: 'completed' }
return newStack
}
if (message.type === 'error') {
const errMsg = message as WsError
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
const newStack = [...prev]
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] }
return newStack
}
if (message.type === 'stream_delta') {
const msg = message as StreamDelta
const existingIdx = layer.messages.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...layer.messages]
const existing = updated[existingIdx]
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
const newStack = [...prev]
newStack[idx] = { ...layer, messages: updated }
return newStack
}
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
const newStack = [...prev]
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }
return newStack
}
if (message.type === 'stream_end') return prev
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
if (message.type === 'assistant_response') {
const existingIdx = layer.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...layer.messages]
updated[existingIdx] = chatMsg
const newStack = [...prev]
newStack[idx] = { ...layer, messages: updated }
return newStack
}
} else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') {
const exists = layer.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type)
if (exists) return prev
}
const newStack = [...prev]
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }
return newStack
})
}, [])
const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => {
const newView: SubAgentView = {
taskId,
description,
subagentType: subagentType || '',
status: 'loading',
messages: [],
}
// 同步设置 ref消除竞态窗口
subAgentViewRef.current = newView
subAgentStackRef.current = [...subAgentStackRef.current, newView]
setSubAgentStack((prev) => [...prev, newView])
return { type: 'load_task_messages', task_id: taskId }
}, [])
const exitSubAgentView = useCallback((): Command | null => {
const current = subAgentStackRef.current
if (current.length <= 1) {
subAgentViewRef.current = null
subAgentStackRef.current = []
setSubAgentStack([])
return null
}
const newStack = current.slice(0, -1)
const newTop = newStack[newStack.length - 1]
subAgentViewRef.current = newTop
const clearedStack = [...newStack]
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
subAgentStackRef.current = clearedStack
setSubAgentStack(clearedStack)
return { type: 'load_task_messages', task_id: newTop.taskId }
}, [])
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
const current = subAgentStackRef.current
if (index < 0) {
subAgentViewRef.current = null
subAgentStackRef.current = []
setSubAgentStack([])
return null
}
if (index >= current.length) return null
const newStack = current.slice(0, index + 1)
const newTop = newStack[newStack.length - 1]
subAgentViewRef.current = newTop
const clearedStack = [...newStack]
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
subAgentStackRef.current = clearedStack
setSubAgentStack(clearedStack)
return { type: 'load_task_messages', task_id: newTop.taskId }
}, [])
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
const handleSubAgentMessage = useCallback((message: WsOutbound): boolean => {
const currentSubAgentView = subAgentViewRef.current
if (!currentSubAgentView) return false
if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (msg.task_id !== top.taskId) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = {
...top,
subagentType: msg.subagent_type,
status: msg.status,
summary: msg.summary,
}
return newStack
})
return true
}
if (message.type === 'task_started') {
const msg = message as TaskStarted
if (msg.parent_task_id === currentSubAgentView.taskId) {
let matched = false
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
if (msg.tool_call_id) {
const idx = updatedMessages.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
matched = true
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
}
for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i]
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
matched = true
break
}
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
})
if (!matched) {
const key = msg.tool_call_id || `fallback:${msg.task_id}`
pendingTaskNavsRef.current.set(key, msg.task_id)
}
return true
}
}
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
appendToSubAgentViewMessage(message)
// 检查 pending navigation当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
if (message.type === 'tool_call') {
const tc = message as ToolCall
if (tc.tool_name === 'task' && tc.tool_call_id) {
const key = tc.tool_call_id
const pendingTaskId = pendingTaskNavsRef.current.get(key)
if (pendingTaskId) {
pendingTaskNavsRef.current.delete(key)
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
const idx = updatedMessages.findIndex(m =>
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
if (idx >= 0) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
return prev
})
}
}
}
// 子代理 todo_write 完成后自动刷新待办列表
if (message.type === 'tool_result' && (message as { tool_name: string }).tool_name === 'todo_write') {
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
sendCommand(refreshCmd)
}
return true
}
// 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId
if (msgSubagentTaskId) {
appendToSubAgentLayerMessage(msgSubagentTaskId, message)
return true
}
// 消息不属于子智能体路由fall through 到主视图
return false
}, [appendToSubAgentViewMessage, appendToSubAgentLayerMessage, sendCommand, requestSubAgentTodoList])
return {
subAgentStack,
setSubAgentStack,
subAgentView,
subAgentViewRef,
subAgentStackRef,
enterSubAgentView,
exitSubAgentView,
navigateToSubAgentLevel,
handleSubAgentMessage,
}
}

View File

@ -1,104 +0,0 @@
import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
import type { Topic, TopicList, TopicSummary, Command } from '../../types/protocol'
export interface UseTopicsReturn {
topics: Topic[]
setTopics: Dispatch<SetStateAction<Topic[]>>
selectedTopic: string | null
setSelectedTopic: Dispatch<SetStateAction<string | null>>
topicRefreshTrigger: number
bumpTopicRefreshTrigger: () => void
topicsRef: MutableRefObject<Topic[]>
selectedTopicRef: MutableRefObject<string | null>
pendingNewTopicRef: MutableRefObject<boolean>
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
handleTopicList: (msg: TopicList) => boolean
createTopic: (title?: string) => Command
switchTopic: (topicId: string) => Command
deleteTopic: (topicId: string) => Command
requestTopicList: (sessionId: string | null) => Command | null
}
export function useTopics(): UseTopicsReturn {
const [topics, setTopics] = useState<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0)
const topicsRef = useRef<Topic[]>([])
const selectedTopicRef = useRef<string | null>(null)
const pendingNewTopicRef = useRef(false)
// ref 同步:确保回调中读到最新值
useEffect(() => {
topicsRef.current = topics
}, [topics])
useEffect(() => {
selectedTopicRef.current = selectedTopic
}, [selectedTopic])
const bumpTopicRefreshTrigger = useCallback(() => {
setTopicRefreshTrigger(n => n + 1)
}, [])
const handleTopicList = useCallback((msg: TopicList): boolean => {
const newTopics: Topic[] = msg.topics.map((t: TopicSummary) => ({
id: t.topic_id,
session_id: t.session_id,
title: t.title,
description: t.description || undefined,
message_count: Number(t.message_count),
created_at: t.created_at,
updated_at: t.last_active_at,
}))
setTopics(newTopics)
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
if (pendingNewTopicRef.current) {
pendingNewTopicRef.current = false
if (newTopics.length > 0) {
setSelectedTopic(newTopics[0].id)
return true
}
}
return false
}, [])
const createTopic = useCallback((title?: string): Command => {
pendingNewTopicRef.current = true
return {
type: 'create_session',
title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
}
}, [])
const switchTopic = useCallback((topicId: string): Command => {
return { type: 'switch_topic', topic_id: topicId }
}, [])
const deleteTopic = useCallback((topicId: string): Command => {
return { type: 'delete_topic', topic_id: topicId }
}, [])
const requestTopicList = useCallback((sessionId: string | null): Command | null => {
if (!sessionId) return null
return { type: 'list_topics', session_id: sessionId }
}, [])
return {
topics,
setTopics,
selectedTopic,
setSelectedTopic,
topicRefreshTrigger,
bumpTopicRefreshTrigger,
topicsRef,
selectedTopicRef,
pendingNewTopicRef,
handleTopicList,
createTopic,
switchTopic,
deleteTopic,
requestTopicList,
}
}

View File

@ -1,407 +0,0 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useChat } from './useChat'
import type {
WsInbound,
SessionEstablished,
SessionList,
SessionSummary,
TopicList,
TopicSummary,
StreamDelta,
AssistantResponse,
ToolCall,
ToolResult,
ToolPending,
WsError,
TaskStarted,
TaskMessagesLoaded,
MemoryList,
MemorySummary,
SkillList,
SkillSummary,
TodoList,
TodoItemSummary,
ChannelList,
Channel,
SchedulerJobList,
SchedulerJobSummary,
SchedulerJobSessionLookup,
ExecutionCancelled,
} from '../types/protocol'
// ---- helpers ----
function renderUseChat() {
const sendMessage = vi.fn((_msg: WsInbound) => true)
const { result } = renderHook(() => useChat())
act(() => {
result.current.setSendMessage(sendMessage)
})
return { result, sendMessage }
}
/** 取出 sendMessage 收到的最后一条 command payload已 JSON.parse */
function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
const calls = sendMessage.mock.calls
const last = calls.length > 0 ? calls[calls.length - 1][0] as WsInbound : undefined
if (last && last.type === 'command') {
return JSON.parse(last.payload)
}
return undefined
}
// ---- fixtures ----
const sessionEstablished: SessionEstablished = {
type: 'session_established',
session_id: 'sess-1',
}
function makeSession(id: string): SessionSummary {
return {
session_id: id,
title: `Session ${id}`,
channel_name: 'websocket',
chat_id: `chat-${id}`,
message_count: 0,
last_active_at: 1000,
}
}
const sessionList: SessionList = {
type: 'session_list',
sessions: [makeSession('s1'), makeSession('s2')],
}
function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
return {
topic_id: id,
session_id: sessionId,
title: `Topic ${id}`,
message_count: 0,
created_at: 1000,
last_active_at: 2000,
}
}
const topicList: TopicList = {
type: 'topic_list',
topics: [makeTopicSummary('t1'), makeTopicSummary('t2')],
session_id: 's1',
}
const streamDelta1: StreamDelta = {
type: 'stream_delta',
id: 'm1',
delta: 'Hello',
}
const streamDelta2: StreamDelta = {
type: 'stream_delta',
id: 'm1',
delta: ' world',
}
const assistantResponse: AssistantResponse = {
type: 'assistant_response',
id: 'm1',
content: 'Hello world',
role: 'assistant',
}
const toolCall: ToolCall = {
type: 'tool_call',
id: 'tc1',
tool_call_id: 'tc1',
tool_name: 'calculator',
arguments: { x: 1 },
content: 'calling calculator',
role: 'tool',
}
const toolResult: ToolResult = {
type: 'tool_result',
id: 'tr1',
tool_call_id: 'tc1',
tool_name: 'calculator',
content: '42',
role: 'tool',
}
const toolPending: ToolPending = {
type: 'tool_pending',
id: 'tp1',
tool_call_id: 'tp1',
tool_name: 'bash',
content: 'waiting',
resume_hint: 'resume later',
role: 'tool',
}
const errorMsg: WsError = {
type: 'error',
code: 'ERR',
message: 'something broke',
}
const executionCancelled: ExecutionCancelled = {
type: 'execution_cancelled',
message: 'stopped by user',
}
const memoryList: MemoryList = {
type: 'memory_list',
memories: [
{ id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 },
] as MemorySummary[],
}
const skillList: SkillList = {
type: 'skill_list',
skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[],
}
const todoList: TodoList = {
type: 'todo_list',
todos: [
{ id: 'todo1', content: 'task', status: 'pending', priority: 'high', created_at: 1, updated_at: 2 },
] as TodoItemSummary[],
scope_key: 'main',
}
const channelList: ChannelList = {
type: 'channel_list',
channels: [
{ id: 'websocket', name: 'WebSocket', isWritable: true },
{ id: 'cli', name: 'CLI', isWritable: false },
] as Channel[],
}
const schedulerJobList: SchedulerJobList = {
type: 'scheduler_job_list',
jobs: [
{ id: 'job1', kind: 'one_off', schedule: {}, enabled: true, state: 'idle', run_count: 0, created_at: 1 } as SchedulerJobSummary,
],
}
// ---- tests ----
beforeEach(() => {
vi.clearAllMocks()
})
describe('useChat - handleServerMessage characterization', () => {
it('1. session_established sets connectionId and isConnected', () => {
const { result } = renderUseChat()
expect(result.current.isConnected).toBe(false)
act(() => result.current.handleServerMessage(sessionEstablished))
expect(result.current.connectionId).toBe('sess-1')
expect(result.current.isConnected).toBe(true)
})
it('2. session_list fills sessions and auto-selects the first', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(sessionList))
expect(result.current.sessions).toHaveLength(2)
expect(result.current.selectedSessionId).toBe('s1')
expect(result.current.session?.session_id).toBe('s1')
})
it('3. topic_list maps topics; after createTopic it auto-focuses the first (newest)', () => {
const { result } = renderUseChat()
// establish session + topic list to set baseline
act(() => result.current.handleServerMessage(sessionEstablished))
act(() => result.current.handleServerMessage(sessionList))
// first topic_list (without createTopic) sets topics but does NOT auto-select
act(() => result.current.handleServerMessage(topicList))
expect(result.current.topics).toHaveLength(2)
expect(result.current.selectedTopic).toBeNull()
// simulate createTopic flow: pendingNewTopicRef set true, then new topic_list arrives
act(() => result.current.createTopic('new topic'))
const newTopicList: TopicList = {
type: 'topic_list',
topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')],
session_id: 's1',
}
act(() => result.current.handleServerMessage(newTopicList))
expect(result.current.selectedTopic).toBe('t3')
})
it('4. stream_delta creates a message then accumulates into it by id', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(streamDelta1))
expect(result.current.messages).toHaveLength(1)
expect(result.current.messages[0].content).toBe('Hello')
act(() => result.current.handleServerMessage(streamDelta2))
expect(result.current.messages).toHaveLength(1)
expect(result.current.messages[0].content).toBe('Hello world')
})
it('5. assistant_response replaces the streamed message by id', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(streamDelta1))
act(() => result.current.handleServerMessage(streamDelta2))
act(() => result.current.handleServerMessage(assistantResponse))
expect(result.current.messages).toHaveLength(1)
expect(result.current.messages[0].content).toBe('Hello world')
expect(result.current.messages[0].id).toBe('m1')
})
it('6. tool_call / tool_result / tool_pending append corresponding message types', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(toolCall))
act(() => result.current.handleServerMessage(toolResult))
act(() => result.current.handleServerMessage(toolPending))
expect(result.current.messages).toHaveLength(3)
expect(result.current.messages[0].type).toBe('tool_call')
expect(result.current.messages[0].toolName).toBe('calculator')
expect(result.current.messages[1].type).toBe('tool_result')
expect(result.current.messages[2].type).toBe('tool_pending')
expect(result.current.messages[2].content).toContain('resume later')
})
it('7. error and execution_cancelled append a message and clear isLoading', () => {
const { result } = renderUseChat()
// set isLoading true via handleMessage
act(() => result.current.handleMessage('hi'))
expect(result.current.isLoading).toBe(true)
act(() => result.current.handleServerMessage(errorMsg))
expect(result.current.isLoading).toBe(false)
const errMsg = result.current.messages[result.current.messages.length - 1]
expect(errMsg?.content).toBe('Error: something broke')
// reset isLoading + cleared, then test execution_cancelled
act(() => result.current.handleMessage('hi again'))
expect(result.current.isLoading).toBe(true)
act(() => result.current.handleServerMessage(executionCancelled))
expect(result.current.isLoading).toBe(false)
const cancelMsg = result.current.messages[result.current.messages.length - 1]
expect(cancelMsg?.content).toBe('stopped by user')
})
it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(memoryList))
act(() => result.current.handleServerMessage(skillList))
act(() => result.current.handleServerMessage(todoList))
act(() => result.current.handleServerMessage(channelList))
act(() => result.current.handleServerMessage(schedulerJobList))
expect(result.current.memories).toHaveLength(1)
expect(result.current.skills).toHaveLength(1)
expect(result.current.todos).toHaveLength(1)
expect(result.current.channels).toHaveLength(2)
expect(result.current.schedulerJobs).toHaveLength(1)
})
it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => {
const { result } = renderUseChat()
const taskToolCall: ToolCall = {
type: 'tool_call',
id: 'tc-task',
tool_call_id: 'tc-task',
tool_name: 'task',
arguments: { prompt: 'do sub' },
content: 'spawning sub',
role: 'tool',
}
act(() => result.current.handleServerMessage(taskToolCall))
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBeUndefined()
const taskStarted: TaskStarted = {
type: 'task_started',
task_id: 'sub-1',
description: 'sub agent',
subagent_type: 'general',
tool_call_id: 'tc-task',
}
act(() => result.current.handleServerMessage(taskStarted))
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe('sub-1')
})
it('10. sub-agent view: task_messages_loaded updates stack top; tagged messages route to sub view not main', () => {
const { result } = renderUseChat()
// enter sub-agent view for task "sub-1"
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'))
expect(result.current.subAgentView?.taskId).toBe('sub-1')
// task_messages_loaded updates top metadata
const loaded: TaskMessagesLoaded = {
type: 'task_messages_loaded',
task_id: 'sub-1',
description: 'sub agent',
subagent_type: 'general',
status: 'running',
summary: 'working',
}
act(() => result.current.handleServerMessage(loaded))
expect(result.current.subAgentView?.status).toBe('running')
expect(result.current.subAgentView?.summary).toBe('working')
// a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main
const subStream: StreamDelta = {
type: 'stream_delta',
id: 'sub-m1',
delta: 'sub hello',
subagent_task_id: 'sub-1',
}
act(() => result.current.handleServerMessage(subStream))
expect(result.current.subAgentView?.messages).toHaveLength(1)
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello')
// exit back to main: main messages should not contain the sub-agent message
act(() => result.current.exitSubAgentView())
expect(result.current.messages.find(m => m.id === 'sub-m1')).toBeUndefined()
})
it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => {
const { result } = renderUseChat()
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' }
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'))
expect(result.current.schedulerView).not.toBeNull()
act(() => result.current.handleServerMessage(assistantResponse))
expect(result.current.schedulerView?.messages).toHaveLength(1)
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world')
// exit scheduler view: main messages should not contain the routed message
act(() => result.current.exitSchedulerJobView())
expect(result.current.messages.find(m => m.id === 'm1')).toBeUndefined()
})
it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => {
const { result, sendMessage } = renderUseChat()
const todoWriteResult: ToolResult = {
type: 'tool_result',
id: 'tr-todo',
tool_call_id: 'tc-todo',
tool_name: 'todo_write',
content: 'updated',
role: 'tool',
}
act(() => result.current.handleServerMessage(todoWriteResult))
const cmd = lastCommand(sendMessage)
expect(cmd).toEqual({ type: 'list_todos' })
})
it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => {
const { result } = renderUseChat()
act(() => {
result.current.handleServerMessage(sessionEstablished)
result.current.handleServerMessage(sessionList)
result.current.handleServerMessage(topicList)
})
// topic_list without createTopic does NOT auto-select; manually select t1
act(() => result.current.selectTopic('t1'))
expect(result.current.selectedTopic).toBe('t1')
const otherTopicStream: StreamDelta = {
type: 'stream_delta',
id: 'm-other',
delta: 'should be dropped',
topic_id: 't-other',
}
act(() => result.current.handleServerMessage(otherTopicStream))
expect(result.current.messages.find(m => m.id === 'm-other')).toBeUndefined()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -114,6 +114,7 @@ export function useWebSocket({
useEffect(() => {
// 首次挂载,或者 url 发生了变化,都要重连
if (prevUrlRef.current !== url) {
console.log(`WebSocket URL changed: ${prevUrlRef.current}${url}`)
// 先断开旧连接
disconnect()
prevUrlRef.current = url

View File

@ -1 +0,0 @@
import '@testing-library/jest-dom/vitest'

View File

@ -95,7 +95,6 @@ export interface WsError {
code: string
message: string
timestamp?: number
subagent_task_id?: string
}
export interface TaskStarted {
@ -260,7 +259,6 @@ export interface TaskMessagesLoaded {
export interface ExecutionCancelled {
type: 'execution_cancelled'
message: string
timestamp?: number
}
export interface StreamDelta {
@ -284,7 +282,6 @@ export interface ExecutionCompleted {
type: 'execution_completed'
topic_id?: string
timestamp?: number
subagent_task_id?: string
}
export type WsOutbound =

View File

@ -1,11 +0,0 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})