fix(web): 输入框/发送按钮状态按话题隔离,避免处理中仍可发送

将单一全局 isLoading 布尔值重构为按 topic_id 跟踪的 processingTopicIds
集合,isLoading 派生自当前选中话题是否在集合中。导航响应不再清空处理
状态,切换话题后切回原话题仍能正确禁用输入。重连时通过新增的
/api/executions 端点对账后端权威执行状态,修正断连期间丢失的
execution_completed 信号导致的状态漂移。
This commit is contained in:
oudecheng 2026-08-07 11:45:47 +08:00
parent feeb9d9c18
commit a9429a5657
6 changed files with 121 additions and 29 deletions

View File

@ -58,6 +58,14 @@ impl CancelManager {
self.tokens.lock().await.len()
}
/// 返回当前正在执行的 Agent 的 topic_id 列表。
///
/// 用于前端重连时对账执行状态:前端通过此 API 判断断连期间
/// 哪些话题的智能体仍在运行、哪些已完成。
pub async fn list_active_topic_ids(&self) -> Vec<String> {
self.tokens.lock().await.keys().cloned().collect()
}
/// 取消所有正在运行的 Agent 并清空注册表。
///
/// 用于 graceful shutdown / restart 场景。

View File

@ -225,6 +225,21 @@ pub async fn restart(
}))
}
#[derive(Serialize)]
pub struct ExecutionsResponse {
/// 当前正在执行的 Agent 的 topic_id 列表
pub topic_ids: Vec<String>,
}
/// GET /api/executions — 返回当前正在执行的 Agent 的 topic_id 列表
///
/// 供前端重连时对账执行状态:前端据此判断断连期间哪些话题的
/// 智能体仍在运行(需保持禁用)、哪些已完成(应解锁)。
pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<ExecutionsResponse> {
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<Arc<GatewayState>>,

View File

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

View File

@ -25,6 +25,8 @@ import type {
import { generateMessageId, getSubagentTaskId } from './messageMappers';
interface UseMessagesOptions {
/** 选中话题 state用于派生 isLoading确保 ref 异步写不导致派生值过期) */
selectedTopic: string | null;
selectedTopicRef: MutableRefObject<string | null>;
topicsRef: MutableRefObject<Topic[]>;
bumpTopicRefreshTrigger: () => void;
@ -33,8 +35,16 @@ interface UseMessagesOptions {
export interface UseMessagesReturn {
messages: ChatMessage[];
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
/** 派生值:仅当前选中话题在处理中时为 true */
isLoading: boolean;
setIsLoading: Dispatch<SetStateAction<boolean>>;
/** 当前正在处理的 topic_id 集合(按话题隔离) */
processingTopicIds: Set<string>;
/** 供重连对账使用:直接设置整个处理集合 */
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>;
/** 标记某话题为处理中 */
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<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 按话题隔离的处理状态:智能体执行是 per-topic 的,
// 切换话题不应清空原话题的处理状态。
const [processingTopicIds, setProcessingTopicIds] = useState<Set<string>>(
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<Set<string>>(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,

View File

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

View File

@ -38,6 +38,7 @@ interface UseChatReturn {
chatId: string;
topics: Topic[];
selectedTopic: string | null;
setSelectedTopic: Dispatch<SetStateAction<string | null>>;
// 消息
messages: ChatMessage[];
@ -123,6 +124,29 @@ interface UseChatReturn {
handleStop: () => Command;
}
/**
* topic_id processingTopicIds
*
* execution_completed
* processingTopicIds loading
*
*
*
*
*/
async function reconcileProcessingTopics(
setProcessingTopicIds: Dispatch<SetStateAction<Set<string>>>,
) {
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,