Compare commits

...

7 Commits

20 changed files with 881 additions and 35 deletions

View File

@ -308,6 +308,7 @@ fn filter_images_by_age_and_count(
tool_call_id: message.tool_call_id.clone(), tool_call_id: message.tool_call_id.clone(),
tool_name: message.tool_name.clone(), tool_name: message.tool_name.clone(),
tool_state: message.tool_state.clone(), tool_state: message.tool_state.clone(),
tool_duration_ms: message.tool_duration_ms,
tool_calls: message.tool_calls.clone(), tool_calls: message.tool_calls.clone(),
}); });
} }
@ -656,6 +657,12 @@ pub struct AgentProcessResult {
#[async_trait] #[async_trait]
pub trait EmittedMessageHandler: Send + Sync + 'static { pub trait EmittedMessageHandler: Send + Sync + 'static {
async fn handle(&self, message: ChatMessage); async fn handle(&self, message: ChatMessage);
/// Handle a tool result message with optional execution timing.
/// Default implementation delegates to `handle()`, ignoring timing.
async fn handle_tool_result(&self, message: ChatMessage, _duration_ms: Option<u64>) {
self.handle(message).await;
}
} }
/// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB /// 装饰器:在内部 emitter 广播前,先将消息持久化到 DB
@ -688,6 +695,17 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
} }
self.inner.handle(message).await; self.inner.handle(message).await;
} }
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
// Persist the ChatMessage first (no duration field, same as before)
if let Err(e) = self.conversation_repository
.append_message_with_topic(&self.session_id, self.topic_id.as_deref(), &message)
{
tracing::error!(error = %e, session_id = %self.session_id,
"Failed to persist emitted message");
}
self.inner.handle_tool_result(message, duration_ms).await;
}
} }
pub trait SkillProvider: Send + Sync + 'static { pub trait SkillProvider: Send + Sync + 'static {
@ -1008,10 +1026,12 @@ impl AgentLoop {
} else { } else {
ToolMessageState::Completed ToolMessageState::Completed
}, },
); )
.with_tool_duration(result.duration.as_millis() as u64);
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone()); emitted_messages.push(tool_message.clone());
self.emit_live_tool_call_message(tool_message).await; let duration_ms = Some(result.duration.as_millis() as u64);
self.emit_tool_result(tool_message, duration_ms).await;
} }
LoopDetectionResult::Ok => { LoopDetectionResult::Ok => {
let tool_message = ChatMessage::tool_with_state( let tool_message = ChatMessage::tool_with_state(
@ -1023,10 +1043,12 @@ impl AgentLoop {
} else { } else {
ToolMessageState::Completed ToolMessageState::Completed
}, },
); )
.with_tool_duration(result.duration.as_millis() as u64);
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message.clone()); emitted_messages.push(tool_message.clone());
self.emit_live_tool_call_message(tool_message).await; let duration_ms = Some(result.duration.as_millis() as u64);
self.emit_tool_result(tool_message, duration_ms).await;
} }
} }
} }
@ -1156,6 +1178,12 @@ impl AgentLoop {
} }
} }
async fn emit_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
if let Some(handler) = &self.emitted_message_handler {
handler.handle_tool_result(message, duration_ms).await;
}
}
/// Determine whether to execute tools in parallel or sequentially. /// Determine whether to execute tools in parallel or sequentially.
/// ///
/// Returns true if: /// Returns true if:

View File

@ -62,6 +62,8 @@ pub struct ChatMessage {
pub tool_name: Option<String>, pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_state: Option<ToolMessageState>, pub tool_state: Option<ToolMessageState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>, pub tool_calls: Option<Vec<ToolCall>>,
} }
@ -78,6 +80,7 @@ impl ChatMessage {
reasoning_content: None, reasoning_content: None,
tool_call_id: None, tool_call_id: None,
tool_name: None, tool_name: None,
tool_duration_ms: None,
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
} }
@ -94,6 +97,7 @@ impl ChatMessage {
reasoning_content: None, reasoning_content: None,
tool_call_id: None, tool_call_id: None,
tool_name: None, tool_name: None,
tool_duration_ms: None,
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
} }
@ -110,6 +114,7 @@ impl ChatMessage {
reasoning_content: None, reasoning_content: None,
tool_call_id: None, tool_call_id: None,
tool_name: None, tool_name: None,
tool_duration_ms: None,
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
} }
@ -138,6 +143,7 @@ impl ChatMessage {
reasoning_content: None, reasoning_content: None,
tool_call_id: None, tool_call_id: None,
tool_name: None, tool_name: None,
tool_duration_ms: None,
tool_state: None, tool_state: None,
tool_calls: Some(tool_calls), tool_calls: Some(tool_calls),
} }
@ -171,6 +177,7 @@ impl ChatMessage {
reasoning_content: None, reasoning_content: None,
tool_call_id: None, tool_call_id: None,
tool_name: None, tool_name: None,
tool_duration_ms: None,
tool_state: None, tool_state: None,
tool_calls: None, tool_calls: None,
} }
@ -205,11 +212,17 @@ impl ChatMessage {
reasoning_content: None, reasoning_content: None,
tool_call_id: Some(tool_call_id.into()), tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name.into()), tool_name: Some(tool_name.into()),
tool_duration_ms: None,
tool_state: Some(tool_state), tool_state: Some(tool_state),
tool_calls: None, tool_calls: None,
} }
} }
pub fn with_tool_duration(mut self, ms: u64) -> Self {
self.tool_duration_ms = Some(ms);
self
}
pub fn has_system_context(&self, expected: &str) -> bool { pub fn has_system_context(&self, expected: &str) -> bool {
self.system_context.as_deref() == Some(expected) self.system_context.as_deref() == Some(expected)
} }

View File

