feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent API client sessions", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("lists normalized sessions in newest-first order", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
sessions: [
{ id: "old", title: "", created_at: 100, updated_at: 120 },
{ id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" }
]
}),
{ status: 200 }
)
)
);
const sessions = await createAgentApiClient("http://agent.local").listSessions();
expect(sessions).toEqual([
{
id: "new",
title: "新会话",
createdAt: 200,
updatedAt: 210,
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
},
{
id: "old",
title: "新对话",
createdAt: 100,
updatedAt: 120,
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
}
]);
});
it("returns null when loading a missing session", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 })));
await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull();
});
it("falls back when a candidate is not the agent service", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}),
{ status: 200 }
)
);
vi.stubGlobal("fetch", fetchMock);
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/agent/chat/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", {
isTitleManuallyEdited: true
});
expect(fetchMock).toHaveBeenCalledWith(
"http://agent.local/api/v1/agent/chat/session/session-1/title",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
title: "标题",
is_title_manually_edited: true
})
})
);
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
[
'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n',
'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n',
'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n'
].join(""),
{ status: 200 }
)
)
);
const events: unknown[] = [];
await createAgentApiClient("http://agent.local").streamSession("session-1", {
onEvent: (event) => events.push(event)
});
expect(events).toEqual([
{
type: "state",
sessionId: "session-1",
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }],
isStreaming: true,
runStatus: "running"
},
{
type: "token",
sessionId: "session-1",
data: { session_id: "session-1", content: "处理中" }
},
{
type: "done",
sessionId: "session-1",
data: { session_id: "session-1", duration_ms: 20 }
}
]);
});
});
+563
View File
@@ -0,0 +1,563 @@
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentModelOption = {
id: string;
label: string;
description: string;
icon: "bolt" | "sparkle";
};
export type AgentModelsResponse = {
defaultModel: string;
models: AgentModelOption[];
};
export type AgentChatSession = {
id?: string;
session_id: string;
title?: string;
status?: string;
run_status?: AgentRunStatus;
is_streaming?: boolean;
messages?: unknown[];
created_at?: number | string;
updated_at?: number | string;
is_title_manually_edited?: boolean;
parent_session_id?: string;
};
export type AgentChatSessionSummary = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
status?: string;
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
messages: unknown[];
};
export type AgentSessionStreamDataEventType =
| "token"
| "progress"
| "todo_update"
| "permission_request"
| "permission_response"
| "question_request"
| "question_response"
| "tool_call"
| "ui_envelope"
| "session_title"
| "done"
| "error"
| "auth_required";
export type AgentSessionStreamEvent =
| {
type: "state";
sessionId: string;
messages: unknown[];
isStreaming: boolean;
runStatus?: AgentRunStatus;
}
| {
type: AgentSessionStreamDataEventType;
sessionId?: string;
data: Record<string, unknown>;
};
export type AgentApiClient = {
createSession: () => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise<void>;
updateSessionTitle: (
sessionId: string,
title: string,
options?: { isTitleManuallyEdited?: boolean }
) => Promise<void>;
deleteSession: (sessionId: string) => Promise<void>;
getModels: () => Promise<AgentModelsResponse>;
getUiRegistry: () => Promise<unknown>;
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
replyPermission: (
requestId: string,
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
) => Promise<void>;
replyQuestion: (
requestId: string,
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
: ["", "http://127.0.0.1:8787"];
const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return toLoadedSession(data);
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(getResponseErrorMessage(data, response.status));
}
await readSessionSseStream(response, options.onEvent);
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: normalizedTitle,
is_title_manually_edited: options?.isTitleManuallyEdited
})
}
);
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId,
reply: options.reply,
message: options.message
})
});
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId,
answers: options.answers
})
});
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId
})
});
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
});
}
};
}
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
}
lastResponse = response;
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
await response.body?.cancel();
continue;
}
return response;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isPresent<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function toModelsResponse(value: unknown): AgentModelsResponse {
if (!isRecord(value)) {
return { defaultModel: "", models: [] };
}
const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : [];
const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? "";
return {
defaultModel,
models
};
}
function toModelOption(value: unknown): AgentModelOption | null {
if (!isRecord(value) || typeof value.id !== "string") {
return null;
}
const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id;
const description =
typeof value.description === "string" && value.description.trim() ? value.description : label;
const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle";
return {
id: value.id,
label,
description,
icon
};
}
function getResponseErrorMessage(data: unknown, status: number) {
return isRecord(data) && typeof data.message === "string"
? data.message
: `Agent API failed with HTTP ${status}`;
}
function toMillis(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : Date.now();
}
return Date.now();
}
function normalizeTitle(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "新对话";
}
function readRunStatus(value: unknown): AgentRunStatus | undefined {
return value === "running" || value === "completed" || value === "error" || value === "aborted"
? value
: undefined;
}
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : "";
if (!id) {
return null;
}
return {
id,
title: normalizeTitle(value.title),
createdAt: toMillis(value.created_at),
updatedAt: toMillis(value.updated_at),
status: typeof value.status === "string" ? value.status : undefined,
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
runStatus: readRunStatus(value.run_status)
};
}
function toLoadedSession(value: unknown): AgentLoadedChatSession {
const summary = toSessionSummary(value);
if (!summary || !isRecord(value)) {
throw new Error("Invalid agent session payload");
}
return {
...summary,
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
isTitleManuallyEdited:
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
messages: Array.isArray(value.messages) ? value.messages : []
};
}
async function readSessionSseStream(
response: Response,
onEvent: (event: AgentSessionStreamEvent) => void
) {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
let delimiterIndex = buffer.indexOf("\n\n");
while (delimiterIndex !== -1) {
const rawEvent = buffer.slice(0, delimiterIndex);
buffer = buffer.slice(delimiterIndex + 2);
emitSessionSseEvent(rawEvent, onEvent);
delimiterIndex = buffer.indexOf("\n\n");
}
if (done) {
break;
}
}
if (buffer.trim()) {
emitSessionSseEvent(buffer, onEvent);
}
}
function emitSessionSseEvent(
rawEvent: string,
onEvent: (event: AgentSessionStreamEvent) => void
) {
const lines = rawEvent.split(/\r?\n/);
const eventType = lines
.find((line) => line.startsWith("event:"))
?.slice("event:".length)
.trim();
const dataText = lines
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!eventType || !dataText) {
return;
}
const data = JSON.parse(dataText) as unknown;
if (!isRecord(data)) {
return;
}
if (eventType === "state") {
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
onEvent({
type: "state",
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
runStatus: readRunStatus(data.run_status)
});
return;
}
if (!isSessionStreamDataEventType(eventType)) {
return;
}
onEvent({
type: eventType,
sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
data
});
}
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
return (
value === "token" ||
value === "progress" ||
value === "todo_update" ||
value === "permission_request" ||
value === "permission_response" ||
value === "question_request" ||
value === "question_response" ||
value === "tool_call" ||
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "auth_required"
);
}
function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) {
return createdAtDiff;
}
const updatedAtDiff = right.updatedAt - left.updatedAt;
if (updatedAtDiff !== 0) {
return updatedAtDiff;
}
return right.id.localeCompare(left.id);
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentApiClient } from "./client";
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
describe("Agent API SWR helpers", () => {
it("loads bootstrap data with a normalized UI registry", async () => {
const client = {
getUiRegistry: vi.fn(async () => ({
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unknown"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
})),
getModels: vi.fn(async () => ({
defaultModel: "model-a",
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }]
}))
} as Pick<AgentApiClient, "getUiRegistry" | "getModels"> as AgentApiClient;
await expect(fetchAgentBootstrap(client)).resolves.toEqual({
defaultModel: "model-a",
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }],
registry: {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
}
});
});
it("loads session summaries through the shared client", async () => {
const sessions = [
{
id: "session-1",
title: "会话",
createdAt: 1,
updatedAt: 2
}
];
const client = {
listSessions: vi.fn(async () => sessions)
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type {
AgentApiClient,
AgentChatSessionSummary,
AgentModelOption
} from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
export const agentBootstrapKey = ["agent", "bootstrap"] as const;
export const agentSessionsKey = ["agent", "sessions"] as const;
export type AgentBootstrapData = {
registry: UIRegistry | null;
models: AgentModelOption[];
defaultModel: string;
};
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(),
client.getModels()
]);
return {
registry: parseUiRegistry(rawRegistry),
models: modelsResponse.models,
defaultModel: modelsResponse.defaultModel
};
}
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
return client.listSessions();
}