Compare commits
36 Commits
d7ff969560
...
53a45ad7c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a45ad7c4 | ||
|
|
0a8d21fe40 | ||
|
|
46a1ca6853 | ||
|
|
47a30e87d8 | ||
|
|
fef5ae7626 | ||
|
|
7c578af0bc | ||
|
|
f3e3ebf55f | ||
|
|
7038d58207 | ||
|
|
f5ccf490ba | ||
|
|
8c6737c999 | ||
|
|
6c393dca05 | ||
|
|
ffc1f79de4 | ||
|
|
a8267631b8 | ||
|
|
393052ae48 | ||
|
|
c8660df14b | ||
|
|
f8d5f0253a | ||
|
|
7eb2933ca5 | ||
|
|
f264a7b307 | ||
|
|
4da7b5f505 | ||
|
|
7652bb16e2 | ||
|
|
7eecd0b6bb | ||
|
|
fdd22556a1 | ||
|
|
b9c880d823 | ||
|
|
6df87fe399 | ||
|
|
bde55cbf14 | ||
|
|
653687276c | ||
|
|
4329cfbbe9 | ||
|
|
43f6ea7b08 | ||
|
|
fc050c5074 | ||
|
|
b4b7c5a208 | ||
|
|
61c2fca2a7 | ||
|
|
5651c4ae7f | ||
|
|
9d7c1f2e52 | ||
|
|
c2d9bf8b5e | ||
|
|
8d30ccd020 | ||
|
|
38b9f661ee |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,5 @@
|
||||
# Rust
|
||||
target
|
||||
Cargo.lock
|
||||
|
||||
# Frontend
|
||||
web/node_modules
|
||||
|
||||
3782
Cargo.lock
generated
Normal file
3782
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@ -50,7 +50,7 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_System_Kernel",
|
||||
] }
|
||||
# MCP (Model Context Protocol) support
|
||||
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", branch = "main", features = [
|
||||
rmcp = { version = "1.7", features = [
|
||||
"client",
|
||||
"transport-child-process",
|
||||
"transport-streamable-http-client-reqwest",
|
||||
|
||||
@ -738,16 +738,6 @@ 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();
|
||||
@ -917,7 +907,7 @@ impl AgentLoop {
|
||||
"Pre-process message state before sanitize"
|
||||
);
|
||||
}
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = Self::sanitize_messages_for_llm(&mut messages);
|
||||
if removed > 0 {
|
||||
tracing::warn!(
|
||||
removed_count = removed,
|
||||
@ -937,13 +927,11 @@ impl AgentLoop {
|
||||
// 检查取消信号
|
||||
// 使用 unwrap_or(true):即使 watch channel 因异常关闭(sender drop 但未 send),
|
||||
// 也视为取消信号。defense-in-depth —— fail safe 而非 fail silent。
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Build request
|
||||
@ -959,8 +947,7 @@ 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 =
|
||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let mid_loop_removed = Self::sanitize_messages_for_llm(&mut messages);
|
||||
if mid_loop_removed > 0 {
|
||||
tracing::warn!(
|
||||
iteration = iteration,
|
||||
@ -969,50 +956,7 @@ impl AgentLoop {
|
||||
);
|
||||
}
|
||||
|
||||
// 过滤超出轮次和数量限制的图片
|
||||
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,
|
||||
};
|
||||
let request = self.build_llm_request(&messages, system_prompt_context, tools);
|
||||
|
||||
// Set up streaming delta consumer
|
||||
// Pre-generate the message ID so stream deltas and the final assistant
|
||||
@ -1116,23 +1060,10 @@ impl AgentLoop {
|
||||
|
||||
// If no tool calls, this is the final response
|
||||
if response.tool_calls.is_empty() {
|
||||
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,
|
||||
});
|
||||
let result = self.build_final_response(
|
||||
response, &streaming_message_id, had_streaming, &mut emitted_messages,
|
||||
).await;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
@ -1201,57 +1132,10 @@ impl AgentLoop {
|
||||
self.execute_tools(&response.tool_calls).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;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.process_tool_results(
|
||||
&response.tool_calls, &tool_results, &mut loop_detector,
|
||||
&mut messages, &mut emitted_messages,
|
||||
).await;
|
||||
|
||||
// Loop continues to next iteration with updated messages
|
||||
// PendingUserAction 工具的结果已在上方加入 messages,
|
||||
@ -1264,116 +1148,8 @@ impl AgentLoop {
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
if removed > 0 {
|
||||
tracing::warn!(removed_count = removed, "Sanitized before max-iterations summary");
|
||||
}
|
||||
|
||||
// Add a message asking for summary
|
||||
let summary_request = ChatMessage::user(
|
||||
"You have reached the maximum number of tool call iterations. \
|
||||
Please provide your best answer based on the work completed so far.",
|
||||
);
|
||||
messages.push(summary_request);
|
||||
|
||||
// 过滤超出轮次和数量限制的图片
|
||||
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<
|
||||
crate::providers::ChatCompletionResponse,
|
||||
Box<dyn std::error::Error + Send + Sync>,
|
||||
>;
|
||||
if self.cancel_token.is_some() {
|
||||
tokio::select! {
|
||||
_ = self.cancel_signal() => {
|
||||
let cancel = Self::build_cancel_result(self.max_iterations, emitted_messages);
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return Ok(cancel);
|
||||
}
|
||||
result = self.provider.chat(request) => {
|
||||
final_result = result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final_result = self.provider.chat(request).await;
|
||||
}
|
||||
|
||||
match final_result {
|
||||
Ok(response) => {
|
||||
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;
|
||||
Ok(AgentProcessResult {
|
||||
final_response: assistant_message,
|
||||
emitted_messages,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
provider = %self.provider.name(),
|
||||
model = %self.provider.model_id(),
|
||||
error = %e,
|
||||
error_details = %format_error_chain(e.as_ref()),
|
||||
"Failed to get summary from LLM"
|
||||
);
|
||||
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;
|
||||
Ok(AgentProcessResult {
|
||||
final_response: final_message,
|
||||
emitted_messages,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Max iterations reached - request final summary from LLM
|
||||
Ok(self.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages).await)
|
||||
}
|
||||
|
||||
/// 等待取消信号。若未配置 cancel_token,永远不返回。
|
||||
@ -1389,6 +1165,217 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
/// 净化消息中的不完整 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 {
|
||||
tracing::warn!("Max iterations reached, requesting final summary from LLM");
|
||||
|
||||
// Defense: sanitize before final summary request
|
||||
let removed = Self::sanitize_messages_for_llm(messages);
|
||||
if removed > 0 {
|
||||
tracing::warn!(removed_count = removed, "Sanitized before max-iterations summary");
|
||||
}
|
||||
|
||||
// Add a message asking for summary
|
||||
let summary_request = ChatMessage::user(
|
||||
"You have reached the maximum number of tool call iterations. \
|
||||
Please provide your best answer based on the work completed so far.",
|
||||
);
|
||||
messages.push(summary_request);
|
||||
|
||||
let request = self.build_llm_request(messages, system_prompt_context, None);
|
||||
|
||||
// 最终 summary 调用也与取消信号竞速
|
||||
let final_result: Result<
|
||||
crate::providers::ChatCompletionResponse,
|
||||
Box<dyn std::error::Error + Send + Sync>,
|
||||
>;
|
||||
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));
|
||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
||||
return cancel;
|
||||
}
|
||||
result = self.provider.chat(request) => {
|
||||
final_result = result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final_result = self.provider.chat(request).await;
|
||||
}
|
||||
|
||||
match final_result {
|
||||
Ok(response) => {
|
||||
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 {
|
||||
final_response: assistant_message,
|
||||
emitted_messages: std::mem::take(emitted_messages),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
provider = %self.provider.name(),
|
||||
model = %self.provider.model_id(),
|
||||
error = %e,
|
||||
error_details = %format_error_chain(e.as_ref()),
|
||||
"Failed to get summary from LLM"
|
||||
);
|
||||
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 {
|
||||
final_response: final_message,
|
||||
emitted_messages: std::mem::take(emitted_messages),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建取消响应,包含已完成的迭代次数和已生成的消息数量。
|
||||
/// 构建取消响应,将取消通知追加到 `emitted_messages` 末尾后一并返回。
|
||||
/// 这样 finalize_result 会把中间消息加入内存历史,确保下一个 LLM 调用有完整上下文。
|
||||
|
||||
@ -60,27 +60,6 @@ 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 { .. })
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@ -5,7 +5,8 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::config::{
|
||||
AgentConfig, ChannelConfig, Config, FeishuChannelConfig, GatewayConfig, ModelConfig,
|
||||
ProviderConfig, SchedulerConfig, TaggedChannelConfig, WechatChannelConfig,
|
||||
ProviderConfig, SchedulerConfig, TaggedChannelConfig, WECHAT_DEFAULT_BASE_URL,
|
||||
WechatChannelConfig,
|
||||
};
|
||||
|
||||
/// Interactive configuration wizard for PicoBot
|
||||
@ -79,6 +80,7 @@ impl InitWizard {
|
||||
mcp_servers: HashMap::new(),
|
||||
image_context: crate::config::ImageContextConfig::default(),
|
||||
subagents: crate::config::SubagentsConfig::default(),
|
||||
experts: crate::config::ExpertsConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -741,7 +743,7 @@ impl InitWizard {
|
||||
|
||||
// Use default values directly
|
||||
let channel_name = "wechat";
|
||||
let base_url = "https://ilinkai.weixin.qq.com";
|
||||
let base_url = WECHAT_DEFAULT_BASE_URL;
|
||||
let cred_path = Self::default_wechat_cred_path();
|
||||
let force_login = false;
|
||||
|
||||
@ -832,6 +834,7 @@ impl InitWizard {
|
||||
mcp_servers: existing.mcp_servers.clone(),
|
||||
image_context: existing.image_context.clone(),
|
||||
subagents: existing.subagents.clone(),
|
||||
experts: existing.experts.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -67,6 +67,7 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
timestamp: Some(crate::protocol::now_timestamp()),
|
||||
subagent_task_id: None,
|
||||
});
|
||||
return outbounds;
|
||||
}
|
||||
@ -197,6 +198,7 @@ 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(),
|
||||
|
||||
@ -2,10 +2,8 @@ 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;
|
||||
@ -13,7 +11,6 @@ use std::sync::Arc;
|
||||
/// 获取当前话题命令处理器
|
||||
pub struct GetCurrentSessionCommandHandler {
|
||||
store: Arc<SessionStore>,
|
||||
session_manager: Option<SessionManager>,
|
||||
system_prompt_provider: Option<Arc<dyn SystemPromptProvider>>,
|
||||
}
|
||||
|
||||
@ -21,16 +18,10 @@ 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
|
||||
@ -79,12 +70,11 @@ 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)))?;
|
||||
|
||||
// Load messages from session memory
|
||||
let messages = get_messages_from_session(
|
||||
&handler.session_manager,
|
||||
&ctx.channel_name,
|
||||
chat_id,
|
||||
).await?;
|
||||
// 直读 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()))?;
|
||||
|
||||
let actual_message_count = messages.len();
|
||||
let message_tokens = estimate_tokens(&messages);
|
||||
|
||||
@ -149,6 +149,15 @@ 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,
|
||||
@ -158,7 +167,7 @@ fn reconstruct_task_from_db(
|
||||
parent_channel_name: record.channel_name.clone(),
|
||||
description,
|
||||
subagent_type,
|
||||
state: TaskSessionState::Completed,
|
||||
state: TaskSessionState::Unknown,
|
||||
created_at: record.created_at,
|
||||
updated_at: now,
|
||||
summary: None,
|
||||
|
||||
@ -26,38 +26,3 @@ 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,9 +213,11 @@ async fn handle_save_session(
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(
|
||||
MessageKind::Notification,
|
||||
&format!("Session saved to: {}", output_path.display()),
|
||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||
&format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")),
|
||||
)
|
||||
.with_metadata("filepath", output_path.to_string_lossy().as_ref())
|
||||
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
|
||||
.with_metadata("message_count", &message_count.to_string()))
|
||||
}
|
||||
|
||||
@ -583,9 +585,11 @@ pub fn generate_system_prompt_markdown(system_prompt: &Option<SystemPrompt>) ->
|
||||
|
||||
output.push_str("# System Prompt\n\n");
|
||||
if let Some(prompt) = system_prompt {
|
||||
output.push_str("```\n");
|
||||
// 直接输出 system prompt 内容,不加 ``` 围栏。
|
||||
// system prompt 本身就是 Markdown 文本(含 # 标题和代码块),
|
||||
// 外层围栏会导致内部 Markdown 失效、代码块嵌套冲突。
|
||||
output.push_str(&prompt.content);
|
||||
output.push_str("\n```\n\n");
|
||||
output.push_str("\n\n");
|
||||
} else {
|
||||
output.push_str("*No system prompt available*\n\n");
|
||||
}
|
||||
@ -703,7 +707,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
||||
// 返回成功或失败消息
|
||||
match result {
|
||||
Ok(output_path) => {
|
||||
let msg = format!("Session saved to: {}", output_path.display());
|
||||
let msg = format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/"));
|
||||
tracing::info!("{}", msg);
|
||||
Ok(Some(msg))
|
||||
}
|
||||
|
||||
@ -5,11 +5,10 @@ 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,
|
||||
get_messages_from_session, load_subagent_data, SubagentTaskData,
|
||||
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;
|
||||
@ -175,7 +174,6 @@ pub struct SaveTopicCommandHandler {
|
||||
store: Arc<SessionStore>,
|
||||
task_repository: Arc<dyn TaskRepository>,
|
||||
system_prompt_provider: Arc<dyn SystemPromptProvider>,
|
||||
session_manager: Option<SessionManager>,
|
||||
}
|
||||
|
||||
impl SaveTopicCommandHandler {
|
||||
@ -188,14 +186,8 @@ 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]
|
||||
@ -252,14 +244,19 @@ async fn handle_save_topic(
|
||||
|
||||
tracing::debug!(topic_id = %topic_id, chat_id = %chat_id, "Attempting to save topic");
|
||||
|
||||
// 从 Session 获取当前 history(包含已压缩的消息)
|
||||
let messages = get_messages_from_session(
|
||||
&handler.session_manager,
|
||||
&ctx.channel_name,
|
||||
chat_id,
|
||||
).await?;
|
||||
// 直读 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)))?;
|
||||
|
||||
tracing::debug!(message_count = messages.len(), "Got messages from session");
|
||||
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");
|
||||
|
||||
// 调用保存函数
|
||||
let output_path = save_topic_to_file(
|
||||
@ -279,8 +276,10 @@ async fn handle_save_topic(
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(
|
||||
MessageKind::Notification,
|
||||
&format!("Topic saved to: {}", output_path.display()),
|
||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||
&format!("Topic saved to: {}", output_path.display().to_string().replace('\\', "/")),
|
||||
)
|
||||
.with_metadata("filepath", output_path.to_string_lossy().as_ref())
|
||||
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
|
||||
.with_metadata("message_count", &message_count.to_string()))
|
||||
}
|
||||
@ -38,6 +38,8 @@ pub struct Config {
|
||||
pub image_context: ImageContextConfig,
|
||||
#[serde(default)]
|
||||
pub subagents: SubagentsConfig,
|
||||
#[serde(default)]
|
||||
pub experts: ExpertsConfig,
|
||||
}
|
||||
|
||||
/// 图片上下文限制配置
|
||||
@ -169,6 +171,34 @@ 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)]
|
||||
@ -390,8 +420,11 @@ 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 {
|
||||
"https://ilinkai.weixin.qq.com".to_string()
|
||||
WECHAT_DEFAULT_BASE_URL.to_string()
|
||||
}
|
||||
|
||||
fn default_wechat_cred_path() -> String {
|
||||
@ -885,19 +918,16 @@ impl Config {
|
||||
tracing::info!(path = %path.display(), "Config loaded");
|
||||
fs::read_to_string(path)?
|
||||
} else {
|
||||
// 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)?
|
||||
}
|
||||
// 主目录配置不存在时直接自动创建,不再 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)?
|
||||
};
|
||||
let content = resolve_env_placeholders(&content);
|
||||
let config: Config = serde_json::from_str(&content)?;
|
||||
|
||||
1520
src/experts/mod.rs
Normal file
1520
src/experts/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,50 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider};
|
||||
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::experts::ExpertPromptProvider;
|
||||
use crate::experts::ExpertRuntime;
|
||||
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
||||
use crate::gateway::todo_prompt_provider::TodoPromptProvider;
|
||||
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
||||
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 实例)
|
||||
@ -36,6 +68,8 @@ 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 {
|
||||
@ -49,6 +83,8 @@ impl AgentFactory {
|
||||
Self {
|
||||
tools,
|
||||
skills,
|
||||
experts,
|
||||
subagent_runtime,
|
||||
reinject_every,
|
||||
prompt_repository,
|
||||
instance_id,
|
||||
@ -69,16 +105,15 @@ impl AgentFactory {
|
||||
"AgentFactory: creating agent with config"
|
||||
);
|
||||
|
||||
// 创建组合的系统提示词提供者
|
||||
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()),
|
||||
]));
|
||||
// 创建组合的系统提示词提供者(与命令侧 /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(),
|
||||
);
|
||||
|
||||
AgentLoop::with_tools_and_system_prompt_provider(
|
||||
request.provider_config,
|
||||
|
||||
@ -43,7 +43,13 @@ impl AgentPromptProvider {
|
||||
/// 记录注入事件
|
||||
fn record_injection(&self, context: &SystemPromptContext) {
|
||||
if let Some(session_id) = &context.session_id {
|
||||
let _ = self.repository.mark_agent_prompt_reinjected(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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,15 +13,7 @@ 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,
|
||||
@ -31,58 +23,4 @@ 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)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,58 +13,6 @@
|
||||
- 当现有工具是完成任务的最直接方式时,优先使用工具。
|
||||
- 除非用户明确要求改变方向,否则保持用户原本目标不变。
|
||||
|
||||
## 记忆处理
|
||||
|
||||
### 记忆检索
|
||||
在绝大多数请求开始时,都应先使用长期记忆检索工具 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保存记忆
|
||||
|
||||
## 助理原则
|
||||
|
||||
- 优先解决问题,而不是展示过程。
|
||||
@ -80,48 +28,14 @@
|
||||
- 默认短而清楚,按信息密度组织内容。
|
||||
- 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。
|
||||
|
||||
## 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工具获取,避免时间不准确
|
||||
|
||||
## 定时任务
|
||||
|
||||
- 默认创建静默任务(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工作项执行任务,如果工作项不在适用就更新,不得随意遗漏工作项
|
||||
- 完成一项工作就标记一项已完成,不建议批量标记已完成,这样用户不能把握任务执行进度
|
||||
- 禁止将未完成的工作项标记为已完成
|
||||
## 用户附件
|
||||
用户发过来了一些附件,先判断文件后缀名能不能直接读取,如果不能直接read的,比如xlsx,就要通过代码等其他方式读取里面的内容
|
||||
|
||||
@ -1,10 +1,40 @@
|
||||
use axum::{Json, extract::State};
|
||||
use axum::{Json, extract::{Query, 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 {
|
||||
@ -119,11 +149,16 @@ 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(),
|
||||
}))
|
||||
}
|
||||
@ -152,7 +187,13 @@ 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;
|
||||
let _ = restart_tx.send(true);
|
||||
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."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(RestartResponse {
|
||||
@ -182,3 +223,539 @@ 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),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,51 +270,6 @@ 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,
|
||||
|
||||
@ -25,7 +25,7 @@ pub mod session_message_service;
|
||||
pub mod session_pool;
|
||||
pub mod static_files;
|
||||
pub mod tool_registry_factory;
|
||||
pub mod todo_prompt_provider;
|
||||
pub mod tool_prompt_provider;
|
||||
pub mod ws;
|
||||
|
||||
use axum::{Router, routing};
|
||||
@ -43,6 +43,7 @@ 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;
|
||||
@ -63,6 +64,9 @@ 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 {
|
||||
@ -80,6 +84,7 @@ 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();
|
||||
|
||||
@ -87,13 +92,14 @@ impl GatewayState {
|
||||
mcp_servers: config.mcp_servers.clone(),
|
||||
};
|
||||
|
||||
let (session_manager, task_repository, mcp_manager) = build_session_manager_with_sender(
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime) = build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
config.time.timezone.clone(),
|
||||
provider_config,
|
||||
provider_configs,
|
||||
skills,
|
||||
skills.clone(),
|
||||
experts.clone(),
|
||||
Arc::new(BusSessionMessageSender::new(bus.clone())),
|
||||
std::collections::HashSet::new(),
|
||||
config.tools.task.clone(),
|
||||
@ -121,6 +127,9 @@ impl GatewayState {
|
||||
cancel_manager,
|
||||
restart_tx,
|
||||
mcp_manager,
|
||||
skills,
|
||||
experts,
|
||||
subagent_runtime,
|
||||
})
|
||||
}
|
||||
|
||||
@ -230,6 +239,17 @@ 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())
|
||||
@ -240,6 +260,17 @@ 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())
|
||||
|
||||
@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::agent::{AgentError, CompositeSystemPromptProvider, PersistingEmittedMessageHandler};
|
||||
use crate::agent::{AgentError, PersistingEmittedMessageHandler};
|
||||
use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
|
||||
use crate::command::adapter::InputAdapter;
|
||||
use crate::command::adapters::channel::ChannelInputAdapter;
|
||||
@ -19,10 +19,9 @@ 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_prompt_provider::AgentPromptProvider;
|
||||
use crate::gateway::agent_factory::build_system_prompt_provider;
|
||||
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;
|
||||
|
||||
@ -65,21 +64,20 @@ impl InboundProcessor {
|
||||
command_router.register(Box::new(switch_handler));
|
||||
|
||||
// 创建 system_prompt_provider(用于 save_session, save_topic, get_current)
|
||||
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)),
|
||||
]));
|
||||
// 与 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(),
|
||||
);
|
||||
|
||||
// 注册 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())
|
||||
));
|
||||
|
||||
@ -98,7 +96,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(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! 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;
|
||||
@ -16,6 +17,7 @@ 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,
|
||||
@ -43,6 +45,7 @@ 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,
|
||||
@ -50,7 +53,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>>), AgentError> {
|
||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
|
||||
build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
@ -58,6 +61,7 @@ pub(crate) fn build_session_manager(
|
||||
provider_config,
|
||||
provider_configs,
|
||||
skills,
|
||||
experts,
|
||||
Arc::new(NoopSessionMessageSender),
|
||||
disabled_tools,
|
||||
task_config,
|
||||
@ -77,6 +81,7 @@ 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,
|
||||
@ -85,7 +90,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>>), AgentError> {
|
||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
|
||||
let store = Arc::new(
|
||||
SessionStore::new()
|
||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
||||
@ -172,7 +177,7 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
}
|
||||
|
||||
// Create SubAgentRuntime (if task tool is enabled)
|
||||
let (factory, task_repository): (_, Arc<dyn TaskRepository>) = if task_config.enabled {
|
||||
let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = 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(
|
||||
@ -185,8 +190,13 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
)
|
||||
);
|
||||
|
||||
// Create subagent catalog with discovery
|
||||
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
||||
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(),
|
||||
@ -197,13 +207,13 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
max_nesting_depth: task_config.max_nesting_depth,
|
||||
};
|
||||
|
||||
let subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
|
||||
let default_subagent_runtime = Arc::new(DefaultSubAgentRuntime::new(
|
||||
runtime_config,
|
||||
task_repository.clone(),
|
||||
conversations.clone(),
|
||||
subagent_tools.clone(),
|
||||
provider_config.clone(),
|
||||
catalog,
|
||||
subagent_runtime.clone(),
|
||||
bus.clone(),
|
||||
store.clone(),
|
||||
));
|
||||
@ -211,14 +221,16 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)
|
||||
if factory.is_enabled("task") {
|
||||
subagent_tools.register(TaskTool::new(
|
||||
subagent_runtime.clone(),
|
||||
default_subagent_runtime.clone(),
|
||||
Some(task_config.max_nesting_depth),
|
||||
));
|
||||
}
|
||||
|
||||
(factory.with_subagent_runtime(subagent_runtime), task_repository)
|
||||
(factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime)
|
||||
} else {
|
||||
(factory, Arc::new(InMemoryTaskRepository::new()))
|
||||
// task_config 未启用时仍创建 subagent_runtime(供 API 使用)
|
||||
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
|
||||
(factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime)
|
||||
};
|
||||
|
||||
// Build base tools
|
||||
@ -260,6 +272,8 @@ 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(),
|
||||
);
|
||||
@ -288,6 +302,8 @@ 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,
|
||||
@ -296,5 +312,5 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
scheduled_tasks,
|
||||
memory_maintenance,
|
||||
task_repository: task_repository.clone(),
|
||||
}), task_repository, mcp_manager))
|
||||
}), task_repository, mcp_manager, subagent_runtime))
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ 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;
|
||||
@ -249,13 +250,19 @@ 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(),
|
||||
);
|
||||
@ -526,16 +533,6 @@ 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) {
|
||||
@ -614,6 +611,8 @@ 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,
|
||||
@ -627,6 +626,8 @@ 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,
|
||||
@ -642,6 +643,8 @@ 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,
|
||||
@ -667,6 +670,9 @@ 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,
|
||||
@ -674,6 +680,7 @@ impl SessionManager {
|
||||
provider_config,
|
||||
provider_configs,
|
||||
skills,
|
||||
experts,
|
||||
disabled_tools,
|
||||
task_config,
|
||||
subagents_config,
|
||||
@ -682,7 +689,7 @@ impl SessionManager {
|
||||
mcp_config,
|
||||
None,
|
||||
)
|
||||
.map(|(session_manager, _, _)| session_manager)
|
||||
.map(|(session_manager, _, _, _)| session_manager)
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Arc<ToolRegistry> {
|
||||
@ -705,6 +712,16 @@ 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()
|
||||
}
|
||||
@ -938,6 +955,7 @@ mod tests {
|
||||
skills,
|
||||
store,
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -986,6 +1004,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2012,6 +2031,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2052,6 +2072,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
100,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -2125,6 +2146,7 @@ mod tests {
|
||||
skills,
|
||||
store.clone(),
|
||||
0,
|
||||
Arc::new(SubagentRuntime::from_config(Default::default())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -259,16 +259,6 @@ 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
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
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"`),更新任务时使用相同的 id。id 可以从之前 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`:
|
||||
- 对话开始时,检查是否有未完成的任务
|
||||
- 不确定当前任务状态时,先查询再操作
|
||||
- 完成一个任务后,查看剩余任务
|
||||
"#;
|
||||
198
src/gateway/tool_prompt_provider.rs
Normal file
198
src/gateway/tool_prompt_provider.rs
Normal file
@ -0,0 +1,198 @@
|
||||
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"`),更新任务时使用相同的 id。id 可以从之前 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 工具"#;
|
||||
@ -92,12 +92,6 @@ 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();
|
||||
|
||||
@ -124,7 +118,7 @@ impl ToolRegistryFactory {
|
||||
}
|
||||
if self.is_enabled("todo_write") {
|
||||
if let Some(ref state) = self.todo_state {
|
||||
registry.register(TodoWriteTool::new(state.clone()));
|
||||
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
|
||||
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
|
||||
}
|
||||
}
|
||||
@ -232,7 +226,7 @@ impl ToolRegistryFactory {
|
||||
// Todo 追踪工具
|
||||
if self.is_enabled("todo_write") {
|
||||
if let Some(ref state) = self.todo_state {
|
||||
registry.register(TodoWriteTool::new(state.clone()));
|
||||
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
|
||||
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::GatewayState;
|
||||
use crate::agent::{AgentError, CompositeSystemPromptProvider};
|
||||
use crate::agent::AgentError;
|
||||
use crate::bus::{InboundMessage, MediaItem};
|
||||
use crate::command::adapter::{InputAdapter, OutputAdapter};
|
||||
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
|
||||
@ -25,9 +25,8 @@ 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_prompt_provider::AgentPromptProvider;
|
||||
use crate::gateway::agent_factory::build_system_prompt_provider;
|
||||
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;
|
||||
@ -267,6 +266,7 @@ 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,6 +278,7 @@ 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;
|
||||
}
|
||||
@ -370,6 +371,7 @@ 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(());
|
||||
@ -380,6 +382,7 @@ 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(());
|
||||
@ -387,7 +390,6 @@ 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();
|
||||
@ -395,14 +397,16 @@ async fn handle_inbound(
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let prompt_repository = state.session_manager.store().clone();
|
||||
|
||||
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)),
|
||||
]));
|
||||
// 与 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 mut router = CommandRouter::new();
|
||||
// 注册 Session 处理器
|
||||
@ -422,7 +426,10 @@ 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())));
|
||||
router.register(Box::new(
|
||||
GetCurrentSessionCommandHandler::new(store.clone())
|
||||
.with_system_prompt_provider(system_prompt_provider.clone()),
|
||||
));
|
||||
// 注册 load_topic 处理器
|
||||
router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
|
||||
// 注册 load_task_messages 处理器
|
||||
@ -440,7 +447,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())
|
||||
@ -796,6 +803,12 @@ 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());
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ pub mod client;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod experts;
|
||||
pub mod gateway;
|
||||
pub mod logging;
|
||||
pub mod mcp;
|
||||
|
||||
@ -252,8 +252,8 @@ impl McpClientManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Get server info (returns Option<&ServerInfo>)
|
||||
let info = client.peer_info().cloned();
|
||||
// Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+)
|
||||
let info = client.peer_info().map(|arc| arc.as_ref().clone());
|
||||
|
||||
// List available tools
|
||||
let tools = client.list_all_tools().await?;
|
||||
@ -364,11 +364,25 @@ impl McpClientManager {
|
||||
let reader = BufReader::new(child_stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::warn!(
|
||||
server_key = %server_key_owned,
|
||||
stderr = %line,
|
||||
"MCP child process stderr"
|
||||
);
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
// Also collect into the shared buffer (cap at 50 lines)
|
||||
if let Ok(mut buf) = stderr_lines_for_task.lock() {
|
||||
if buf.len() < 50 {
|
||||
|
||||
@ -209,6 +209,8 @@ 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 {
|
||||
@ -301,6 +303,8 @@ 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 {
|
||||
|
||||
@ -172,6 +172,7 @@ 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(),
|
||||
@ -197,6 +198,7 @@ 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(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +159,9 @@ 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,
|
||||
|
||||
@ -263,6 +263,10 @@ 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,
|
||||
|
||||
@ -25,6 +25,17 @@ 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,
|
||||
@ -163,6 +174,24 @@ 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()
|
||||
@ -437,25 +466,9 @@ impl SkillCatalog {
|
||||
return None;
|
||||
}
|
||||
|
||||
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",
|
||||
);
|
||||
// 仅输出技能索引列表。
|
||||
// skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。
|
||||
let mut prompt = String::from("# 可用技能(Skills)\n\n<available_skills>\n");
|
||||
|
||||
for skill in &self.skills {
|
||||
let entry = format!(
|
||||
@ -1049,7 +1062,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 <skill> & usage</description>"));
|
||||
|
||||
@ -1435,4 +1448,65 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
270
src/storage/migrations.rs
Normal file
270
src/storage/migrations.rs
Normal file
@ -0,0 +1,270 @@
|
||||
//! Schema migration helpers.
|
||||
//!
|
||||
//! Each `ensure_*_schema` function brings a table up to the current shape,
|
||||
//! either by adding missing columns or by rebuilding the table. They run on
|
||||
//! every [`super::SessionStore`] construction and are idempotent.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::StorageError;
|
||||
|
||||
pub(super) fn ensure_sessions_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
if !has_column(conn, "sessions", "user_turn_count")? {
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"ALTER TABLE sessions ADD COLUMN user_turn_count INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "sessions", "agent_prompt_reinjection_count")? {
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"ALTER TABLE sessions ADD COLUMN agent_prompt_reinjection_count INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
if !has_column(conn, "messages", "system_context")? {
|
||||
add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN system_context TEXT")?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "messages", "reasoning_content")? {
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"ALTER TABLE messages ADD COLUMN reasoning_content TEXT",
|
||||
)?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "messages", "topic_id")? {
|
||||
add_column_if_missing(conn, "ALTER TABLE messages ADD COLUMN topic_id TEXT")?;
|
||||
// 添加外键约束(SQLite 不支持 ALTER TABLE ADD FOREIGN KEY,需要重建表)
|
||||
// 这里只添加列,外键约束由应用层保证
|
||||
}
|
||||
|
||||
if !has_column(conn, "messages", "tool_duration_ms")? {
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
"ALTER TABLE messages ADD COLUMN tool_duration_ms INTEGER",
|
||||
)?;
|
||||
}
|
||||
|
||||
// 创建 topic_id 索引(如果不存在)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_topic_seq ON messages(topic_id, seq) WHERE topic_id IS NOT NULL",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn ensure_scheduler_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
if !has_column(conn, "scheduler_jobs", "schedule_json")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduler_jobs ADD COLUMN schedule_json TEXT NOT NULL DEFAULT '{}'",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "state")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduler_jobs ADD COLUMN state TEXT NOT NULL DEFAULT 'scheduled'",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "last_status")? {
|
||||
conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN last_status TEXT", [])?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "last_error")? {
|
||||
conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN last_error TEXT", [])?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "run_count")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduler_jobs ADD COLUMN run_count INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "max_runs")? {
|
||||
conn.execute("ALTER TABLE scheduler_jobs ADD COLUMN max_runs INTEGER", [])?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "paused_at")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduler_jobs ADD COLUMN paused_at INTEGER",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
if !has_column(conn, "scheduler_jobs", "completed_at")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE scheduler_jobs ADD COLUMN completed_at INTEGER",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(), StorageError> {
|
||||
// 步骤1:去重。多条记录 scope_key 不同,改为 "default" 后会违反唯一约束。
|
||||
// 对每个 (scope_kind, namespace, memory_key) 组合保留 updated_at 最新的一条。
|
||||
conn.execute(
|
||||
"
|
||||
DELETE FROM memories
|
||||
WHERE rowid NOT IN (
|
||||
SELECT rowid FROM (
|
||||
SELECT rowid, ROW_NUMBER() OVER (
|
||||
PARTITION BY scope_kind, namespace, memory_key
|
||||
ORDER BY updated_at DESC
|
||||
) AS rn
|
||||
FROM memories
|
||||
)
|
||||
WHERE rn = 1
|
||||
)
|
||||
",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 步骤2:统一 scope_key
|
||||
conn.execute(
|
||||
"UPDATE memories SET scope_key = 'default' WHERE scope_key != 'default'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
let table_exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='todos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|count| count > 0)?;
|
||||
|
||||
if !table_exists {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
topic_id TEXT,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_by_message_id TEXT,
|
||||
PRIMARY KEY (id, scope_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_scope
|
||||
ON todos(scope_key, created_at ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_session
|
||||
ON todos(session_id);
|
||||
",
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Migration: check if old schema has single-column PRIMARY KEY on `id`
|
||||
// If so, migrate to composite PRIMARY KEY (id, scope_key)
|
||||
let sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='todos'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
let needs_migration = sql.contains("id TEXT PRIMARY KEY")
|
||||
|| (sql.contains("PRIMARY KEY") && !sql.contains("PRIMARY KEY (id, scope_key)"));
|
||||
|
||||
if needs_migration {
|
||||
tracing::info!("Migrating todos table to composite PRIMARY KEY (id, scope_key)");
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE todos_new (
|
||||
id TEXT NOT NULL,
|
||||
scope_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
topic_id TEXT,
|
||||
content TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_by_message_id TEXT,
|
||||
PRIMARY KEY (id, scope_key)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO todos_new
|
||||
SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at
|
||||
FROM todos;
|
||||
|
||||
DROP TABLE todos;
|
||||
|
||||
ALTER TABLE todos_new RENAME TO todos;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_scope
|
||||
ON todos(scope_key, created_at ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_session
|
||||
ON todos(session_id);
|
||||
",
|
||||
)?;
|
||||
tracing::info!("Todos table migration complete");
|
||||
}
|
||||
|
||||
// Column migration: add created_by_message_id if it doesn't exist
|
||||
let has_column = has_column(&conn, "todos", "created_by_message_id")?;
|
||||
if !has_column {
|
||||
tracing::info!("Adding created_by_message_id column to todos table");
|
||||
conn.execute(
|
||||
"ALTER TABLE todos ADD COLUMN created_by_message_id TEXT",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("Todos table column migration complete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn has_column(
|
||||
conn: &Connection,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
) -> Result<bool, StorageError> {
|
||||
let pragma = format!("PRAGMA table_info({})", table_name);
|
||||
let mut stmt = conn.prepare(&pragma)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let existing_name: String = row.get(1)?;
|
||||
if existing_name == column_name {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(super) fn add_column_if_missing(conn: &Connection, sql: &str) -> Result<(), StorageError> {
|
||||
match conn.execute(sql, []) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.contains("duplicate column name") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(StorageError::Database(error)),
|
||||
}
|
||||
}
|
||||
1139
src/storage/mod.rs
1139
src/storage/mod.rs
File diff suppressed because it is too large
Load Diff
220
src/storage/row_mapping.rs
Normal file
220
src/storage/row_mapping.rs
Normal file
@ -0,0 +1,220 @@
|
||||
//! Row <-> record mapping helpers and single-record lookups by connection.
|
||||
//!
|
||||
//! These free functions operate on a borrowed [`rusqlite::Connection`] (or
|
||||
//! [`rusqlite::Row`]) and have no access to the [`super::SessionStore`] pool.
|
||||
//! They are extracted from `mod.rs` to keep the main module focused on
|
||||
//! `SessionStore` methods and repository implementations.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::bus::ChatMessage;
|
||||
|
||||
use super::{
|
||||
MemoryRecord, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, SessionRecord,
|
||||
SkillEventRecord, StorageError,
|
||||
};
|
||||
|
||||
pub(super) fn get_session_with_conn(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
) -> Result<Option<SessionRecord>, StorageError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"
|
||||
SELECT id, title, channel_name, chat_id, summary,
|
||||
created_at, updated_at, last_active_at,
|
||||
archived_at, deleted_at, message_count,
|
||||
user_turn_count, agent_prompt_reinjection_count
|
||||
FROM sessions
|
||||
WHERE id = ?1 AND deleted_at IS NULL
|
||||
",
|
||||
)?;
|
||||
|
||||
stmt.query_row(params![session_id], map_session_record)
|
||||
.optional()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
|
||||
pub(super) fn get_memory_with_conn(
|
||||
conn: &Connection,
|
||||
scope_kind: &str,
|
||||
scope_key: &str,
|
||||
namespace: &str,
|
||||
memory_key: &str,
|
||||
) -> Result<Option<MemoryRecord>, StorageError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"
|
||||
SELECT id, scope_kind, scope_key, namespace, memory_key, content,
|
||||
source_type, source_session_id, source_message_id, source_message_seq,
|
||||
source_channel_name, source_chat_id, created_at, updated_at
|
||||
FROM memories
|
||||
WHERE scope_kind = ?1 AND scope_key = ?2 AND namespace = ?3 AND memory_key = ?4
|
||||
",
|
||||
)?;
|
||||
|
||||
stmt.query_row(
|
||||
params![scope_kind, scope_key, namespace, memory_key],
|
||||
map_memory_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
|
||||
pub(super) fn get_scheduler_job_with_conn(
|
||||
conn: &Connection,
|
||||
job_id: &str,
|
||||
) -> Result<Option<SchedulerJobRecord>, StorageError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"
|
||||
SELECT id, kind, schedule_json, interval_secs, startup_delay_secs,
|
||||
target_json, payload_json, enabled, state, last_status, last_error,
|
||||
run_count, max_runs, last_fired_at, next_fire_at, paused_at, completed_at,
|
||||
created_at, updated_at
|
||||
FROM scheduler_jobs
|
||||
WHERE id = ?1
|
||||
",
|
||||
)?;
|
||||
|
||||
stmt.query_row(params![job_id], map_scheduler_job_record)
|
||||
.optional()
|
||||
.map_err(StorageError::from)
|
||||
}
|
||||
|
||||
pub(super) fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
|
||||
Ok(SessionRecord {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
channel_name: row.get(2)?,
|
||||
chat_id: row.get(3)?,
|
||||
summary: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
last_active_at: row.get(7)?,
|
||||
archived_at: row.get(8)?,
|
||||
deleted_at: row.get(9)?,
|
||||
message_count: row.get(10)?,
|
||||
user_turn_count: row.get(11)?,
|
||||
agent_prompt_reinjection_count: row.get(12)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillEventRecord> {
|
||||
let payload_json: String = row.get(4)?;
|
||||
let payload = serde_json::from_str(&payload_json).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err))
|
||||
})?;
|
||||
|
||||
Ok(SkillEventRecord {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
event_type: row.get(2)?,
|
||||
skill_name: row.get(3)?,
|
||||
payload,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChatMessage> {
|
||||
let media_refs_json: String = row.get(5)?;
|
||||
let media_refs: Vec<String> = serde_json::from_str(&media_refs_json).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
media_refs_json.len(),
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(err),
|
||||
)
|
||||
})?;
|
||||
|
||||
let tool_calls_json: Option<String> = row.get(9)?;
|
||||
let tool_calls = tool_calls_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
9,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(err),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(ChatMessage {
|
||||
id: row.get(0)?,
|
||||
role: row.get(1)?,
|
||||
content: row.get(2)?,
|
||||
system_context: row.get(3)?,
|
||||
reasoning_content: row.get(4)?,
|
||||
media_refs,
|
||||
timestamp: row.get(6)?,
|
||||
tool_call_id: row.get(7)?,
|
||||
tool_name: row.get(8)?,
|
||||
tool_state: None,
|
||||
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
|
||||
tool_calls,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_memory_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryRecord> {
|
||||
Ok(MemoryRecord {
|
||||
id: row.get(0)?,
|
||||
scope_kind: row.get(1)?,
|
||||
scope_key: row.get(2)?,
|
||||
namespace: row.get(3)?,
|
||||
memory_key: row.get(4)?,
|
||||
content: row.get(5)?,
|
||||
source_type: row.get(6)?,
|
||||
source_session_id: row.get(7)?,
|
||||
source_message_id: row.get(8)?,
|
||||
source_message_seq: row.get(9)?,
|
||||
source_channel_name: row.get(10)?,
|
||||
source_chat_id: row.get(11)?,
|
||||
created_at: row.get(12)?,
|
||||
updated_at: row.get(13)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn map_scheduler_job_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<SchedulerJobRecord> {
|
||||
let schedule_json: String = row.get(2)?;
|
||||
let target_json: String = row.get(5)?;
|
||||
let payload_json: String = row.get(6)?;
|
||||
let state: String = row.get(8)?;
|
||||
let last_status: Option<String> = row.get(9)?;
|
||||
|
||||
let schedule = serde_json::from_str(&schedule_json).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(err))
|
||||
})?;
|
||||
let target = serde_json::from_str(&target_json).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(err))
|
||||
})?;
|
||||
let payload = serde_json::from_str(&payload_json).map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(err))
|
||||
})?;
|
||||
|
||||
Ok(SchedulerJobRecord {
|
||||
id: row.get(0)?,
|
||||
kind: row.get(1)?,
|
||||
schedule,
|
||||
interval_secs: row.get(3)?,
|
||||
startup_delay_secs: row.get(4)?,
|
||||
target,
|
||||
payload,
|
||||
enabled: row.get::<_, i64>(7)? != 0,
|
||||
state: SchedulerJobState::from_str(&state).ok_or_else(|| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
8,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid scheduler job state: {}", state).into(),
|
||||
)
|
||||
})?,
|
||||
last_status: last_status.and_then(|value| SchedulerJobStatus::from_str(&value)),
|
||||
last_error: row.get(10)?,
|
||||
run_count: row.get(11)?,
|
||||
max_runs: row.get(12)?,
|
||||
last_fired_at: row.get(13)?,
|
||||
next_fire_at: row.get(14)?,
|
||||
paused_at: row.get(15)?,
|
||||
completed_at: row.get(16)?,
|
||||
created_at: row.get(17)?,
|
||||
updated_at: row.get(18)?,
|
||||
})
|
||||
}
|
||||
667
src/storage/tests.rs
Normal file
667
src/storage/tests.rs
Normal file
@ -0,0 +1,667 @@
|
||||
use super::*;
|
||||
use super::migrations::has_column;
|
||||
use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT;
|
||||
use crate::domain::messages::ToolCall;
|
||||
|
||||
const TEST_CHANNEL: &str = "test-channel";
|
||||
|
||||
#[test]
|
||||
fn test_persistent_session_id_for_cli_and_channel() {
|
||||
assert_eq!(persistent_session_id("cli", "abc"), "abc");
|
||||
// 幂等:已带前缀的 chat_id 会被清理,不会累积前缀
|
||||
assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc");
|
||||
assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc");
|
||||
assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc");
|
||||
// 其他通道也幂等
|
||||
assert_eq!(persistent_session_id(TEST_CHANNEL, "test-channel:abc"), "test-channel:abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_store_roundtrip_and_lifecycle() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
let session = store.create_cli_session(Some("demo")).unwrap();
|
||||
assert_eq!(session.title, "demo");
|
||||
assert_eq!(session.channel_name, "cli");
|
||||
assert_eq!(session.chat_id, session.id);
|
||||
assert_eq!(session.message_count, 0);
|
||||
assert_eq!(session.user_turn_count, 0);
|
||||
assert_eq!(session.agent_prompt_reinjection_count, 0);
|
||||
|
||||
let first = ChatMessage::user("hello");
|
||||
let second = ChatMessage::assistant("world");
|
||||
store.append_message(&session.id, &first).unwrap();
|
||||
store.append_message(&session.id, &second).unwrap();
|
||||
|
||||
let stored = store.get_session(&session.id).unwrap().unwrap();
|
||||
assert_eq!(stored.message_count, 2);
|
||||
assert!(stored.archived_at.is_none());
|
||||
assert_eq!(stored.user_turn_count, 1);
|
||||
assert_eq!(stored.agent_prompt_reinjection_count, 0);
|
||||
|
||||
let messages = store.load_messages(&session.id).unwrap();
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].role, "assistant");
|
||||
assert_eq!(messages[1].content, "world");
|
||||
|
||||
store.rename_session(&session.id, "renamed").unwrap();
|
||||
let renamed = store.get_session(&session.id).unwrap().unwrap();
|
||||
assert_eq!(renamed.title, "renamed");
|
||||
|
||||
store.archive_session(&session.id).unwrap();
|
||||
let archived = store.get_session(&session.id).unwrap().unwrap();
|
||||
assert!(archived.archived_at.is_some());
|
||||
|
||||
let active_only = store.list_sessions("cli", false).unwrap();
|
||||
assert!(active_only.is_empty());
|
||||
|
||||
let including_archived = store.list_sessions("cli", true).unwrap();
|
||||
assert_eq!(including_archived.len(), 1);
|
||||
|
||||
store.clear_messages(&session.id).unwrap();
|
||||
let cleared = store.load_messages(&session.id).unwrap();
|
||||
assert!(cleared.is_empty());
|
||||
let cleared_session = store.get_session(&session.id).unwrap().unwrap();
|
||||
assert_eq!(cleared_session.message_count, 0);
|
||||
assert_eq!(cleared_session.user_turn_count, 0);
|
||||
assert_eq!(cleared_session.agent_prompt_reinjection_count, 0);
|
||||
|
||||
store.delete_session(&session.id).unwrap();
|
||||
assert!(store.get_session(&session.id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_channel_session_is_stable() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
||||
let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
assert_eq!(first.chat_id, "chat-1");
|
||||
assert_eq!(second.channel_name, TEST_CHANNEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_tool_calls_roundtrip() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("tools")).unwrap();
|
||||
|
||||
let assistant = ChatMessage::assistant_with_tool_calls(
|
||||
"calling tool",
|
||||
vec![ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "calculator".to_string(),
|
||||
arguments: serde_json::json!({ "expression": "3*7" }),
|
||||
}],
|
||||
);
|
||||
|
||||
store.append_message(&session.id, &assistant).unwrap();
|
||||
|
||||
let messages = store.load_messages(&session.id).unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].role, "assistant");
|
||||
assert_eq!(messages[0].tool_calls.as_ref().unwrap().len(), 1);
|
||||
assert_eq!(messages[0].tool_calls.as_ref().unwrap()[0].id, "call_1");
|
||||
assert_eq!(
|
||||
messages[0].tool_calls.as_ref().unwrap()[0].name,
|
||||
"calculator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_reasoning_content_roundtrip() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("reasoning")).unwrap();
|
||||
|
||||
let assistant = ChatMessage::assistant_with_reasoning("final answer", "hidden reasoning");
|
||||
|
||||
store.append_message(&session.id, &assistant).unwrap();
|
||||
|
||||
let messages = store.load_messages(&session.id).unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].content, "final answer");
|
||||
assert_eq!(
|
||||
messages[0].reasoning_content.as_deref(),
|
||||
Some("hidden reasoning")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_migration_adds_user_turn_and_reinjection_columns() {
|
||||
let tmp = std::env::temp_dir().join(format!("picobot_test_mig2_{}.db", uuid::Uuid::new_v4()));
|
||||
let conn = Connection::open(&tmp).unwrap();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_active_at INTEGER NOT NULL,
|
||||
archived_at INTEGER,
|
||||
deleted_at INTEGER,
|
||||
message_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
media_refs_json TEXT NOT NULL,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_calls_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
UNIQUE(session_id, seq)
|
||||
);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path_str = tmp.to_string_lossy().to_string();
|
||||
let store = SessionStore::from_connection(conn, &path_str).unwrap();
|
||||
let session = store.create_cli_session(Some("migrated")).unwrap();
|
||||
assert_eq!(session.user_turn_count, 0);
|
||||
assert_eq!(session.agent_prompt_reinjection_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_migration_adds_reasoning_content_column_to_messages() {
|
||||
let tmp = std::env::temp_dir()
|
||||
.join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
|
||||
let conn = Connection::open(&tmp).unwrap();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_active_at INTEGER NOT NULL,
|
||||
archived_at INTEGER,
|
||||
deleted_at INTEGER,
|
||||
message_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
media_refs_json TEXT NOT NULL,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_calls_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
UNIQUE(session_id, seq)
|
||||
);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path_str = tmp.to_string_lossy().to_string();
|
||||
let _store = SessionStore::from_connection(conn, &path_str).unwrap();
|
||||
let conn = _store.pool.get().unwrap();
|
||||
|
||||
assert!(has_column(&conn, "messages", "reasoning_content").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_active_history_rebuilds_active_segment_with_delta_messages() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("compact-history")).unwrap();
|
||||
|
||||
let agent_prompt = ChatMessage::system_with_context(
|
||||
"agent",
|
||||
Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()),
|
||||
);
|
||||
let seed_messages = vec![
|
||||
agent_prompt.clone(),
|
||||
ChatMessage::user("u1"),
|
||||
ChatMessage::assistant("a1"),
|
||||
ChatMessage::user("u2"),
|
||||
ChatMessage::assistant("a2"),
|
||||
ChatMessage::user("u3"),
|
||||
ChatMessage::assistant("a3"),
|
||||
ChatMessage::user("u4"),
|
||||
ChatMessage::assistant("a4"),
|
||||
];
|
||||
|
||||
for message in &seed_messages {
|
||||
store.append_message(&session.id, message).unwrap();
|
||||
}
|
||||
|
||||
let snapshot_end_seq = store
|
||||
.get_session(&session.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.message_count;
|
||||
let preserved_messages = store.load_messages(&session.id).unwrap()[3..].to_vec();
|
||||
let preserved_system_messages = vec![agent_prompt];
|
||||
|
||||
store
|
||||
.append_message(&session.id, &ChatMessage::user("u5"))
|
||||
.unwrap();
|
||||
store
|
||||
.append_message(&session.id, &ChatMessage::assistant("a5"))
|
||||
.unwrap();
|
||||
|
||||
let summary_message = ChatMessage::system("[Compressed History]\n\nsummary");
|
||||
let compacted = store
|
||||
.compact_active_history(
|
||||
&session.id,
|
||||
snapshot_end_seq,
|
||||
&preserved_system_messages,
|
||||
&summary_message,
|
||||
&preserved_messages,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(compacted);
|
||||
|
||||
let active_messages = store.load_messages(&session.id).unwrap();
|
||||
assert_eq!(active_messages.len(), 10);
|
||||
assert_eq!(active_messages[0].role, "system");
|
||||
assert_eq!(active_messages[0].content, "agent");
|
||||
assert_eq!(
|
||||
active_messages[0].system_context.as_deref(),
|
||||
Some(SYSTEM_CONTEXT_AGENT_PROMPT)
|
||||
);
|
||||
assert_eq!(active_messages[1].role, "system");
|
||||
assert_eq!(
|
||||
active_messages[1].content,
|
||||
"[Compressed History]\n\nsummary"
|
||||
);
|
||||
assert_eq!(active_messages[2].content, "u2");
|
||||
assert_eq!(active_messages[3].content, "a2");
|
||||
assert_eq!(active_messages[8].content, "u5");
|
||||
assert_eq!(active_messages[9].content, "a5");
|
||||
|
||||
let stored = store.get_session(&session.id).unwrap().unwrap();
|
||||
assert_eq!(stored.user_turn_count, 4);
|
||||
|
||||
let all_messages = store.load_all_messages(&session.id).unwrap();
|
||||
assert_eq!(all_messages.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_agent_prompt_reinjected_increments_counter() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("prompt")).unwrap();
|
||||
|
||||
store.mark_agent_prompt_reinjected(&session.id).unwrap();
|
||||
store.mark_agent_prompt_reinjected(&session.id).unwrap();
|
||||
|
||||
let stored = store.get_session(&session.id).unwrap().unwrap();
|
||||
assert_eq!(stored.agent_prompt_reinjection_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_result_roundtrip() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("tool-result")).unwrap();
|
||||
|
||||
let tool_message = ChatMessage::tool("call_9", "write", "saved to /tmp/output.txt");
|
||||
store.append_message(&session.id, &tool_message).unwrap();
|
||||
|
||||
let messages = store.load_messages(&session.id).unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].role, "tool");
|
||||
assert_eq!(messages[0].content, "saved to /tmp/output.txt");
|
||||
assert_eq!(messages[0].tool_call_id.as_deref(), Some("call_9"));
|
||||
assert_eq!(messages[0].tool_name.as_deref(), Some("write"));
|
||||
assert!(messages[0].tool_calls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_events_roundtrip() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
let session = store.create_cli_session(Some("skill-events")).unwrap();
|
||||
|
||||
store
|
||||
.append_skill_event(None, "discovered", None, &serde_json::json!({"count": 2}))
|
||||
.unwrap();
|
||||
store
|
||||
.append_skill_event(
|
||||
Some(&session.id),
|
||||
"activated",
|
||||
Some("code-review"),
|
||||
&serde_json::json!({"source": "project"}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_events = store.list_skill_events(None).unwrap();
|
||||
assert_eq!(global_events.len(), 1);
|
||||
assert_eq!(global_events[0].event_type, "discovered");
|
||||
assert_eq!(global_events[0].payload["count"], 2);
|
||||
|
||||
let session_events = store.list_skill_events(Some(&session.id)).unwrap();
|
||||
assert_eq!(session_events.len(), 1);
|
||||
assert_eq!(session_events[0].event_type, "activated");
|
||||
assert_eq!(session_events[0].skill_name.as_deref(), Some("code-review"));
|
||||
assert_eq!(session_events[0].payload["source"], "project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_roundtrip_with_source_fields() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
let saved = store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "language".to_string(),
|
||||
content: "Rust".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-1".to_string()),
|
||||
source_message_seq: Some(7),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-1".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(saved.content, "Rust");
|
||||
assert_eq!(saved.source_type, "message");
|
||||
assert_eq!(saved.source_session_id.as_deref(), Some("test-channel:chat-1"));
|
||||
assert_eq!(saved.source_message_id.as_deref(), Some("msg-1"));
|
||||
assert_eq!(saved.source_message_seq, Some(7));
|
||||
|
||||
let fetched = store
|
||||
.get_memory("user", "test-channel:user-1", "user", "language")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(fetched.id, saved.id);
|
||||
assert_eq!(fetched.source_chat_id.as_deref(), Some("chat-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_fts_tracks_upsert_and_delete() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "editor".to_string(),
|
||||
content: "Prefers rust-analyzer and cargo test output".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-2".to_string()),
|
||||
source_message_seq: Some(3),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-2".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let hits = store
|
||||
.search_memories("user", "test-channel:user-1", "rust-analyzer", None, 10)
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].memory_key, "editor");
|
||||
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "editor".to_string(),
|
||||
content: "Prefers clippy diagnostics".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-3", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-3".to_string()),
|
||||
source_message_seq: Some(4),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-3".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let old_hits = store
|
||||
.search_memories("user", "test-channel:user-1", "rust-analyzer", None, 10)
|
||||
.unwrap();
|
||||
assert!(old_hits.is_empty());
|
||||
|
||||
let new_hits = store
|
||||
.search_memories("user", "test-channel:user-1", "clippy", None, 10)
|
||||
.unwrap();
|
||||
assert_eq!(new_hits.len(), 1);
|
||||
|
||||
let deleted = store
|
||||
.delete_memory("user", "test-channel:user-1", "user", "editor")
|
||||
.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let hits_after_delete = store
|
||||
.search_memories("user", "test-channel:user-1", "clippy", None, 10)
|
||||
.unwrap();
|
||||
assert!(hits_after_delete.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_search_matches_memory_key_field() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "email_folder_preference".to_string(),
|
||||
content: "用户提到邮件时默认查看代收邮箱。".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-8", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-8".to_string()),
|
||||
source_message_seq: Some(8),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-8".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let hits = store
|
||||
.search_memories("user", "test-channel:user-1", "email_folder_preference", None, 10)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].memory_key, "email_folder_preference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_memories_any_matches_multiple_keywords_once() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "editor".to_string(),
|
||||
content: "Prefers rust-analyzer and cargo test output".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-2".to_string()),
|
||||
source_message_seq: Some(3),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-2".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "episodic".to_string(),
|
||||
memory_key: "quality".to_string(),
|
||||
content: "Tracks clippy warnings before release".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-3", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-3".to_string()),
|
||||
source_message_seq: Some(4),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-3".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let hits = store
|
||||
.search_memories_any(
|
||||
"user",
|
||||
"test-channel:user-1",
|
||||
&["rust-analyzer".to_string(), "clippy".to_string()],
|
||||
None,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.iter().any(|memory| memory.memory_key == "editor"));
|
||||
assert!(hits.iter().any(|memory| memory.memory_key == "quality"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_scope_listing_and_full_scope_read() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-2", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "style".to_string(),
|
||||
content: "偏好简洁表达".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-2", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-2".to_string()),
|
||||
source_message_seq: Some(2),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-2".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "user".to_string(),
|
||||
memory_key: "work".to_string(),
|
||||
content: "用户在做AI产品".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-1".to_string()),
|
||||
source_message_seq: Some(1),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-1".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.put_memory(&MemoryUpsert {
|
||||
scope_kind: "user".to_string(),
|
||||
scope_key: format!("{}:user-1", TEST_CHANNEL),
|
||||
namespace: "patterns".to_string(),
|
||||
memory_key: "workflow".to_string(),
|
||||
content: "习惯先问方案再要代码".to_string(),
|
||||
source_type: "message".to_string(),
|
||||
source_session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||
source_message_id: Some("msg-3".to_string()),
|
||||
source_message_seq: Some(3),
|
||||
source_channel_name: Some(TEST_CHANNEL.to_string()),
|
||||
source_chat_id: Some("chat-1".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let scope_keys = store.list_memory_scope_keys("user").unwrap();
|
||||
assert_eq!(
|
||||
scope_keys,
|
||||
vec!["test-channel:user-1".to_string(), "test-channel:user-2".to_string()]
|
||||
);
|
||||
|
||||
let full_scope = store
|
||||
.list_memories_for_scope("user", "test-channel:user-1")
|
||||
.unwrap();
|
||||
assert_eq!(full_scope.len(), 2);
|
||||
assert!(
|
||||
full_scope
|
||||
.iter()
|
||||
.all(|memory| memory.scope_key == "test-channel:user-1")
|
||||
);
|
||||
assert!(full_scope.iter().any(|memory| memory.memory_key == "work"));
|
||||
assert!(
|
||||
full_scope
|
||||
.iter()
|
||||
.any(|memory| memory.memory_key == "workflow")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_job_roundtrip_and_runtime_update() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
let saved = store
|
||||
.upsert_scheduler_job(&SchedulerJobUpsert {
|
||||
id: "heartbeat".to_string(),
|
||||
kind: "outbound_message".to_string(),
|
||||
schedule: serde_json::json!({
|
||||
"type": "interval",
|
||||
"seconds": 300,
|
||||
"startup_delay_secs": 10,
|
||||
}),
|
||||
interval_secs: 300,
|
||||
startup_delay_secs: 10,
|
||||
target: serde_json::json!({
|
||||
"channel": "test-channel",
|
||||
"chat_id": "oc_demo",
|
||||
}),
|
||||
payload: serde_json::json!({
|
||||
"content": "heartbeat",
|
||||
}),
|
||||
enabled: true,
|
||||
state: SchedulerJobState::Scheduled,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
run_count: 0,
|
||||
max_runs: Some(3),
|
||||
last_fired_at: None,
|
||||
next_fire_at: Some(1_700_000_000_000),
|
||||
paused_at: None,
|
||||
completed_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(saved.id, "heartbeat");
|
||||
assert_eq!(saved.kind, "outbound_message");
|
||||
assert_eq!(saved.state, SchedulerJobState::Scheduled);
|
||||
assert_eq!(saved.max_runs, Some(3));
|
||||
|
||||
store
|
||||
.update_scheduler_job_runtime(
|
||||
"heartbeat",
|
||||
SchedulerJobState::Completed,
|
||||
Some(SchedulerJobStatus::Ok),
|
||||
None,
|
||||
1,
|
||||
Some(1_700_000_000_000),
|
||||
None,
|
||||
None,
|
||||
Some(1_700_000_000_100),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fetched = store.get_scheduler_job("heartbeat").unwrap().unwrap();
|
||||
assert_eq!(fetched.state, SchedulerJobState::Completed);
|
||||
assert_eq!(fetched.last_status, Some(SchedulerJobStatus::Ok));
|
||||
assert_eq!(fetched.run_count, 1);
|
||||
assert_eq!(fetched.completed_at, Some(1_700_000_000_100));
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -279,6 +279,63 @@ 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
|
||||
}
|
||||
|
||||
/// 发布子智能体执行完成事件(ExecutionCompleted),metadata 含 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布子智能体执行错误事件(ErrorNotification),metadata 含 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 {
|
||||
@ -295,8 +352,8 @@ pub struct DefaultSubAgentRuntime {
|
||||
conversation_repository: Arc<dyn ConversationRepository>,
|
||||
subagent_tools: Arc<ToolRegistry>,
|
||||
provider_config: LLMProviderConfig,
|
||||
/// 子代理定义目录(内置 + 自定义)
|
||||
catalog: Arc<SubagentCatalog>,
|
||||
/// 子代理运行时协调层(管理禁用状态)
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
store: Arc<SessionStore>,
|
||||
}
|
||||
@ -308,7 +365,7 @@ impl DefaultSubAgentRuntime {
|
||||
conversation_repository: Arc<dyn ConversationRepository>,
|
||||
subagent_tools: Arc<ToolRegistry>,
|
||||
provider_config: LLMProviderConfig,
|
||||
catalog: Arc<SubagentCatalog>,
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
store: Arc<SessionStore>,
|
||||
) -> Self {
|
||||
@ -318,27 +375,17 @@ impl DefaultSubAgentRuntime {
|
||||
conversation_repository,
|
||||
subagent_tools,
|
||||
provider_config,
|
||||
catalog,
|
||||
subagent_runtime,
|
||||
bus,
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找子代理定义,找不到时 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())
|
||||
/// 查找子代理定义(过滤禁用项),找不到或被禁用时返回 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))
|
||||
}
|
||||
|
||||
/// 获取实际执行时间
|
||||
@ -530,7 +577,9 @@ 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());
|
||||
let def = self
|
||||
.find_subagent_def(task.subagent_type.as_str())
|
||||
.map_err(TaskError::InvalidArguments)?;
|
||||
|
||||
// 3. 创建任务会话
|
||||
let topic_id = parent_context.topic_id.clone();
|
||||
@ -632,6 +681,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
"Task completed, updating session"
|
||||
);
|
||||
self.task_repository.save_task_session(&session).await?;
|
||||
// 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||||
publish_subagent_completion(&self.bus, &session).await;
|
||||
Ok(tool_result)
|
||||
}
|
||||
Err(e) => {
|
||||
@ -650,6 +701,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
session.mark_failed(e.to_string());
|
||||
}
|
||||
self.task_repository.save_task_session(&session).await?;
|
||||
// 发布子智能体 ErrorNotification,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||||
publish_subagent_error(&self.bus, &session, &e.to_string()).await;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@ -708,12 +761,16 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
let mut session = session;
|
||||
session.mark_completed(tool_result.summary.clone());
|
||||
self.task_repository.save_task_session(&session).await?;
|
||||
// 发布子智能体 ExecutionCompleted,metadata 注入 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?;
|
||||
// 发布子智能体 ErrorNotification,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||||
publish_subagent_error(&self.bus, &session, &e.to_string()).await;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@ -733,7 +790,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
}
|
||||
|
||||
fn available_subagent_names(&self) -> Vec<String> {
|
||||
self.catalog.names()
|
||||
self.subagent_runtime.available_names()
|
||||
}
|
||||
}
|
||||
|
||||
@ -870,6 +927,357 @@ fn xml_escape(s: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
// ========== 子代理运行时协调层(管理禁用状态) ==========
|
||||
|
||||
#[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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 自定义子代理发现 ==========
|
||||
|
||||
/// 源顺序解析
|
||||
@ -1056,3 +1464,166 @@ 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 不应包含 general(explore 仍可用,所以 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ pub enum TaskSessionState {
|
||||
Failed,
|
||||
/// 已超时
|
||||
Timeout,
|
||||
/// 状态未知(如重启后从 DB 重建时无法可靠推断原状态)
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for TaskSessionState {
|
||||
@ -36,6 +38,17 @@ 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 {
|
||||
|
||||
@ -6,6 +6,7 @@ use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::storage::TodoRepository;
|
||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||
|
||||
// ── 数据模型 ──────────────────────────────────────────────
|
||||
@ -61,11 +62,16 @@ 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>>>>) -> Self {
|
||||
Self { state }
|
||||
pub(crate) fn new(
|
||||
state: Arc<RwLock<HashMap<String, Vec<TodoItem>>>>,
|
||||
repository: Arc<dyn TodoRepository>,
|
||||
) -> Self {
|
||||
Self { state, repository }
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,10 +164,39 @@ impl Tool for TodoWriteTool {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
// 3. 读锁获取旧状态
|
||||
// 3. 读锁获取旧状态;内存为空时从 DB 回填(与 TodoReadTool 一致,保证 merge 模式不丢失旧项)
|
||||
let old_items = {
|
||||
let guard = self.state.read().await;
|
||||
guard.get(&scope_key).cloned().unwrap_or_default()
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 构建 id → TodoItem 的旧状态映射
|
||||
@ -444,9 +479,59 @@ 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());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let result = tool
|
||||
@ -473,7 +558,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_single_in_progress_constraint() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -510,7 +595,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_state_transition_in_progress_to_completed() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -560,7 +645,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_completed_can_revert_to_in_progress() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
// 创建并完成一个任务
|
||||
@ -609,7 +694,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_can_revert_to_pending() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -657,7 +742,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_in_progress_cannot_revert_to_pending() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -703,7 +788,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_item_can_be_any_status() {
|
||||
let tool = TodoWriteTool::new(test_state());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
// 新项直接 completed — 应该允许(id 必填后不再限制初始状态)
|
||||
@ -724,7 +809,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_item_can_start_as_in_progress() {
|
||||
let tool = TodoWriteTool::new(test_state());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let result = tool
|
||||
@ -751,7 +836,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_remove_items_by_omission() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -791,7 +876,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_topic_isolation() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
|
||||
let main_context = ToolContext {
|
||||
session_id: Some("cli:chat-1".to_string()),
|
||||
@ -841,7 +926,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_list() {
|
||||
let tool = TodoWriteTool::new(test_state());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let result = tool
|
||||
@ -859,7 +944,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_todos_param() {
|
||||
let tool = TodoWriteTool::new(test_state());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let result = tool
|
||||
@ -873,7 +958,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_context() {
|
||||
let tool = TodoWriteTool::new(test_state());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
|
||||
let result = tool.execute(json!({})).await.unwrap();
|
||||
|
||||
@ -884,7 +969,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_subagent_isolation() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
|
||||
let parent_ctx = ToolContext {
|
||||
session_id: Some("cli:chat-1".to_string()),
|
||||
@ -935,7 +1020,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_merge_mode_preserves_unreferenced_items() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -977,7 +1062,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_merge_mode_add_new_item() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -1015,7 +1100,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_merge_mode_never_removes() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -1052,7 +1137,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_non_merge_still_removes_by_omission() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -1091,7 +1176,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_merge_match_by_content_fallback() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let _ = tool
|
||||
@ -1137,7 +1222,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_missing_id_validation_error() {
|
||||
let tool = TodoWriteTool::new(test_state());
|
||||
let tool = TodoWriteTool::new(test_state(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
let result = tool
|
||||
@ -1159,7 +1244,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_default_is_merge_mode() {
|
||||
let state = test_state();
|
||||
let tool = TodoWriteTool::new(state.clone());
|
||||
let tool = TodoWriteTool::new(state.clone(), mock_repo());
|
||||
let context = test_context();
|
||||
|
||||
// 先创建 2 个 todo
|
||||
@ -1196,4 +1281,59 @@ 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
1189
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react": "^19.2.15",
|
||||
@ -20,10 +22,15 @@
|
||||
},
|
||||
"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"
|
||||
"vite": "^8.0.14",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,6 +166,9 @@ 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 })
|
||||
@ -219,7 +222,7 @@ function App() {
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [topicRefreshTrigger])
|
||||
}, [topicRefreshTrigger, status, handleCommand, sendMessage, requestTopicList])
|
||||
|
||||
// Topics 加载后,自动选择第一个(仅当用户尚未手动选择 topic 时)
|
||||
useEffect(() => {
|
||||
@ -355,8 +358,18 @@ function App() {
|
||||
)
|
||||
|
||||
const handleExitSubAgentView = useCallback(() => {
|
||||
exitSubAgentView()
|
||||
}, [exitSubAgentView])
|
||||
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])
|
||||
|
||||
// 切换到定时任务 tab 时自动获取列表
|
||||
useEffect(() => {
|
||||
@ -424,10 +437,7 @@ function App() {
|
||||
alert('该待办的完成记录无法定位,可能是历史数据')
|
||||
return
|
||||
}
|
||||
// 若处于子智能体视图或定时任务视图,先退出回到主会话视图
|
||||
if (subAgentStack.length > 0) {
|
||||
navigateToSubAgentLevel(-1)
|
||||
}
|
||||
// 仅定时任务视图需要退出(其消息源是另一个 chat_id)
|
||||
if (schedulerView) {
|
||||
exitSchedulerJobView()
|
||||
}
|
||||
@ -438,7 +448,7 @@ function App() {
|
||||
setTimeout(() => {
|
||||
setHighlightedMessageId(msgId)
|
||||
}, 50)
|
||||
}, [setHighlightedMessageId, subAgentStack.length, schedulerView, navigateToSubAgentLevel, exitSchedulerJobView])
|
||||
}, [setHighlightedMessageId, schedulerView, exitSchedulerJobView])
|
||||
|
||||
const handleRefreshSchedulerJobs = useCallback(() => {
|
||||
const cmd = requestSchedulerJobList()
|
||||
@ -567,7 +577,10 @@ function App() {
|
||||
<Brain className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfigPageOpen(true)}
|
||||
onClick={() => {
|
||||
setConfigInitialTab('providers')
|
||||
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"
|
||||
@ -726,7 +739,7 @@ function App() {
|
||||
</button>
|
||||
{/* Breadcrumb: 主会话 */}
|
||||
<button
|
||||
onClick={() => navigateToSubAgentLevel(-1)}
|
||||
onClick={() => handleNavigateToSubAgentLevel(-1)}
|
||||
className="flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors shrink-0"
|
||||
title="返回主会话"
|
||||
>
|
||||
@ -742,6 +755,7 @@ function App() {
|
||||
level.status === 'timeout' ? '超时' :
|
||||
level.status === 'running' ? '执行中' :
|
||||
level.status === 'loading' ? '加载中...' :
|
||||
level.status === 'unknown' ? '未知' :
|
||||
level.status
|
||||
const statusColor =
|
||||
level.status === 'completed' ? 'text-emerald-400' :
|
||||
@ -762,7 +776,7 @@ function App() {
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigateToSubAgentLevel(idx)}
|
||||
onClick={() => handleNavigateToSubAgentLevel(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>
|
||||
@ -793,6 +807,12 @@ function App() {
|
||||
showThinking={showThinking}
|
||||
viewKey={viewKey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
sessionId={sessionId}
|
||||
settingsClosedTick={settingsClosedTick}
|
||||
onOpenSettings={() => {
|
||||
setConfigInitialTab('experts')
|
||||
setConfigPageOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -881,7 +901,14 @@ function App() {
|
||||
|
||||
{/* 系统配置页面 */}
|
||||
{configPageOpen && (
|
||||
<ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} />
|
||||
<ConfigPage
|
||||
onClose={() => {
|
||||
setConfigPageOpen(false)
|
||||
setSettingsClosedTick(t => t + 1)
|
||||
}}
|
||||
onSaveConnection={handleSaveConnection}
|
||||
initialTab={configInitialTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
56
web/src/api/client.ts
Normal file
56
web/src/api/client.ts
Normal file
@ -0,0 +1,56 @@
|
||||
// 统一的 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
|
||||
}
|
||||
}
|
||||
32
web/src/api/config.ts
Normal file
32
web/src/api/config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
53
web/src/api/experts.ts
Normal file
53
web/src/api/experts.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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 }
|
||||
}
|
||||
6
web/src/api/mcp.ts
Normal file
6
web/src/api/mcp.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { API, apiGetSilent } from './client'
|
||||
import type { McpStatusResponse } from '../components/Settings/types'
|
||||
|
||||
export function getMcpStatus(): Promise<McpStatusResponse | null> {
|
||||
return apiGetSilent<McpStatusResponse>(API.mcpStatus)
|
||||
}
|
||||
14
web/src/api/skills.ts
Normal file
14
web/src/api/skills.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
14
web/src/api/subagents.ts
Normal file
14
web/src/api/subagents.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
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 {
|
||||
@ -15,6 +17,12 @@ interface ChatContainerProps {
|
||||
viewKey?: string
|
||||
/** 高亮的消息 ID */
|
||||
highlightedMessageId?: string | null
|
||||
/** 当前 session ID,用于专家选择 */
|
||||
sessionId?: string | null
|
||||
/** 打开设置页(用于专家管理入口) */
|
||||
onOpenSettings?: () => void
|
||||
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
|
||||
settingsClosedTick?: number
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
@ -28,12 +36,23 @@ 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}
|
||||
@ -41,6 +60,7 @@ export function ChatContainer({
|
||||
isLoading={isLoading}
|
||||
isReadOnly={isReadOnly}
|
||||
channelName={channelName}
|
||||
selectedExpert={selectedExpert}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
259
web/src/components/Chat/ExpertSelector.tsx
Normal file
259
web/src/components/Chat/ExpertSelector.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -12,9 +12,11 @@ interface MessageInputProps {
|
||||
placeholder?: string
|
||||
isReadOnly?: boolean
|
||||
channelName?: string
|
||||
selectedExpert?: { name: string; description: string } | null
|
||||
}
|
||||
|
||||
interface FileAttachment {
|
||||
id: string
|
||||
file: File
|
||||
attachment: Attachment
|
||||
preview?: string // 用于图片预览
|
||||
@ -33,10 +35,13 @@ 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)
|
||||
@ -88,6 +93,7 @@ export function MessageInput({
|
||||
}
|
||||
|
||||
const fileAttachment: FileAttachment = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
attachment,
|
||||
preview: mediaType === 'image' ? base64 : undefined,
|
||||
@ -166,6 +172,7 @@ export function MessageInput({
|
||||
}
|
||||
|
||||
const fileAttachment: FileAttachment = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
attachment,
|
||||
preview: mediaType === 'image' ? base64 : undefined,
|
||||
@ -291,7 +298,7 @@ export function MessageInput({
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((att, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={att.id}
|
||||
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 ? (
|
||||
@ -360,7 +367,7 @@ export function MessageInput({
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
placeholder={effectivePlaceholder}
|
||||
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"
|
||||
|
||||
@ -1,294 +1,33 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Settings, Server, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
|
||||
Users, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
|
||||
CheckCircle, Plug, Radio, RefreshCw,
|
||||
Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
|
||||
CheckCircle, RefreshCw, UserCheck, Pencil,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────
|
||||
interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record<string, string>; llm_timeout_secs: number; memory_maintenance_timeout_secs: number }
|
||||
interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number }
|
||||
interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number }
|
||||
interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number }
|
||||
interface TimeConfig { timezone: string }
|
||||
interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: any[] }
|
||||
interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number }
|
||||
interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] }
|
||||
interface ToolsConfig { disabled: string[]; task: TaskConfig }
|
||||
interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number }
|
||||
interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number }
|
||||
interface SubagentsConfig { enabled: boolean; sources: string[] }
|
||||
interface ClientConfig { gateway_url: string }
|
||||
interface McpServerConfig {
|
||||
name?: string
|
||||
type: 'stdio' | 'streamableHttp' | 'http'
|
||||
is_active: boolean
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
cwd?: string
|
||||
base_url?: string
|
||||
headers?: Record<string, string>
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface 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>
|
||||
)
|
||||
}
|
||||
// ── 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'
|
||||
|
||||
// ── Main Component ─────────────────────────────────────
|
||||
export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPageProps) {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<TabId>('gateway')
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? 'providers')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Connection settings (localStorage-based)
|
||||
const [connHost, setConnHost] = useState(() => {
|
||||
@ -306,12 +45,71 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
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 () => {
|
||||
try {
|
||||
const resp = await fetch('/api/mcp/status')
|
||||
if (resp.ok) setMcpStatus(await resp.json())
|
||||
} catch { /* ignore fetch errors */ }
|
||||
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)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@ -321,10 +119,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
|
||||
// Load config
|
||||
useEffect(() => {
|
||||
fetch('/api/config').then(r => r.json()).then(data => {
|
||||
setConfig(data)
|
||||
getAppConfig().then(([data, err]) => {
|
||||
if (data) setConfig(data)
|
||||
if (err) setError('加载配置失败: ' + err)
|
||||
setLoading(false)
|
||||
}).catch(e => { setError('加载配置失败: ' + e.message); setLoading(false) })
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Fetch MCP status when MCP tab is selected
|
||||
@ -332,6 +131,21 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
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() }
|
||||
@ -347,63 +161,52 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
const handleSave = async () => {
|
||||
if (!config) return
|
||||
setSaving(true); setError('')
|
||||
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 || '保存失败')
|
||||
const [ok, err] = await updateAppConfig(config)
|
||||
if (!ok) {
|
||||
setError(err || '保存失败')
|
||||
} else {
|
||||
// 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 resp = await fetch('/api/restart', { method: 'POST' })
|
||||
const data = await resp.json()
|
||||
if (resp.status === 409) {
|
||||
const { status, data } = await restartGateway()
|
||||
if (status === 409) {
|
||||
setToast(data.message || '有任务运行中,请等待完成后再试')
|
||||
setRestarting(false)
|
||||
setTimeout(() => setToast(''), 5000)
|
||||
return
|
||||
}
|
||||
if (!resp.ok) throw new Error(data.message || '重启失败')
|
||||
if (status < 200 || status >= 300) 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))
|
||||
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 */ }
|
||||
if (await checkHealth()) {
|
||||
const [refreshed] = await getAppConfig()
|
||||
if (refreshed) setConfig(refreshed)
|
||||
setToast('服务已重启,配置已生效')
|
||||
setRestarting(false)
|
||||
setTimeout(() => setToast(''), 3000)
|
||||
return
|
||||
}
|
||||
}
|
||||
setToast('重启超时,请手动刷新页面')
|
||||
setRestarting(false)
|
||||
setTimeout(() => setToast(''), 5000)
|
||||
}
|
||||
poll()
|
||||
} catch (e: any) {
|
||||
setError(e.message || '重启失败')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : '重启失败')
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
@ -587,7 +390,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
<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 any })} 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 SchedulerConfig['misfire_policy'] })} className={selectCls}><option value="skip">跳过 (Skip)</option><option value="catch_up">追赶 (Catch Up)</option></select></Field>
|
||||
</SectionCard>
|
||||
</div>
|
||||
)
|
||||
@ -621,9 +424,85 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
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: '编辑文件' },
|
||||
@ -689,9 +568,332 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
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)
|
||||
@ -803,11 +1005,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
}
|
||||
}
|
||||
const delChannel = (name: string) => { if (confirm(`删除渠道 "${name}"?`)) { const { [name]: _, ...rest } = config.channels; update('channels', rest) } }
|
||||
const updChannel = (name: string, patch: Record<string, any>) => update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } })
|
||||
const getChannelType = (ch: any): string => {
|
||||
const updChannel = (name: string, patch: Partial<ChannelConfig>) => update('channels', { ...config.channels, [name]: { ...config.channels[name], ...patch } })
|
||||
const getChannelType = (ch: ChannelConfig): string => {
|
||||
if (ch.type) return ch.type
|
||||
if (ch.app_id !== undefined || ch.app_secret !== undefined) return 'feishu'
|
||||
if (ch.cred_path !== undefined) return 'wechat'
|
||||
if ('app_id' in ch || 'app_secret' in ch) return 'feishu'
|
||||
if ('cred_path' in ch) return 'wechat'
|
||||
return 'feishu'
|
||||
}
|
||||
return (
|
||||
@ -877,13 +1079,14 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
||||
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]" onClick={handleClose}>
|
||||
<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="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()}
|
||||
|
||||
50
web/src/components/Settings/constants.ts
Normal file
50
web/src/components/Settings/constants.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// Config-related constants extracted from ConfigPage.tsx
|
||||
import {
|
||||
Settings, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
|
||||
Plug, Radio, Wifi, Server, Users, UserCheck,
|
||||
} from 'lucide-react'
|
||||
import type { TabId } from './types'
|
||||
|
||||
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
||||
{ id: 'providers', label: '服务商', icon: Cpu },
|
||||
{ id: 'models', label: '模型', icon: Brain },
|
||||
{ id: 'agents', label: '代理', icon: Bot },
|
||||
{ id: 'mcp', label: 'MCP 服务器', icon: Plug },
|
||||
{ id: 'skills', label: '技能', icon: Wrench },
|
||||
{ id: 'subagents', label: '子代理', icon: Bot },
|
||||
{ id: 'experts', label: '专家', icon: UserCheck },
|
||||
{ id: 'channels', label: '渠道', icon: Radio },
|
||||
{ id: 'tools', label: '工具', icon: Settings },
|
||||
{ id: 'memory', label: '记忆维护', icon: Users },
|
||||
{ id: 'scheduler', label: '调度器', icon: Calendar },
|
||||
{ id: 'image', label: '图片上下文', icon: Image },
|
||||
{ id: 'time', label: '时间', icon: Clock },
|
||||
{ id: 'connection', label: '连接', icon: Wifi },
|
||||
{ id: 'gateway', label: '网关', icon: Server },
|
||||
]
|
||||
|
||||
export const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
|
||||
export const selectCls = inputCls
|
||||
|
||||
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' },
|
||||
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (日本标准时间, UTC+9)' },
|
||||
{ value: 'Asia/Seoul', label: 'Asia/Seoul (韩国标准时间, UTC+9)' },
|
||||
{ value: 'Asia/Singapore', label: 'Asia/Singapore (新加坡时间, UTC+8)' },
|
||||
{ value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (香港时间, UTC+8)' },
|
||||
{ value: 'Asia/Taipei', label: 'Asia/Taipei (台北时间, UTC+8)' },
|
||||
{ value: 'Asia/Bangkok', label: 'Asia/Bangkok (曼谷时间, UTC+7)' },
|
||||
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata (印度标准时间, UTC+5:30)' },
|
||||
{ value: 'Asia/Dubai', label: 'Asia/Dubai (海湾标准时间, UTC+4)' },
|
||||
{ value: 'Europe/London', label: 'Europe/London (格林威治时间, UTC+0)' },
|
||||
{ value: 'Europe/Paris', label: 'Europe/Paris (中欧时间, UTC+1)' },
|
||||
{ value: 'Europe/Berlin', label: 'Europe/Berlin (中欧时间, UTC+1)' },
|
||||
{ value: 'Europe/Moscow', label: 'Europe/Moscow (莫斯科时间, UTC+3)' },
|
||||
{ value: 'America/New_York', label: 'America/New_York (美东时间, UTC-5)' },
|
||||
{ value: 'America/Chicago', label: 'America/Chicago (美中时间, UTC-6)' },
|
||||
{ value: 'America/Denver', label: 'America/Denver (美山地时间, UTC-7)' },
|
||||
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles (美太平洋时间, UTC-8)' },
|
||||
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' },
|
||||
{ value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' },
|
||||
{ value: 'UTC', label: 'UTC (协调世界时)' },
|
||||
]
|
||||
165
web/src/components/Settings/types.ts
Normal file
165
web/src/components/Settings/types.ts
Normal file
@ -0,0 +1,165 @@
|
||||
// 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
|
||||
}
|
||||
169
web/src/components/Settings/ui.tsx
Normal file
169
web/src/components/Settings/ui.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
// Shared UI primitives extracted from ConfigPage.tsx
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { X, Plus, Trash2 } from 'lucide-react'
|
||||
import { inputCls } from './constants'
|
||||
import type { KnownSource } from './types'
|
||||
|
||||
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
|
||||
const [input, setInput] = useState('')
|
||||
const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } }
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((t, i) => (
|
||||
<span key={t} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
|
||||
{t}
|
||||
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
|
||||
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
|
||||
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SourceEditor({
|
||||
sources,
|
||||
onChange,
|
||||
knownSources,
|
||||
examplePaths,
|
||||
showCustom = true,
|
||||
}: {
|
||||
sources: string[]
|
||||
onChange: (s: string[]) => void
|
||||
knownSources: KnownSource[]
|
||||
examplePaths?: string[]
|
||||
showCustom?: boolean
|
||||
}) {
|
||||
const [customInput, setCustomInput] = useState('')
|
||||
const knownKeys = new Set(knownSources.map(k => k.key))
|
||||
const customPaths = sources.filter(s => !knownKeys.has(s))
|
||||
|
||||
const toggleKnown = (key: string) => {
|
||||
if (sources.includes(key)) {
|
||||
onChange(sources.filter(s => s !== key))
|
||||
} else {
|
||||
onChange([...sources, key])
|
||||
}
|
||||
}
|
||||
|
||||
const addCustom = () => {
|
||||
const v = customInput.trim()
|
||||
if (v && !sources.includes(v)) {
|
||||
onChange([...sources, v])
|
||||
setCustomInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeCustom = (path: string) => {
|
||||
onChange(sources.filter(s => s !== path))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Known sources as toggles */}
|
||||
<div className="space-y-2">
|
||||
{knownSources.map(src => (
|
||||
<div key={src.key} className="flex items-center justify-between py-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
|
||||
<div className="text-xs text-[var(--text-muted)] font-mono">{src.description}</div>
|
||||
</div>
|
||||
<Toggle checked={sources.includes(src.key)} onChange={() => toggleKnown(src.key)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom paths (only shown when showCustom is true) */}
|
||||
{showCustom && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">自定义路径</div>
|
||||
{customPaths.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{customPaths.map((p) => (
|
||||
<span key={p} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
|
||||
{p}
|
||||
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={customInput}
|
||||
onChange={e => setCustomInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
|
||||
placeholder="输入绝对路径,如 D:\my-skills"
|
||||
className={inputCls + ' !text-xs font-mono'}
|
||||
/>
|
||||
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{examplePaths && (
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
示例: {examplePaths.join('、')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [val, setVal] = useState(name)
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
|
||||
{editing ? (
|
||||
<input value={val} onChange={e => setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus />
|
||||
) : (
|
||||
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
125
web/src/hooks/chat/messageMappers.ts
Normal file
125
web/src/hooks/chat/messageMappers.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
22
web/src/hooks/chat/types.ts
Normal file
22
web/src/hooks/chat/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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'
|
||||
34
web/src/hooks/chat/useConnection.ts
Normal file
34
web/src/hooks/chat/useConnection.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
297
web/src/hooks/chat/useMessages.ts
Normal file
297
web/src/hooks/chat/useMessages.ts
Normal file
@ -0,0 +1,297 @@
|
||||
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
|
||||
/** 处理主视图的消息类 case(task_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,
|
||||
}
|
||||
}
|
||||
98
web/src/hooks/chat/useSchedulerView.ts
Normal file
98
web/src/hooks/chat/useSchedulerView.ts
Normal file
@ -0,0 +1,98 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
56
web/src/hooks/chat/useSessions.ts
Normal file
56
web/src/hooks/chat/useSessions.ts
Normal file
@ -0,0 +1,56 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
104
web/src/hooks/chat/useSideData.ts
Normal file
104
web/src/hooks/chat/useSideData.ts
Normal file
@ -0,0 +1,104 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
387
web/src/hooks/chat/useSubAgentView.ts
Normal file
387
web/src/hooks/chat/useSubAgentView.ts
Normal file
@ -0,0 +1,387 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
104
web/src/hooks/chat/useTopics.ts
Normal file
104
web/src/hooks/chat/useTopics.ts
Normal file
@ -0,0 +1,104 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
407
web/src/hooks/useChat.test.ts
Normal file
407
web/src/hooks/useChat.test.ts
Normal file
@ -0,0 +1,407 @@
|
||||
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
@ -114,7 +114,6 @@ export function useWebSocket({
|
||||
useEffect(() => {
|
||||
// 首次挂载,或者 url 发生了变化,都要重连
|
||||
if (prevUrlRef.current !== url) {
|
||||
console.log(`WebSocket URL changed: ${prevUrlRef.current} → ${url}`)
|
||||
// 先断开旧连接
|
||||
disconnect()
|
||||
prevUrlRef.current = url
|
||||
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@ -95,6 +95,7 @@ export interface WsError {
|
||||
code: string
|
||||
message: string
|
||||
timestamp?: number
|
||||
subagent_task_id?: string
|
||||
}
|
||||
|
||||
export interface TaskStarted {
|
||||
@ -259,6 +260,7 @@ export interface TaskMessagesLoaded {
|
||||
export interface ExecutionCancelled {
|
||||
type: 'execution_cancelled'
|
||||
message: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface StreamDelta {
|
||||
@ -282,6 +284,7 @@ export interface ExecutionCompleted {
|
||||
type: 'execution_completed'
|
||||
topic_id?: string
|
||||
timestamp?: number
|
||||
subagent_task_id?: string
|
||||
}
|
||||
|
||||
export type WsOutbound =
|
||||
|
||||
11
web/vitest.config.ts
Normal file
11
web/vitest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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'],
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user