Compare commits
7 Commits
eebfe0faa5
...
ea6fabe41d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6fabe41d | ||
|
|
025c355c7d | ||
|
|
590ea9abb0 | ||
|
|
5f2bc950b1 | ||
|
|
4d6d989247 | ||
|
|
5273a7b335 | ||
|
|
f8fc0f7d0f |
@ -308,6 +308,7 @@ fn filter_images_by_age_and_count(
|
||||
tool_call_id: message.tool_call_id.clone(),
|
||||
tool_name: message.tool_name.clone(),
|
||||
tool_state: message.tool_state.clone(),
|
||||
tool_duration_ms: message.tool_duration_ms,
|
||||
tool_calls: message.tool_calls.clone(),
|
||||
});
|
||||
}
|
||||
@ -656,6 +657,12 @@ pub struct AgentProcessResult {
|
||||
#[async_trait]
|
||||
pub trait EmittedMessageHandler: Send + Sync + 'static {
|
||||
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
|
||||
@ -688,6 +695,17 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
|
||||
}
|
||||
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 {
|
||||
@ -1008,10 +1026,12 @@ impl AgentLoop {
|
||||
} else {
|
||||
ToolMessageState::Completed
|
||||
},
|
||||
);
|
||||
)
|
||||
.with_tool_duration(result.duration.as_millis() as u64);
|
||||
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 => {
|
||||
let tool_message = ChatMessage::tool_with_state(
|
||||
@ -1023,10 +1043,12 @@ impl AgentLoop {
|
||||
} else {
|
||||
ToolMessageState::Completed
|
||||
},
|
||||
);
|
||||
)
|
||||
.with_tool_duration(result.duration.as_millis() as u64);
|
||||
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.
|
||||
///
|
||||
/// Returns true if:
|
||||
|
||||
@ -62,6 +62,8 @@ pub struct ChatMessage {
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
@ -78,6 +80,7 @@ impl ChatMessage {
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_duration_ms: None,
|
||||
tool_state: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
@ -94,6 +97,7 @@ impl ChatMessage {
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_duration_ms: None,
|
||||
tool_state: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
@ -110,6 +114,7 @@ impl ChatMessage {
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_duration_ms: None,
|
||||
tool_state: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
@ -138,6 +143,7 @@ impl ChatMessage {
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_duration_ms: None,
|
||||
tool_state: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
}
|
||||
@ -171,6 +177,7 @@ impl ChatMessage {
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_name: None,
|
||||
tool_duration_ms: None,
|
||||
tool_state: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
@ -205,11 +212,17 @@ impl ChatMessage {
|
||||
reasoning_content: None,
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
tool_name: Some(tool_name.into()),
|
||||
tool_duration_ms: None,
|
||||
tool_state: Some(tool_state),
|
||||
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 {
|
||||
self.system_context.as_deref() == Some(expected)
|
||||
}
|
||||
|
||||
96
src/command/handlers/list_scheduler_jobs.rs
Normal file
96
src/command/handlers/list_scheduler_jobs.rs
Normal 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 无执行对话
|
||||
}
|
||||
}
|
||||
65
src/command/handlers/load_chat_messages.rs
Normal file
65
src/command/handlers/load_chat_messages.rs
Normal 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!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
pub mod get_current;
|
||||
pub mod help;
|
||||
pub mod list_channels;
|
||||
pub mod list_scheduler_jobs;
|
||||
pub mod list_sessions;
|
||||
pub mod list_sessions_by_channel;
|
||||
pub mod list_topics;
|
||||
pub mod load_chat_messages;
|
||||
pub mod load_task_messages;
|
||||
pub mod load_topic;
|
||||
pub mod save_session;
|
||||
|
||||
@ -45,6 +45,13 @@ pub enum Command {
|
||||
ListTopics { session_id: String },
|
||||
/// 加载子智能体任务的消息历史
|
||||
LoadTaskMessages { task_id: String },
|
||||
/// 列出所有定时任务
|
||||
ListSchedulerJobs,
|
||||
/// 加载指定 channel + chat_id 的对话消息
|
||||
LoadChatMessages {
|
||||
channel: String,
|
||||
chat_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
@ -63,6 +70,8 @@ impl Command {
|
||||
Command::ListSessionsByChannel { .. } => "list_sessions_by_channel",
|
||||
Command::ListTopics { .. } => "list_topics",
|
||||
Command::LoadTaskMessages { .. } => "load_task_messages",
|
||||
Command::ListSchedulerJobs => "list_scheduler_jobs",
|
||||
Command::LoadChatMessages { .. } => "load_chat_messages",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
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::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::storage::ConversationRepository;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::compaction::schedule_background_history_compaction;
|
||||
use super::message_prepare::enrich_user_content_with_media_refs;
|
||||
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”、“定时”等词,只应视为任务背景,不应再解释为新的建任务请求。";
|
||||
|
||||
pub(crate) fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String {
|
||||
@ -269,7 +279,7 @@ impl AgentExecutionService {
|
||||
&self,
|
||||
request: ScheduledExecutionRequest<'_>,
|
||||
) -> 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;
|
||||
|
||||
session_guard.ensure_persistent_session(request.chat_id)?;
|
||||
@ -311,9 +321,27 @@ impl AgentExecutionService {
|
||||
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 {
|
||||
session_id: Some(format!("{}:{}", request.channel_name, request.chat_id)),
|
||||
|
||||
@ -132,6 +132,18 @@ impl MemoryMaintenanceService {
|
||||
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)))
|
||||
}
|
||||
|
||||
@ -619,6 +631,9 @@ pub(crate) fn is_recoverable_maintenance_llm_error(error: &str) -> bool {
|
||||
|| normalized.contains("stream timeout")
|
||||
|| normalized.contains("timed out")
|
||||
|| normalized.contains("timeout")
|
||||
// 验证拒绝 — 记忆太少,跳过本次 scope 即可,不应视为作业失败
|
||||
|| error.contains("保留数不足")
|
||||
|| error.contains("合并比例超限")
|
||||
}
|
||||
|
||||
fn is_recoverable_maintenance_scope_error(error: &AgentError) -> bool {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -8,9 +8,11 @@ use crate::command::handler::CommandRouter;
|
||||
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
||||
use crate::command::handlers::help::HelpCommandHandler;
|
||||
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_by_channel::ListSessionsByChannelCommandHandler;
|
||||
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_topic::LoadTopicCommandHandler;
|
||||
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||
@ -399,6 +401,10 @@ async fn handle_inbound(
|
||||
// 注册 help 处理器
|
||||
let metadata = router.metadata_arc();
|
||||
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!(
|
||||
@ -473,6 +479,34 @@ async fn handle_inbound(
|
||||
}).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 let Some(topics_json) = response.metadata.get("topics") {
|
||||
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(),
|
||||
role: msg.role.clone(),
|
||||
subagent_task_id: None,
|
||||
duration_ms: msg.tool_duration_ms,
|
||||
}),
|
||||
ToolMessageState::PendingUserAction => Some(WsOutbound::ToolPending {
|
||||
id: msg.id.clone(),
|
||||
|
||||
@ -48,6 +48,31 @@ pub struct MediaSummary {
|
||||
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)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum WsInbound {
|
||||
@ -104,6 +129,8 @@ pub enum WsOutbound {
|
||||
role: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subagent_task_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "tool_pending")]
|
||||
ToolPending {
|
||||
@ -156,6 +183,10 @@ pub enum WsOutbound {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
},
|
||||
#[serde(rename = "scheduler_job_list")]
|
||||
SchedulerJobList {
|
||||
jobs: Vec<SchedulerJobSummary>,
|
||||
},
|
||||
#[serde(rename = "pong")]
|
||||
Pong,
|
||||
}
|
||||
|
||||
@ -57,6 +57,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
content: message.content.clone(),
|
||||
role: message.role.clone(),
|
||||
subagent_task_id: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
||||
id: message.id.clone(),
|
||||
@ -119,6 +120,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
||||
content: message.content.clone(),
|
||||
role: message.role.clone(),
|
||||
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 {
|
||||
id: message
|
||||
|
||||
@ -572,8 +572,8 @@ impl SessionStore {
|
||||
"
|
||||
INSERT INTO messages (
|
||||
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
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
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, ?14)
|
||||
",
|
||||
params![
|
||||
message.id,
|
||||
@ -588,6 +588,7 @@ impl SessionStore {
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.timestamp,
|
||||
],
|
||||
)?;
|
||||
@ -649,8 +650,8 @@ impl SessionStore {
|
||||
INSERT INTO messages (
|
||||
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
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
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, ?14)
|
||||
",
|
||||
params![
|
||||
message.id,
|
||||
@ -665,6 +666,7 @@ impl SessionStore {
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
message.timestamp,
|
||||
],
|
||||
)?;
|
||||
@ -1414,7 +1416,7 @@ impl SessionStore {
|
||||
let conn = self.conn.lock().expect("session db mutex poisoned");
|
||||
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
|
||||
WHERE topic_id = ?1
|
||||
ORDER BY seq ASC
|
||||
@ -1455,6 +1457,7 @@ impl SessionStore {
|
||||
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,
|
||||
})
|
||||
})?;
|
||||
@ -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 索引(如果不存在)
|
||||
conn.execute(
|
||||
"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 (
|
||||
id, session_id, seq, role, content,
|
||||
system_context, reasoning_content, media_refs_json, tool_call_id, tool_name, tool_calls_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||
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)
|
||||
",
|
||||
params![
|
||||
message.id,
|
||||
@ -1762,6 +1772,7 @@ fn insert_message_with_seq(
|
||||
message.tool_call_id,
|
||||
message.tool_name,
|
||||
tool_calls_json,
|
||||
message.tool_duration_ms.map(|v| v as i64),
|
||||
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_name: message.tool_name.clone(),
|
||||
tool_state: message.tool_state.clone(),
|
||||
tool_duration_ms: message.tool_duration_ms,
|
||||
tool_calls: message.tool_calls.clone(),
|
||||
}
|
||||
}
|
||||
@ -1792,7 +1804,7 @@ fn load_messages_between(
|
||||
) -> Result<Vec<ChatMessage>, StorageError> {
|
||||
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
|
||||
WHERE session_id = ?1 AND seq > ?2 AND seq <= ?3
|
||||
ORDER BY seq ASC
|
||||
@ -1836,6 +1848,7 @@ fn load_messages_between(
|
||||
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,
|
||||
})
|
||||
},
|
||||
@ -1855,7 +1868,7 @@ fn load_messages_after(
|
||||
) -> Result<Vec<ChatMessage>, StorageError> {
|
||||
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
|
||||
WHERE session_id = ?1 AND seq > ?2
|
||||
ORDER BY seq ASC
|
||||
@ -1896,6 +1909,7 @@ fn load_messages_after(
|
||||
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,
|
||||
})
|
||||
})?;
|
||||
|
||||
@ -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 {
|
||||
|
||||
111
web/src/App.tsx
111
web/src/App.tsx
@ -1,13 +1,14 @@
|
||||
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 { TopicList } from './components/Sidebar/TopicList'
|
||||
import { SessionInfo } from './components/Sidebar/SessionInfo'
|
||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||
import { ToolPanel } from './components/Panel/ToolPanel'
|
||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
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'
|
||||
|
||||
@ -31,6 +32,14 @@ function App() {
|
||||
isReadOnly,
|
||||
// 子智能体视图
|
||||
subAgentView,
|
||||
// 定时任务
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
requestSchedulerJobList,
|
||||
schedulerView,
|
||||
enterSchedulerJobView,
|
||||
exitSchedulerJobView,
|
||||
// 方法
|
||||
handleMessage,
|
||||
handleCommand,
|
||||
@ -171,6 +180,34 @@ function App() {
|
||||
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 result: ChatMessage[] = []
|
||||
const toolCallIndex = new Map<string, number>()
|
||||
@ -192,6 +229,7 @@ function App() {
|
||||
...result[idx],
|
||||
status: 'result',
|
||||
resultContent: msg.content,
|
||||
durationMs: msg.durationMs,
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'tool_pending') {
|
||||
@ -246,13 +284,39 @@ function App() {
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 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
|
||||
session={session}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
<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">
|
||||
{sidebarTab === 'topics' ? (
|
||||
<TopicList
|
||||
sessionId={sessionId}
|
||||
sessionTitle={session?.title ?? ''}
|
||||
@ -262,11 +326,42 @@ function App() {
|
||||
onCreateTopic={handleCreateTopic}
|
||||
onSwitchTopic={handleSwitchTopic}
|
||||
/>
|
||||
) : (
|
||||
<SchedulerJobList
|
||||
jobs={schedulerJobs}
|
||||
onRefresh={handleRefreshSchedulerJobs}
|
||||
onViewJob={handleViewSchedulerJob}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Chat */}
|
||||
<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 */}
|
||||
{subAgentView && (
|
||||
<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
|
||||
messages={chatMessages}
|
||||
isLoading={isLoading}
|
||||
isReadOnly={subAgentView ? true : isReadOnly}
|
||||
channelName={subAgentView ? `子智能体: ${subAgentView.description}` : (session?.title ?? 'PicoBot')}
|
||||
onSendMessage={subAgentView ? () => {} : handleSendMessage}
|
||||
isReadOnly={subAgentView || schedulerView ? true : isReadOnly}
|
||||
channelName={
|
||||
schedulerView ? `定时任务: ${schedulerView.description}` :
|
||||
subAgentView ? `子智能体: ${subAgentView.description}` :
|
||||
(session?.title ?? 'PicoBot')
|
||||
}
|
||||
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
||||
onNavigateToSubAgent={handleNavigateToSubAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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 }) {
|
||||
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}
|
||||
</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} />}
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
{toolExpanded ? (
|
||||
|
||||
@ -13,6 +13,19 @@ interface ToolCallItem {
|
||||
arguments?: unknown
|
||||
resultContent: 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 {
|
||||
@ -52,6 +65,7 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
||||
} else if (m.type === 'tool_result') {
|
||||
entry.status = 'result'
|
||||
entry.resultContent = m.content
|
||||
entry.durationMs = m.durationMs
|
||||
} else if (m.type === 'tool_pending') {
|
||||
entry.status = 'pending'
|
||||
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}`}>
|
||||
{config.label}
|
||||
</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>
|
||||
<span className="flex-shrink-0 ml-2">
|
||||
{isExpanded ? (
|
||||
|
||||
193
web/src/components/Sidebar/SchedulerJobList.tsx
Normal file
193
web/src/components/Sidebar/SchedulerJobList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -15,6 +15,9 @@ import type {
|
||||
Session,
|
||||
TaskMessagesLoaded,
|
||||
Attachment,
|
||||
SchedulerJobList,
|
||||
SchedulerJobSummary,
|
||||
SchedulerJobSessionLookup,
|
||||
} from '../types/protocol'
|
||||
|
||||
// 简化后的层级状态
|
||||
@ -58,6 +61,17 @@ interface UseChatReturn {
|
||||
// 子智能体导航方法
|
||||
enterSubAgentView: (taskId: string, description: string) => Command
|
||||
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 {
|
||||
@ -69,6 +83,14 @@ interface SubAgentView {
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
interface SchedulerJobView {
|
||||
jobId: string
|
||||
description: string
|
||||
channel: string
|
||||
chatId: string
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNEL = 'websocket'
|
||||
const DEFAULT_CHAT_ID = 'default'
|
||||
|
||||
@ -82,6 +104,9 @@ export function useChat(): UseChatReturn {
|
||||
const [topics, setTopics] = useState<Topic[]>([])
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | 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
|
||||
const messageIdCounter = useRef(0)
|
||||
@ -90,8 +115,9 @@ export function useChat(): UseChatReturn {
|
||||
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 schedulerViewRef = useRef<SchedulerJobView | null>(null)
|
||||
|
||||
const isConnected = useMemo(() => connectionId !== null, [connectionId])
|
||||
const sessionId = useMemo(() => session?.id ?? null, [session])
|
||||
@ -147,6 +173,7 @@ export function useChat(): UseChatReturn {
|
||||
toolName: msg.tool_name,
|
||||
toolCallId: msg.tool_call_id,
|
||||
subagentTaskId: msg.subagent_task_id,
|
||||
durationMs: msg.duration_ms,
|
||||
}
|
||||
}
|
||||
case 'tool_pending': {
|
||||
@ -191,6 +218,21 @@ export function useChat(): UseChatReturn {
|
||||
const handleServerMessage = useCallback((message: WsOutbound) => {
|
||||
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
|
||||
const currentSubAgentView = subAgentViewRef.current
|
||||
if (currentSubAgentView) {
|
||||
@ -345,6 +387,7 @@ export function useChat(): UseChatReturn {
|
||||
toolName: msg.tool_name,
|
||||
toolCallId: msg.tool_call_id,
|
||||
subagentTaskId: msg.subagent_task_id,
|
||||
durationMs: msg.duration_ms,
|
||||
},
|
||||
])
|
||||
break
|
||||
@ -382,6 +425,12 @@ export function useChat(): UseChatReturn {
|
||||
break
|
||||
}
|
||||
|
||||
case 'scheduler_job_list': {
|
||||
const msg = message as SchedulerJobList
|
||||
setSchedulerJobs(msg.jobs)
|
||||
break
|
||||
}
|
||||
|
||||
case 'channel_list':
|
||||
case 'pong':
|
||||
// 忽略这些消息
|
||||
@ -458,11 +507,15 @@ export function useChat(): UseChatReturn {
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Keep ref in sync with state
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
subAgentViewRef.current = subAgentView
|
||||
}, [subAgentView])
|
||||
|
||||
useEffect(() => {
|
||||
schedulerViewRef.current = schedulerView
|
||||
}, [schedulerView])
|
||||
|
||||
const enterSubAgentView = useCallback((taskId: string, description: string): Command => {
|
||||
const newView: SubAgentView = {
|
||||
taskId,
|
||||
@ -485,13 +538,46 @@ export function useChat(): UseChatReturn {
|
||||
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(() => {
|
||||
if (subAgentView) {
|
||||
return subAgentView.messages
|
||||
}
|
||||
if (schedulerView) {
|
||||
return schedulerView.messages
|
||||
}
|
||||
return messages
|
||||
}, [subAgentView, messages])
|
||||
}, [subAgentView, schedulerView, messages])
|
||||
|
||||
// WebSocket 通道始终可写
|
||||
const isReadOnly = false
|
||||
@ -519,5 +605,12 @@ export function useChat(): UseChatReturn {
|
||||
requestTopicList,
|
||||
enterSubAgentView,
|
||||
exitSubAgentView,
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
requestSchedulerJobList,
|
||||
schedulerView,
|
||||
enterSchedulerJobView,
|
||||
exitSchedulerJobView,
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,6 +64,7 @@ export interface ToolResult {
|
||||
content: string
|
||||
role: string
|
||||
subagent_task_id?: string
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
export interface ToolPending {
|
||||
@ -156,6 +157,32 @@ export interface 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 {
|
||||
type: 'task_messages_loaded'
|
||||
task_id: string
|
||||
@ -179,6 +206,7 @@ export type WsOutbound =
|
||||
| TopicList
|
||||
| ChannelList
|
||||
| TaskMessagesLoaded
|
||||
| SchedulerJobList
|
||||
| Pong
|
||||
|
||||
// ============================================================================
|
||||
@ -246,6 +274,16 @@ export interface LoadTaskMessagesCommand {
|
||||
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 =
|
||||
| CreateSessionCommand
|
||||
| ListSessionsCommand
|
||||
@ -259,6 +297,8 @@ export type Command =
|
||||
| ListSessionsByChannelCommand
|
||||
| ListTopicsCommand
|
||||
| LoadTaskMessagesCommand
|
||||
| ListSchedulerJobsCommand
|
||||
| LoadChatMessagesCommand
|
||||
|
||||
// ============================================================================
|
||||
// UI Types
|
||||
@ -278,6 +318,7 @@ export interface ChatMessage {
|
||||
resultContent?: string
|
||||
callContent?: string
|
||||
subagentTaskId?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/** task 工具返回的 JSON 结构 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user