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
+103 -6
View File
@@ -1,5 +1,6 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
@@ -37,6 +38,9 @@ export type AgentModelsResponse = {
models: AgentModelOption[];
};
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
export type AgentChatSession = {
id?: string;
session_id: string;
@@ -70,6 +74,21 @@ export type AgentChatSessionSummary = {
taskId?: string;
};
export type AgentRecentActivity = {
id: string;
kind: "alert" | "task" | "analysis" | "session";
title: string;
occurred_at: string;
summary?: string;
status?: string;
origin: "backend" | "agent" | "demo";
};
export type AgentPromptSuggestion = {
question: string;
activityId: string | null;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
@@ -94,7 +113,12 @@ export type AgentSessionStreamDataEventType =
| "session_title"
| "done"
| "error"
| "auth_required";
| "auth_required"
| "credential_refresh_required"
| "credential_refreshed"
| "credential_refresh_failed"
| "agent_message"
| "user_input";
export type AgentSessionStreamEvent =
| {
@@ -112,6 +136,12 @@ export type AgentSessionStreamEvent =
};
export type AgentApiClient = {
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
@@ -143,17 +173,18 @@ export type AgentApiClient = {
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
refreshCredential: (sessionId: string, requestId: string) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
getProjectId?: () => Promise<string>;
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
@@ -187,6 +218,44 @@ export function createAgentApiClient(
};
return {
async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
});
},
async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
return result.items;
},
async updateResponseInput(sessionId, id, delivery) {
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
});
},
async generatePromptSuggestions(activities, model, signal) {
const result = await requestJson<unknown>("/suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activities, model }),
signal,
});
if (!isRecord(result) || !Array.isArray(result.suggestions) || result.suggestions.length < 1 || result.suggestions.length > 3) {
throw new Error("Invalid Agent suggestions");
}
const ids = new Set(activities.map((item) => item.id));
const questions = new Set<string>();
return result.suggestions.map((item): AgentPromptSuggestion => {
if (!isRecord(item) || typeof item.question !== "string" || !item.question.trim() || item.question.length > 180 ||
(item.activity_id !== null && (typeof item.activity_id !== "string" || !ids.has(item.activity_id)))) {
throw new Error("Invalid Agent suggestion");
}
const question = item.question.trim();
if (questions.has(question)) throw new Error("Duplicate Agent suggestion");
questions.add(question);
return { question, activityId: item.activity_id as string | null };
});
},
async createSession(options) {
return requestJson<AgentChatSession>("/session", {
method: "POST",
@@ -254,6 +323,22 @@ export function createAgentApiClient(
await readSessionSseStream(response, options.onEvent);
},
async listAgentMessages(sessionId, options) {
const query = new URLSearchParams({ limit: "50" });
if (options?.cursor) query.set("cursor", options.cursor);
return agentMessagePageSchema.parse(await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/agent-messages?${query}`, { signal: options?.signal }
));
},
async streamAgentMessages(sessionId, options) {
const response = await request(`/session/${encodeURIComponent(sessionId)}/agent-messages/stream`, { signal: options.signal });
if (!response.ok) throw new Error(getResponseErrorMessage(await response.json(), response.status));
await readSessionSseStream(response, (event) => {
if (event.type === "agent_message" && event.sessionId === sessionId) options.onMessage();
});
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
@@ -329,6 +414,13 @@ export function createAgentApiClient(
});
},
async refreshCredential(sessionId, requestId) {
await options.getAccessToken?.(true);
await requestJson(`/session/${encodeURIComponent(sessionId)}/credentials`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId })
});
},
async abort(sessionId) {
await requestJson<unknown>("/abort", {
method: "POST",
@@ -346,7 +438,7 @@ async function fetchWithFallback(
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string>
getProjectId?: () => Promise<string | undefined>
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
@@ -361,7 +453,7 @@ async function fetchWithFallback(
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -603,7 +695,12 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
value === "credential_refresh_failed" ||
value === "auth_required"
|| value === "agent_message"
|| value === "user_input"
);
}