feat: add Keycloak authentication

This commit is contained in:
2026-08-19 12:11:18 +08:00
parent bdd5eff776
commit d08cf2abc1
27 changed files with 533 additions and 128 deletions
+63 -79
View File
@@ -1,4 +1,5 @@
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
@@ -112,19 +113,47 @@ export type AgentApiClient = {
abort: (sessionId: string) => Promise<void>;
};
export type AgentApiClientOptions = {
getAccessToken?: AccessTokenProvider;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
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 request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
path,
init,
options.getAccessToken
);
const requestJson = async <T,>(path: string, init?: RequestInit) => {
const response = await request(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;
};
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
return requestJson<AgentChatSession>("/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async getFrontendActionRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
return requestJson<unknown>("/frontend-action-registry");
},
async submitFrontendActionResult(sessionId, actionId, result) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/frontend-actions/${encodeURIComponent(actionId)}/result`, {
await requestJson<unknown>(`/frontend-actions/${encodeURIComponent(actionId)}/result`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
body: JSON.stringify(result)
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
const response = await request(
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
await requestJson<unknown>(
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
const payload = await requestJson<unknown>("/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
return requestJson<unknown>("/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
return requestJson<unknown>(
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
await requestJson<unknown>("/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
@@ -304,38 +292,28 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
};
}
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
init?: RequestInit,
getAccessToken?: AccessTokenProvider
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const requestInit = accessToken
? {
...init,
headers: withBearerToken(init?.headers, accessToken)
}
: init;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
@@ -359,6 +337,12 @@ async function fetchWithFallback(
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) {
const headers = new Headers(headersInit);
headers.set("Authorization", `Bearer ${accessToken}`);
return headers;
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}