fix: deduplicate workbench requests and remove unused map paths
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m7s

This commit is contained in:
2026-11-09 18:49:32 +08:00
parent 038d3458bc
commit d7eb01652a
13 changed files with 403 additions and 321 deletions
+4 -28
View File
@@ -233,35 +233,11 @@ describe("Agent API client sessions", () => {
});
});
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 }
)
);
it("uses the configured Agent address once and propagates its error", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
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/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
+9 -49
View File
@@ -220,24 +220,16 @@ export type AgentApiClientOptions = {
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
baseUrl: string = env.TJWATER_AGENT_URL,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
fetchAgent(
normalizedBaseUrl,
path,
init,
options.getAccessToken,
@@ -265,7 +257,7 @@ export function createAgentApiClient(
return token;
} : undefined;
const answerStateRequest = async (path: string, init?: RequestInit) => {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init,
const response = await fetchAgent(normalizedBaseUrl, path, init,
checkedAnswerStateToken, options.getProjectId);
// An identity change while awaiting the network also invalidates the result.
await checkedAnswerStateToken?.();
@@ -275,7 +267,7 @@ export function createAgentApiClient(
return {
...createInteractionRequestApi(answerStateRequest),
async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
const response = await fetchAgent(normalizedBaseUrl, "/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 服务尚未就绪");
@@ -579,19 +571,14 @@ export function createAgentApiClient(
};
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
async function fetchAgent(
baseUrl: string,
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const projectId = await getProjectId?.();
const hasContextHeaders = Boolean(accessToken || projectId);
@@ -600,34 +587,7 @@ async function fetchWithFallback(
if (projectId) headers?.set("X-Project-Id", projectId);
const requestInit = hasContextHeaders ? { ...init, headers } : init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
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;
return fetch(`${baseUrl}${prefix}${path}`, requestInit);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false });
([, id]) => client.listAgentMessages(id), { revalidateOnMount: false, revalidateOnFocus: true, shouldRetryOnError: false });
const [loadingMore, setLoadingMore] = useState(false);
const mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1500;
let initialized = false;
// Prefer the initial stream notification, but do not wait indefinitely for its first frame.
const initialLoadTimer = setTimeout(() => { initialized = true; refreshRef.current(); }, 1500);
function subscribe() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
.catch(() => {
// A failed subscription must not prevent the initial list from loading.
if (!initialized && !controller.signal.aborted) { initialized = true; clearTimeout(initialLoadTimer); refreshRef.current(); }
retryDelay = Math.min(retryDelay * 2, 30_000);
})
.finally(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
});
}
subscribe();
return () => { controller.abort(); clearTimeout(timer); };
return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
}, [client, sessionId]);
return {
messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading,
loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
error: inbox.error ? "任务消息暂时无法加载" : null,
refresh,
loadingMore,