feat: 添加 TaskStarted 事件,支持任务开始通知功能

This commit is contained in:
ooodc 2026-06-03 21:53:37 +08:00
parent 1d4ebb27a7
commit e8a3a47ac7
8 changed files with 171 additions and 39 deletions

View File

@ -293,6 +293,7 @@ pub enum OutboundEventKind {
ToolPending,
SchedulerNotification,
ErrorNotification,
TaskStarted,
}
impl OutboundMessage {

View File

@ -145,6 +145,12 @@ pub enum WsOutbound {
},
#[serde(rename = "error")]
Error { code: String, message: String },
#[serde(rename = "task_started")]
TaskStarted {
task_id: String,
description: String,
subagent_type: String,
},
#[serde(rename = "session_established")]
SessionEstablished { session_id: String },
#[serde(rename = "session_created")]

View File

@ -141,6 +141,11 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
code: "AGENT_ERROR".to_string(),
message: message.content.clone(),
}],
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
description: message.metadata.get("task_description").cloned().unwrap_or_default(),
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
}],
}
}

View File

@ -9,7 +9,7 @@ use serde::Deserialize;
use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::bus::ChatMessage;
use crate::bus::message::OutboundMessage;
use crate::bus::message::{OutboundMessage, OutboundEventKind};
use crate::bus::MessageBus;
use crate::config::{LLMProviderConfig, SubagentsConfig};
use crate::storage::ConversationRepository;
@ -418,6 +418,33 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
);
self.task_repository.save_task_session(&session).await?;
// 5.1 立即通知前端 task_id让前端可以显示"查看实时进度"按钮)
if let Some(bus) = &self.bus {
let mut metadata = HashMap::new();
metadata.insert("task_id".to_string(), session.id.clone());
metadata.insert("task_description".to_string(), session.description.clone());
metadata.insert("task_subagent_type".to_string(), session.subagent_type.clone());
let event = OutboundMessage {
channel: session.parent_channel_name.clone(),
chat_id: session.parent_chat_id.clone(),
session_id: Some(session.parent_session_id.clone()),
content: String::new(),
reply_to: None,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::TaskStarted,
role: "system".to_string(),
tool_call_id: None,
tool_name: None,
tool_arguments: None,
};
if let Err(e) = bus.publish_outbound(event).await {
tracing::warn!(error = %e, task_id = %session.id, "Failed to publish TaskStarted event");
}
}
// 6. 构建子代理系统提示词
let system_prompt = SubagentPromptBuilder::build(
&def,

View File

@ -1,9 +1,60 @@
import { useState } from 'react'
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check, Loader2, XCircle, Clock, Loader } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
// 状态图标组件
function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout', size?: number }) {
const iconClass = `transition-all duration-300`
switch (status) {
case 'calling':
return (
<Loader2
className={`${iconClass} animate-spin`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'result':
case 'success':
return (
<CheckCircle
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'failed':
return (
<XCircle
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'timeout':
return (
<Clock
className={`${iconClass} animate-scale-in`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
case 'pending':
return (
<Loader
className={`${iconClass} animate-spin`}
style={{ width: size, height: size }}
strokeWidth={2.5}
/>
)
default:
return null
}
}
interface MessageBubbleProps {
message: ChatMessage
onNavigateToSubAgent?: (taskId: string, description: string) => void
@ -156,25 +207,22 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
const statusConfig = {
calling: {
dot: 'bg-amber-400 animate-pulse',
label: '执行中',
fullBorder: 'border-amber-500/30',
labelColor: 'text-amber-400',
iconColor: 'text-amber-400',
avatarBg: 'bg-amber-500/20',
avatarIcon: 'text-amber-400',
},
result: {
dot: 'bg-emerald-400',
label: '已完成',
fullBorder: 'border-emerald-500/30',
labelColor: 'text-emerald-400',
iconColor: 'text-emerald-400',
avatarBg: 'bg-emerald-500/20',
avatarIcon: 'text-emerald-400',
},
pending: {
dot: 'bg-orange-400 animate-pulse',
label: '待确认',
fullBorder: 'border-orange-500/30',
labelColor: 'text-orange-400',
iconColor: 'text-orange-400',
avatarBg: 'bg-orange-500/20',
avatarIcon: 'text-orange-400',
},
@ -215,9 +263,9 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
// task tool 专用的状态配色
const taskStatusConfig = {
success: { dot: 'bg-emerald-400', label: '成功', borderColor: 'border-emerald-500/40', labelColor: 'text-emerald-400' },
failed: { dot: 'bg-red-400', label: '失败', borderColor: 'border-red-500/40', labelColor: 'text-red-400' },
timeout: { dot: 'bg-amber-400', label: '超时', borderColor: 'border-amber-500/40', labelColor: 'text-amber-400' },
success: { dot: 'bg-emerald-400', borderColor: 'border-emerald-500/40', iconColor: 'text-emerald-400' },
failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' },
timeout: { dot: 'bg-amber-400', borderColor: 'border-amber-500/40', iconColor: 'text-amber-400' },
} as const
return (
@ -260,10 +308,14 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
<span className="text-sm font-medium text-zinc-300 truncate">
{isTaskTool ? (taskDescription || '子智能体任务') : (message.toolName || 'Tool')}
</span>
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${
taskResult ? taskStatusConfig[taskResult.status].labelColor : statusConfig.labelColor
<span className={`flex-shrink-0 transition-all duration-300 ${
taskResult ? taskStatusConfig[taskResult.status].iconColor : statusConfig.iconColor
}`}>
{taskResult ? taskStatusConfig[taskResult.status].label : statusConfig.label}
{taskResult ? (
<StatusIcon status={taskResult.status} />
) : (
<StatusIcon status={status} />
)}
</span>
{status === 'result' && message.durationMs != null && (
<span className="text-xs text-zinc-600 flex-shrink-0 tabular-nums ml-1">
@ -299,8 +351,7 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
{taskResult.summary}
</div>
)}
{taskResult ? (
<>
{taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
@ -313,11 +364,8 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
<span></span>
</button>
</div>
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
<span></span>
</div>
</>
) : isTaskTool && message.subagentTaskId ? (
)}
{isTaskTool && message.subagentTaskId && !taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
@ -330,13 +378,15 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
<span></span>
</button>
</div>
) : hasResult ? (
<div className="px-3 pb-2 text-xs text-[#00f0ff]/50 flex items-center gap-1 select-none">
<span></span>
</div>
) : (
)}
{!taskResult && !isTaskTool && !hasResult && (
<div className="px-3 pb-2 text-xs text-zinc-500">
{isTaskTool ? '子智能体正在执行...' : '等待工具执行...'}
...
</div>
)}
{isTaskTool && !taskResult && !message.subagentTaskId && (
<div className="px-3 pb-2 text-xs text-zinc-500">
...
</div>
)}
</>

View File

@ -14,6 +14,7 @@ import type {
TopicSummary,
Session,
TaskMessagesLoaded,
TaskStarted,
Attachment,
SchedulerJobList,
SchedulerJobSummary,
@ -289,6 +290,22 @@ export function useChat(): UseChatReturn {
break
}
case 'task_started': {
const msg = message as TaskStarted
// 立即更新对应的 task tool_call让用户可以点击查看实时进度
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], subagentTaskId: msg.task_id }
return updated
}
}
return prev
})
break
}
case 'session_list': {
const msg = message as SessionList
console.log('Session list received:', msg)

View File

@ -149,6 +149,20 @@ body {
}
}
@keyframes scale-in {
0% {
opacity: 0;
transform: scale(0.5);
}
50% {
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
@ -157,6 +171,10 @@ body {
animation: fade-in 0.2s ease-out;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
.typing-indicator span {
animation: typing-dot 1.4s infinite;
display: inline-block;

View File

@ -84,6 +84,13 @@ export interface WsError {
message: string
}
export interface TaskStarted {
type: 'task_started'
task_id: string
description: string
subagent_type: string
}
export interface SessionEstablished {
type: 'session_established'
session_id: string
@ -203,6 +210,7 @@ export type WsOutbound =
| ToolResult
| ToolPending
| WsError
| TaskStarted
| SessionEstablished
| SessionCreated
| SessionList