@ -0,0 +1,96 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary};
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
pub struct ListSchedulerJobsCommandHandler {
store: Arc<SessionStore>,
}
impl ListSchedulerJobsCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListSchedulerJobsCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListSchedulerJobs)
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "list_scheduler_jobs",
description: "列出所有定时任务",
usage: "/list_scheduler_jobs",
})
}
async fn handle(
&self,
_cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let records = self
.store
.list_scheduler_jobs(false)
.map_err(|e| CommandError::new("LIST_JOBS_ERROR", e.to_string()))?;
let summaries: Vec<SchedulerJobSummary> = records
.into_iter()
.map(|r| {
let session_lookup = build_session_lookup(&r);
SchedulerJobSummary {
id: r.id,
kind: r.kind,
schedule: r.schedule,
enabled: r.enabled,
state: r.state.as_str().to_string(),
last_status: r.last_status.map(|s| s.as_str().to_string()),
last_error: r.last_error,
run_count: r.run_count,
max_runs: r.max_runs,
last_fired_at: r.last_fired_at,
next_fire_at: r.next_fire_at,
created_at: r.created_at,
session_lookup,
}
})
.collect();
let jobs_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("scheduler_jobs", &jobs_json))
}
}
/// 从 job 的 target_json 推导 session_lookup。
/// 只有 agent_task / silent_agent_task 才有执行对话可查看。
fn build_session_lookup(
r: &crate::storage::SchedulerJobRecord,
) -> Option<SchedulerJobSessionLookup> {
let target: serde_json::Value = r.target.clone();
match r.kind.as_str() {
"agent_task" => {
let channel = target.get("channel")?.as_str()?.to_string();
let chat_id = target.get("chat_id")?.as_str()?.to_string();
Some(SchedulerJobSessionLookup { channel, chat_id })
}
"silent_agent_task" => {
let channel = target.get("channel")?.as_str()?.to_string();
// silent_agent_task 使用虚拟 chat_id: scheduler/{job_id}
let chat_id = format!("scheduler/{}", r.id);
Some(SchedulerJobSessionLookup { channel, chat_id })
}
_ => None, // internal_event / outbound_message 无执行对话
}
}

View File

@ -0,0 +1,65 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use async_trait::async_trait;
/// 加载指定 channel + chat_id 的对话消息。
/// 实际的消息加载和发送在 ws.rs 中处理(类似 send_task_messages
/// 此 handler 仅验证参数并通过 metadata 传递 chat_id。
pub struct LoadChatMessagesCommandHandler;
impl LoadChatMessagesCommandHandler {
pub fn new() -> Self {
Self
}
}
impl Default for LoadChatMessagesCommandHandler {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CommandHandler for LoadChatMessagesCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::LoadChatMessages { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "load_chat_messages",
description: "加载指定 channel 和 chat_id 的对话消息",
usage: "/load_chat_messages <channel> <chat_id>",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::LoadChatMessages { channel, chat_id } => {
if channel.is_empty() {
return Err(CommandError::new(
"INVALID_ARGUMENT",
"channel must not be empty".to_string(),
));
}
if chat_id.is_empty() {
return Err(CommandError::new(
"INVALID_ARGUMENT",
"chat_id must not be empty".to_string(),
));
}
Ok(CommandResponse::success(ctx.request_id)
.with_metadata("load_chat_channel", &channel)
.with_metadata("load_chat_id", &chat_id))
}
_ => unreachable!(),
}
}
}

View File

@ -1,9 +1,11 @@
pub mod get_current; pub mod get_current;
pub mod help; pub mod help;
pub mod list_channels; pub mod list_channels;
pub mod list_scheduler_jobs;
pub mod list_sessions; pub mod list_sessions;
pub mod list_sessions_by_channel; pub mod list_sessions_by_channel;
pub mod list_topics; pub mod list_topics;
pub mod load_chat_messages;
pub mod load_task_messages; pub mod load_task_messages;
pub mod load_topic; pub mod load_topic;
pub mod save_session; pub mod save_session;

View File

@ -45,6 +45,13 @@ pub enum Command {
ListTopics { session_id: String }, ListTopics { session_id: String },
/// 加载子智能体任务的消息历史 /// 加载子智能体任务的消息历史
LoadTaskMessages { task_id: String }, LoadTaskMessages { task_id: String },
/// 列出所有定时任务
ListSchedulerJobs,
/// 加载指定 channel + chat_id 的对话消息
LoadChatMessages {
channel: String,
chat_id: String,
},
} }
impl Command { impl Command {
@ -63,6 +70,8 @@ impl Command {
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel", Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
Command::ListTopics { .. } => "list_topics", Command::ListTopics { .. } => "list_topics",
Command::LoadTaskMessages { .. } => "load_task_messages", Command::LoadTaskMessages { .. } => "load_task_messages",
Command::ListSchedulerJobs => "list_scheduler_jobs",
Command::LoadChatMessages { .. } => "load_chat_messages",
} }
} }
} }

View File

