feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { ResponsesChatTransport, createResponseRequest } from "@/features/agent";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
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 {
FrontendActionExecutor,
agentTaskDiscussionContextSchema,
@@ -15,8 +15,10 @@ import {
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
useAgentMessageInbox,
fetchAgentBootstrap,
fetchAgentSessions,
resolveAgentModelSelection,
hasPendingAgentInput,
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
@@ -25,6 +27,7 @@ import {
type AgentConnectionStatus,
type AgentEventTriggerContext,
type AgentModelOption,
type AgentInputDelivery,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
@@ -68,6 +71,8 @@ export function useWorkbenchAgent({
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const projectIdPromiseRef = useRef<Promise<string> | null>(null);
const [conversationScope, setConversationScope] = useState<"personal" | "project">("project");
const conversationScopeRef = useRef<"personal" | "project">("project");
const getProjectId = useCallback(() => {
projectIdPromiseRef.current ??= findOwnedWorkbenchProject(getAccessToken).then(
({ project }) => project.project_id,
@@ -78,7 +83,15 @@ export function useWorkbenchAgent({
);
return projectIdPromiseRef.current;
}, [getAccessToken]);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId }));
const getConversationProjectId = useCallback(() =>
conversationScopeRef.current === "personal" ? Promise.resolve(undefined) : getProjectId(), [getProjectId]);
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken, getProjectId: getConversationProjectId }));
const scopeClient = useMemo(() => createAgentApiClient(undefined, {
getAccessToken,
getProjectId: conversationScope === "project" ? getProjectId : undefined
}), [conversationScope, getAccessToken, getProjectId]);
const generatePromptSuggestions = useCallback((...args: Parameters<typeof clientRef.current.generatePromptSuggestions>) =>
clientRef.current.generatePromptSuggestions(...args), []);
const sessionIdRef = useRef<string | null>(null);
const approvalModeRef = useRef<AgentApprovalMode>("request");
const registryRef = useRef<UIRegistry | null>(null);
@@ -92,7 +105,7 @@ export function useWorkbenchAgent({
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | 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 后端连接中");
@@ -101,11 +114,17 @@ export function useWorkbenchAgent({
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [inputDelivery, setInputDelivery] = useState<AgentInputDelivery>("steer");
const selectedModelRef = useRef("");
useEffect(() => { selectedModelRef.current = selectedModel; }, [selectedModel]);
const [promptDraft, setPromptDraft] = useState("");
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" && conversationScope === "project" ? sessionId : null);
const refreshAgentMessageInbox = agentMessageInbox.refresh;
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -154,6 +173,19 @@ export function useWorkbenchAgent({
[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}`;
@@ -183,6 +215,11 @@ export function useWorkbenchAgent({
);
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");
@@ -191,6 +228,7 @@ export function useWorkbenchAgent({
}
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") {
@@ -225,37 +263,37 @@ export function useWorkbenchAgent({
});
});
},
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
[refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const transport = useMemo(
() =>
new DefaultChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
new ResponsesChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`,
headers: async (): Promise<Record<string, string>> => {
const accessToken = await getAccessToken?.();
const projectId = await getProjectId();
const projectId = await getConversationProjectId();
return {
...(projectId ? { "X-Project-Id": projectId } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
};
},
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return {
body: {
...body,
id,
messages,
trigger,
messageId,
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
approval_mode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
}
};
async prepareSendMessagesRequest({ messages, body }) {
const catalog = await scopeClient.getModels();
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? selectedModelRef.current);
setModelOptions(catalog.models);
setSelectedModel(model);
selectedModelRef.current = model;
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
conversation: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
model, approvalMode: readBodyString(body, "approval_mode") ?? approvalModeRef.current,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [],
triggerContext: body?.trigger_context, taskContext: body?.task_context
}) };
}
}),
[getAccessToken, getProjectId]
[getAccessToken, getConversationProjectId, scopeClient]
);
const chat = useChat<AgentUiMessage>({
@@ -287,7 +325,7 @@ export function useWorkbenchAgent({
const {
data: backendConnection,
error: backendConnectionError
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
} = useSWR([...agentBootstrapKey, getAccessToken, conversationScope], () => fetchAgentBootstrap(scopeClient), {
shouldRetryOnError: false
});
const {
@@ -295,7 +333,7 @@ export function useWorkbenchAgent({
isLoading: sessionHistoryLoading,
isValidating: sessionHistoryValidating,
mutate: mutateSessionHistory
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
} = useSWR([...agentSessionsKey, getAccessToken, conversationScope], () => fetchAgentSessions(scopeClient), {
revalidateOnFocus: false,
shouldRetryOnError: false
});
@@ -339,7 +377,7 @@ export function useWorkbenchAgent({
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current));
if (chatRef.current.status === "ready") {
setConnectionStatus("online");
setStatusLabel("准备就绪");
@@ -402,6 +440,20 @@ export function useWorkbenchAgent({
}, [clearMobileCollapseTimer]);
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
const { data: pendingInputs = [], mutate: refreshPendingInputs } = useSWR(
sessionId ? ["agent", "response-inputs", getAccessToken, conversationScope, sessionId] : null,
() => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
);
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 messages = useMemo(
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
[chat.messages, permissionOverrides, questionOverrides]
@@ -446,6 +498,8 @@ export function useWorkbenchAgent({
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) {
@@ -497,7 +551,7 @@ export function useWorkbenchAgent({
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
},
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
[refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
);
const startSessionStreamSubscription = useCallback(
@@ -557,6 +611,7 @@ export function useWorkbenchAgent({
}
applySessionId(loadedSession.sessionId);
setTaskDiscussion(null);
setSessionPurpose(loadedSession.purpose);
setSessionTaskId(loadedSession.taskId ?? null);
setSessionTitle(loadedSession.title);
@@ -570,6 +625,7 @@ export function useWorkbenchAgent({
startSessionStreamSubscription(loadedSession.sessionId);
}
await refreshSessionHistory();
return true;
} catch (error) {
showMapNotice({
id: "agent-history-status",
@@ -582,20 +638,34 @@ export function useWorkbenchAgent({
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const openTaskCollaboration = useCallback(async (taskId: string, context?: AgentTaskDiscussionContext) => {
const openTaskCollaboration = useCallback(async (taskId: string, discussion?: InvestigationDiscussion) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("工况任务 ID 不能为空。");
}
if (streaming) {
throw new Error("Agent 正在处理上一条指令,完成后再打开任务协作。");
}
const normalizedContext = agentTaskDiscussionContextSchema.parse(discussion?.context ?? {});
conversationScopeRef.current = "project";
setConversationScope("project");
const collaboration = await clientRef.current.createTaskCollaborationSession(normalizedTaskId);
const collaborationSessionId = collaboration.session_id || collaboration.id;
if (!collaborationSessionId) {
throw new Error("Agent 未返回任务协作会话。");
}
await loadHistorySession(collaborationSessionId);
taskDiscussionRef.current = { sessionId: collaborationSessionId, context: agentTaskDiscussionContextSchema.parse(context ?? {}) };
const loaded = await loadHistorySession(collaborationSessionId);
if (!loaded || sessionIdRef.current !== collaborationSessionId) {
throw new Error("任务协作会话未能打开,请重试。");
}
setTaskDiscussion({ sessionId: collaborationSessionId, context: normalizedContext, reportGeneration: discussion?.reportGeneration });
const draft = discussion?.prompt;
if (draft?.trim()) {
setPromptDraft((current) => current.trim() ? `${current}\n\n${draft.trim()}` : draft.trim());
}
setPromptFocusRequest((current) => current + 1);
return collaborationSessionId;
}, [loadHistorySession]);
}, [loadHistorySession, streaming]);
const renameHistorySession = useCallback(
async (nextSessionId: string, title: string) => {
@@ -644,6 +714,7 @@ export function useWorkbenchAgent({
setSessionTitle("新对话");
setSessionPurpose("ordinary_chat");
setSessionTaskId(null);
setTaskDiscussion(null);
setStatusLabel(nextStatusLabel);
setConnectionStatus("online");
setPermissionOverrides({});
@@ -697,6 +768,13 @@ export function useWorkbenchAgent({
resetDraftSession();
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
const changeConversationScope = useCallback(async (scope: "personal" | "project") => {
if (scope === conversationScopeRef.current || streaming) return;
await startNewSession();
conversationScopeRef.current = scope;
setConversationScope(scope);
}, [startNewSession, streaming]);
const submitPrompt = useCallback(
async (prompt: string) => {
const normalizedPrompt = prompt.trim();
@@ -705,7 +783,19 @@ export function useWorkbenchAgent({
}
if (streaming) {
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
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(() => {});
} catch (error) {
showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
throw error;
}
return;
}
@@ -718,12 +808,12 @@ export function useWorkbenchAgent({
session_id: sessionIdRef.current ?? undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
task_context: taskDiscussionRef.current?.sessionId === sessionIdRef.current ? taskDiscussionRef.current.context : undefined
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
);
},
[approvalMode, chat, selectedModel, streaming]
[approvalMode, chat, inputDelivery, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
);
const triggerEvent = useCallback(
@@ -795,10 +885,10 @@ export function useWorkbenchAgent({
stopSessionStreamSubscription();
const activeSessionId = sessionIdRef.current;
if (activeSessionId) {
void clientRef.current.abort(activeSessionId).catch(() => undefined);
void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined);
}
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, stopSessionStreamSubscription]);
}, [chat, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
setPermissionOverrides((current) => ({
@@ -907,6 +997,11 @@ export function useWorkbenchAgent({
return {
apiClient: clientRef.current,
inputDelivery,
setInputDelivery,
pendingInputs,
updateInputDelivery,
agentMessageInbox,
approvalMode,
collapsePanel,
expandPanel,
@@ -917,6 +1012,8 @@ export function useWorkbenchAgent({
openMobilePanel,
closeMobilePanel,
connectionStatus,
conversationScope,
changeConversationScope,
panelCollapsing,
panelOpen,
personaState,
@@ -927,15 +1024,19 @@ export function useWorkbenchAgent({
renameHistorySession,
deleteHistorySession,
sessionHistory,
generatePromptSuggestions,
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
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,