fix(api): align frontend with REST contracts

This commit is contained in:
2026-07-30 20:38:52 +08:00
parent b57e58ff87
commit 782363cfb6
65 changed files with 99362 additions and 318 deletions
+21
View File
@@ -0,0 +1,21 @@
import { resolveRequestUrl } from "@/lib/api";
describe("resolveRequestUrl", () => {
it("does not prepend baseURL to an absolute request URL", () => {
expect(
resolveRequestUrl({
baseURL: "http://localhost:8000",
url: "http://localhost:8000/api/v1/schemes",
}),
).toBe("http://localhost:8000/api/v1/schemes");
});
it("combines baseURL with a relative request URL", () => {
expect(
resolveRequestUrl({
baseURL: "http://localhost:8000",
url: "/api/v1/schemes",
}),
).toBe("http://localhost:8000/api/v1/schemes");
});
});
+25 -3
View File
@@ -15,10 +15,22 @@ export const api = axios.create({
let isSigningOut = false;
const resolveRequestUrl = (request: {
export const resolveRequestUrl = (request: {
baseURL?: string;
url?: string;
}) => `${request.baseURL ?? ""}${request.url ?? ""}`;
}) => {
const requestUrl = request.url ?? "";
if (/^([a-z][a-z\d+\-.]*:)?\/\//i.test(requestUrl)) {
return requestUrl;
}
const baseURL = request.baseURL ?? "";
if (!baseURL || !requestUrl) {
return `${baseURL}${requestUrl}`;
}
return `${baseURL.replace(/\/+$/, "")}/${requestUrl.replace(/^\/+/, "")}`;
};
export interface ApiRequestConfig
extends InternalAxiosRequestConfig,
@@ -38,7 +50,17 @@ api.interceptors.request.use(async (request: ApiRequestConfig) => {
});
api.interceptors.response.use(
(response) => response,
(response) => {
if (
response.data &&
typeof response.data === "object" &&
Array.isArray(response.data.items) &&
typeof response.data.total === "number"
) {
response.data = response.data.items;
}
return response;
},
async (error) => {
if (error?.response?.status === 401 && typeof window !== "undefined") {
useAuthStore.getState().setAccessToken(null);
+33 -3
View File
@@ -7,6 +7,30 @@ import {
let isSigningOut = false;
const unwrapPage = async (response: Response) => {
if (
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const payload = await response.clone().json().catch(() => null);
if (
!payload ||
typeof payload !== "object" ||
!Array.isArray(payload.items) ||
typeof payload.total !== "number"
) {
return response;
}
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(JSON.stringify(payload.items), {
headers,
status: response.status,
statusText: response.statusText,
});
};
const resolveUrl = (input: RequestInfo | URL) => {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
@@ -22,10 +46,16 @@ export const apiFetch = async (
input: RequestInfo | URL,
init: ApiFetchInit = {},
) => {
const headers = new Headers(init.headers ?? {});
const headers = new Headers(
init.headers ?? (input instanceof Request ? input.headers : undefined),
);
await applyAuthContextHeaders(resolveUrl(input), headers, init);
const response = await fetch(input, { ...init, headers });
const requestInit: RequestInit = {
...init,
headers,
};
const response = await fetch(input, requestInit);
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
useAuthStore.getState().setAccessToken(null);
@@ -35,5 +65,5 @@ export const apiFetch = async (
}
}
return response;
return unwrapPage(response);
};
+1 -1
View File
@@ -39,7 +39,7 @@ describe("fetchAgentModels", () => {
],
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/models"),
expect.stringContaining("/api/v1/agent/models"),
expect.objectContaining({
method: "GET",
projectHeaderMode: "include",
+1 -1
View File
@@ -47,7 +47,7 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
};
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
method: "GET",
projectHeaderMode: "include",
skipAuthRedirect: true,
+41 -30
View File
@@ -41,20 +41,34 @@ const makeStream = (chunks: string[]) =>
},
});
const mockNewSessionStream = (response: unknown) => {
apiFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ session_id: "s1" }),
})
.mockResolvedValueOnce(response);
};
describe("streamAgentChat", () => {
beforeEach(() => {
apiFetch.mockReset();
});
it("parses token and done events from chunked SSE", async () => {
apiFetch.mockResolvedValue({
ok: true,
body: makeStream([
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
apiFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ session_id: "s1" }),
})
.mockResolvedValueOnce({
ok: true,
body: makeStream([
'event: token\ndata: {"session_id":"s1","content":"he"}\n\n',
'event: token\ndata: {"session_id":"s1","content":"llo"}\n\n',
'event: done\ndata: {"session_id":"s1"}\n\n',
]),
});
const events: Array<{ type: string; content?: string; sessionId?: string }> = [];
@@ -65,14 +79,13 @@ describe("streamAgentChat", () => {
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/stream"),
expect.stringContaining("/api/v1/agent/sessions/s1/runs"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
message: "hi",
session_id: undefined,
model: "provider/model",
approval_mode: undefined,
}),
@@ -111,7 +124,7 @@ describe("streamAgentChat", () => {
});
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/session/s1/stream"),
expect.stringContaining("/api/v1/agent/sessions/s1/runs/current/events"),
expect.objectContaining({
method: "GET",
projectHeaderMode: "include",
@@ -132,7 +145,7 @@ describe("streamAgentChat", () => {
});
it("parses progress events", async () => {
apiFetch.mockResolvedValue({
mockNewSessionStream({
ok: true,
body: makeStream([
'event: progress\ndata: {"session_id":"s1","id":"p1","phase":"tool","status":"running","title":"正在调用后端数据查询","detail":"GET /api/v1/demo"}\n\n',
@@ -159,7 +172,7 @@ describe("streamAgentChat", () => {
});
it("parses tool_call arguments when params is empty", async () => {
apiFetch.mockResolvedValue({
mockNewSessionStream({
ok: true,
body: makeStream([
'event: tool_call\ndata: {"session_id":"agent-1e75dd01-29e","tool":"locate_features","params":{},"arguments":"{\\"ids\\":[\\"142902\\"],\\"feature_type\\":\\"junction\\"}"}\n\n',
@@ -183,7 +196,7 @@ describe("streamAgentChat", () => {
});
it("parses permission request and response events", async () => {
apiFetch.mockResolvedValue({
mockNewSessionStream({
ok: true,
body: makeStream([
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"target":"rm tmp.txt","always":["rm *"],"created_at":123}\n\n',
@@ -220,7 +233,7 @@ describe("streamAgentChat", () => {
});
it("parses question request, response, and todo update events", async () => {
apiFetch.mockResolvedValue({
mockNewSessionStream({
ok: true,
body: makeStream([
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
@@ -283,7 +296,7 @@ describe("streamAgentChat", () => {
});
it("emits error when response is not ok", async () => {
apiFetch.mockResolvedValue({
mockNewSessionStream({
ok: false,
body: null,
text: async () => "bad request",
@@ -301,7 +314,7 @@ describe("streamAgentChat", () => {
});
it("emits re-login message on unauthorized response", async () => {
apiFetch.mockResolvedValue({
mockNewSessionStream({
ok: false,
status: 401,
body: null,
@@ -343,14 +356,11 @@ describe("streamAgentChat", () => {
await abortAgentChat("s1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/abort"),
expect.stringContaining("/api/v1/agent/sessions/s1/runs/current"),
expect.objectContaining({
method: "POST",
method: "DELETE",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
}),
}),
);
});
@@ -365,13 +375,13 @@ describe("streamAgentChat", () => {
await replyAgentPermission("s1", "perm-1", "once");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"),
expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
request_id: "perm-1",
reply: "once",
}),
}),
@@ -389,25 +399,27 @@ describe("streamAgentChat", () => {
await rejectAgentQuestion("s1", "q-2");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"),
expect.stringContaining("/api/v1/agent/sessions/s1/question-responses"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
request_id: "q-1",
action: "reply",
answers: [["城区"]],
}),
}),
);
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"),
expect.stringContaining("/api/v1/agent/sessions/s1/question-responses"),
expect.objectContaining({
method: "POST",
projectHeaderMode: "include",
skipAuthRedirect: true,
body: JSON.stringify({
session_id: "s1",
request_id: "q-2",
action: "reject",
}),
}),
);
@@ -425,11 +437,10 @@ describe("streamAgentChat", () => {
expect(sessionId).toBe("forked-s1");
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/agent/chat/fork"),
expect.stringContaining("/api/v1/agent/sessions/s1/forks"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
session_id: "s1",
keep_message_count: 3,
}),
}),
+36 -19
View File
@@ -466,6 +466,28 @@ const readStreamEvents = async (
}
};
const ensureAgentSession = async (sessionId?: string) => {
if (sessionId) return sessionId;
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
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,
@@ -476,8 +498,9 @@ export const streamAgentChat = async ({
}: StreamOptions) => {
let response: Response;
try {
const effectiveSessionId = await ensureAgentSession(sessionId);
response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/stream`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(effectiveSessionId)}/runs`,
{
method: "POST",
signal,
@@ -487,7 +510,6 @@ export const streamAgentChat = async ({
},
body: JSON.stringify({
message,
session_id: sessionId,
model,
approval_mode: approvalMode,
}),
@@ -535,7 +557,7 @@ export const resumeAgentChatStream = async ({
let response: Response;
try {
response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/stream`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current/events`,
{
method: "GET",
signal,
@@ -576,14 +598,8 @@ export const abortAgentChat = async (sessionId?: string) => {
return;
}
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/abort`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
}),
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current`, {
method: "DELETE",
projectHeaderMode: "include",
skipAuthRedirect: true,
});
@@ -600,14 +616,14 @@ export const replyAgentPermission = async (
reply: PermissionReply,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/permission-responses`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
reply,
}),
projectHeaderMode: "include",
@@ -627,14 +643,15 @@ export const replyAgentQuestion = async (
answers: string[][],
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
action: "reply",
answers,
}),
projectHeaderMode: "include",
@@ -653,14 +670,15 @@ export const rejectAgentQuestion = async (
requestId: string,
) => {
const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`,
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
action: "reject",
}),
projectHeaderMode: "include",
skipAuthRedirect: true,
@@ -674,13 +692,12 @@ export const rejectAgentQuestion = async (
};
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId ?? "")}/forks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
session_id: sessionId,
keep_message_count: keepMessageCount,
}),
projectHeaderMode: "include",
+24
View File
@@ -0,0 +1,24 @@
import createClient from "openapi-fetch";
import { config } from "@config/config";
import type { paths as AgentPaths } from "@/generated/agentApi";
import type { paths as ServerPaths } from "@/generated/serverApi";
import { applyAuthContextHeaders } from "@/lib/requestHeaders";
const authMiddleware = {
async onRequest({ request }: { request: Request }) {
const headers = new Headers(request.headers);
await applyAuthContextHeaders(request.url, headers);
return new Request(request, { headers });
},
};
export const serverApi = createClient<ServerPaths>({
baseUrl: config.BACKEND_URL,
});
export const agentApi = createClient<AgentPaths>({
baseUrl: config.AGENT_URL,
});
serverApi.use(authMiddleware);
agentApi.use(authMiddleware);
+2 -1
View File
@@ -21,7 +21,8 @@ const shouldIncludeProjectHeader = (
return false;
}
return !url.includes("/api/v1/meta/projects");
const pathname = new URL(url, "http://tjwater.local").pathname.replace(/\/+$/, "");
return pathname !== "/api/v1/projects";
};
export const applyAuthContextHeaders = async (