Files
next-tjwater-frontend/src/features/workbench/hooks/use-workbench-agent.ts
T
jiang 692bbf609c refactor: consolidate requests and finalize agent tool states
Share project JSON transport, interaction replies and report selectors.
Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
2026-11-09 17:55:00 +08:00

1295 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createAgentInteractionReplies } from "./agent-interaction-replies";
import { getWorkbenchAccessToken, getWorkbenchTokenProvider, getWorkbenchProject } from "../api/request-context";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useChat } from "@ai-sdk/react";
import { ResponsesChatTransport, createResponseRequest } from "@/features/agent";
import useSWR from "swr";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import type { InvestigationDiscussion } from "../api/investigation-report";
import {
useInteractionRequests,
useAgentConnection,
FrontendActionExecutor,
AnswerViewStateScope,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
createAgentApiClient,
AgentResponseFinishedError,
useAgentMessageInbox,
fetchAgentBootstrap,
resolveAgentModelSelection,
hasPendingAgentInput,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type AgentApprovalMode,
type AgentApiClient,
type AgentChatMessage,
type AgentChatSessionSummary,
type AgentConnectionStatus,
type AgentEventTriggerContext,
type AgentModelOption,
type AgentInputDelivery,
type AgentSessionStreamEvent,
type AgentSessionPurpose,
type AgentStreamRenderState,
type FrontendActionRequest,
type UIEnvelopePayload,
type UIRegistry
} from "@/features/agent";
import {
applySessionStreamEvent,
createCompletedStreamRenderState,
getDataString,
markLastAssistantStreamDone,
markStreamRenderPending,
readBodyString,
toAgentChatMessages,
toAgentUiMessages,
type AgentDataPart,
type AgentUiMessage,
type PermissionOverride,
type QuestionOverride
} from "./agent-session-message-state";
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;
const LEGACY_COLLABORATION_TITLES = new Set(["任务协作", "任务协作问答", "新对话"]);
type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
getAccessToken?: WorkbenchAccess;
};
export function useWorkbenchAgent({
onUiEnvelope,
onFrontendAction,
getAccessToken
}: UseWorkbenchAgentOptions) {
const resources = useWorkbenchSessionResources();
useSyncExternalStore(resources.subscribe, resources.getSnapshot, resources.getSnapshot);
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const projectIdPromiseRef = useRef<Promise<string> | null>(null);
const getProjectId = useCallback(() => {
projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then(
({ project }) => project.project_id,
(error) => {
projectIdPromiseRef.current = null;
throw error;
}
);
return projectIdPromiseRef.current;
}, [getAccessToken]);
const scopeClient = useMemo(() => createAgentApiClient(undefined, {
getAccessToken: getWorkbenchTokenProvider(getAccessToken),
getProjectId
}), [getAccessToken, getProjectId]);
const clientRef = useRef(scopeClient);
clientRef.current = scopeClient;
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
const scopeKey = projectScopeKey;
const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
const answerStateScope = useMemo(() => new AnswerViewStateScope(`${resources.id}:${scopeKey}`, scopeClient), [resources.id, scopeClient, scopeKey]);
useEffect(() => {
answerStateScope.retain();
resources.disposers.add(answerStateScope.dispose);
return () => { resources.disposers.delete(answerStateScope.dispose); answerStateScope.release(); };
}, [answerStateScope, resources]);
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")
.map((entry) => ({ id: entry.sessionId!, title: entry.title, topicId: entry.topicId && topics.topics.some((topic) => topic.id === entry.topicId) ? entry.topicId : null, createdAt: entry.updatedAt, updatedAt: entry.updatedAt,
purpose: entry.purpose, isStreaming: runtimeRunning(entry), needsInput: runtimeNeedsInput(entry),
runStatus: runtimeRunning(entry) ? "running" as const : entry.error ? "error" as const : entry.runStatus ?? "completed" as const }));
const localSummaries = new Map(runtimeSummaries.map((summary) => [summary.id, summary]));
const unsortedHistory = [...history.sessions.map((summary) => ({ ...summary, ...localSummaries.get(summary.id) })),
...runtimeSummaries.filter((summary) => !history.sessions.some((existing) => existing.id === summary.id))]
.filter((summary) => !history.topicId || (history.topicId === "unclassified" ? !summary.topicId : summary.topicId === history.topicId))
.filter((summary) => Boolean(summary.searchSnippet) || summary.title.toLocaleLowerCase().includes(history.query.toLocaleLowerCase())
|| agentRuntimes(resources).find((entry) => entry.sessionId === summary.id)?.chat.messages.some((message) => message.parts.some((part) => part.type === "text" && part.text.toLocaleLowerCase().includes(history.query.toLocaleLowerCase()))));
const historyOrderRef = useRef<{ key: string; open: boolean; ids: string[] }>({ key: "", open: false, ids: [] });
const orderKey = JSON.stringify([scopeKey, history.query, history.topicId, history.orderVersion]);
if (!history.isOpen || !historyOrderRef.current.open || historyOrderRef.current.key !== orderKey) {
historyOrderRef.current = { key: orderKey, open: history.isOpen,
ids: [...unsortedHistory].sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id)).map((item) => item.id) };
}
const order = new Map(historyOrderRef.current.ids.map((id, index) => [id, index]));
const sessionHistory = [...unsortedHistory].sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER));
const recentSessions = [...new Map([...history.recentSessions, ...runtimeSummaries].map((summary) => [summary.id, summary])).values()]
.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 30);
const sessionHistoryLoading = history.loading;
const runtimeRef = useRef<AgentSessionRuntime | null>(null);
const loadingGenerationRef = useRef(0);
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
const generatePromptSuggestions = useCallback((...args: Parameters<typeof clientRef.current.generatePromptSuggestions>) =>
clientRef.current.generatePromptSuggestions(...args), []);
const sessionIdRef = useRef<string | null>(null);
const registryRef = useRef<UIRegistry | null>(null);
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(false);
const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
const [taskDiscussion, setTaskDiscussion] = useState<{ sessionId: string; context: AgentTaskDiscussionContext; reportGeneration?: number | null } | null>(null);
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [inputDelivery, setInputDelivery] = useState<AgentInputDelivery>("steer");
const [promptDraft, setPromptDraftState] = useState("");
const promptDraftRef = useRef("");
const setPromptDraft = useCallback((value: string | ((current: string) => string)) => {
const next = typeof value === "function" ? value(promptDraftRef.current) : value;
promptDraftRef.current = next;
if (resources.activeAgent) resources.activeAgent.draft = next;
setPromptDraftState(next);
}, [resources]);
const [promptFocusRequest, setPromptFocusRequest] = useState(0);
const [approvalMode, setApprovalModeState] = useState<AgentApprovalMode>("request");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
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@2"] : ["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, loaded));
resources.notify();
}
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
}
}
}, [resources, scopeKey, scopeClient, selectedModel, approvalMode]);
const interactionRequests = useInteractionRequests(
scopeClient,
scopeKey,
sessionPurpose === "task_execution" ? null : sessionId,
continueInteraction
);
const refreshInteractionRequests = interactionRequests.refresh;
useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
const setApprovalMode = useCallback((mode: AgentApprovalMode) => {
setApprovalModeState(mode);
}, []);
useEffect(() => {
registryRef.current = registry;
}, [registry]);
useEffect(() => {
onFrontendActionRef.current = onFrontendAction;
}, [onFrontendAction]);
const applySessionId = useCallback((nextSessionId: string | undefined) => {
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
return;
}
sessionIdRef.current = nextSessionId;
if (runtimeRef.current) runtimeRef.current.sessionId = nextSessionId;
setSessionId(nextSessionId);
}, []);
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
}, []);
const frontendActionExecutor = useMemo(
() => new FrontendActionExecutor(
(request, signal) => onFrontendActionRef.current(request, signal),
(nextSessionId, actionId, result) => (agentRuntimes(resources).find((entry) => entry.sessionId === nextSessionId)?.client ?? clientRef.current)
.submitFrontendActionResult(nextSessionId, actionId, result)
),
[resources]
);
const handleFrontendAction = useCallback(
(value: unknown) => frontendActionExecutor.handle(value, sessionIdRef.current),
[frontendActionExecutor]
);
const credentialRequestsRef = useRef(new Set<string>());
const handleCredentialRefresh = useCallback((data: unknown) => {
const requestId = getDataString(data, "request_id");
const activeSessionId = getDataString(data, "session_id") ?? sessionIdRef.current;
if (!requestId || !activeSessionId || activeSessionId !== sessionIdRef.current) return;
if (credentialRequestsRef.current.has(requestId)) return;
credentialRequestsRef.current.add(requestId);
void clientRef.current.refreshCredential(activeSessionId, requestId).catch(() => {
credentialRequestsRef.current.delete(requestId);
setStatusLabel("会话凭据续期失败,请重新登录后重试");
});
}, []);
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
if (payload.session_id !== activeSessionId) return false;
const key = `${activeSessionId}:${payload.envelope_id}`;
if (processedEnvelopeIdsRef.current.has(key)) return false;
processedEnvelopeIdsRef.current.add(key);
if (processedEnvelopeIdsRef.current.size > 256) {
const oldest = processedEnvelopeIdsRef.current.values().next().value;
if (oldest) processedEnvelopeIdsRef.current.delete(oldest);
}
return true;
}, []);
const handleDataPart = useCallback(
(part: AgentDataPart) => {
const eventSessionId = getDataString(part.data, "session_id");
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
return;
}
applySessionId(eventSessionId);
if (part.type === "data-stream_token") {
markStreamRenderPending(
part.data,
chatRef.current.messages,
setStreamRenderState
);
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 }));
return;
}
if (part.type === "data-progress") {
const title = getDataString(part.data, "title");
if (title) {
setStatusLabel(title);
}
return;
}
if (part.type === "data-credential_refresh_required") { handleCredentialRefresh(part.data); return; }
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
if (part.type === "data-session_title") {
const title = getDataString(part.data, "title");
if (title) {
setSessionTitle(title);
}
return;
}
if (part.type !== "data-ui_envelope") {
return;
}
const payload = parseUiEnvelopePayload(part.data);
const activeSessionId = getDataString(part.data, "session_id") ?? sessionIdRef.current;
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
showMapNotice({
tone: "warning",
title: "Agent 展示已降级",
message: "收到的 UIEnvelope 未通过前端白名单校验。"
});
return;
}
if (!claimEnvelope(payload, activeSessionId)) return;
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({
tone: "error",
title: "Agent UIEnvelope 处理失败",
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
});
});
},
[refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const synchronizeModelCatalog = useCallback((catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => {
// A background request from an old project must not replace this project's controls.
if (resources.disposed || resources.activeAgent?.scope !== scopeKey) return;
setModelOptions(catalog.models);
setSelectedModel((current) => resolveAgentModelSelection(catalog, current));
}, [resources, scopeKey]);
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
getProjectId, synchronizeModelCatalog), [getAccessToken, getProjectId, scopeClient, synchronizeModelCatalog]);
const defaultRuntime = useMemo(() => {
const created = createAgentRuntime(resources, scopeKey, scopeClient, transport);
return created;
}, [resources, scopeClient, scopeKey, transport]);
const [runtimeKey, setRuntimeKey] = useState(defaultRuntime.key);
const selectedRuntime = resources.agents.get(runtimeKey) as AgentSessionRuntime | undefined;
const runtime = selectedRuntime?.scope === scopeKey ? selectedRuntime : defaultRuntime;
runtimeRef.current = runtime;
const chat = useChat<AgentUiMessage>({ chat: runtime.chat });
const chatRef = useRef(chat);
const activateRuntime = useCallback((next: AgentSessionRuntime) => {
const previous = resources.activeAgent as AgentSessionRuntime | null;
if (previous && previous !== next) {
previous.handlers = undefined;
frontendActionExecutor.cancelAll();
}
resources.activeAgent = next;
setRuntimeKey(next.key);
sessionIdRef.current = next.sessionId;
setSessionId(next.sessionId);
setSessionTitle(next.title);
setSessionPurpose(next.purpose);
setSessionTaskId(next.taskId ?? null);
setTaskDiscussion(next.taskDiscussion && next.sessionId ? { sessionId: next.sessionId, ...next.taskDiscussion } : null);
setPromptDraft(next.draft);
setPermissionOverrides(next.permissionOverrides);
setQuestionOverrides(next.questionOverrides);
setStreamRenderState(createCompletedStreamRenderState(next.chat.messages));
setStatusLabel(next.error ?? (runtimeRunning(next) ? "Agent 正在分析" : "Agent 历史会话已打开"));
resources.notify();
}, [frontendActionExecutor, resources, setPromptDraft]);
useLayoutEffect(() => {
// Ownership changes only when selecting a different runtime, never on token updates.
if (resources.activeAgent !== runtime) activateRuntime(runtime);
}, [activateRuntime, resources, runtime]);
useEffect(() => {
let changed = false;
const summaries = new Map(history.sessions.map((summary) => [summary.id, summary]));
for (const entry of agentRuntimes(resources)) {
if (entry.scope !== scopeKey || entry.purpose !== "task_collaboration"
|| !LEGACY_COLLABORATION_TITLES.has(entry.title)) continue;
const summary = entry.sessionId ? summaries.get(entry.sessionId) : undefined;
if (!summary || LEGACY_COLLABORATION_TITLES.has(summary.title)) continue;
entry.title = summary.title;
if (resources.activeAgent === entry) setSessionTitle(summary.title);
changed = true;
}
if (changed) resources.notify();
}, [history.sessions, resources, scopeKey]);
useEffect(() => {
chatRef.current = chat;
}, [chat]);
const {
data: backendConnection,
error: backendConnectionError,
mutate: refreshBootstrap
} = useSWR([...agentBootstrapKey, getAccessToken, scopeKey], () => fetchAgentBootstrap(scopeClient), {
shouldRetryOnError: true,
errorRetryInterval: 5_000,
errorRetryCount: 3,
revalidateOnFocus: true,
revalidateOnReconnect: true
});
const clearCollapseTimer = useCallback(() => {
if (collapseTimerRef.current !== null) {
window.clearTimeout(collapseTimerRef.current);
collapseTimerRef.current = null;
}
}, []);
const clearMobileCollapseTimer = useCallback(() => {
if (mobileCollapseTimerRef.current !== null) {
window.clearTimeout(mobileCollapseTimerRef.current);
mobileCollapseTimerRef.current = null;
}
}, []);
const stopSessionStreamSubscription = useCallback(() => {
clearTimeout(runtimeRef.current?.recoveryTimer);
runtimeRef.current?.controller?.abort();
if (runtimeRef.current) { runtimeRef.current.controller = undefined; runtimeRef.current.resumed = false; }
}, []);
const invalidatePendingLoad = useCallback(() => { loadingGenerationRef.current++; }, []);
useEffect(() => {
return () => {
invalidatePendingLoad();
clearCollapseTimer();
clearMobileCollapseTimer();
if (resources.activeAgent === runtimeRef.current) {
runtimeRef.current!.handlers = undefined;
resources.activeAgent = null;
}
frontendActionExecutor.cancelAll();
};
}, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, invalidatePendingLoad, resources]);
useEffect(() => {
if (!backendConnection) {
return;
}
registryRef.current = backendConnection.registry;
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current));
if (chatRef.current.status === "ready" && runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) {
setStatusLabel("准备就绪");
}
}, [backendConnection]);
useEffect(() => {
if (!backendConnectionError) {
return;
}
if (runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) setStatusLabel("Agent 初始化信息暂不可用");
}, [backendConnectionError]);
const collapsePanel = useCallback(() => {
clearCollapseTimer();
if (prefersReducedMotion()) {
setPanelOpen(false);
setPanelCollapsing(false);
return;
}
setPanelCollapsing(true);
collapseTimerRef.current = window.setTimeout(() => {
setPanelOpen(false);
setPanelCollapsing(false);
collapseTimerRef.current = null;
}, AGENT_PANEL_COLLAPSE_MS);
}, [clearCollapseTimer]);
const expandPanel = useCallback(() => {
clearCollapseTimer();
setPanelCollapsing(false);
setPanelOpen(true);
}, [clearCollapseTimer]);
const openMobilePanel = useCallback(() => {
clearMobileCollapseTimer();
setMobilePanelCollapsing(false);
setMobileOpen(true);
}, [clearMobileCollapseTimer]);
const closeMobilePanel = useCallback(() => {
clearMobileCollapseTimer();
if (prefersReducedMotion()) {
setMobileOpen(false);
setMobilePanelCollapsing(false);
return;
}
setMobilePanelCollapsing(true);
mobileCollapseTimerRef.current = window.setTimeout(() => {
setMobileOpen(false);
setMobilePanelCollapsing(false);
mobileCollapseTimerRef.current = null;
}, AGENT_PANEL_COLLAPSE_MS);
}, [clearMobileCollapseTimer]);
const streaming = runtimeRunning(runtime);
const { data: pendingInputs = [], mutate: refreshPendingInputs } = useSWR(
sessionId && sessionPurpose !== "task_execution"
? ["agent", "response-inputs", getAccessToken, scopeKey, sessionId]
: null,
() => 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 {
const updated = await clientRef.current.updateResponseInput(sessionIdRef.current, id, delivery);
await refreshPendingInputs((current = []) => current.map((input) => input.id === id ? updated : input), { revalidate: false });
} catch {
showMapNotice({ tone: "warning", message: "消息可能已开始处理,无法修改发送方式。" });
} finally { void refreshPendingInputs().catch(() => {}); }
}, [refreshPendingInputs]);
const withdrawInput = useCallback(async (id: string, edit: boolean) => {
const target = resources.activeAgent;
const activeSessionId = target?.sessionId;
if (!target || !activeSessionId) return;
try {
const withdrawn = await target.client.cancelResponseInput(activeSessionId, id);
if (withdrawn.status !== "cancelled") throw new Error("消息未能撤回,请刷新后重试。");
if (edit) {
if (resources.activeAgent !== target) {
target.draft = target.draft.trim() ? `${target.draft}\n\n${withdrawn.text}` : withdrawn.text;
resources.notify();
return;
}
setPromptDraft((current) => current.trim() ? `${current}\n\n${withdrawn.text}` : withdrawn.text);
setInputDelivery(withdrawn.delivery);
setPromptFocusRequest((current) => current + 1);
}
await refreshPendingInputs((current = []) => current.filter((input) => input.id !== id), { revalidate: false });
} catch (error) {
if (sessionIdRef.current !== activeSessionId) return;
showMapNotice({ tone: "warning", title: edit ? "无法退回编辑" : "无法关闭排队",
message: error instanceof Error ? error.message : "消息可能已开始处理,请稍后重试。" });
} finally {
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).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,
connectionStatus
}),
[chat.status, connectionStatus, messages]
);
const refreshSessionHistory = history.refresh;
const mutateSessionHistory = history.update;
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.runStatus === "waiting_input" ? "等待补充信息" : event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
return;
}
const eventSessionId = event.sessionId;
if (eventSessionId && eventSessionId !== sessionIdRef.current) {
return;
}
if (event.type === "credential_refresh_required") { handleCredentialRefresh(event.data); return; }
if (event.type === "agent_message") { refreshAgentMessageInbox(); return; }
if (event.type === "session_title") {
const title = getDataString(event.data, "title");
if (title) {
setSessionTitle(title);
}
} else if (event.type === "token") {
markStreamRenderPending(
event.data,
chatRef.current.messages,
setStreamRenderState
);
} else if (event.type === "ui_envelope") {
const payload = parseUiEnvelopePayload(event.data);
const activeSessionId = eventSessionId ?? sessionIdRef.current;
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
showMapNotice({
tone: "warning",
title: "Agent 展示已降级",
message: "收到的 UIEnvelope 未通过前端白名单校验。"
});
return;
}
if (!claimEnvelope(payload, activeSessionId)) return;
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({
tone: "error",
title: "Agent UIEnvelope 处理失败",
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
});
});
} else if (event.type === "frontend_action") {
void handleFrontendAction(event.data);
} else if (event.type === "progress") {
const title = getDataString(event.data, "title");
if (title) {
setStatusLabel(title);
}
} else if (event.type === "done") {
void refreshInteractionRequests();
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "aborted" || event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel(event.type === "aborted" || event.data.status === "aborted" ? "Agent 已停止" : "Agent 后端请求失败");
runtimeRef.current!.loadedAt = Date.now();
}
// Message changes are applied once by the owning runtime, including background streams.
},
[refreshInteractionRequests, refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
useLayoutEffect(() => {
runtime.handlers = {
onData: handleDataPart,
onEvent: (event) => handleSessionStreamEvent(event, runtime.sessionId ?? ""),
onError: (error) => {
void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
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);
if (status === "error") checkAgentConnection();
setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : status === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
},
onReconnect: () => {
checkAgentConnection();
setStatusLabel("连接已中断,正在恢复会话");
},
};
return () => { runtime.handlers = undefined; };
}, [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 || 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("这个历史会话不存在或已被删除。");
if (generation !== loadingGenerationRef.current) return false;
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
else {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title; target.topicId = loaded.topicId; target.loadedAt = Date.now(); target.runStatus = loaded.runStatus;
target.error = undefined;
target.messagePage ??= loaded.messagePage;
}
if (loaded.isStreaming || loaded.runStatus === "running") resumeAgentRuntime(resources, target);
}
if (generation !== loadingGenerationRef.current) return false;
activateRuntime(target);
return true;
} catch (error) {
showMapNotice({ id: "agent-history-status", tone: "error", title: "Agent 历史记录打开失败",
message: error instanceof Error ? error.message : "无法打开这个历史会话。" });
throw error;
}
}, [activateRuntime, resources, scopeClient, scopeKey, transport]);
const loadOlderMessages = useCallback(async () => {
const target = runtimeRef.current;
const before = target?.messagePage?.beforeCursor;
if (!target?.sessionId || !before || loadingOlderMessages) return;
setLoadingOlderMessages(true);
try {
const page = await target.client.loadOlderMessages(target.sessionId, before, AbortSignal.timeout(15_000));
const older = toAgentUiMessages(page.messages);
const ids = new Set(target.chat.messages.map((message) => message.id));
target.chat.messages = [...older.filter((message) => !ids.has(message.id)), ...target.chat.messages];
target.messagePage = page.messagePage;
resources.notify();
} catch (error) {
showMapNotice({ tone: "error", title: "更早消息加载失败", message: error instanceof Error ? error.message : "请重试" });
} finally { setLoadingOlderMessages(false); }
}, [loadingOlderMessages, resources]);
const openTaskCollaboration = useCallback(async (taskId: string, discussion?: InvestigationDiscussion) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) throw new Error("工况任务 ID 不能为空。");
const context = agentTaskDiscussionContextSchema.parse(discussion?.context ?? {});
const sourceDraft = resources.activeAgent?.draft ?? promptDraftRef.current;
const titlesBeforeOpen = new Map(agentRuntimes(resources).filter((entry) => entry.scope === projectScopeKey).map((entry) => [entry, entry.title]));
const projectClient = createAgentApiClient(undefined, { getAccessToken: getWorkbenchTokenProvider(getAccessToken), getProjectId });
const collaboration = await projectClient.createTaskCollaborationSession(normalizedTaskId);
const id = collaboration.session_id || collaboration.id;
if (!id) throw new Error("Agent 未返回任务协作会话。");
let target = agentRuntimes(resources).find((entry) => entry.scope === projectScopeKey && entry.sessionId === id);
if (!target) {
const loaded = await projectClient.loadSession(id, { messageLimit: 40 });
if (!loaded) throw new Error("任务协作会话未能打开,请重试。");
target = createAgentRuntime(resources, projectScopeKey, projectClient,
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog), loaded);
if (loaded.isStreaming || loaded.runStatus === "running") resumeAgentRuntime(resources, target);
}
const titleBeforeOpen = titlesBeforeOpen.get(target);
const canApplyTitle = titleBeforeOpen === undefined
? LEGACY_COLLABORATION_TITLES.has(target.title) : target.title === titleBeforeOpen;
if (collaboration.title?.trim() && canApplyTitle) target.title = collaboration.title.trim();
target.taskDiscussion = { context, reportGeneration: discussion?.reportGeneration };
if (!target.draft.trim() && target !== resources.activeAgent) target.draft = sourceDraft;
if (discussion?.prompt?.trim()) target.draft = target.draft.trim() ? `${target.draft}\n\n${discussion.prompt.trim()}` : discussion.prompt.trim();
activateRuntime(target);
setPromptFocusRequest((current) => current + 1);
return id;
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
const openTaskExecutionHistory = useCallback(async (taskId: string, runId: string, executionSessionId: string) => {
const normalizedTaskId = taskId.trim();
const normalizedRunId = runId.trim();
const normalizedSessionId = executionSessionId.trim();
if (!normalizedTaskId || !normalizedRunId || !normalizedSessionId) {
throw new Error("调查任务缺少可用的执行记录标识。");
}
const projectClient = createAgentApiClient(undefined, {
getAccessToken: getWorkbenchTokenProvider(getAccessToken),
getProjectId
});
const loaded = await projectClient.loadTaskExecutionSession(
normalizedTaskId,
normalizedRunId,
normalizedSessionId,
AbortSignal.timeout(15_000)
);
if (!loaded) throw new Error("调查执行记录不存在或尚未持久化。");
let target = agentRuntimes(resources).find((entry) =>
entry.scope === projectScopeKey && entry.sessionId === loaded.sessionId
);
if (!target) {
target = createAgentRuntime(
resources,
projectScopeKey,
projectClient,
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog),
loaded
);
} else {
target.client = projectClient;
target.chat.messages = toAgentUiMessages(loaded.messages, loaded);
target.title = loaded.title;
target.purpose = "task_execution";
target.taskId = normalizedTaskId;
target.loadedAt = Date.now();
target.updatedAt = loaded.updatedAt;
target.runStatus = loaded.runStatus;
target.error = undefined;
}
target.taskDiscussion = undefined;
target.taskExecutionHistory = {
taskId: normalizedTaskId,
runId: normalizedRunId,
sessionId: normalizedSessionId
};
target.draft = "";
activateRuntime(target);
setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已打开");
return loaded.sessionId;
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
useEffect(() => {
const target = runtimeRef.current;
const binding = target?.taskExecutionHistory;
if (sessionPurpose !== "task_execution" || !target || !binding || target.sessionId !== binding.sessionId) return;
let stopped = false;
let refreshing = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const controller = new AbortController();
const schedule = (delay = 2_000) => {
if (!stopped) timer = setTimeout(() => void refresh(), delay);
};
const refresh = async () => {
if (stopped || refreshing || resources.activeAgent !== target || target.purpose !== "task_execution") return;
refreshing = true;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
try {
const loaded = await target.client.loadTaskExecutionSession(
binding.taskId,
binding.runId,
binding.sessionId,
controller.signal
);
if (!loaded || stopped || resources.activeAgent !== target || target.purpose !== "task_execution") {
refreshing = false;
return;
}
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title;
target.updatedAt = loaded.updatedAt;
target.loadedAt = Date.now();
target.runStatus = loaded.runStatus;
target.connectionError = undefined;
setSessionTitle(loaded.title);
setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已同步");
resources.notify();
refreshing = false;
if (loaded.runStatus === "running" || loaded.isStreaming) schedule();
} catch (error) {
refreshing = false;
if (controller.signal.aborted || stopped) return;
target.connectionError = error instanceof Error ? error.message : "调查执行记录同步失败";
setStatusLabel("调查执行记录同步中断,正在重试");
resources.notify();
schedule(4_000);
}
};
const refreshOnFocus = () => void refresh();
window.addEventListener("focus", refreshOnFocus);
schedule();
return () => {
stopped = true;
controller.abort();
if (timer) clearTimeout(timer);
window.removeEventListener("focus", refreshOnFocus);
};
}, [resources, sessionId, sessionPurpose]);
const renameHistorySession = useCallback(
async (nextSessionId: string, title: string) => {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
showMapNotice({ tone: "warning", message: "对话标题不能为空。" });
return;
}
try {
await clientRef.current.updateSessionTitle(nextSessionId, normalizedTitle, {
isTitleManuallyEdited: true
});
await mutateSessionHistory(
(current = []) =>
current.map((session) =>
session.id === nextSessionId
? {
...session,
title: normalizedTitle,
updatedAt: Date.now()
}
: session
),
);
if (sessionIdRef.current === nextSessionId) {
setSessionTitle(normalizedTitle);
}
resources.cache.delete(JSON.stringify(["session", scopeKey, nextSessionId]));
const target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === nextSessionId);
if (target) { target.title = normalizedTitle; target.updatedAt = Date.now(); }
} catch (error) {
showMapNotice({
id: "agent-history-status",
tone: "error",
title: "Agent 对话重命名失败",
message: error instanceof Error ? error.message : "无法更新这个对话标题。"
});
throw error;
}
},
[mutateSessionHistory, resources, scopeKey]
);
const resetDraftSession = useCallback((nextStatusLabel = "Agent 新对话已准备") => {
loadingGenerationRef.current++;
const next = createAgentRuntime(resources, scopeKey, scopeClient, transport);
next.topicId = runtimeRef.current?.topicId;
activateRuntime(next);
setTaskDiscussion(null);
setStatusLabel(nextStatusLabel);
}, [activateRuntime, resources, scopeClient, scopeKey, transport]);
const selectTopic = (topicId: string | null) => {
if (resources.disposed || resources.activeAgent?.scope !== scopeKey) return;
loadingGenerationRef.current++;
const next = createAgentRuntime(resources, scopeKey, scopeClient, transport);
next.topicId = topicId;
activateRuntime(next);
};
const moveSessionToTopic = async (id: string, topicId: string | null) => {
await scopeClient.moveSessionToTopic(id, topicId);
const target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === id);
if (target) target.topicId = topicId;
resources.cache.delete(JSON.stringify(["session", scopeKey, id]));
await history.update((current) => current.map((session) => session.id === id ? { ...session, topicId, topicName: topics.topics.find((topic) => topic.id === topicId)?.name } : session));
resources.notify();
};
const deleteTopic = async (id: string) => {
await topics.remove(id);
for (const entry of agentRuntimes(resources)) if (entry.scope === scopeKey && entry.topicId === id) entry.topicId = null;
await history.update((current) => current.map((session) => session.topicId === id ? { ...session, topicId: null, topicName: undefined } : session));
if (history.topicId === id) history.setTopicId(undefined);
resources.notify();
};
const deleteHistorySession = useCallback(
async (nextSessionId: string) => {
try {
await clientRef.current.deleteSession(nextSessionId);
invalidatePendingLoad();
await mutateSessionHistory(
(current = []) => current.filter((session) => session.id !== nextSessionId),
);
if (sessionIdRef.current === nextSessionId) {
resetDraftSession();
}
resources.cache.delete(JSON.stringify(["session", scopeKey, nextSessionId]));
const target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === nextSessionId);
if (target) { target.controller?.abort(); void target.chat.stop(); resources.agents.delete(target.key); }
resources.notify();
return true;
} catch (error) {
showMapNotice({
id: "agent-history-status",
tone: "error",
title: "Agent 历史记录删除失败",
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
});
return false;
}
},
[invalidatePendingLoad, mutateSessionHistory, resources, scopeKey, resetDraftSession]
);
const startNewSession = useCallback(async () => { resetDraftSession(); }, [resetDraftSession]);
const submitPrompt = useCallback(
async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt) {
return;
}
let activeSessionId = sessionIdRef.current;
if (runtimeRef.current?.purpose === "task_execution") {
const target = runtimeRef.current;
const binding = target.taskExecutionHistory;
if (!binding) throw new Error("调查执行记录缺少调试分支信息,请重新打开后再试。");
setStatusLabel("正在创建调查调试分支");
try {
const branch = await target.client.createTaskExecutionDebugSession(
binding.taskId,
binding.runId,
binding.sessionId
);
const loaded = await target.client.loadSession(branch.session_id, {
messageLimit: 40,
signal: AbortSignal.timeout(15_000)
});
if (!loaded) throw new Error("调查调试分支创建后无法读取。");
if (runtimeRef.current !== target) throw new Error("当前 Agent 会话已切换,请在新会话中重新发送。");
target.sessionId = loaded.sessionId;
target.title = loaded.title;
target.purpose = "task_collaboration";
target.taskId = binding.taskId;
target.taskExecutionHistory = undefined;
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.messagePage = loaded.messagePage;
target.loadedAt = Date.now();
target.updatedAt = loaded.updatedAt;
target.runStatus = loaded.runStatus;
activeSessionId = loaded.sessionId;
activateRuntime(target);
setStatusLabel("调查调试会话已创建");
} catch (error) {
showMapNotice({ tone: "error", title: "调查调试会话创建失败", message: error instanceof Error ? error.message : "请稍后重试。" });
throw error;
}
}
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("-", "")}`);
// A failed list refresh must not turn an accepted message into a
// failed send and restore a draft that the user might submit twice.
void refreshPendingInputs((current = []) => receipt.status === "pending"
? [...current.filter((input) => input.id !== receipt.id), receipt]
: current.filter((input) => input.id !== receipt.id), { revalidate: false })
.then(() => refreshPendingInputs()).catch(() => {});
return;
} catch (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;
}
}
}
setStatusLabel("Agent 正在分析");
runtimeRef.current!.error = undefined;
runtimeRef.current!.runStatus = "running";
await chat.sendMessage(
{ text: normalizedPrompt },
{
body: {
session_id: activeSessionId ?? undefined,
topic_id: !activeSessionId ? runtimeRef.current?.topicId ?? undefined : undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
);
},
[activateRuntime, approvalMode, chat, inputDelivery, interactionRequests.awaitingInput, refreshInteractionRequests, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
);
const triggerEvent = useCallback(async (trigger: AgentEventTriggerContext, prompt: string) => {
const reserved = await scopeClient.createSession({ triggerContext: trigger });
const id = reserved.session_id;
let target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === id);
if (!target) {
const loaded = await scopeClient.loadSession(id, { messageLimit: 40 });
if (!loaded) throw new Error("Agent 事件会话创建后无法读取。");
target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
if (loaded.runStatus === "running" || loaded.isStreaming) resumeAgentRuntime(resources, target);
}
activateRuntime(target);
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@2", "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;
if (activeSessionId) {
void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined);
}
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const { replyPermission, replyQuestion, rejectQuestion } = useMemo(() => createAgentInteractionReplies(resources, {
permission: setPermissionOverrides, question: setQuestionOverrides
}), [resources]);
return {
apiClient: clientRef.current,
answerStateScope,
contentReference: runtime.reference,
setContentReference: (value: import("@/features/agent").AnswerUiSelection | undefined) => {
const active = resources.activeAgent;
if (active) { active.reference = value; resources.notify(); }
},
inputDelivery,
setInputDelivery,
pendingInputs,
interactionRequests,
updateInputDelivery,
withdrawInput,
agentMessageInbox,
approvalMode,
collapsePanel,
expandPanel,
mobileOpen,
mobilePanelCollapsing,
messages,
modelOptions,
openMobilePanel,
closeMobilePanel,
connectionStatus,
connectionRecoveryVersion: recoveryVersion,
panelCollapsing,
panelOpen,
personaState,
refreshSessionHistory,
startNewSession,
loadHistorySession,
openTaskCollaboration,
openTaskExecutionHistory,
renameHistorySession,
deleteHistorySession,
sessionHistory,
generatePromptSuggestions,
sessionHistoryLoading,
history,
topicControls: {
topics: topics.topics, topicId: runtime.topicId, loading: topics.loading,
error: topics.error ? "主题加载失败,请重试" : undefined,
onOpen: topics.ensure, onSelect: selectTopic,
onCreate: async (name: string) => { const topic = await topics.create(name); selectTopic(topic.id); },
onRename: async (id: string, name: string) => {
await topics.rename(id, name);
await history.update((current) => current.map((session) => session.topicId === id ? { ...session, topicName: name } : session));
},
onDelete: deleteTopic,
},
moveSessionToTopic,
closeHistory: () => { history.close(); invalidatePendingLoad(); },
recentSessions,
cacheScope: scopeKey,
hasOlderMessages: runtime.messagePage?.hasMore ?? false,
loadingOlderMessages, loadOlderMessages,
readingPosition: runtime.readingPosition,
onReadingPositionChange: (position: AgentReadingPosition) => { runtime.readingPosition = position; },
backgroundStatus: (() => {
const background = agentRuntimes(resources).filter((entry) => entry !== runtime && entry.scope === scopeKey);
const running = background.filter(runtimeRunning).length;
const pending = background.filter(runtimeNeedsInput).length;
return pending ? `${pending} 个后台会话需要确认,点击查看` : running ? `${running} 个会话正在后台处理,点击查看` : undefined;
})(),
sessionId,
sessionPurpose,
sessionTaskId,
taskDiscussionContext: taskDiscussion?.sessionId === sessionId ? taskDiscussion.context : undefined,
taskDiscussionReportGeneration: taskDiscussion?.sessionId === sessionId ? taskDiscussion.reportGeneration : undefined,
sessionTitle,
statusLabel,
streamRenderState,
selectedModel,
promptDraft,
promptFocusRequest,
setApprovalMode,
setPromptDraft,
setSelectedModel,
stopPrompt,
streaming,
rejectQuestion,
replyPermission,
replyQuestion,
resolveRenderRef,
triggerEvent,
submitPrompt
};
}
function deriveAgentPersonaState(
messages: AgentChatMessage[],
{
chatStatus,
connectionStatus
}: {
chatStatus: string;
connectionStatus: AgentConnectionStatus;
}
): PersonaState {
if (connectionStatus === "offline") {
return "asleep";
}
if (hasPendingAgentInput(messages)) {
return "listening";
}
if (
chatStatus === "submitted" ||
chatStatus === "streaming" ||
hasRunningSessionWork(messages) ||
connectionStatus === "connecting"
) {
return "thinking";
}
return "idle";
}
function hasRunningSessionWork(messages: AgentChatMessage[]) {
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
return latestProgress?.status === "running" || latestTodo?.status === "in_progress";
}
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>, onModels?: (catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => void) {
return new ResponsesChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`,
headers: async (): Promise<Record<string, string>> => {
const token = await getWorkbenchAccessToken(access);
const projectId = await getProjectId?.();
return { ...(projectId ? { "X-Project-Id": projectId } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) };
},
async prepareSendMessagesRequest({ messages, body }) {
const catalog = await client.getModels();
onModels?.(catalog);
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
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@2" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
}) };
},
});
}