diff --git a/src/gateway/cancel_manager.rs b/src/gateway/cancel_manager.rs index ad7a3c0..0f222a1 100644 --- a/src/gateway/cancel_manager.rs +++ b/src/gateway/cancel_manager.rs @@ -58,6 +58,14 @@ impl CancelManager { self.tokens.lock().await.len() } + /// 返回当前正在执行的 Agent 的 topic_id 列表。 + /// + /// 用于前端重连时对账执行状态:前端通过此 API 判断断连期间 + /// 哪些话题的智能体仍在运行、哪些已完成。 + pub async fn list_active_topic_ids(&self) -> Vec { + self.tokens.lock().await.keys().cloned().collect() + } + /// 取消所有正在运行的 Agent 并清空注册表。 /// /// 用于 graceful shutdown / restart 场景。 diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 24916f7..fbe95b6 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -225,6 +225,21 @@ pub async fn restart( })) } +#[derive(Serialize)] +pub struct ExecutionsResponse { + /// 当前正在执行的 Agent 的 topic_id 列表 + pub topic_ids: Vec, +} + +/// GET /api/executions — 返回当前正在执行的 Agent 的 topic_id 列表 +/// +/// 供前端重连时对账执行状态:前端据此判断断连期间哪些话题的 +/// 智能体仍在运行(需保持禁用)、哪些已完成(应解锁)。 +pub async fn list_executions(State(state): State>) -> Json { + let topic_ids = state.cancel_manager.list_active_topic_ids().await; + Json(ExecutionsResponse { topic_ids }) +} + /// GET /api/mcp/status — Return MCP server connection status pub async fn mcp_status( State(state): State>, diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 71d111c..35257fd 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -290,6 +290,7 @@ pub async fn run( routing::get(http::get_config).put(http::save_config), ) .route("/api/restart", routing::post(http::restart)) + .route("/api/executions", routing::get(http::list_executions)) .route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/skills", routing::get(http::skills_list)) .route("/api/skills/toggle", routing::post(http::skills_toggle)) diff --git a/web/src/hooks/chat/useMessages.ts b/web/src/hooks/chat/useMessages.ts index 79f5f77..3eea0e5 100644 --- a/web/src/hooks/chat/useMessages.ts +++ b/web/src/hooks/chat/useMessages.ts @@ -25,6 +25,8 @@ import type { import { generateMessageId, getSubagentTaskId } from './messageMappers'; interface UseMessagesOptions { + /** 选中话题 state(用于派生 isLoading,确保 ref 异步写不导致派生值过期) */ + selectedTopic: string | null; selectedTopicRef: MutableRefObject; topicsRef: MutableRefObject; bumpTopicRefreshTrigger: () => void; @@ -33,8 +35,16 @@ interface UseMessagesOptions { export interface UseMessagesReturn { messages: ChatMessage[]; setMessages: Dispatch>; + /** 派生值:仅当前选中话题在处理中时为 true */ isLoading: boolean; - setIsLoading: Dispatch>; + /** 当前正在处理的 topic_id 集合(按话题隔离) */ + processingTopicIds: Set; + /** 供重连对账使用:直接设置整个处理集合 */ + setProcessingTopicIds: Dispatch>>; + /** 标记某话题为处理中 */ + markTopicProcessing: (topicId: string) => void; + /** 标记某话题处理完成 */ + markTopicDone: (topicId: string) => void; handleMessage: (content: string, attachments?: Attachment[]) => void; clearMessages: () => void; finishStreaming: () => void; @@ -44,9 +54,37 @@ export interface UseMessagesReturn { } export function useMessages(options: UseMessagesOptions): UseMessagesReturn { - const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options; + const { selectedTopic, selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options; const [messages, setMessages] = useState([]); - const [isLoading, setIsLoading] = useState(false); + // 按话题隔离的处理状态:智能体执行是 per-topic 的, + // 切换话题不应清空原话题的处理状态。 + const [processingTopicIds, setProcessingTopicIds] = useState>( + new Set(), + ); + + // 派生:仅当前选中话题在处理中时才禁用输入框/显示 STOP 按钮。 + // 使用 selectedTopic state(非 ref)作为依赖,确保 selectedTopic 变化时 + // isLoading 立即重算,不受 selectedTopicRef 异步 useEffect 写入延迟影响。 + const isLoading = + selectedTopic !== null && processingTopicIds.has(selectedTopic); + + const markTopicProcessing = useCallback((topicId: string) => { + setProcessingTopicIds((prev) => { + if (prev.has(topicId)) return prev; + const next = new Set(prev); + next.add(topicId); + return next; + }); + }, []); + + const markTopicDone = useCallback((topicId: string) => { + setProcessingTopicIds((prev) => { + if (!prev.has(topicId)) return prev; + const next = new Set(prev); + next.delete(topicId); + return next; + }); + }, []); const syncedUserMessageIdsRef = useRef>(new Set()); @@ -181,8 +219,9 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { attachments: attachments || [], }, ]); - setIsLoading(true); - }, []); + // 乐观标记当前话题为处理中,execution_completed 负责移除 + if (selectedTopicRef.current) markTopicProcessing(selectedTopicRef.current); + }, [selectedTopicRef, markTopicProcessing]); const clearMessages = useCallback(() => { clearStreaming(); @@ -277,8 +316,10 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { bumpTopicRefreshTrigger(); return true; } + // 按 topic_id 移除处理状态,不论当前选中哪个话题。 + // 这样切走话题后收到的完成信号也能正确清理原话题状态。 + if (msg.topic_id) markTopicDone(msg.topic_id); if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true; - setIsLoading(false); // 主代理本次执行结束:刷新 topic 列表以更新 token 统计 bumpTopicRefreshTrigger(); return true; @@ -386,7 +427,8 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { type: 'message', }, ]); - setIsLoading(false); + // execution_cancelled 无 topic_id 字段,保守清空所有处理状态 + setProcessingTopicIds(new Set()); return true; } @@ -402,7 +444,8 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { type: 'message', }, ]); - setIsLoading(false); + // WsError 无 topic_id 字段,保守清空所有处理状态,避免卡死 + setProcessingTopicIds(new Set()); return true; } @@ -417,6 +460,8 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { applyUserMessageId, finishStreaming, scheduleFlush, + markTopicDone, + setProcessingTopicIds, ], ); @@ -424,7 +469,10 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { messages, setMessages, isLoading, - setIsLoading, + processingTopicIds, + setProcessingTopicIds, + markTopicProcessing, + markTopicDone, handleMessage, clearMessages, finishStreaming, diff --git a/web/src/hooks/useChat.test.ts b/web/src/hooks/useChat.test.ts index a7dab48..bf66c8e 100644 --- a/web/src/hooks/useChat.test.ts +++ b/web/src/hooks/useChat.test.ts @@ -278,6 +278,8 @@ describe('useChat - handleServerMessage characterization', () => { it('7. error and execution_cancelled append a message and clear isLoading', () => { const { result } = renderUseChat(); + // 选中话题后 handleMessage 才会标记该话题为处理中(与生产使用场景一致) + act(() => result.current.setSelectedTopic('topic-1')); // set isLoading true via handleMessage act(() => result.current.handleMessage('hi')); expect(result.current.isLoading).toBe(true); diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 077bd2a..c139a17 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -38,6 +38,7 @@ interface UseChatReturn { chatId: string; topics: Topic[]; selectedTopic: string | null; + setSelectedTopic: Dispatch>; // 消息 messages: ChatMessage[]; @@ -123,6 +124,29 @@ interface UseChatReturn { handleStop: () => Command; } +/** + * 重连对账:查询后端当前正在执行的 topic_id 列表,修正前端 processingTopicIds。 + * + * 断连期间,后端可能已发送 execution_completed 但前端未收到, + * 导致前端 processingTopicIds 残留已完成的话题(卡在 loading)。 + * 通过后端权威数据修正:后端无记录的话题 → 已完成,移除; + * 后端有记录但前端无的 → 断连期间新开始,添加。 + * + * 查询失败时保留前端现有状态(保守,宁可多禁用也不误允许发送)。 + */ +async function reconcileProcessingTopics( + setProcessingTopicIds: Dispatch>>, +) { + try { + const res = await fetch('/api/executions'); + if (!res.ok) return; + const data = (await res.json()) as { topic_ids?: string[] }; + setProcessingTopicIds(new Set(data.topic_ids ?? [])); + } catch { + // 查询失败:保留前端现有状态 + } +} + export function useChat(): UseChatReturn { // 调用顺序确保依赖方向:useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList) const conn = useConnection(); @@ -130,6 +154,7 @@ export function useChat(): UseChatReturn { const sessions = useSessions(); const topics = useTopics(); const messages = useMessages({ + selectedTopic: topics.selectedTopic, selectedTopicRef: topics.selectedTopicRef, topicsRef: topics.topicsRef, bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger, @@ -178,8 +203,9 @@ export function useChat(): UseChatReturn { // 刷新 topic 列表(断连期间可能新建了 topic) const topicCmd = topics.requestTopicList(prevSid!); if (topicCmd) conn.sendCommand(topicCmd); - // 重置 loading 状态(断连时可能卡在 loading) - messages.setIsLoading(false); + // 重连对账:查询后端当前正在执行的 topic_id 列表, + // 修正断连期间丢失的 execution_completed 信号导致的状态漂移 + reconcileProcessingTopics(messages.setProcessingTopicIds); } else { // 首次连接、切换通道、或原 session 已被删除:清空旧数据避免污染 topics.setTopics([]); @@ -192,20 +218,17 @@ export function useChat(): UseChatReturn { ? message.sessions[0].session_id : null, ); - messages.setIsLoading(false); } return; } case 'session_created': case 'session_loaded': - messages.setIsLoading(false); return; case 'topic_list': { const autoFocused = topics.handleTopicList(message); if (autoFocused) messages.clearMessages(); - messages.setIsLoading(false); return; } @@ -258,19 +281,9 @@ export function useChat(): UseChatReturn { } }, []); - // ---- handleCommand: 根据命令类型设置 loading 状态 ---- - const handleCommand = useCallback((command: Command) => { - switch (command.type) { - case 'create_session': - case 'switch_topic': - case 'load_topic': - case 'list_sessions': - case 'list_sessions_by_channel': - case 'delete_topic': - case 'list_topics': - messages.setIsLoading(true); - break; - } + // ---- handleCommand: 命令分发 hook(保留接口兼容,处理状态已迁移至 per-topic 跟踪) ---- + const handleCommand = useCallback((_command: Command) => { + // 处理状态由 handleMessage 按 topic_id 跟踪,导航不再设置全局 loading }, []); // ---- selectTopic: 切换话题,清空消息和子智能体栈 ---- @@ -296,7 +309,9 @@ export function useChat(): UseChatReturn { subAgent.subAgentViewRef.current = null; subAgent.subAgentStackRef.current = []; subAgent.setSubAgentStack([]); - messages.setIsLoading(true); + // 切换通道后旧通道的 execution_completed 不再到达主视图, + // 清空处理状态避免残留;切回时由 reconcileProcessingTopics 重建 + messages.setProcessingTopicIds(new Set()); }, [sideData.selectedChannel], ); @@ -312,7 +327,9 @@ export function useChat(): UseChatReturn { subAgent.subAgentViewRef.current = null; subAgent.subAgentStackRef.current = []; subAgent.setSubAgentStack([]); - messages.setIsLoading(true); + // 切换 session 后旧 session 的 execution_completed 不再到达主视图, + // 清空处理状态避免残留;切回时由 reconcileProcessingTopics 重建 + messages.setProcessingTopicIds(new Set()); }, [sessions.selectedSessionId], ); @@ -350,6 +367,7 @@ export function useChat(): UseChatReturn { chatId: sessions.chatId, topics: topics.topics, selectedTopic: topics.selectedTopic, + setSelectedTopic: topics.setSelectedTopic, messages: resolvedMessages, isLoading: messages.isLoading, isReadOnly,