@ -1,16 +1,26 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, SystemPromptContext}; use async_trait::async_trait;
use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPromptContext};
use crate::bus::message::ToolMessageState; use crate::bus::message::ToolMessageState;
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT}; use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::storage::ConversationRepository;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::compaction::schedule_background_history_compaction; use super::compaction::schedule_background_history_compaction;
use super::message_prepare::enrich_user_content_with_media_refs; use super::message_prepare::enrich_user_content_with_media_refs;
use super::session::Session; use super::session::Session;
/// 空的 EmittedMessageHandler不转发消息仅配合 PersistingEmittedMessageHandler 做持久化。
struct NoOpEmittedMessageHandler;
#[async_trait]
impl EmittedMessageHandler for NoOpEmittedMessageHandler {
async fn handle(&self, _message: ChatMessage) {}
}
const SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT: &str = "系统说明当前输入来自一次已经触发的定时任务执行。你现在需要执行任务内容本身而不是创建、修改、恢复、暂停或查询新的定时任务。除非当前任务内容明确要求管理调度器否则不要调用任何定时任务管理工具像“每小时”、“每天”、“cron”、“定时”等词只应视为任务背景不应再解释为新的建任务请求。"; const SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT: &str = "系统说明当前输入来自一次已经触发的定时任务执行。你现在需要执行任务内容本身而不是创建、修改、恢复、暂停或查询新的定时任务。除非当前任务内容明确要求管理调度器否则不要调用任何定时任务管理工具像“每小时”、“每天”、“cron”、“定时”等词只应视为任务背景不应再解释为新的建任务请求。";
pub(crate) fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String { pub(crate) fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String {
@ -269,7 +279,7 @@ impl AgentExecutionService {
&self, &self,
request: ScheduledExecutionRequest<'_>, request: ScheduledExecutionRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> { ) -> Result<Vec<OutboundMessage>, AgentError> {
let (history, agent, user_message, user_message_count, original_topic_id) = { let (history, mut agent, user_message, user_message_count, original_topic_id, store, session_id) = {
let mut session_guard = request.session.lock().await; let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?; session_guard.ensure_persistent_session(request.chat_id)?;
@ -311,9 +321,27 @@ impl AgentExecutionService {
request.provider_config.clone(), request.provider_config.clone(),
)?; )?;
(history, agent, user_message, user_message_count, original_topic_id) // 获取 store 和 session_id用于构造消息持久化 handler
let store = session_guard.store();
let session_id = crate::storage::persistent_session_id(
request.channel_name,
request.chat_id,
);
(history, agent, user_message, user_message_count, original_topic_id, store, session_id)
}; };
// 定时任务没有 live_emitter需要 PersistingEmittedMessageHandler 来持久化消息
{
let persisting_handler = PersistingEmittedMessageHandler::new(
NoOpEmittedMessageHandler,
store as Arc<dyn ConversationRepository>,
&session_id,
None,
);
agent = agent.with_emitted_message_handler(Arc::new(persisting_handler));
}
// 构建系统提示词上下文 // 构建系统提示词上下文
let system_prompt_context = SystemPromptContext { let system_prompt_context = SystemPromptContext {
session_id: Some(format!("{}:{}", request.channel_name, request.chat_id)), session_id: Some(format!("{}:{}", request.channel_name, request.chat_id)),

View File

@ -132,6 +132,18 @@ impl MemoryMaintenanceService {
return Ok(None); return Ok(None);
} }
// 记忆数量不足最小保留数时,无需整理,直接跳过
// 避免浪费 LLM token 并触发无意义的 "保留数不足" 错误
if memories.len() < self.maintenance_config.min_memories_to_keep {
tracing::info!(
scope_key = %scope_key,
count = memories.len(),
min_required = self.maintenance_config.min_memories_to_keep,
"Skipping scope: not enough memories to organize"
);
return Ok(None);
}
Ok(Some(build_memory_maintenance_plan(&memories))) Ok(Some(build_memory_maintenance_plan(&memories)))
} }
@ -619,6 +631,9 @@ pub(crate) fn is_recoverable_maintenance_llm_error(error: &str) -> bool {
|| normalized.contains("stream timeout") || normalized.contains("stream timeout")
|| normalized.contains("timed out") || normalized.contains("timed out")
|| normalized.contains("timeout") || normalized.contains("timeout")
// 验证拒绝 — 记忆太少,跳过本次 scope 即可,不应视为作业失败
|| error.contains("保留数不足")
|| error.contains("合并比例超限")
} }
fn is_recoverable_maintenance_scope_error(error: &AgentError) -> bool { fn is_recoverable_maintenance_scope_error(error: &AgentError) -> bool {

View File

@ -86,6 +86,25 @@ impl EmittedMessageHandler for BusToolCallEmitter {
} }
} }
} }
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
let mut metadata = self.metadata.clone();
if let Some(ms) = duration_ms {
metadata.insert("tool_duration_ms".to_string(), ms.to_string());
}
for outbound in OutboundMessage::from_chat_message(
&self.channel_name,
&self.chat_id,
None, // session_id
None,
&metadata,
&message,
) {
if let Err(error) = self.bus.publish_outbound(outbound).await {
tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live outbound tool call");
}
}
}
} }
impl Session { impl Session {

View File

@ -8,9 +8,11 @@ use crate::command::handler::CommandRouter;
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_channels::ListChannelsCommandHandler; use crate::command::handlers::list_channels::ListChannelsCommandHandler;
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
use crate::command::handlers::list_topics::ListTopicsCommandHandler; use crate::command::handlers::list_topics::ListTopicsCommandHandler;
use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler;
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler; use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler;
@ -399,6 +401,10 @@ async fn handle_inbound(
// 注册 help 处理器 // 注册 help 处理器
let metadata = router.metadata_arc(); let metadata = router.metadata_arc();
router.register(Box::new(HelpCommandHandler::new(metadata))); router.register(Box::new(HelpCommandHandler::new(metadata)));
// 注册 list_scheduler_jobs 处理器
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
// 注册 load_chat_messages 处理器
router.register(Box::new(LoadChatMessagesCommandHandler::new()));
// 构建命令上下文 // 构建命令上下文
tracing::debug!( tracing::debug!(
@ -473,6 +479,34 @@ async fn handle_inbound(
}).await; }).await;
} }
// 处理定时任务列表
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
if let Ok(jobs) = serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json) {
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
}
}
// 处理加载聊天消息请求
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
let load_chat_channel = response.metadata.get("load_chat_channel")
.cloned()
.unwrap_or_default();
// session_id = "{channel}:{chat_id}" (cli channel 例外)
let session_id = crate::storage::persistent_session_id(
&load_chat_channel,
load_chat_id,
);
if let Err(e) = send_task_messages(&store, &session_id, sender).await {
tracing::warn!(
error = %e,
channel = %load_chat_channel,
chat_id = %load_chat_id,
session_id = %session_id,
"Failed to send chat messages"
);
}
}
if current_topic_id.is_none() { if current_topic_id.is_none() {
if let Some(topics_json) = response.metadata.get("topics") { if let Some(topics_json) = response.metadata.get("topics") {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) { match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
@ -662,6 +696,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Option<WsOutbou
content: msg.content.clone(), content: msg.content.clone(),
role: msg.role.clone(), role: msg.role.clone(),
subagent_task_id: None, subagent_task_id: None,
duration_ms: msg.tool_duration_ms,
}), }),
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending { ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
id: msg.id.clone(), id: msg.id.clone(),

View File

@ -48,6 +48,31 @@ pub struct MediaSummary {
pub file_name: Option<String>, pub file_name: Option<String>,
} }
/// 定时任务会话查找键(用于前端加载执行对话)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerJobSessionLookup {
pub channel: String,
pub chat_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerJobSummary {
pub id: String,
pub kind: String,
pub schedule: serde_json::Value,
pub enabled: bool,
pub state: String,
pub last_status: Option<String>,
pub last_error: Option<String>,
pub run_count: i64,
pub max_runs: Option<i64>,
pub last_fired_at: Option<i64>,
pub next_fire_at: Option<i64>,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_lookup: Option<SchedulerJobSessionLookup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsInbound { pub enum WsInbound {
@ -104,6 +129,8 @@ pub enum WsOutbound {
role: String, role: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
subagent_task_id: Option<String>, subagent_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
duration_ms: Option<u64>,
}, },
#[serde(rename = "tool_pending")] #[serde(rename = "tool_pending")]
ToolPending { ToolPending {
@ -156,6 +183,10 @@ pub enum WsOutbound {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>, summary: Option<String>,
}, },
#[serde(rename = "scheduler_job_list")]
SchedulerJobList {
jobs: Vec<SchedulerJobSummary>,
},
#[serde(rename = "pong")] #[serde(rename = "pong")]
Pong, Pong,
} }

View File

@ -57,6 +57,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
subagent_task_id: None, subagent_task_id: None,
duration_ms: None,
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: message.id.clone(), id: message.id.clone(),
@ -119,6 +120,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
subagent_task_id: message.metadata.get("subagent_task_id").cloned(), subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
duration_ms: message
.metadata
.get("tool_duration_ms")
.and_then(|v| v.parse().ok()),
}], }],
OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending { OutboundEventKind::ToolPending => vec![WsOutbound::ToolPending {
id: message id: message

View File

@ -572,8 +572,8 @@ impl SessionStore {
" "
INSERT INTO messages ( INSERT INTO messages (
id, session_id, topic_id, seq, role, content, id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, created_at system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
", ",
params![ params![
message.id, message.id,
@ -588,6 +588,7 @@ impl SessionStore {
message.tool_call_id, message.tool_call_id,
message.tool_name, message.tool_name,
tool_calls_json, tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.timestamp, message.timestamp,
], ],
)?; )?;
@ -649,8 +650,8 @@ impl SessionStore {
INSERT INTO messages ( INSERT INTO messages (
id, session_id, topic_id, seq, role, content, id, session_id, topic_id, seq, role, content,
system_context, reasoning_content, media_refs_json, system_context, reasoning_content, media_refs_json,
tool_call_id, tool_name, tool_calls_json, created_at tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
", ",
params![ params![
message.id, message.id,
@ -665,6 +666,7 @@ impl SessionStore {
message.tool_call_id, message.tool_call_id,
message.tool_name, message.tool_name,
tool_calls_json, tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.timestamp, message.timestamp,
], ],
)?; )?;
@ -1414,7 +1416,7 @@ impl SessionStore {
let conn = self.conn.lock().expect("session db mutex poisoned"); let conn = self.conn.lock().expect("session db mutex poisoned");
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
" "
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages FROM messages
WHERE topic_id = ?1 WHERE topic_id = ?1
ORDER BY seq ASC ORDER BY seq ASC
@ -1455,6 +1457,7 @@ impl SessionStore {
tool_call_id: row.get(7)?, tool_call_id: row.get(7)?,
tool_name: row.get(8)?, tool_name: row.get(8)?,
tool_state: None, tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls, tool_calls,
}) })
})?; })?;
@ -1640,6 +1643,13 @@ fn ensure_messages_schema(conn: &Connection) -> Result<(), StorageError> {
// 这里只添加列,外键约束由应用层保证 // 这里只添加列,外键约束由应用层保证
} }
if !has_column(conn, "messages", "tool_duration_ms")? {
add_column_if_missing(
conn,
"ALTER TABLE messages ADD COLUMN tool_duration_ms INTEGER",
)?;
}
// 创建 topic_id 索引(如果不存在) // 创建 topic_id 索引(如果不存在)
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_topic_seq ON messages(topic_id, seq) WHERE topic_id IS NOT NULL", "CREATE INDEX IF NOT EXISTS idx_messages_topic_seq ON messages(topic_id, seq) WHERE topic_id IS NOT NULL",
@ -1747,8 +1757,8 @@ fn insert_message_with_seq(
" "
INSERT INTO messages ( INSERT INTO messages (
id, session_id, seq, role, content, id, session_id, seq, role, content,
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, created_at system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, tool_duration_ms, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
", ",
params![ params![
message.id, message.id,
@ -1762,6 +1772,7 @@ fn insert_message_with_seq(
message.tool_call_id, message.tool_call_id,
message.tool_name, message.tool_name,
tool_calls_json, tool_calls_json,
message.tool_duration_ms.map(|v| v as i64),
message.timestamp, message.timestamp,
], ],
)?; )?;
@ -1780,6 +1791,7 @@ fn clone_message_for_compaction(message: &ChatMessage, timestamp: i64) -> ChatMe
tool_call_id: message.tool_call_id.clone(), tool_call_id: message.tool_call_id.clone(),
tool_name: message.tool_name.clone(), tool_name: message.tool_name.clone(),
tool_state: message.tool_state.clone(), tool_state: message.tool_state.clone(),
tool_duration_ms: message.tool_duration_ms,
tool_calls: message.tool_calls.clone(), tool_calls: message.tool_calls.clone(),
} }
} }
@ -1792,7 +1804,7 @@ fn load_messages_between(
) -> Result<Vec<ChatMessage>, StorageError> { ) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
" "
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages FROM messages
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3 WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
ORDER BY seq ASC ORDER BY seq ASC
@ -1836,6 +1848,7 @@ fn load_messages_between(
tool_call_id: row.get(7)?, tool_call_id: row.get(7)?,
tool_name: row.get(8)?, tool_name: row.get(8)?,
tool_state: None, tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls, tool_calls,
}) })
}, },
@ -1855,7 +1868,7 @@ fn load_messages_after(
) -> Result<Vec<ChatMessage>, StorageError> { ) -> Result<Vec<ChatMessage>, StorageError> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
" "
SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json SELECT id, role, content, system_context, reasoning_content, media_refs_json, created_at, tool_call_id, tool_name, tool_calls_json, tool_duration_ms
FROM messages FROM messages
WHERE session_id = ?1 AND seq > ?2 WHERE session_id = ?1 AND seq > ?2
ORDER BY seq ASC ORDER BY seq ASC
@ -1896,6 +1909,7 @@ fn load_messages_after(
tool_call_id: row.get(7)?, tool_call_id: row.get(7)?,
tool_name: row.get(8)?, tool_name: row.get(8)?,
tool_state: None, tool_state: None,
tool_duration_ms: row.get::<_, Option<i64>>(10)?.map(|v| v as u64),
tool_calls, tool_calls,
}) })
})?; })?;

