fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
+33 -8
View File
@@ -1,8 +1,9 @@
import { createInteractionRequestApi, type InteractionRequestApi } from "./interaction-requests";
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 AgentRunStatus = "running" | "completed" | "error" | "aborted" | "waiting_input";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
export type AgentEventTriggerContext = {
@@ -40,6 +41,10 @@ export type AgentModelsResponse = {
export type AgentInputDelivery = "steer" | "queue";
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
export class AgentResponseFinishedError extends Error {}
export class AgentSessionStreamError extends Error {
constructor(message: string, readonly status: number) { super(message); }
}
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
@@ -115,6 +120,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update"
| "permission_request"
| "permission_response"
| "interaction_request"
| "question_request"
| "question_response"
| "tool_call"
@@ -148,7 +154,8 @@ export type AgentSessionStreamEvent =
data: Record<string, unknown>;
};
export type AgentApiClient = {
export type AgentApiClient = InteractionRequestApi & {
getHealth: (signal: AbortSignal) => Promise<void>;
downloadFile: (path: string) => Promise<void>;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
@@ -191,6 +198,7 @@ export type AgentApiClient = {
requestId: string,
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
) => Promise<void>;
getQuestionStatus: (sessionId: string, requestId: string) => Promise<{ status: "pending" | "answered" | "rejected" | "invalidated" }>;
replyQuestion: (
requestId: string,
options: { sessionId: string; answers: string[][] }
@@ -239,8 +247,14 @@ export function createAgentApiClient(
return data as T;
};
return {
...createInteractionRequestApi(request),
async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
if (!response.ok) throw new Error("Agent 服务暂不可用");
const data: unknown = await response.json();
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
},
async downloadFile(path) {
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
if (!match) throw new Error("无效的会话文件地址");
@@ -255,10 +269,14 @@ export function createAgentApiClient(
setTimeout(() => URL.revokeObjectURL(url), 1000);
},
async submitResponseInput(sessionId, text, delivery, id) {
return requestJson<AgentResponseInput>("/responses", {
const response = await request("/responses", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: text, conversation: sessionId, stream: false, tjwater: { delivery, input_id: id } })
});
const data = await response.json();
if (response.status === 409 && data?.code === "RESPONSE_FINISHED") throw new AgentResponseFinishedError("当前回复已结束");
if (!response.ok) throw new Error(getResponseErrorMessage(data, response.status));
return data as AgentResponseInput;
},
async listResponseInputs(sessionId) {
const result = await requestJson<{ items: AgentResponseInput[] }>(`/session/${encodeURIComponent(sessionId)}/inputs`);
@@ -384,7 +402,7 @@ export function createAgentApiClient(
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(getResponseErrorMessage(data, response.status));
throw new AgentSessionStreamError(getResponseErrorMessage(data, response.status), response.status);
}
await readSessionSseStream(response, options.onEvent);
@@ -460,6 +478,11 @@ export function createAgentApiClient(
});
},
async getQuestionStatus(sessionId, requestId) {
const value = await requestJson<{ status: "pending" | "answered" | "rejected" | "invalidated" }>(`/session/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(requestId)}`, { cache: "no-store" });
if (!["pending", "answered", "rejected", "invalidated"].includes(value.status)) throw new Error("问题状态核对失败");
return value;
},
async replyQuestion(requestId, options) {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
@@ -505,7 +528,8 @@ async function fetchWithFallback(
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined>
getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
@@ -520,7 +544,7 @@ async function fetchWithFallback(
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${API_PATH}${path}`, requestInit);
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -609,7 +633,7 @@ function normalizeTitle(value: unknown) {
}
function readRunStatus(value: unknown): AgentRunStatus | undefined {
return value === "running" || value === "completed" || value === "error" || value === "aborted"
return value === "waiting_input" || value === "running" || value === "completed" || value === "error" || value === "aborted"
? value
: undefined;
}
@@ -757,6 +781,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
value === "interaction_request" ||
value === "question_request" ||
value === "question_response" ||
value === "tool_call" ||
@@ -0,0 +1,13 @@
import { expect, it, vi } from "vitest";
import { createInteractionRequestApi, interactionRequestSchema } from "./interaction-requests";
it("rejects malformed host requests and scopes escaped URLs with no-store reads", async () => {
expect(interactionRequestSchema.safeParse({ version: "agent-interaction-request@1", questions: [] }).success).toBe(false);
const request = vi.fn(async () => new Response(JSON.stringify({ items: [] }), { status: 200 }));
const api = createInteractionRequestApi(request);
await expect(api.listInteractionRequests("session/other")).resolves.toEqual({ items: [] });
expect(request).toHaveBeenCalledWith("/session/session%2Fother/interactions", { cache: "no-store" });
});
it("keeps denied status available to purge the scope even with an empty response", async () => {
const api = createInteractionRequestApi(async () => new Response(null, { status: 403 }));
await expect(api.listInteractionRequests("session")).rejects.toMatchObject({ status: 403 });
});
@@ -0,0 +1,51 @@
import { z } from "zod";
const id = z.string().min(1).max(128);
const revision = z.number().int().nonnegative();
const option = z.object({ id, label: z.string().max(200) }).strict();
export const interactionAnswerSchema = z.object({ question_id: id, selected_option_ids: z.array(id).max(12), custom_text: z.string().max(2000) }).strict();
export const interactionRequestSchema = z.object({
version: z.literal("agent-interaction-request@1"), interaction_id: id, message_id: id, turn_id: id, call_id: id,
questions: z.array(z.object({ question_id: id, title: z.string().max(200), body: z.string().max(2000), options: z.array(option).max(12), mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean() }).strict()).min(1).max(3),
status: z.enum(["pending", "answered", "skipped", "cancelled", "invalidated"]), revision, draft_revision: revision,
draft: z.array(interactionAnswerSchema).max(3), answers: z.array(interactionAnswerSchema.extend({ selected_options: z.array(option) }).strict()).optional(),
created_at: z.string(), resolved_at: z.string().optional(),
delivery: z.object({ native_message_id: id, revision, status: z.enum(["queued", "dispatching", "delivered", "blocked", "cancelled"]), error_code: z.string().optional(), response_id: z.string().optional() }).strict().optional(),
}).strict();
export type InteractionRequest = z.infer<typeof interactionRequestSchema>;
export const canReplaceInteractionRequest = (next: InteractionRequest, current: InteractionRequest) =>
(next.delivery?.revision ?? -1) >= (current.delivery?.revision ?? -1)
&& (next.revision > current.revision || (next.revision === current.revision && next.draft_revision >= current.draft_revision));
export function readInteractionReply(data: unknown, messageId: string): InteractionRequest | undefined {
const parsed = interactionRequestSchema.safeParse(data);
return parsed.success && ["answered", "skipped"].includes(parsed.data.status)
&& parsed.data.delivery?.native_message_id === messageId ? parsed.data : undefined;
}
export type InteractionAnswer = z.infer<typeof interactionAnswerSchema>;
export type InteractionWrite = { version: "agent-interaction-request@1"; request_id: string; expected_revision: number; answers?: InteractionAnswer[] };
export type InteractionDraftWrite = { version: "agent-interaction-request@1"; request_id: string; expected_draft_revision: number; answers: InteractionAnswer[] };
export class InteractionRequestApiError extends Error {
constructor(message: string, readonly status: number, readonly current?: InteractionRequest) { super(message); }
}
export function createInteractionRequestApi(request: (path: string, init?: RequestInit) => Promise<Response>) {
const path = (sessionId: string, interactionId?: string) => `/session/${encodeURIComponent(sessionId)}/interactions${interactionId ? `/${encodeURIComponent(interactionId)}` : ""}`;
const read = async (response: Response) => {
const value: unknown = await response.json().catch(() => {
throw new InteractionRequestApiError("提问状态暂不可用", response.status);
});
if (!response.ok) {
const error = z.object({ detail: z.string().optional(), current: z.unknown().optional() }).passthrough().safeParse(value);
const current = interactionRequestSchema.safeParse(error.success ? error.data.current : null);
throw new InteractionRequestApiError(error.success ? error.data.detail ?? "提问状态暂不可用" : "提问状态暂不可用", response.status, current.success ? current.data : undefined);
}
return value;
};
return {
async listInteractionRequests(sessionId: string) { return z.object({ items: z.array(interactionRequestSchema), next_cursor: z.string().optional() }).strict().parse(await read(await request(path(sessionId), { cache: "no-store" }))); },
async getInteractionRequest(sessionId: string, interactionId: string) { return interactionRequestSchema.parse(await read(await request(path(sessionId, interactionId), { cache: "no-store" }))); },
async writeInteractionRequest(sessionId: string, interactionId: string, action: "draft" | "reply" | "skip" | "cancel" | "resume", body: InteractionWrite | InteractionDraftWrite) {
return interactionRequestSchema.parse(await read(await request(`${path(sessionId, interactionId)}/${action}`, { method: action === "draft" ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })));
},
};
}
export type InteractionRequestApi = ReturnType<typeof createInteractionRequestApi>;
@@ -1,10 +1,11 @@
import { HttpChatTransport, type UIMessage, type UIMessageChunk } from "ai";
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export class AgentStreamDisconnectedError extends Error {}
export function createResponseRequest(messages: UIMessage[], options: {
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
triggerContext?: unknown; taskContext?: unknown; topicId?: string;
interactionId?: string; triggerContext?: unknown; taskContext?: unknown; topicId?: string;
}) {
const latest = [...messages].reverse().find((message) => message.role === "user");
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
@@ -13,7 +14,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
...(options.model ? { model: options.model } : {}),
...(options.conversation ? { conversation: options.conversation } : {}),
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
tjwater: { ...(options.interactionId ? { interaction_id: options.interactionId } : {}), ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
...(options.taskContext ? { task_context: options.taskContext } : {}),
},
@@ -99,7 +100,7 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
buffer += decoder.decode();
flushBlocks(controller);
if (buffer.trim()) parse(buffer, controller);
if (!terminal) throw new Error("Responses 流在完成前断开,请重新打开会话查看状态");
if (!terminal) throw new AgentStreamDisconnectedError("Responses 流在完成前断开,正在恢复会话连接");
},
}));
}