feat: 添加定时任务和聊天消息加载功能,增强调度管理

This commit is contained in:
oudecheng 2026-06-02 17:04:00 +08:00
parent 4d6d989247
commit 5f2bc950b1
10 changed files with 669 additions and 19 deletions

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 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;

View File

@ -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",
}
}
}

View File

@ -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) {

View File

@ -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,
}

View File

@ -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>

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,
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,
}
}

View File

@ -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