Files
TJWaterFrontend_Refine/src/lib/chatStream.ts
T
jiang 0dad61ff1f
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s
feat(chat): 优化分析过程与结果展示
2026-08-26 18:05:03 +08:00

902 lines
25 KiB
TypeScript

import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
const AGENT_SESSIONS_URL = `${config.AGENT_URL}/api/v1/agent/sessions`;
const getAgentSessionUrl = (sessionId: string, suffix = "") =>
`${AGENT_SESSIONS_URL}/${encodeURIComponent(sessionId)}${suffix}`;
export type AgentModel = string;
export type PermissionDecision = "once" | "always" | "reject";
export type PermissionReply = PermissionDecision;
export type AgentApprovalMode = "request" | "auto" | "always";
export type AgentQuestionStatus =
| "pending"
| "submitting"
| "answered"
| "rejected"
| "error";
export type AgentQuestionRequest = {
requestId: string;
sessionId: string;
questions: Array<{
header: string;
question: string;
options: Array<{
label: string;
description: string;
}>;
multiple?: boolean;
custom?: boolean;
}>;
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
repliedAt?: number;
status: AgentQuestionStatus;
answers?: string[][];
error?: string;
};
export type AgentTodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
createdAt?: number;
updatedAt?: number;
};
export type AgentTodoUpdate = {
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
export type AgentActivityStatus = "running" | "completed" | "error" | "cancelled";
export type AgentActivityAction = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentActivity = {
id: string;
title: string;
reason: string;
status: AgentActivityStatus;
actions: AgentActivityAction[];
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type StreamEvent =
| {
type: "state";
sessionId: string;
messages: unknown[];
isStreaming: boolean;
runStatus?: string;
}
| { type: "token"; sessionId: string; content: string }
| { type: "final_answer"; sessionId: string; content: string }
| { type: "done"; sessionId: string; totalDurationMs?: number }
| { type: "session_title"; sessionId: string; title: string }
| {
type: "activity_update";
sessionId: string;
activity: AgentActivity;
todos?: AgentTodoItem[];
todosCreatedAt?: number;
}
| {
type: "progress";
sessionId: string;
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
durationMs?: number;
}
| {
type: "error";
sessionId?: string;
message: string;
detail?: string;
totalDurationMs?: number;
}
| {
type: "auth_required";
sessionId?: string;
reason?: string;
message: string;
}
| {
type: "credential_refresh_required";
sessionId: string;
requestId: string;
reason?: string;
timeoutMs?: number;
}
| {
type: "credential_refreshed";
sessionId: string;
requestId: string;
}
| {
type: "credential_refresh_failed";
sessionId: string;
requestId: string;
message: string;
}
| {
type: "tool_call";
sessionId: string;
tool: string;
params: Record<string, unknown>;
}
| {
type: "permission_request";
sessionId: string;
requestId: string;
permission: string;
patterns: string[];
target?: string;
activityId?: string;
reason?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
createdAt: number;
}
| {
type: "permission_response";
sessionId: string;
requestId: string;
reply: PermissionReply;
}
| {
type: "question_request";
sessionId: string;
requestId: string;
questions: AgentQuestionRequest["questions"];
tool?: AgentQuestionRequest["tool"];
createdAt: number;
}
| {
type: "question_response";
sessionId: string;
requestId: string;
answers?: string[][];
rejected?: boolean;
}
| {
type: "todo_update";
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
type StreamOptions = {
message: string;
sessionId?: string;
model?: AgentModel;
approvalMode?: AgentApprovalMode;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
type ResumeStreamOptions = {
sessionId: string;
signal?: AbortSignal;
onEvent: (event: StreamEvent) => void;
};
const parseEventBlock = (block: string): { event?: string; data?: string } => {
const lines = block.split("\n");
let event: string | undefined;
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trim());
}
}
return {
event,
data: dataLines.length ? dataLines.join("\n") : undefined,
};
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const resolveToolParams = (
params: unknown,
argumentsPayload: unknown,
): Record<string, unknown> => {
if (isObjectRecord(params) && Object.keys(params).length > 0) {
return params;
}
if (isObjectRecord(argumentsPayload)) {
return argumentsPayload;
}
if (typeof argumentsPayload === "string") {
try {
const parsed = JSON.parse(argumentsPayload) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return isObjectRecord(params) ? params : {};
};
const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => {
if (!Array.isArray(value)) return [];
return value
.filter(isObjectRecord)
.map((question) => ({
header: typeof question.header === "string" ? question.header : "",
question: typeof question.question === "string" ? question.question : "",
options: Array.isArray(question.options)
? question.options.filter(isObjectRecord).map((option) => ({
label: typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string" ? option.description : "",
}))
: [],
multiple: typeof question.multiple === "boolean" ? question.multiple : undefined,
custom: typeof question.custom === "boolean" ? question.custom : undefined,
}));
};
const normalizeAnswers = (value: unknown): string[][] | undefined => {
if (!Array.isArray(value)) return undefined;
return value.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
);
};
const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => {
if (!isObjectRecord(value)) return undefined;
const messageID =
typeof value.messageID === "string"
? value.messageID
: typeof value.message_id === "string"
? value.message_id
: undefined;
const callID =
typeof value.callID === "string"
? value.callID
: typeof value.call_id === "string"
? value.call_id
: undefined;
return messageID && callID ? { messageID, callID } : undefined;
};
const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => {
if (value === "in_progress" || value === "completed" || value === "cancelled") {
return value;
}
return "pending";
};
const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
};
const normalizeTodos = (value: unknown): AgentTodoItem[] => {
if (!Array.isArray(value)) return [];
return value.filter(isObjectRecord).map((todo, index) => ({
id:
typeof todo.id === "string" && todo.id.trim()
? todo.id
: `todo-${index}`,
content: typeof todo.content === "string" ? todo.content : "",
status: normalizeTodoStatus(todo.status),
priority: normalizeTodoPriority(todo.priority),
createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined,
updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined,
}));
};
const normalizeActivityStatus = (value: unknown): AgentActivityStatus => {
if (value === "completed" || value === "error" || value === "cancelled") {
return value;
}
return "running";
};
const normalizeActivity = (value: unknown): AgentActivity | undefined => {
if (!isObjectRecord(value) || typeof value.id !== "string") return undefined;
const now = Date.now();
const actions: AgentActivityAction[] = Array.isArray(value.actions)
? value.actions.filter(isObjectRecord).map((action, index) => ({
id: typeof action.id === "string" ? action.id : `${value.id}-action-${index}`,
tool: typeof action.tool === "string" ? action.tool : "tool",
title: typeof action.title === "string" ? action.title : "执行操作",
status: action.status === "completed" || action.status === "error"
? action.status
: "running",
target: typeof action.target === "string" ? action.target : undefined,
error: typeof action.error === "string" ? action.error : undefined,
startedAt: typeof action.started_at === "number" ? action.started_at : now,
endedAt: typeof action.ended_at === "number" ? action.ended_at : undefined,
elapsedMs: typeof action.elapsed_ms === "number" ? action.elapsed_ms : undefined,
elapsedSnapshotAt: typeof action.elapsed_ms === "number" ? now : undefined,
durationMs: typeof action.duration_ms === "number" ? action.duration_ms : undefined,
}))
: [];
return {
id: value.id,
title: typeof value.title === "string" ? value.title : "正在处理",
reason: typeof value.reason === "string" ? value.reason : "",
status: normalizeActivityStatus(value.status),
actions,
startedAt: typeof value.started_at === "number" ? value.started_at : now,
endedAt: typeof value.ended_at === "number" ? value.ended_at : undefined,
elapsedMs: typeof value.elapsed_ms === "number" ? value.elapsed_ms : undefined,
elapsedSnapshotAt: typeof value.elapsed_ms === "number" ? now : undefined,
durationMs: typeof value.duration_ms === "number" ? value.duration_ms : undefined,
};
};
const emitParsedStreamEvent = (
event: string,
data: string,
onEvent: (event: StreamEvent) => void,
) => {
try {
const parsed = JSON.parse(data) as {
session_id?: string;
content?: string;
message?: string;
detail?: string;
tool?: unknown;
params?: Record<string, unknown>;
arguments?: unknown;
id?: string;
phase?: string;
status?: "running" | "completed" | "error";
title?: string;
messages?: unknown[];
is_streaming?: boolean;
run_status?: string;
started_at?: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
total_duration_ms?: number;
request_id?: string;
permission?: string;
patterns?: unknown;
target?: string;
always?: unknown;
created_at?: number;
todos_created_at?: number;
reply?: PermissionReply;
questions?: unknown;
answers?: unknown;
rejected?: boolean;
message_id?: string;
todos?: unknown;
reason?: string;
timeout_ms?: number;
activity?: unknown;
activity_id?: string;
};
if (event === "state") {
onEvent({
type: "state",
sessionId: parsed.session_id ?? "",
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
isStreaming: parsed.is_streaming ?? false,
runStatus: parsed.run_status,
});
} else if (event === "token") {
onEvent({
type: "token",
sessionId: parsed.session_id ?? "",
content: parsed.content ?? "",
});
} else if (event === "final_answer") {
onEvent({
type: "final_answer",
sessionId: parsed.session_id ?? "",
content: parsed.content ?? "",
});
} else if (event === "progress") {
onEvent({
type: "progress",
sessionId: parsed.session_id ?? "",
id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`,
phase: parsed.phase ?? "progress",
status: parsed.status ?? "running",
title: parsed.title ?? "正在处理",
detail: parsed.detail,
startedAt: parsed.started_at,
endedAt: parsed.ended_at,
elapsedMs: parsed.elapsed_ms,
durationMs: parsed.duration_ms,
});
} else if (event === "activity_update") {
const activity = normalizeActivity(parsed.activity);
if (activity) {
onEvent({
type: "activity_update",
sessionId: parsed.session_id ?? "",
activity,
todos: Array.isArray(parsed.todos)
? normalizeTodos(parsed.todos)
: undefined,
todosCreatedAt: parsed.todos_created_at,
});
}
} else if (event === "done") {
onEvent({
type: "done",
sessionId: parsed.session_id ?? "",
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "session_title") {
onEvent({
type: "session_title",
sessionId: parsed.session_id ?? "",
title: typeof parsed.title === "string" ? parsed.title : "",
});
} else if (event === "error") {
onEvent({
type: "error",
sessionId: parsed.session_id,
message: parsed.message ?? "unknown error",
detail: parsed.detail,
totalDurationMs: parsed.total_duration_ms,
});
} else if (event === "auth_required") {
onEvent({
type: "auth_required",
sessionId: parsed.session_id,
reason: parsed.reason,
message: parsed.message ?? "登录态已过期,请刷新登录后重试",
});
} else if (event === "credential_refresh_required") {
onEvent({
type: "credential_refresh_required",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
reason: parsed.reason,
timeoutMs: parsed.timeout_ms,
});
} else if (event === "credential_refreshed") {
onEvent({
type: "credential_refreshed",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
});
} else if (event === "credential_refresh_failed") {
onEvent({
type: "credential_refresh_failed",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
message: parsed.message ?? "登录凭据续期失败",
});
} else if (event === "tool_call") {
onEvent({
type: "tool_call",
sessionId: parsed.session_id ?? "",
tool: typeof parsed.tool === "string" ? parsed.tool : "",
params: resolveToolParams(parsed.params, parsed.arguments),
});
} else if (event === "permission_request") {
onEvent({
type: "permission_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
permission: parsed.permission ?? "",
patterns: Array.isArray(parsed.patterns)
? parsed.patterns.filter((item): item is string => typeof item === "string")
: [],
target: typeof parsed.target === "string" ? parsed.target : undefined,
activityId: typeof parsed.activity_id === "string" ? parsed.activity_id : undefined,
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
always: Array.isArray(parsed.always)
? parsed.always.filter((item): item is string => typeof item === "string")
: [],
tool: isObjectRecord(parsed.tool) &&
typeof parsed.tool.messageID === "string" &&
typeof parsed.tool.callID === "string"
? {
messageID: parsed.tool.messageID,
callID: parsed.tool.callID,
}
: undefined,
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "permission_response") {
onEvent({
type: "permission_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
reply: parsed.reply ?? "reject",
});
} else if (event === "question_request") {
onEvent({
type: "question_request",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
questions: normalizeQuestionList(parsed.questions),
tool: normalizeQuestionTool(parsed.tool),
createdAt: parsed.created_at ?? Date.now(),
});
} else if (event === "question_response") {
onEvent({
type: "question_response",
sessionId: parsed.session_id ?? "",
requestId: parsed.request_id ?? "",
answers: normalizeAnswers(parsed.answers),
rejected: parsed.rejected === true,
});
} else if (event === "todo_update") {
onEvent({
type: "todo_update",
sessionId: parsed.session_id ?? "",
messageId: parsed.message_id,
todos: normalizeTodos(parsed.todos),
createdAt: parsed.created_at ?? Date.now(),
});
}
} catch {
onEvent({
type: "error",
message: "invalid SSE data payload",
detail: data,
});
}
};
const readStreamEvents = async (
response: Response,
onEvent: (event: StreamEvent) => void,
) => {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const { event, data } = parseEventBlock(block);
if (!event || !data) continue;
emitParsedStreamEvent(event, data, onEvent);
}
}
};
const ensureAgentSession = async (sessionId?: string) => {
if (sessionId) return sessionId;
const response = await apiFetch(AGENT_SESSIONS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
projectHeaderMode: "include",
skipAuthRedirect: true,
});
if (!response.ok) {
throw new Error(
(await response.text()) ||
`session creation failed: ${response.status}`,
);
}
const payload = (await response.json()) as { session_id?: string };
if (!payload.session_id) {
throw new Error("session creation returned no session_id");
}
return payload.session_id;
};
export const streamAgentChat = async ({
message,
sessionId,
model,
approvalMode,
signal,
onEvent,
}: StreamOptions) => {
let response: Response;
try {
const effectiveSessionId = await ensureAgentSession(sessionId);
response = await apiFetch(
getAgentSessionUrl(effectiveSessionId, "/runs"),
{
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
message,
model,
approval_mode: approvalMode,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
onEvent({
type: "error",
message: "network request failed",
detail,
});
return;
}
if (!response.ok || !response.body) {
const detail = await response.text();
let message = "stream request failed";
if (response.status === 403) {
message = "Permission denied. Please contact administrator.";
} else if (response.status === 401) {
message = "Login expired. Please sign in again.";
}
onEvent({
type: "error",
message,
detail:
response.status === 403 || response.status === 401 ? undefined : detail,
});
return;
}
await readStreamEvents(response, onEvent);
};
export const resumeAgentChatStream = async ({
sessionId,
signal,
onEvent,
}: ResumeStreamOptions) => {
let response: Response;
try {
response = await apiFetch(
getAgentSessionUrl(sessionId, "/runs/current/events"),
{
method: "GET",
signal,
headers: {
Accept: "text/event-stream",
},
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
onEvent({
type: "error",
sessionId,
message: "network request failed",
detail,
});
return;
}
if (!response.ok || !response.body) {
const detail = await response.text();
onEvent({
type: "error",
sessionId,
message: "stream request failed",
detail,
});
return;
}
await readStreamEvents(response, onEvent);
};
export const abortAgentChat = async (sessionId?: string) => {
if (!sessionId) {
return;
}
const response = await apiFetch(
getAgentSessionUrl(sessionId, "/runs/current"),
{
method: "DELETE",
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `abort request failed: ${response.status}`);
}
};
export const replyAgentPermission = async (
sessionId: string,
requestId: string,
reply: PermissionDecision,
) => {
const response = await apiFetch(
getAgentSessionUrl(sessionId, "/permission-responses"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
request_id: requestId,
reply,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `permission reply failed: ${response.status}`);
}
};
export const replyAgentCredentialRefresh = async (
sessionId: string,
requestId: string,
) => {
const response = await apiFetch(
getAgentSessionUrl(sessionId, "/credential-refreshes"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId }),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(
detail || `credential refresh reply failed: ${response.status}`,
);
}
};
export const replyAgentQuestion = async (
sessionId: string,
requestId: string,
answers: string[][],
) => {
const response = await apiFetch(
getAgentSessionUrl(sessionId, "/question-responses"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
request_id: requestId,
action: "reply",
answers,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `question reply failed: ${response.status}`);
}
};
export const rejectAgentQuestion = async (
sessionId: string,
requestId: string,
) => {
const response = await apiFetch(
getAgentSessionUrl(sessionId, "/question-responses"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
request_id: requestId,
action: "reject",
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `question reject failed: ${response.status}`);
}
};
export const forkAgentChat = async (
sessionId: string | undefined,
keepMessageCount: number,
) => {
const response = await apiFetch(
getAgentSessionUrl(sessionId ?? "", "/forks"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
keep_message_count: keepMessageCount,
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
},
);
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `fork request failed: ${response.status}`);
}
const payload = (await response.json()) as { session_id?: string };
if (!payload.session_id) {
throw new Error("fork request returned no session_id");
}
return payload.session_id;
};