View File

@ -128,6 +128,30 @@ impl EmittedMessageHandler for SubAgentEmitter {
} }
} }
} }
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
let mut metadata = self.metadata.clone();
if let Some(ms) = duration_ms {
metadata.insert("tool_duration_ms".to_string(), ms.to_string());
}
for outbound in OutboundMessage::from_chat_message(
&self.channel_name,
&self.chat_id,
None,
None,
&metadata,
&message,
) {
if let Err(error) = self.bus.publish_outbound(outbound).await {
tracing::error!(
error = %error,
channel = %self.channel_name,
chat_id = %self.chat_id,
"Failed to publish live sub-agent tool call"
);
}
}
}
} }
impl SystemPromptProvider for StaticSystemPromptProvider { impl SystemPromptProvider for StaticSystemPromptProvider {

View File

@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Zap, Cpu, MessageSquare, ArrowLeft, Bot } from 'lucide-react' import { Zap, Cpu, MessageSquare, ArrowLeft, Bot, Clock } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { SessionInfo } from './components/Sidebar/SessionInfo' import { SessionInfo } from './components/Sidebar/SessionInfo'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
import { ToolPanel } from './components/Panel/ToolPanel' import { ToolPanel } from './components/Panel/ToolPanel'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { useWebSocket } from './hooks/useWebSocket' import { useWebSocket } from './hooks/useWebSocket'
import { useChat } from './hooks/useChat' import { useChat } from './hooks/useChat'
import type { ChatMessage, Command, Attachment } from './types/protocol' import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
const WS_URL = 'ws://127.0.0.1:19876/ws' const WS_URL = 'ws://127.0.0.1:19876/ws'
@ -31,6 +32,14 @@ function App() {
isReadOnly, isReadOnly,
// 子智能体视图 // 子智能体视图
subAgentView, subAgentView,
// 定时任务
schedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
schedulerView,
enterSchedulerJobView,
exitSchedulerJobView,
// 方法 // 方法
handleMessage, handleMessage,
handleCommand, handleCommand,
@ -171,6 +180,34 @@ function App() {
exitSubAgentView() exitSubAgentView()
}, [exitSubAgentView]) }, [exitSubAgentView])
// 切换到定时任务 tab 时自动获取列表
useEffect(() => {
if (sidebarTab === 'scheduler' && status === 'connected') {
const cmd = requestSchedulerJobList()
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}
}, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList])
const handleRefreshSchedulerJobs = useCallback(() => {
const cmd = requestSchedulerJobList()
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}, [handleCommand, sendMessage, requestSchedulerJobList])
const handleViewSchedulerJob = useCallback(
(lookup: SchedulerJobSessionLookup, jobId: string, description: string) => {
const cmd = enterSchedulerJobView(lookup, jobId, description)
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
},
[enterSchedulerJobView, handleCommand, sendMessage]
)
const handleExitSchedulerJobView = useCallback(() => {
exitSchedulerJobView()
}, [exitSchedulerJobView])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
const result: ChatMessage[] = [] const result: ChatMessage[] = []
const toolCallIndex = new Map<string, number>() const toolCallIndex = new Map<string, number>()
@ -192,6 +229,7 @@ function App() {
...result[idx], ...result[idx],
status: 'result', status: 'result',
resultContent: msg.content, resultContent: msg.content,
durationMs: msg.durationMs,
} }
} }
} else if (msg.type === 'tool_pending') { } else if (msg.type === 'tool_pending') {
@ -246,13 +284,39 @@ function App() {
{/* Main Content */} {/* Main Content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Sidebar */} {/* Left Sidebar */}
<div className={`w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col ${subAgentView ? 'opacity-50 pointer-events-none' : ''}`}> <div className={`w-72 shrink-0 border-r border-white/8 bg-[#12121a]/50 flex flex-col ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}>
<SessionInfo <SessionInfo
session={session} session={session}
connectionId={connectionId} connectionId={connectionId}
/> />
<div className="border-b border-white/8" /> <div className="border-b border-white/8" />
{/* Tab 栏 */}
<div className="flex border-b border-white/8">
<button
onClick={() => setSidebarTab('topics')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
sidebarTab === 'topics'
? 'text-[#00f0ff] border-b-2 border-[#00f0ff]'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
</button>
<button
onClick={() => setSidebarTab('scheduler')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
sidebarTab === 'scheduler'
? 'text-[#00f0ff] border-b-2 border-[#00f0ff]'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
</button>
</div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{sidebarTab === 'topics' ? (
<TopicList <TopicList
sessionId={sessionId} sessionId={sessionId}
sessionTitle={session?.title ?? ''} sessionTitle={session?.title ?? ''}
@ -262,11 +326,42 @@ function App() {
onCreateTopic={handleCreateTopic} onCreateTopic={handleCreateTopic}
onSwitchTopic={handleSwitchTopic} onSwitchTopic={handleSwitchTopic}
/> />
) : (
<SchedulerJobList
jobs={schedulerJobs}
onRefresh={handleRefreshSchedulerJobs}
onViewJob={handleViewSchedulerJob}
sessionId={sessionId}
/>
)}
</div> </div>
</div> </div>
{/* Center - Chat */} {/* Center - Chat */}
<div className="flex-1 min-w-0 bg-[#0a0a0f] flex flex-col"> <div className="flex-1 min-w-0 bg-[#0a0a0f] flex flex-col">
{/* Scheduler job view back bar */}
{schedulerView && (
<div className="shrink-0 border-b border-white/8 bg-[#12121a]/80 px-4 py-2 flex items-center gap-4">
<button
onClick={handleExitSchedulerJobView}
className="flex items-center gap-1.5 text-sm text-[#00f0ff] hover:text-[#00f0ff]/80 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
<span></span>
</button>
<div className="h-4 w-px bg-white/20" />
<div className="flex items-center gap-1.5 text-sm text-zinc-300">
<Clock className="h-4 w-4 text-amber-400" />
<span className="text-zinc-500">:</span>
<span className="text-white font-medium font-mono text-xs truncate max-w-[250px]">{schedulerView.description}</span>
</div>
<div className="h-4 w-px bg-white/20" />
<div className="flex items-center gap-1.5 text-sm">
<span className="text-zinc-500">:</span>
<span className="text-zinc-300">{schedulerView.channel}</span>
</div>
</div>
)}
{/* Sub-agent back bar */} {/* Sub-agent back bar */}
{subAgentView && ( {subAgentView && (
<div className="shrink-0 border-b border-white/8 bg-[#12121a]/80 px-4 py-2 flex items-center gap-4"> <div className="shrink-0 border-b border-white/8 bg-[#12121a]/80 px-4 py-2 flex items-center gap-4">
@ -312,9 +407,13 @@ function App() {
<ChatContainer <ChatContainer
messages={chatMessages} messages={chatMessages}
isLoading={isLoading} isLoading={isLoading}
isReadOnly={subAgentView ? true : isReadOnly} isReadOnly={subAgentView || schedulerView ? true : isReadOnly}
channelName={subAgentView ? `子智能体: ${subAgentView.description}` : (session?.title ?? 'PicoBot')} channelName={
onSendMessage={subAgentView ? () => {} : handleSendMessage} schedulerView ? `定时任务: ${schedulerView.description}` :
subAgentView ? `子智能体: ${subAgentView.description}` :
(session?.title ?? 'PicoBot')
}
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}
/> />
</div> </div>

View File

@ -31,6 +31,18 @@ function formatTime(timestamp: number) {
}) })
} }
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`
}
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor((ms % 60000) / 1000)
return `${minutes}m ${seconds}s`
}
function AttachmentCard({ attachment }: { attachment: Attachment }) { function AttachmentCard({ attachment }: { attachment: Attachment }) {
const fileName = attachment.file_name || getFileName(attachment.path) const fileName = attachment.file_name || getFileName(attachment.path)
@ -253,6 +265,11 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
}`}> }`}>
{taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label} {taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label}
</span> </span>
{status === 'result' && message.durationMs != null && (
<span className="text-xs text-zinc-600 flex-shrink-0 tabular-nums ml-1">
{formatDuration(message.durationMs)}
</span>
)}
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />} {hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
<span className="ml-auto flex-shrink-0"> <span className="ml-auto flex-shrink-0">
{toolExpanded ? ( {toolExpanded ? (

View File

@ -13,6 +13,19 @@ interface ToolCallItem {
arguments?: unknown arguments?: unknown
resultContent: string resultContent: string
callContent: string callContent: string
durationMs?: number
}
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`
}
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor((ms % 60000) / 1000)
return `${minutes}m ${seconds}s`
} }
function formatResultText(content: string): string { function formatResultText(content: string): string {
@ -52,6 +65,7 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
} else if (m.type === 'tool_result') { } else if (m.type === 'tool_result') {
entry.status = 'result' entry.status = 'result'
entry.resultContent = m.content entry.resultContent = m.content
entry.durationMs = m.durationMs
} else if (m.type === 'tool_pending') { } else if (m.type === 'tool_pending') {
entry.status = 'pending' entry.status = 'pending'
entry.resultContent = m.content entry.resultContent = m.content
@ -167,6 +181,11 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}> <span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
{config.label} {config.label}
</span> </span>
{tool.status === 'result' && tool.durationMs != null && (
<span className="text-xs text-zinc-600 flex-shrink-0 tabular-nums ml-1">
{formatDuration(tool.durationMs)}
</span>
)}
</div> </div>
<span className="flex-shrink-0 ml-2"> <span className="flex-shrink-0 ml-2">
{isExpanded ? ( {isExpanded ? (

View File

@ -0,0 +1,193 @@
import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react'
import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol'
interface SchedulerJobListProps {
jobs: SchedulerJobSummary[]
onRefresh: () => void
onViewJob: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => void
sessionId: string | null
}
function kindLabel(kind: string): string {
const map: Record<string, string> = {
internal_event: '内部事件',
outbound_message: '外发消息',
agent_task: '智能体',
silent_agent_task: '静默智能体',
}
return map[kind] ?? kind
}
function stateBadge(state: string): { label: string; color: string; pulse: boolean } {
switch (state) {
case 'running':
return { label: '执行中', color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', pulse: true }
case 'scheduled':
return { label: '已调度', color: 'bg-amber-500/15 text-amber-300 border-amber-500/20', pulse: false }
case 'paused':
return { label: '已暂停', color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', pulse: false }
case 'completed':
return { label: '已完成', color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', pulse: false }
default:
return { label: state, color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', pulse: false }
}
}
function formatTime(tsMillis: number | undefined): string {
if (tsMillis == null) return '--'
const d = new Date(tsMillis)
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function scheduleDescription(schedule: unknown): string {
if (!schedule || typeof schedule !== 'object') return '--'
const s = schedule as Record<string, unknown>
switch (s.type) {
case 'cron':
return `Cron: ${s.expression}`
case 'interval':
return `${s.seconds}`
case 'delay':
return `延迟 ${s.seconds}`
case 'at':
return `定时: ${s.timestamp}`
default:
return JSON.stringify(s)
}
}
function lastStatusIcon(lastStatus: string | undefined) {
switch (lastStatus) {
case 'ok':
return <Check className="h-3 w-3 text-emerald-400" />
case 'error':
return <X className="h-3 w-3 text-red-400" />
default:
return <Minus className="h-3 w-3 text-zinc-500" />
}
}
export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: SchedulerJobListProps) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/8 px-3 py-2.5">
<h2 className="font-semibold text-white flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-[#00f0ff]" />
{jobs.length > 0 && (
<span className="text-xs text-zinc-500">({jobs.length})</span>
)}
</h2>
<button
onClick={onRefresh}
className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/10 transition-all"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{/* Job List */}
<div className="flex-1 overflow-y-auto p-2">
{!sessionId ? (
<div className="p-4 text-center text-sm text-zinc-500">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>...</p>
</div>
) : jobs.length === 0 ? (
<div className="p-4 text-center text-sm text-zinc-500">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p></p>
</div>
) : (
<div className="space-y-1.5">
{jobs.map((job) => {
const badge = stateBadge(job.state)
const hasLookup = !!job.session_lookup
return (
<button
key={job.id}
onClick={() => {
if (hasLookup && job.session_lookup) {
onViewJob(job.session_lookup, job.id, job.id)
}
}}
disabled={!hasLookup}
className={`w-full rounded-lg px-3 py-2.5 text-left transition-all border ${
hasLookup
? 'hover:bg-white/5 border-transparent hover:border-white/10 cursor-pointer'
: 'border-transparent cursor-default opacity-70'
}`}
>
{/* Row 1: ID + kind + enabled */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs text-zinc-500 font-mono truncate max-w-[120px]">
{job.id}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-white/10 text-zinc-300 shrink-0">
{kindLabel(job.kind)}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
{hasLookup && <ChevronRight className="h-3.5 w-3.5 text-zinc-600" />}
</div>
</div>
{/* Row 2: State badge + last status */}
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}>
{badge.pulse && <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />}
{badge.label}
</span>
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500">
{lastStatusIcon(job.last_status)}
<span className={
job.last_status === 'error' ? 'text-red-400' :
job.last_status === 'ok' ? 'text-emerald-400' : 'text-zinc-500'
}>
{job.last_status === 'ok' ? '正常' :
job.last_status === 'error' ? '异常' :
job.last_status === 'skipped' ? '跳过' : '--'}
</span>
</span>
</div>
{/* Row 3: Error message (if any) */}
{job.last_error && (
<div className="mb-1.5 text-xs text-red-400/80 truncate bg-red-500/10 rounded px-1.5 py-0.5">
{job.last_error}
</div>
)}
{/* Row 4: Run count + schedule */}
<div className="flex items-center justify-between text-xs text-zinc-500 mb-1">
<span>
: {job.run_count}{job.max_runs ? `/${job.max_runs}` : ''}
</span>
<span className="truncate max-w-[120px] text-zinc-600">
{scheduleDescription(job.schedule)}
</span>
</div>
{/* Row 5: Times */}
<div className="flex items-center justify-between text-xs text-zinc-600">
<span>: {formatTime(job.last_fired_at)}</span>
<span>: {formatTime(job.next_fire_at)}</span>
</div>
</button>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@ -15,6 +15,9 @@ import type {
Session, Session,
TaskMessagesLoaded, TaskMessagesLoaded,
Attachment, Attachment,
SchedulerJobList,
SchedulerJobSummary,
SchedulerJobSessionLookup,
} from '../types/protocol' } from '../types/protocol'
// 简化后的层级状态 // 简化后的层级状态
@ -58,6 +61,17 @@ interface UseChatReturn {
// 子智能体导航方法 // 子智能体导航方法
enterSubAgentView: (taskId: string, description: string) => Command enterSubAgentView: (taskId: string, description: string) => Command
exitSubAgentView: () => void exitSubAgentView: () => void
// 定时任务状态
schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler'
setSidebarTab: (tab: 'topics' | 'scheduler') => void
requestSchedulerJobList: () => Command
// 定时任务执行对话查看
schedulerView: SchedulerJobView | null
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
exitSchedulerJobView: () => void
} }
interface SubAgentView { interface SubAgentView {
@ -69,6 +83,14 @@ interface SubAgentView {
messages: ChatMessage[] messages: ChatMessage[]
} }
interface SchedulerJobView {
jobId: string
description: string
channel: string
chatId: string
messages: ChatMessage[]
}
const DEFAULT_CHANNEL = 'websocket' const DEFAULT_CHANNEL = 'websocket'
const DEFAULT_CHAT_ID = 'default' const DEFAULT_CHAT_ID = 'default'
@ -82,6 +104,9 @@ export function useChat(): UseChatReturn {
const [topics, setTopics] = useState<Topic[]>([]) const [topics, setTopics] = useState<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string | null>(null) const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null) const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
// Message ID generator // Message ID generator
const messageIdCounter = useRef(0) const messageIdCounter = useRef(0)
@ -90,8 +115,9 @@ export function useChat(): UseChatReturn {
return `msg_${Date.now()}_${messageIdCounter.current}` return `msg_${Date.now()}_${messageIdCounter.current}`
} }
// Ref to track subAgentView for use in callbacks // Ref to track subAgentView and schedulerView for use in callbacks
const subAgentViewRef = useRef<SubAgentView | null>(null) const subAgentViewRef = useRef<SubAgentView | null>(null)
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
const isConnected = useMemo(() => connectionId !== null, [connectionId]) const isConnected = useMemo(() => connectionId !== null, [connectionId])
const sessionId = useMemo(() => session?.id ?? null, [session]) const sessionId = useMemo(() => session?.id ?? null, [session])
@ -147,6 +173,7 @@ export function useChat(): UseChatReturn {
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
} }
} }
case 'tool_pending': { case 'tool_pending': {
@ -191,6 +218,21 @@ export function useChat(): UseChatReturn {
const handleServerMessage = useCallback((message: WsOutbound) => { const handleServerMessage = useCallback((message: WsOutbound) => {
console.log('Received message:', message) console.log('Received message:', message)
// Route to scheduler job view if active
const currentSchedulerView = schedulerViewRef.current
if (currentSchedulerView) {
// Route all chat messages to the scheduler view
const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) {
setSchedulerView((prev) =>
prev
? { ...prev, messages: [...prev.messages, chatMsg] }
: prev
)
}
return
}
// Route to sub-agent view if active // Route to sub-agent view if active
const currentSubAgentView = subAgentViewRef.current const currentSubAgentView = subAgentViewRef.current
if (currentSubAgentView) { if (currentSubAgentView) {
@ -345,6 +387,7 @@ export function useChat(): UseChatReturn {
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
}, },
]) ])
break break
@ -382,6 +425,12 @@ export function useChat(): UseChatReturn {
break break
} }
case 'scheduler_job_list': {
const msg = message as SchedulerJobList
setSchedulerJobs(msg.jobs)
break
}
case 'channel_list': case 'channel_list':
case 'pong': case 'pong':
// 忽略这些消息 // 忽略这些消息
@ -458,11 +507,15 @@ export function useChat(): UseChatReturn {
} }
}, [sessionId]) }, [sessionId])
// Keep ref in sync with state // Keep refs in sync with state
useEffect(() => { useEffect(() => {
subAgentViewRef.current = subAgentView subAgentViewRef.current = subAgentView
}, [subAgentView]) }, [subAgentView])
useEffect(() => {
schedulerViewRef.current = schedulerView
}, [schedulerView])
const enterSubAgentView = useCallback((taskId: string, description: string): Command => { const enterSubAgentView = useCallback((taskId: string, description: string): Command => {
const newView: SubAgentView = { const newView: SubAgentView = {
taskId, taskId,
@ -485,13 +538,46 @@ export function useChat(): UseChatReturn {
setSubAgentView(null) setSubAgentView(null)
}, []) }, [])
// Memoize messages: when in sub-agent view, return sub-agent messages // 定时任务方法
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)
}, [])
// Memoize messages: sub-agent view > scheduler view > main
const resolvedMessages = useMemo(() => { const resolvedMessages = useMemo(() => {
if (subAgentView) { if (subAgentView) {
return subAgentView.messages return subAgentView.messages
} }
if (schedulerView) {
return schedulerView.messages
}
return messages return messages
}, [subAgentView, messages]) }, [subAgentView, schedulerView, messages])
// WebSocket 通道始终可写 // WebSocket 通道始终可写
const isReadOnly = false const isReadOnly = false
@ -519,5 +605,12 @@ export function useChat(): UseChatReturn {
requestTopicList, requestTopicList,
enterSubAgentView, enterSubAgentView,
exitSubAgentView, exitSubAgentView,
schedulerJobs,
sidebarTab,
setSidebarTab,
requestSchedulerJobList,
schedulerView,
enterSchedulerJobView,
exitSchedulerJobView,
} }
} }

View File

@ -64,6 +64,7 @@ export interface ToolResult {
content: string content: string
role: string role: string
subagent_task_id?: string subagent_task_id?: string
duration_ms?: number
} }
export interface ToolPending { export interface ToolPending {
@ -156,6 +157,32 @@ export interface Pong {
type: 'pong' type: 'pong'
} }
export interface SchedulerJobSessionLookup {
channel: string
chat_id: string
}
export interface SchedulerJobSummary {
id: string
kind: string
schedule: unknown
enabled: boolean
state: string
last_status?: string
last_error?: string
run_count: number
max_runs?: number
last_fired_at?: number
next_fire_at?: number
created_at: number
session_lookup?: SchedulerJobSessionLookup
}
export interface SchedulerJobList {
type: 'scheduler_job_list'
jobs: SchedulerJobSummary[]
}
export interface TaskMessagesLoaded { export interface TaskMessagesLoaded {
type: 'task_messages_loaded' type: 'task_messages_loaded'
task_id: string task_id: string
@ -179,6 +206,7 @@ export type WsOutbound =
| TopicList | TopicList
| ChannelList | ChannelList
| TaskMessagesLoaded | TaskMessagesLoaded
| SchedulerJobList
| Pong | Pong
// ============================================================================ // ============================================================================
@ -246,6 +274,16 @@ export interface LoadTaskMessagesCommand {
task_id: string task_id: string
} }
export interface ListSchedulerJobsCommand {
type: 'list_scheduler_jobs'
}
export interface LoadChatMessagesCommand {
type: 'load_chat_messages'
channel: string
chat_id: string
}
export type Command = export type Command =
| CreateSessionCommand | CreateSessionCommand
| ListSessionsCommand | ListSessionsCommand
@ -259,6 +297,8 @@ export type Command =
| ListSessionsByChannelCommand | ListSessionsByChannelCommand
| ListTopicsCommand | ListTopicsCommand
| LoadTaskMessagesCommand | LoadTaskMessagesCommand
| ListSchedulerJobsCommand
| LoadChatMessagesCommand
// ============================================================================ // ============================================================================
// UI Types // UI Types
@ -278,6 +318,7 @@ export interface ChatMessage {
resultContent?: string resultContent?: string
callContent?: string callContent?: string
subagentTaskId?: string subagentTaskId?: string
durationMs?: number
} }
/** task 工具返回的 JSON 结构 */ /** task 工具返回的 JSON 结构 */