fix(web): 输入框/发送按钮状态按话题隔离,避免处理中仍可发送
将单一全局 isLoading 布尔值重构为按 topic_id 跟踪的 processingTopicIds 集合,isLoading 派生自当前选中话题是否在集合中。导航响应不再清空处理 状态,切换话题后切回原话题仍能正确禁用输入。重连时通过新增的 /api/executions 端点对账后端权威执行状态,修正断连期间丢失的 execution_completed 信号导致的状态漂移。
This commit is contained in:
parent
feeb9d9c18
commit
a9429a5657
@ -58,6 +58,14 @@ impl CancelManager {
|
|||||||
self.tokens.lock().await.len()
|
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 并清空注册表。
|
/// 取消所有正在运行的 Agent 并清空注册表。
|
||||||
///
|
///
|
||||||
/// 用于 graceful shutdown / restart 场景。
|
/// 用于 graceful shutdown / restart 场景。
|
||||||
|
|||||||
@ -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
|
/// GET /api/mcp/status — Return MCP server connection status
|
||||||
pub async fn mcp_status(
|
pub async fn mcp_status(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
|||||||
@ -290,6 +290,7 @@ pub async fn run(
|
|||||||
routing::get(http::get_config).put(http::save_config),
|
routing::get(http::get_config).put(http::save_config),
|
||||||
)
|
)
|
||||||
.route("/api/restart", routing::post(http::restart))
|
.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/mcp/status", routing::get(http::mcp_status))
|
||||||
.route("/api/skills", routing::get(http::skills_list))
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import type {
|
|||||||
import { generateMessageId, getSubagentTaskId } from './messageMappers';
|
import { generateMessageId, getSubagentTaskId } from './messageMappers';
|
||||||
|
|
||||||
interface UseMessagesOptions {
|
interface UseMessagesOptions {
|
||||||
|
/** 选中话题 state(用于派生 isLoading,确保 ref 异步写不导致派生值过期) */
|
||||||
|
selectedTopic: string | null;
|
||||||
selectedTopicRef: MutableRefObject<string | null>;
|
selectedTopicRef: MutableRefObject<string | null>;
|
||||||
topicsRef: MutableRefObject<Topic[]>;
|
topicsRef: MutableRefObject<Topic[]>;
|
||||||
bumpTopicRefreshTrigger: () => void;
|
bumpTopicRefreshTrigger: () => void;
|
||||||
@ -33,8 +35,16 @@ interface UseMessagesOptions {
|
|||||||
export interface UseMessagesReturn {
|
export interface UseMessagesReturn {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||||
|
/** 派生值:仅当前选中话题在处理中时为 true */
|
||||||
isLoading: boolean;
|
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;
|
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
finishStreaming: () => void;
|
finishStreaming: () => void;
|
||||||
@ -44,9 +54,37 @@ export interface UseMessagesReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
||||||
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options;
|
const { selectedTopic, selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options;
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
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());
|
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@ -181,8 +219,9 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
attachments: attachments || [],
|
attachments: attachments || [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setIsLoading(true);
|
// 乐观标记当前话题为处理中,execution_completed 负责移除
|
||||||
}, []);
|
if (selectedTopicRef.current) markTopicProcessing(selectedTopicRef.current);
|
||||||
|
}, [selectedTopicRef, markTopicProcessing]);
|
||||||
|
|
||||||
const clearMessages = useCallback(() => {
|
const clearMessages = useCallback(() => {
|
||||||
clearStreaming();
|
clearStreaming();
|
||||||
@ -277,8 +316,10 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
bumpTopicRefreshTrigger();
|
bumpTopicRefreshTrigger();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// 按 topic_id 移除处理状态,不论当前选中哪个话题。
|
||||||
|
// 这样切走话题后收到的完成信号也能正确清理原话题状态。
|
||||||
|
if (msg.topic_id) markTopicDone(msg.topic_id);
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
setIsLoading(false);
|
|
||||||
// 主代理本次执行结束:刷新 topic 列表以更新 token 统计
|
// 主代理本次执行结束:刷新 topic 列表以更新 token 统计
|
||||||
bumpTopicRefreshTrigger();
|
bumpTopicRefreshTrigger();
|
||||||
return true;
|
return true;
|
||||||
@ -386,7 +427,8 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setIsLoading(false);
|
// execution_cancelled 无 topic_id 字段,保守清空所有处理状态
|
||||||
|
setProcessingTopicIds(new Set());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -402,7 +444,8 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setIsLoading(false);
|
// WsError 无 topic_id 字段,保守清空所有处理状态,避免卡死
|
||||||
|
setProcessingTopicIds(new Set());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,6 +460,8 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
applyUserMessageId,
|
applyUserMessageId,
|
||||||
finishStreaming,
|
finishStreaming,
|
||||||
scheduleFlush,
|
scheduleFlush,
|
||||||
|
markTopicDone,
|
||||||
|
setProcessingTopicIds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -424,7 +469,10 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
isLoading,
|
isLoading,
|
||||||
setIsLoading,
|
processingTopicIds,
|
||||||
|
setProcessingTopicIds,
|
||||||
|
markTopicProcessing,
|
||||||
|
markTopicDone,
|
||||||
handleMessage,
|
handleMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
finishStreaming,
|
finishStreaming,
|
||||||
|
|||||||
@ -278,6 +278,8 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
|
|
||||||
it('7. error and execution_cancelled append a message and clear isLoading', () => {
|
it('7. error and execution_cancelled append a message and clear isLoading', () => {
|
||||||
const { result } = renderUseChat();
|
const { result } = renderUseChat();
|
||||||
|
// 选中话题后 handleMessage 才会标记该话题为处理中(与生产使用场景一致)
|
||||||
|
act(() => result.current.setSelectedTopic('topic-1'));
|
||||||
// set isLoading true via handleMessage
|
// set isLoading true via handleMessage
|
||||||
act(() => result.current.handleMessage('hi'));
|
act(() => result.current.handleMessage('hi'));
|
||||||
expect(result.current.isLoading).toBe(true);
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
|||||||
@ -38,6 +38,7 @@ interface UseChatReturn {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
selectedTopic: string | null;
|
selectedTopic: string | null;
|
||||||
|
setSelectedTopic: Dispatch<SetStateAction<string | null>>;
|
||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@ -123,6 +124,29 @@ interface UseChatReturn {
|
|||||||
handleStop: () => Command;
|
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 {
|
export function useChat(): UseChatReturn {
|
||||||
// 调用顺序确保依赖方向:useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList)
|
// 调用顺序确保依赖方向:useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList)
|
||||||
const conn = useConnection();
|
const conn = useConnection();
|
||||||
@ -130,6 +154,7 @@ export function useChat(): UseChatReturn {
|
|||||||
const sessions = useSessions();
|
const sessions = useSessions();
|
||||||
const topics = useTopics();
|
const topics = useTopics();
|
||||||
const messages = useMessages({
|
const messages = useMessages({
|
||||||
|
selectedTopic: topics.selectedTopic,
|
||||||
selectedTopicRef: topics.selectedTopicRef,
|
selectedTopicRef: topics.selectedTopicRef,
|
||||||
topicsRef: topics.topicsRef,
|
topicsRef: topics.topicsRef,
|
||||||
bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger,
|
bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger,
|
||||||
@ -178,8 +203,9 @@ export function useChat(): UseChatReturn {
|
|||||||
// 刷新 topic 列表(断连期间可能新建了 topic)
|
// 刷新 topic 列表(断连期间可能新建了 topic)
|
||||||
const topicCmd = topics.requestTopicList(prevSid!);
|
const topicCmd = topics.requestTopicList(prevSid!);
|
||||||
if (topicCmd) conn.sendCommand(topicCmd);
|
if (topicCmd) conn.sendCommand(topicCmd);
|
||||||
// 重置 loading 状态(断连时可能卡在 loading)
|
// 重连对账:查询后端当前正在执行的 topic_id 列表,
|
||||||
messages.setIsLoading(false);
|
// 修正断连期间丢失的 execution_completed 信号导致的状态漂移
|
||||||
|
reconcileProcessingTopics(messages.setProcessingTopicIds);
|
||||||
} else {
|
} else {
|
||||||
// 首次连接、切换通道、或原 session 已被删除:清空旧数据避免污染
|
// 首次连接、切换通道、或原 session 已被删除:清空旧数据避免污染
|
||||||
topics.setTopics([]);
|
topics.setTopics([]);
|
||||||
@ -192,20 +218,17 @@ export function useChat(): UseChatReturn {
|
|||||||
? message.sessions[0].session_id
|
? message.sessions[0].session_id
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
messages.setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'session_created':
|
case 'session_created':
|
||||||
case 'session_loaded':
|
case 'session_loaded':
|
||||||
messages.setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'topic_list': {
|
case 'topic_list': {
|
||||||
const autoFocused = topics.handleTopicList(message);
|
const autoFocused = topics.handleTopicList(message);
|
||||||
if (autoFocused) messages.clearMessages();
|
if (autoFocused) messages.clearMessages();
|
||||||
messages.setIsLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,19 +281,9 @@ export function useChat(): UseChatReturn {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- handleCommand: 根据命令类型设置 loading 状态 ----
|
// ---- handleCommand: 命令分发 hook(保留接口兼容,处理状态已迁移至 per-topic 跟踪) ----
|
||||||
const handleCommand = useCallback((command: Command) => {
|
const handleCommand = useCallback((_command: Command) => {
|
||||||
switch (command.type) {
|
// 处理状态由 handleMessage 按 topic_id 跟踪,导航不再设置全局 loading
|
||||||
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;
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
||||||
@ -296,7 +309,9 @@ export function useChat(): UseChatReturn {
|
|||||||
subAgent.subAgentViewRef.current = null;
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.subAgentStackRef.current = [];
|
subAgent.subAgentStackRef.current = [];
|
||||||
subAgent.setSubAgentStack([]);
|
subAgent.setSubAgentStack([]);
|
||||||
messages.setIsLoading(true);
|
// 切换通道后旧通道的 execution_completed 不再到达主视图,
|
||||||
|
// 清空处理状态避免残留;切回时由 reconcileProcessingTopics 重建
|
||||||
|
messages.setProcessingTopicIds(new Set());
|
||||||
},
|
},
|
||||||
[sideData.selectedChannel],
|
[sideData.selectedChannel],
|
||||||
);
|
);
|
||||||
@ -312,7 +327,9 @@ export function useChat(): UseChatReturn {
|
|||||||
subAgent.subAgentViewRef.current = null;
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.subAgentStackRef.current = [];
|
subAgent.subAgentStackRef.current = [];
|
||||||
subAgent.setSubAgentStack([]);
|
subAgent.setSubAgentStack([]);
|
||||||
messages.setIsLoading(true);
|
// 切换 session 后旧 session 的 execution_completed 不再到达主视图,
|
||||||
|
// 清空处理状态避免残留;切回时由 reconcileProcessingTopics 重建
|
||||||
|
messages.setProcessingTopicIds(new Set());
|
||||||
},
|
},
|
||||||
[sessions.selectedSessionId],
|
[sessions.selectedSessionId],
|
||||||
);
|
);
|
||||||
@ -350,6 +367,7 @@ export function useChat(): UseChatReturn {
|
|||||||
chatId: sessions.chatId,
|
chatId: sessions.chatId,
|
||||||
topics: topics.topics,
|
topics: topics.topics,
|
||||||
selectedTopic: topics.selectedTopic,
|
selectedTopic: topics.selectedTopic,
|
||||||
|
setSelectedTopic: topics.setSelectedTopic,
|
||||||
messages: resolvedMessages,
|
messages: resolvedMessages,
|
||||||
isLoading: messages.isLoading,
|
isLoading: messages.isLoading,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user