1298 lines
61 KiB
TypeScript
1298 lines
61 KiB
TypeScript
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 }
|
||
);
|
||
const handledRecovery = useRef({ scopeKey, version: 0 });
|
||
useEffect(() => {
|
||
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
|
||
handledRecovery.current = { scopeKey, version: recoveryVersion };
|
||
if (!recoveryVersion) return;
|
||
void refreshBootstrap();
|
||
void refreshInteractionRequests();
|
||
void refreshPendingInputs();
|
||
}, [scopeKey, 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,
|
||
}) };
|
||
},
|
||
});
|
||
}
|