feat: 添加定时任务和聊天消息加载功能,增强调度管理
This commit is contained in:
parent
4d6d989247
commit
5f2bc950b1
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,28 @@ 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();
|
||||
if let Err(e) = send_task_messages(&store, load_chat_id, sender).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
channel = %load_chat_channel,
|
||||
chat_id = %load_chat_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) {
|
||||
|
||||
@ -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 {
|
||||
@ -158,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,
|
||||
}
|
||||
|
||||
110
web/src/App.tsx
110
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>()
|
||||
@ -247,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 ?? ''}
|
||||
@ -263,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">
|
||||
@ -313,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>
|
||||
|
||||
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])
|
||||
@ -192,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) {
|
||||
@ -384,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':
|
||||
// 忽略这些消息
|
||||
@ -460,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,
|
||||
@ -487,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
|
||||
@ -521,5 +605,12 @@ export function useChat(): UseChatReturn {
|
||||
requestTopicList,
|
||||
enterSubAgentView,
|
||||
exitSubAgentView,
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
requestSchedulerJobList,
|
||||
schedulerView,
|
||||
enterSchedulerJobView,
|
||||
exitSchedulerJobView,
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,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
|
||||
@ -180,6 +206,7 @@ export type WsOutbound =
|
||||
| TopicList
|
||||
| ChannelList
|
||||
| TaskMessagesLoaded
|
||||
| SchedulerJobList
|
||||
| Pong
|
||||
|
||||
// ============================================================================
|
||||
@ -247,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
|
||||
@ -260,6 +297,8 @@ export type Command =
|
||||
| ListSessionsByChannelCommand
|
||||
| ListTopicsCommand
|
||||
| LoadTaskMessagesCommand
|
||||
| ListSchedulerJobsCommand
|
||||
| LoadChatMessagesCommand
|
||||
|
||||
// ============================================================================
|
||||
// UI Types
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user