fix(api): align frontend with REST contracts
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user