fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -10,11 +10,14 @@ import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import type { InvestigationDiscussion } from "../api/investigation-report";
import {
useInteractionRequests,
useAgentConnection,
FrontendActionExecutor,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
createAgentApiClient,
AgentResponseFinishedError,
useAgentMessageInbox,
fetchAgentBootstrap,
resolveAgentModelSelection,
@@ -59,6 +62,7 @@ import { useWorkbenchSessionResources } from "./workbench-session-resources";
import { useAgentTopics } from "./use-agent-topics";
import { useAgentHistory } from "./use-agent-history";
import { agentRuntimes, createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeNeedsInput, runtimeRunning,
isPendingInteractionError,
type AgentReadingPosition, type AgentSessionRuntime } from "./agent-session-runtime";
const AGENT_PANEL_COLLAPSE_MS = 180;
@@ -98,6 +102,7 @@ export function useWorkbenchAgent({
clientRef.current = scopeClient;
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
const scopeKey = projectScopeKey;
const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
const history = useAgentHistory(scopeClient, scopeKey);
const topics = useAgentTopics(scopeClient, scopeKey);
const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution")
@@ -142,8 +147,6 @@ export function useWorkbenchAgent({
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
const [connectionStatus, setConnectionStatus] =
useState<AgentConnectionStatus>("connecting");
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [inputDelivery, setInputDelivery] = useState<AgentInputDelivery>("steer");
@@ -162,6 +165,44 @@ export function useWorkbenchAgent({
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
const agentMessageInbox = useAgentMessageInbox(clientRef.current, sessionPurpose === "ordinary_chat" ? sessionId : null);
const refreshAgentMessageInbox = agentMessageInbox.refresh;
const continueInteraction = useCallback(async (owningSessionId: string, interactionId: string) => {
const target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === owningSessionId);
if (!target || target.client !== scopeClient || resources.disposed) throw new Error("原会话已关闭,请重新打开后继续。");
const record = await target.client.getInteractionRequest(owningSessionId, interactionId);
if (target.client !== scopeClient || resources.disposed || !agentRuntimes(resources).includes(target)) return;
if (!record.delivery || ["delivered", "cancelled"].includes(record.delivery.status)) return;
const text = record.status === "skipped" ? "已跳过本组提问。" : record.questions.map((question) => {
const answer = record.answers?.find((item) => item.question_id === question.question_id);
return `${question.title}:${[...(answer?.selected_options.map((option) => option.label) ?? []), answer?.custom_text].filter(Boolean).join(";")}`;
}).join("\n");
const alreadyPresent = target.chat.messages.some((message) => message.id === record.delivery!.native_message_id);
const replyMessage: AgentUiMessage = { id: record.delivery.native_message_id, role: "user", parts: [
{ type: "text", text }, { type: "data-interaction_reply", data: record },
] };
if (alreadyPresent) target.chat.messages = target.chat.messages.map((message) => message.id === replyMessage.id ? replyMessage : message);
target.runStatus = "running";
try {
await target.chat.sendMessage(alreadyPresent ? undefined : replyMessage, { body: {
session_id: owningSessionId, interaction_id: interactionId, model: selectedModel || undefined,
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@1"] : ["host-question@1"],
approval_mode: approvalMode, task_context: target.taskDiscussion?.context,
} });
} catch (error) { target.runStatus = "error"; throw error; }
finally {
if (!resources.disposed && target.client === scopeClient && !runtimeRunning(target)) {
try {
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
if (loaded && !resources.disposed && target.client === scopeClient) {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
resources.notify();
}
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
}
}
}, [resources, scopeKey, scopeClient, selectedModel, approvalMode]);
const interactionRequests = useInteractionRequests(scopeClient, scopeKey, sessionId, continueInteraction);
const refreshInteractionRequests = interactionRequests.refresh;
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -232,7 +273,6 @@ export function useWorkbenchAgent({
const handleDataPart = useCallback(
(part: AgentDataPart) => {
setConnectionStatus("online");
const eventSessionId = getDataString(part.data, "session_id");
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
return;
@@ -247,6 +287,7 @@ export function useWorkbenchAgent({
);
return;
}
if (part.type === "data-interaction_request") { void refreshInteractionRequests(); return; }
if (part.type === "data-agent_message") { refreshAgentMessageInbox(); return; }
if (part.type === "data-user_input" && part.data.status === "delivered") {
chatRef.current.setMessages((current) => applySessionStreamEvent(current, { type: "user_input", data: part.data }));
@@ -295,7 +336,7 @@ export function useWorkbenchAgent({
});
});
},
[refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
[refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
@@ -355,9 +396,14 @@ export function useWorkbenchAgent({
const {
data: backendConnection,
error: backendConnectionError
error: backendConnectionError,
mutate: refreshBootstrap
} = useSWR([...agentBootstrapKey, getAccessToken, scopeKey], () => fetchAgentBootstrap(scopeClient), {
shouldRetryOnError: false
shouldRetryOnError: true,
errorRetryInterval: 5_000,
errorRetryCount: 3,
revalidateOnFocus: true,
revalidateOnReconnect: true
});
const clearCollapseTimer = useCallback(() => {
if (collapseTimerRef.current !== null) {
@@ -374,6 +420,7 @@ export function useWorkbenchAgent({
}, []);
const stopSessionStreamSubscription = useCallback(() => {
clearTimeout(runtimeRef.current?.recoveryTimer);
runtimeRef.current?.controller?.abort();
if (runtimeRef.current) { runtimeRef.current.controller = undefined; runtimeRef.current.resumed = false; }
}, []);
@@ -402,8 +449,7 @@ export function useWorkbenchAgent({
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current));
if (chatRef.current.status === "ready") {
setConnectionStatus("online");
if (chatRef.current.status === "ready" && runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) {
setStatusLabel("准备就绪");
}
}, [backendConnection]);
@@ -413,8 +459,7 @@ export function useWorkbenchAgent({
return;
}
setConnectionStatus("offline");
setStatusLabel("Agent 后端不可用");
if (runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) setStatusLabel("Agent 初始化信息暂不可用");
}, [backendConnectionError]);
const collapsePanel = useCallback(() => {
@@ -469,6 +514,12 @@ export function useWorkbenchAgent({
() => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
);
useEffect(() => {
if (!recoveryVersion) return;
void refreshBootstrap();
void refreshInteractionRequests();
void refreshPendingInputs();
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return;
try {
@@ -504,10 +555,38 @@ export function useWorkbenchAgent({
if (sessionIdRef.current === activeSessionId) void refreshPendingInputs().catch(() => {});
}
}, [refreshPendingInputs, resources, setPromptDraft]);
const checkedLegacyQuestions = useRef(new Set<string>());
const checkingLegacyQuestions = useRef(new Set<string>());
const legacyQuestionScope = useRef(`${scopeKey}:${sessionId}`);
if (legacyQuestionScope.current !== `${scopeKey}:${sessionId}`) {
legacyQuestionScope.current = `${scopeKey}:${sessionId}`;
checkedLegacyQuestions.current.clear();
checkingLegacyQuestions.current.clear();
}
const messages = useMemo(
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
[chat.messages, permissionOverrides, questionOverrides]
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides).map((message) => ({ ...message,
questions: message.questions?.map((question) => question.status === "pending" && !checkedLegacyQuestions.current.has(`${scopeKey}:${question.sessionId}:${question.requestId}`)
? { ...question, status: "checking" as const } : question),
})),
[chat.messages, permissionOverrides, questionOverrides, scopeKey]
);
useEffect(() => {
const target = runtime;
for (const message of messages) for (const question of message.questions ?? []) {
const identity = `${scopeKey}:${question.sessionId}:${question.requestId}`;
if (question.status !== "checking" || checkingLegacyQuestions.current.has(identity)) continue;
checkingLegacyQuestions.current.add(identity);
void target.client.getQuestionStatus(question.sessionId, question.requestId).then(({ status }) => {
checkedLegacyQuestions.current.add(identity);
checkingLegacyQuestions.current.delete(identity);
target.questionOverrides = { ...target.questionOverrides, [question.requestId]: { status } };
if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
}).catch(() => {
// Uncertainty keeps historical controls disabled until the session is reopened.
checkingLegacyQuestions.current.delete(identity);
});
}
}, [messages, resources, runtime, scopeKey]);
const personaState = useMemo(
() => deriveAgentPersonaState(messages, {
chatStatus: chat.status,
@@ -521,13 +600,15 @@ export function useWorkbenchAgent({
const handleSessionStreamEvent = useCallback(
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
if (event.type === "interaction_request") { void refreshInteractionRequests(); return; }
if (event.type === "state") {
void refreshInteractionRequests();
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
return;
}
const nextMessages = runtimeRef.current?.chat.messages ?? toAgentUiMessages(event.messages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
setStatusLabel(event.runStatus === "waiting_input" ? "等待补充信息" : event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
return;
}
@@ -576,8 +657,9 @@ export function useWorkbenchAgent({
setStatusLabel(title);
}
} else if (event.type === "done") {
void refreshInteractionRequests();
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 分析完成");
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
@@ -587,7 +669,7 @@ export function useWorkbenchAgent({
// Message changes are applied once by the owning runtime, including background streams.
},
[refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
[refreshInteractionRequests, refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
useLayoutEffect(() => {
@@ -595,23 +677,35 @@ export function useWorkbenchAgent({
onData: handleDataPart,
onEvent: (event) => handleSessionStreamEvent(event, runtime.sessionId ?? ""),
onError: (error) => {
void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
setConnectionStatus("offline"); setStatusLabel(error.message);
if (isPendingInteractionError(error)) {
setStatusLabel("等待补充信息");
showMapNotice({ id: "agent-stream-status", tone: "warning", title: "有待回答的问题", message: "请在对话中的提问卡片中选择或填写后提交,也可以取消提问。" });
return;
}
checkAgentConnection();
setStatusLabel(error.message);
showMapNotice({ id: "agent-stream-status", tone: "error", title: "Agent 请求失败", message: error.message });
},
onFinish: (status) => {
void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
setConnectionStatus(status === "error" ? "offline" : "online");
setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : "Agent 分析完成");
if (status === "error") checkAgentConnection();
setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : status === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
},
onReconnect: () => {
checkAgentConnection();
setStatusLabel("连接已中断,正在恢复会话");
},
};
return () => { runtime.handlers = undefined; };
}, [handleDataPart, handleSessionStreamEvent, runtime]);
}, [checkAgentConnection, handleDataPart, handleSessionStreamEvent, refreshInteractionRequests, runtime]);
const loadHistorySession = useCallback(async (nextSessionId: string) => {
const generation = ++loadingGenerationRef.current;
let target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === nextSessionId);
try {
if (!target || target.error || (!runtimeRunning(target) && Date.now() - target.loadedAt >= 60_000)) {
if (!target || target.error || target.connectionError || (!runtimeRunning(target) && Date.now() - target.loadedAt >= 60_000)) {
const loaded = await resources.cache.load(JSON.stringify(["session", scopeKey, nextSessionId]),
() => scopeClient.loadSession(nextSessionId, { messageLimit: 40, signal: AbortSignal.timeout(15_000) }), 60_000, { force: Boolean(target?.error) });
if (!loaded) throw new Error("这个历史会话不存在或已被删除。");
@@ -732,7 +826,6 @@ export function useWorkbenchAgent({
activateRuntime(next);
setTaskDiscussion(null);
setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
}, [activateRuntime, resources, scopeClient, scopeKey, transport]);
const selectTopic = (topicId: string | null) => {
@@ -798,7 +891,9 @@ export function useWorkbenchAgent({
return;
}
if (streaming) {
if (streaming || interactionRequests.awaitingInput) {
const owningRuntime = runtimeRef.current;
const owningSessionId = sessionIdRef.current;
try {
if (!sessionIdRef.current) throw new Error("Agent 正在建立会话,请稍后追加消息。");
const receipt = await clientRef.current.submitResponseInput(sessionIdRef.current, normalizedPrompt, inputDelivery, `msg_${crypto.randomUUID().replaceAll("-", "")}`);
@@ -808,15 +903,23 @@ export function useWorkbenchAgent({
? [...current.filter((input) => input.id !== receipt.id), receipt]
: current.filter((input) => input.id !== receipt.id), { revalidate: false })
.then(() => refreshPendingInputs()).catch(() => {});
return;
} catch (error) {
showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
throw error;
if (error instanceof AgentResponseFinishedError) {
await refreshInteractionRequests();
if (runtimeRef.current !== owningRuntime || sessionIdRef.current !== owningSessionId) {
throw new Error("原会话已切换,请回到原会话后重新发送。");
}
} else {
showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
throw error;
}
}
return;
}
setStatusLabel("Agent 正在分析");
runtimeRef.current!.error = undefined;
runtimeRef.current!.runStatus = "running";
await chat.sendMessage(
{ text: normalizedPrompt },
@@ -826,13 +929,13 @@ export function useWorkbenchAgent({
topic_id: !sessionIdRef.current ? runtimeRef.current?.topicId ?? undefined : undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [],
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
);
},
[approvalMode, chat, inputDelivery, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
[approvalMode, chat, inputDelivery, interactionRequests.awaitingInput, refreshInteractionRequests, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
);
const triggerEvent = useCallback(async (trigger: AgentEventTriggerContext, prompt: string) => {
@@ -846,17 +949,19 @@ export function useWorkbenchAgent({
if (loaded.runStatus === "running" || loaded.isStreaming) resumeAgentRuntime(resources, target);
}
activateRuntime(target);
setConnectionStatus("online");
if (runtimeRunning(target) || target.chat.messages.length) return { sessionId: id, reused: true };
setStatusLabel("Agent 正在调查水质投诉事件");
void target.chat.sendMessage({ text: prompt }, { body: {
session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [],
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
} }).catch(() => {});
return { sessionId: id, reused: reserved.event_duplicate === true };
}, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]);
const stopPrompt = useCallback(() => {
for (const entry of interactionRequests.entries) {
if (entry.record.status === "pending") void interactionRequests.act(entry.record.interaction_id, "cancel");
}
chat.stop();
stopSessionStreamSubscription();
const activeSessionId = sessionIdRef.current;
@@ -864,7 +969,7 @@ export function useWorkbenchAgent({
void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined);
}
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, refreshPendingInputs, stopSessionStreamSubscription]);
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
const target = resources.activeAgent;
@@ -933,6 +1038,7 @@ export function useWorkbenchAgent({
inputDelivery,
setInputDelivery,
pendingInputs,
interactionRequests,
updateInputDelivery,
withdrawInput,
agentMessageInbox,
@@ -946,6 +1052,7 @@ export function useWorkbenchAgent({
openMobilePanel,
closeMobilePanel,
connectionStatus,
connectionRecoveryVersion: recoveryVersion,
panelCollapsing,
panelOpen,
personaState,
@@ -1063,9 +1170,9 @@ function createWorkbenchAgentTransport(client: AgentApiClient, access?: Workbenc
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
conversation: readBodyString(body, "session_id"), model, topicId: readBodyString(body, "topic_id"),
conversation: readBodyString(body, "session_id"), interactionId: readBodyString(body, "interaction_id"), model, topicId: readBodyString(body, "topic_id"),
approvalMode: readBodyString(body, "approval_mode") ?? "request",
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
}) };
},
});