feat: 新增 ExecutionCompleted 信号修复发送按钮状态

- 后端 OutboundEventKind/WsOutbound 新增 ExecutionCompleted 变体
- processor 在 handle_message 完成后发送该信号
- 飞书/微信通道过滤该信号(不发送)
- 前端 useChat 移除 stream_delta/assistant_response 的 isLoading=false
- 改由 execution_completed 事件统一设置 isLoading=false,确保智能体迭代期间按钮保持停止态
This commit is contained in:
oudecheng 2026-07-03 19:32:14 +08:00
parent 76abdbd1de
commit 994db87f11
8 changed files with 79 additions and 4 deletions

View File

@ -425,6 +425,8 @@ pub enum OutboundEventKind {
StreamDelta,
/// 流式结束信号
StreamEnd,
/// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
ExecutionCompleted,
}
impl OutboundMessage {
@ -630,6 +632,31 @@ impl OutboundMessage {
}
}
/// 构造执行完成信号
pub fn execution_completed(
channel: impl Into<String>,
chat_id: impl Into<String>,
session_id: Option<String>,
metadata: HashMap<String, String>,
) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
session_id,
content: String::new(),
reply_to: None,
media: Vec::new(),
metadata,
event_kind: OutboundEventKind::ExecutionCompleted,
role: "assistant".to_string(),
tool_call_id: None,
tool_name: None,
tool_arguments: None,
reasoning_content: None,
message_id: None,
}
}
pub fn from_chat_message(
channel: &str,
chat_id: &str,

View File

@ -2461,7 +2461,7 @@ impl Channel for FeishuChannel {
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd)
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted)
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
{
return Ok(());

View File

@ -315,6 +315,7 @@ impl Channel for WechatChannel {
| OutboundEventKind::ToolCall
| OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted
) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
{
return Ok(());

View File

@ -387,6 +387,25 @@ impl InboundProcessor {
self.cancel_manager.remove_by_topic(topic_id).await;
}
// 发送执行完成信号,通知前端可以停止 loading 状态
// 无论成功还是失败都发送,确保前端状态正确
let mut completion_metadata = inbound.forwarded_metadata.clone();
if let Some(ref topic_id) = current_topic {
completion_metadata.insert("topic_id".to_string(), topic_id.clone());
}
if let Err(error) = self
.bus
.publish_outbound(OutboundMessage::execution_completed(
channel,
chat_id,
Some(session_id),
completion_metadata,
))
.await
{
tracing::error!(error = %error, "Failed to publish execution_completed");
}
Ok(())
}
}

View File

@ -295,6 +295,13 @@ pub enum WsOutbound {
#[serde(default, skip_serializing_if = "Option::is_none")]
topic_id: Option<String>,
},
#[serde(rename = "execution_completed")]
ExecutionCompleted {
#[serde(default, skip_serializing_if = "Option::is_none")]
topic_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
timestamp: Option<i64>,
},
#[serde(rename = "todo_list")]
TodoList {
todos: Vec<TodoItemSummary>,

View File

@ -194,6 +194,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
subagent_task_id: message.metadata.get("subagent_task_id").cloned(),
topic_id: message.metadata.get("topic_id").cloned(),
}],
OutboundEventKind::ExecutionCompleted => vec![WsOutbound::ExecutionCompleted {
topic_id: message.metadata.get("topic_id").cloned(),
timestamp: Some(crate::protocol::now_timestamp()),
}],
}
}

View File

@ -29,6 +29,7 @@ import type {
ChannelList,
StreamDelta,
StreamEnd,
ExecutionCompleted,
WsInbound,
} from '../types/protocol'
@ -658,7 +659,7 @@ export function useChat(): UseChatReturn {
},
]
})
setIsLoading(false)
// 注意stream_delta 期间不设置 isLoading=false智能体仍在生成
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
break
}
@ -668,6 +669,15 @@ export function useChat(): UseChatReturn {
break
}
case 'execution_completed': {
// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
const msg = message as ExecutionCompleted
// 按 topic_id 隔离:只处理当前话题的完成信号
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return
setIsLoading(false)
break
}
case 'assistant_response': {
const msg = message as AssistantResponse
// 按 topic_id 隔离:如果消息属于其他话题则丢弃
@ -692,7 +702,7 @@ export function useChat(): UseChatReturn {
}
return [...prev, newMsg]
})
setIsLoading(false)
// 注意assistant_response 不设置 isLoading=false智能体可能还会调用工具继续迭代
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)

View File

@ -278,6 +278,12 @@ export interface StreamEnd {
topic_id?: string
}
export interface ExecutionCompleted {
type: 'execution_completed'
topic_id?: string
timestamp?: number
}
export type WsOutbound =
| AssistantResponse
| ToolCall
@ -287,6 +293,7 @@ export type WsOutbound =
| TaskStarted
| StreamDelta
| StreamEnd
| ExecutionCompleted
| SessionEstablished
| SessionCreated
| SessionList