feat: add Keycloak authentication
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
TJWATER_AUTH_MODE=required
|
||||||
|
TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN=
|
TJWATER_MAPBOX_ACCESS_TOKEN=
|
||||||
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||||
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
TJWATER_GEOSERVER_WORKSPACE=tjwater
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
|||||||
|
|
||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `TJWATER_AUTH_MODE` | `required` | Keycloak 认证模式;本地测试可显式设为 `disabled` |
|
||||||
|
| `TJWATER_KEYCLOAK_ISSUER` | 无 | Keycloak Realm issuer,例如 `https://keycloak.example.com/realms/tjwater` |
|
||||||
|
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
|
||||||
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
|
||||||
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
|
||||||
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 |
|
||||||
@@ -43,6 +46,10 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
|
|||||||
|
|
||||||
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
`.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。
|
||||||
|
|
||||||
|
浏览器使用 Keycloak Standard Flow 和 PKCE S256,不需要也不能配置 client secret。生产环境必须提供 Realm issuer;未认证用户会跳转到 Keycloak,同一 Realm 中已有登录会话时会直接完成单点登录。
|
||||||
|
|
||||||
|
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
|
||||||
|
|
||||||
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
runtime_config=$(jq -cn \
|
runtime_config=$(jq -cn \
|
||||||
|
--arg authMode "${TJWATER_AUTH_MODE:-required}" \
|
||||||
|
--arg keycloakIssuer "${TJWATER_KEYCLOAK_ISSUER:-}" \
|
||||||
|
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
|
||||||
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
|
||||||
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
|
||||||
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \
|
||||||
@@ -9,6 +12,9 @@ runtime_config=$(jq -cn \
|
|||||||
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
|
||||||
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
|
||||||
'{
|
'{
|
||||||
|
TJWATER_AUTH_MODE: $authMode,
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: $keycloakIssuer,
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: $keycloakClientId,
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
|
||||||
TJWATER_MAP_URL: $mapUrl,
|
TJWATER_MAP_URL: $mapUrl,
|
||||||
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"edge-tts-ts": "^1.0.0",
|
"edge-tts-ts": "^1.0.0",
|
||||||
"katex": "^0.17.0",
|
"katex": "^0.17.0",
|
||||||
|
"keycloak-js": "26.2.4",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
|||||||
trace: "on-first-retry"
|
trace: "on-first-retry"
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
command: `TJWATER_AUTH_MODE=disabled TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
reuseExistingServer: false
|
reuseExistingServer: false
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -83,6 +83,9 @@ importers:
|
|||||||
katex:
|
katex:
|
||||||
specifier: ^0.17.0
|
specifier: ^0.17.0
|
||||||
version: 0.17.0
|
version: 0.17.0
|
||||||
|
keycloak-js:
|
||||||
|
specifier: 26.2.4
|
||||||
|
version: 26.2.4
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.468.0
|
specifier: ^0.468.0
|
||||||
version: 0.468.0(react@19.2.7)
|
version: 0.468.0(react@19.2.7)
|
||||||
@@ -2859,6 +2862,9 @@ packages:
|
|||||||
kdbush@4.1.0:
|
kdbush@4.1.0:
|
||||||
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
|
||||||
|
|
||||||
|
keycloak-js@26.2.4:
|
||||||
|
resolution: {integrity: sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -6645,6 +6651,8 @@ snapshots:
|
|||||||
|
|
||||||
kdbush@4.1.0: {}
|
kdbush@4.1.0: {}
|
||||||
|
|
||||||
|
keycloak-js@26.2.4: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
globalThis.__TJWATER_CONFIG__ = {};
|
globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||||
|
};
|
||||||
|
|||||||
+7
-2
@@ -1,11 +1,16 @@
|
|||||||
import { MapToaster } from "@/features/map/core";
|
import { MapToaster } from "@/features/map/core";
|
||||||
import { MapWorkbenchPage } from "@/features/workbench";
|
import { MapWorkbenchPage } from "@/features/workbench";
|
||||||
|
import type { Authentication } from "@/shared/auth/keycloak-auth";
|
||||||
import { AppProviders } from "./providers";
|
import { AppProviders } from "./providers";
|
||||||
|
|
||||||
export function App() {
|
export function App({ authentication }: { authentication: Authentication }) {
|
||||||
return (
|
return (
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<MapWorkbenchPage />
|
<MapWorkbenchPage
|
||||||
|
user={authentication.user ?? undefined}
|
||||||
|
onLogout={authentication.logout}
|
||||||
|
getAccessToken={authentication.getAccessToken}
|
||||||
|
/>
|
||||||
<MapToaster />
|
<MapToaster />
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Droplets, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/ui/button";
|
||||||
|
|
||||||
|
export function AuthStartupScreen({ error }: { error?: boolean }) {
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-[100dvh] place-items-center bg-slate-100 px-5 text-slate-900">
|
||||||
|
<section className="surface-reading w-full max-w-md p-6 text-center shadow-xl shadow-slate-900/5">
|
||||||
|
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl bg-blue-600 text-white">
|
||||||
|
<Droplets size={22} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-4 text-lg font-semibold">
|
||||||
|
{error ? "统一身份认证连接失败" : "正在连接统一身份认证"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
{error ? "请检查网络或 Keycloak 配置后重试。" : "正在检查登录状态,请稍候。"}
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<Button className="mt-5" onClick={() => window.location.reload()}>
|
||||||
|
<RefreshCw size={15} aria-hidden="true" />
|
||||||
|
重新连接
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,6 +105,23 @@ describe("Agent API client sessions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds the current Keycloak access token without dropping request headers", async () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||||
|
new Response(JSON.stringify({ sessions: [] }), { status: 200 })
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const getAccessToken = vi.fn().mockResolvedValue("keycloak-token");
|
||||||
|
|
||||||
|
await createAgentApiClient("http://agent.local", { getAccessToken }).createSession();
|
||||||
|
|
||||||
|
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
if (!init) throw new Error("Expected Agent request init");
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
expect(headers.get("Authorization")).toBe("Bearer keycloak-token");
|
||||||
|
expect(headers.get("Content-Type")).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
it("streams session events from the backend SSE endpoint", async () => {
|
it("streams session events from the backend SSE endpoint", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
|
|
||||||
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
|
||||||
|
|
||||||
@@ -112,19 +113,47 @@ export type AgentApiClient = {
|
|||||||
abort: (sessionId: string) => Promise<void>;
|
abort: (sessionId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentApiClientOptions = {
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
|
};
|
||||||
|
|
||||||
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")];
|
||||||
|
|
||||||
const CHAT_PATH = "/api/v1/agent/chat";
|
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(/\/$/, ""));
|
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
|
||||||
let activeBaseUrl = candidates[0] ?? "";
|
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 {
|
return {
|
||||||
async createSession() {
|
async createSession() {
|
||||||
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
return requestJson<AgentChatSession>("/session", {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/session", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
@@ -132,23 +161,16 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listSessions() {
|
async listSessions() {
|
||||||
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
|
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
"/sessions"
|
|
||||||
);
|
|
||||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getFrontendActionRegistry() {
|
async getFrontendActionRegistry() {
|
||||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/frontend-action-registry");
|
return requestJson<unknown>("/frontend-action-registry");
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitFrontendActionResult(sessionId, actionId, result) {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
headers: { "Content-Type": "application/json", "x-agent-session-id": sessionId },
|
||||||
body: JSON.stringify(result)
|
body: JSON.stringify(result)
|
||||||
@@ -156,9 +178,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadSession(sessionId) {
|
async loadSession(sessionId) {
|
||||||
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
|
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/session/${encodeURIComponent(sessionId)}`);
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const data = text ? JSON.parse(text) : null;
|
const data = text ? JSON.parse(text) : null;
|
||||||
|
|
||||||
@@ -173,12 +193,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async streamSession(sessionId, options) {
|
async streamSession(sessionId, options) {
|
||||||
const response = await fetchWithFallback(
|
const response = await request(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||||
{ signal: options.signal }
|
{ signal: options.signal }
|
||||||
);
|
);
|
||||||
@@ -198,12 +213,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestJsonWithFallback<unknown>(
|
await requestJson<unknown>(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/session/${encodeURIComponent(sessionId)}/title`,
|
`/session/${encodeURIComponent(sessionId)}/title`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -217,46 +227,30 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteSession(sessionId) {
|
async deleteSession(sessionId) {
|
||||||
await requestJsonWithFallback<unknown>(
|
await requestJson<unknown>(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/session/${encodeURIComponent(sessionId)}`,
|
`/session/${encodeURIComponent(sessionId)}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getModels() {
|
async getModels() {
|
||||||
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
const payload = await requestJson<unknown>("/models");
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/models");
|
|
||||||
return toModelsResponse(payload);
|
return toModelsResponse(payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUiRegistry() {
|
async getUiRegistry() {
|
||||||
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
return requestJson<unknown>("/ui-registry");
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/ui-registry");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveRenderRef(renderRef, sessionId) {
|
async resolveRenderRef(renderRef, sessionId) {
|
||||||
const params = new URLSearchParams({ session_id: sessionId });
|
const params = new URLSearchParams({ session_id: sessionId });
|
||||||
return requestJsonWithFallback<unknown>(
|
return requestJson<unknown>(
|
||||||
candidates,
|
|
||||||
activeBaseUrl,
|
|
||||||
(nextBaseUrl) => {
|
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
},
|
|
||||||
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async replyPermission(requestId, options) {
|
async replyPermission(requestId, options) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>(`/permission/${encodeURIComponent(requestId)}/reply`, {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -268,9 +262,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async replyQuestion(requestId, options) {
|
async replyQuestion(requestId, options) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reply`, {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/question/${encodeURIComponent(requestId)}/reply`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -281,9 +273,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async rejectQuestion(requestId, options) {
|
async rejectQuestion(requestId, options) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>(`/question/${encodeURIComponent(requestId)}/reject`, {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, `/question/${encodeURIComponent(requestId)}/reject`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -293,9 +283,7 @@ export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BAS
|
|||||||
},
|
},
|
||||||
|
|
||||||
async abort(sessionId) {
|
async abort(sessionId) {
|
||||||
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
|
await requestJson<unknown>("/abort", {
|
||||||
activeBaseUrl = nextBaseUrl;
|
|
||||||
}, "/abort", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ session_id: sessionId })
|
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(
|
async function fetchWithFallback(
|
||||||
baseUrls: string[],
|
baseUrls: string[],
|
||||||
activeBaseUrl: string,
|
activeBaseUrl: string,
|
||||||
setActiveBaseUrl: (baseUrl: string) => void,
|
setActiveBaseUrl: (baseUrl: string) => void,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit
|
init?: RequestInit,
|
||||||
|
getAccessToken?: AccessTokenProvider
|
||||||
) {
|
) {
|
||||||
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
let lastResponse: Response | null = null;
|
let lastResponse: Response | null = null;
|
||||||
|
const accessToken = await getAccessToken?.();
|
||||||
|
const requestInit = accessToken
|
||||||
|
? {
|
||||||
|
...init,
|
||||||
|
headers: withBearerToken(init?.headers, accessToken)
|
||||||
|
}
|
||||||
|
: init;
|
||||||
|
|
||||||
for (const baseUrl of orderedBaseUrls) {
|
for (const baseUrl of orderedBaseUrls) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
|
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, requestInit);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setActiveBaseUrl(baseUrl);
|
setActiveBaseUrl(baseUrl);
|
||||||
return response;
|
return response;
|
||||||
@@ -359,6 +337,12 @@ async function fetchWithFallback(
|
|||||||
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
|
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) {
|
function shouldFallbackOnHttpStatus(status: number) {
|
||||||
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export { AgentPersona } from "./components/agent-persona";
|
|||||||
export { createAgentApiClient } from "./api/client";
|
export { createAgentApiClient } from "./api/client";
|
||||||
export type {
|
export type {
|
||||||
AgentApiClient,
|
AgentApiClient,
|
||||||
|
AgentApiClientOptions,
|
||||||
AgentChatSessionSummary,
|
AgentChatSessionSummary,
|
||||||
AgentLoadedChatSession,
|
AgentLoadedChatSession,
|
||||||
AgentSessionStreamEvent
|
AgentSessionStreamEvent
|
||||||
|
|||||||
@@ -1141,7 +1141,9 @@ function RunningEvidencePreview({ items }: { items: string[] }) {
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1">{item}</span>
|
<span className="line-clamp-1">{item}</span>
|
||||||
<span className="mt-0.5 rounded-full bg-blue-50 px-1.5 py-0.5 text-xs font-semibold leading-4 text-blue-700">采集中</span>
|
<StatusBadge tone="info" activity="loading" className="mt-0.5">
|
||||||
|
采集中
|
||||||
|
</StatusBadge>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ export function UserMenu({
|
|||||||
onRefreshTiles,
|
onRefreshTiles,
|
||||||
onShowDataStatus,
|
onShowDataStatus,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onExportConfig
|
onExportConfig,
|
||||||
|
onLogout
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -292,6 +293,7 @@ export function UserMenu({
|
|||||||
onShowDataStatus: () => void;
|
onShowDataStatus: () => void;
|
||||||
onShowShortcuts: () => void;
|
onShowShortcuts: () => void;
|
||||||
onExportConfig: () => void;
|
onExportConfig: () => void;
|
||||||
|
onLogout?: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -320,7 +322,15 @@ export function UserMenu({
|
|||||||
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
|
||||||
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
<DropdownMenuItem
|
||||||
|
disabled={!onLogout}
|
||||||
|
onSelect={onLogout ? () => void onLogout() : undefined}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-2",
|
||||||
|
!onLogout && "text-slate-400",
|
||||||
|
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
<LogOut size={15} aria-hidden="true" />
|
<LogOut size={15} aria-hidden="true" />
|
||||||
退出登录
|
退出登录
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = {
|
|||||||
onRefreshTiles: () => void;
|
onRefreshTiles: () => void;
|
||||||
onShowShortcuts: () => void;
|
onShowShortcuts: () => void;
|
||||||
onExportConfig: () => void;
|
onExportConfig: () => void;
|
||||||
|
onLogout?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
|
||||||
@@ -72,7 +73,8 @@ export function WorkbenchTopBar({
|
|||||||
onShowDataStatus,
|
onShowDataStatus,
|
||||||
onRefreshTiles,
|
onRefreshTiles,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
onExportConfig
|
onExportConfig,
|
||||||
|
onLogout
|
||||||
}: WorkbenchTopBarProps) {
|
}: WorkbenchTopBarProps) {
|
||||||
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
|
||||||
const activeScenario =
|
const activeScenario =
|
||||||
@@ -176,6 +178,7 @@ export function WorkbenchTopBar({
|
|||||||
onShowDataStatus={onShowDataStatus}
|
onShowDataStatus={onShowDataStatus}
|
||||||
onShowShortcuts={onShowShortcuts}
|
onShowShortcuts={onShowShortcuts}
|
||||||
onExportConfig={onExportConfig}
|
onExportConfig={onExportConfig}
|
||||||
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DefaultChatTransport } from "ai";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useSWRImmutable from "swr/immutable";
|
import useSWRImmutable from "swr/immutable";
|
||||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import { showMapNotice } from "@/features/map/core";
|
import { showMapNotice } from "@/features/map/core";
|
||||||
import {
|
import {
|
||||||
@@ -50,13 +51,18 @@ const AGENT_PANEL_COLLAPSE_MS = 180;
|
|||||||
type UseWorkbenchAgentOptions = {
|
type UseWorkbenchAgentOptions = {
|
||||||
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
||||||
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
export function useWorkbenchAgent({
|
||||||
|
onUiEnvelope,
|
||||||
|
onFrontendAction,
|
||||||
|
getAccessToken
|
||||||
|
}: UseWorkbenchAgentOptions) {
|
||||||
const collapseTimerRef = useRef<number | null>(null);
|
const collapseTimerRef = useRef<number | null>(null);
|
||||||
const mobileCollapseTimerRef = useRef<number | null>(null);
|
const mobileCollapseTimerRef = useRef<number | null>(null);
|
||||||
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
||||||
const clientRef = useRef(createAgentApiClient());
|
const clientRef = useRef(createAgentApiClient(undefined, { getAccessToken }));
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
const approvalModeRef = useRef<AgentApprovalMode>("request");
|
||||||
const registryRef = useRef<UIRegistry | null>(null);
|
const registryRef = useRef<UIRegistry | null>(null);
|
||||||
@@ -205,6 +211,10 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
() =>
|
() =>
|
||||||
new DefaultChatTransport<AgentUiMessage>({
|
new DefaultChatTransport<AgentUiMessage>({
|
||||||
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
||||||
|
headers: async (): Promise<Record<string, string>> => {
|
||||||
|
const accessToken = await getAccessToken?.();
|
||||||
|
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||||
|
},
|
||||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||||
return {
|
return {
|
||||||
body: {
|
body: {
|
||||||
@@ -220,7 +230,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[]
|
[getAccessToken]
|
||||||
);
|
);
|
||||||
|
|
||||||
const chat = useChat<AgentUiMessage>({
|
const chat = useChat<AgentUiMessage>({
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type MapSourceStatus
|
type MapSourceStatus
|
||||||
} from "@/features/map/core";
|
} from "@/features/map/core";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||||
import { MapDevPanel } from "./components/map-dev-panel";
|
import { MapDevPanel } from "./components/map-dev-panel";
|
||||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||||
@@ -76,7 +77,8 @@ import type {
|
|||||||
DetailFeature,
|
DetailFeature,
|
||||||
ScheduledConditionItem,
|
ScheduledConditionItem,
|
||||||
ScheduledConditionRecord,
|
ScheduledConditionRecord,
|
||||||
WorkbenchAlert
|
WorkbenchAlert,
|
||||||
|
WorkbenchUser
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
createScheduledConditionAlerts,
|
createScheduledConditionAlerts,
|
||||||
@@ -86,7 +88,15 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
|
|||||||
|
|
||||||
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
|
||||||
|
|
||||||
export function MapWorkbenchPage() {
|
export function MapWorkbenchPage({
|
||||||
|
user = WORKBENCH_USER,
|
||||||
|
onLogout,
|
||||||
|
getAccessToken
|
||||||
|
}: {
|
||||||
|
user?: WorkbenchUser;
|
||||||
|
onLogout?: () => Promise<void>;
|
||||||
|
getAccessToken?: AccessTokenProvider;
|
||||||
|
}) {
|
||||||
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
const hasMapboxToken = Boolean(env.TJWATER_MAPBOX_ACCESS_TOKEN);
|
||||||
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
const devPanelEnabled = env.TJWATER_ENABLE_DEV_PANEL;
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -129,7 +139,8 @@ export function MapWorkbenchPage() {
|
|||||||
|
|
||||||
const agent = useWorkbenchAgent({
|
const agent = useWorkbenchAgent({
|
||||||
onUiEnvelope: handleAgentUiEnvelope,
|
onUiEnvelope: handleAgentUiEnvelope,
|
||||||
onFrontendAction: handleFrontendAction
|
onFrontendAction: handleFrontendAction,
|
||||||
|
getAccessToken
|
||||||
});
|
});
|
||||||
const clearActiveTool = useCallback(() => {
|
const clearActiveTool = useCallback(() => {
|
||||||
setActiveToolId(null);
|
setActiveToolId(null);
|
||||||
@@ -766,7 +777,7 @@ export function MapWorkbenchPage() {
|
|||||||
scenarios={WORKBENCH_SCENARIOS}
|
scenarios={WORKBENCH_SCENARIOS}
|
||||||
activeScenarioId={activeScenarioId}
|
activeScenarioId={activeScenarioId}
|
||||||
alerts={workbenchAlerts}
|
alerts={workbenchAlerts}
|
||||||
user={WORKBENCH_USER}
|
user={user}
|
||||||
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
|
||||||
taskTickerAvailable={taskTickerAvailable}
|
taskTickerAvailable={taskTickerAvailable}
|
||||||
taskTickerVisible={taskTickerVisible}
|
taskTickerVisible={taskTickerVisible}
|
||||||
@@ -785,6 +796,7 @@ export function MapWorkbenchPage() {
|
|||||||
onRefreshTiles={handleRefreshTiles}
|
onRefreshTiles={handleRefreshTiles}
|
||||||
onShowShortcuts={handleShowShortcuts}
|
onShowShortcuts={handleShowShortcuts}
|
||||||
onExportConfig={handleExportConfig}
|
onExportConfig={handleExportConfig}
|
||||||
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkbenchAgentPanels
|
<WorkbenchAgentPanels
|
||||||
|
|||||||
+24
-14
@@ -1,23 +1,33 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
import { App } from "@/app/app";
|
import { AuthStartupScreen } from "@/app/auth-startup-screen";
|
||||||
import { env } from "@/shared/config/env";
|
import { initializeAuthentication } from "@/shared/auth/keycloak-auth";
|
||||||
import "@/styles.css";
|
import "@/styles.css";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
import "streamdown/styles.css";
|
import "streamdown/styles.css";
|
||||||
|
|
||||||
async function prepareMocks() {
|
const root = ReactDOM.createRoot(document.getElementById("root")!);
|
||||||
if (!env.TJWATER_ENABLE_MSW) return;
|
|
||||||
const { worker } = await import("@/mocks/browser");
|
root.render(<AuthStartupScreen />);
|
||||||
await worker.start({ onUnhandledRequest: "bypass" });
|
|
||||||
|
try {
|
||||||
|
const [{ App }, { env }] = await Promise.all([
|
||||||
|
import("@/app/app"),
|
||||||
|
import("@/shared/config/env")
|
||||||
|
]);
|
||||||
|
if (env.TJWATER_ENABLE_MSW) {
|
||||||
|
const { worker } = await import("@/mocks/browser");
|
||||||
|
await worker.start({ onUnhandledRequest: "bypass" });
|
||||||
|
}
|
||||||
|
const authentication = await initializeAuthentication(env);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App authentication={authentication} />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize authentication", error);
|
||||||
|
root.render(<AuthStartupScreen error />);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prepareMocks();
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { parseRuntimeConfig } from "@/shared/config/env";
|
||||||
|
import {
|
||||||
|
initializeAuthentication,
|
||||||
|
toAuthenticatedUser,
|
||||||
|
toKeycloakConfig
|
||||||
|
} from "./keycloak-auth";
|
||||||
|
|
||||||
|
const requiredConfig = parseRuntimeConfig({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/auth/realms/tjwater",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Keycloak authentication", () => {
|
||||||
|
it("derives the Keycloak server and Realm from the issuer", () => {
|
||||||
|
expect(toKeycloakConfig(requiredConfig.TJWATER_KEYCLOAK_ISSUER, "next-tjwater")).toEqual({
|
||||||
|
url: "https://auth.example.test/auth",
|
||||||
|
realm: "tjwater",
|
||||||
|
clientId: "next-tjwater"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes the public SPA flow and exposes the refreshed token", async () => {
|
||||||
|
const client = {
|
||||||
|
token: "access-token",
|
||||||
|
idTokenParsed: {
|
||||||
|
sub: "user-1",
|
||||||
|
name: "张调度",
|
||||||
|
email: "operator@example.test"
|
||||||
|
},
|
||||||
|
init: vi.fn().mockResolvedValue(true),
|
||||||
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
|
logout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateToken: vi.fn().mockResolvedValue(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||||
|
|
||||||
|
expect(client.init).toHaveBeenCalledWith({
|
||||||
|
onLoad: "login-required",
|
||||||
|
flow: "standard",
|
||||||
|
pkceMethod: "S256"
|
||||||
|
});
|
||||||
|
expect(authentication.user).toEqual({
|
||||||
|
name: "张调度",
|
||||||
|
role: "operator@example.test"
|
||||||
|
});
|
||||||
|
await expect(authentication.getAccessToken()).resolves.toBe("access-token");
|
||||||
|
expect(client.updateToken).toHaveBeenCalledWith(30);
|
||||||
|
|
||||||
|
await authentication.logout?.();
|
||||||
|
expect(client.logout).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stable user claim fallbacks", () => {
|
||||||
|
expect(toAuthenticatedUser({ sub: "user-2", preferred_username: "dispatcher" })).toEqual({
|
||||||
|
name: "dispatcher",
|
||||||
|
role: "统一认证用户"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to Keycloak instead of using a token after refresh failure", async () => {
|
||||||
|
const refreshError = new Error("refresh failed");
|
||||||
|
const client = {
|
||||||
|
token: "expired-token",
|
||||||
|
tokenParsed: { sub: "user-3" },
|
||||||
|
init: vi.fn().mockResolvedValue(true),
|
||||||
|
login: vi.fn().mockResolvedValue(undefined),
|
||||||
|
logout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateToken: vi.fn().mockRejectedValue(refreshError)
|
||||||
|
};
|
||||||
|
const authentication = await initializeAuthentication(requiredConfig, () => client);
|
||||||
|
|
||||||
|
await expect(authentication.getAccessToken()).rejects.toBe(refreshError);
|
||||||
|
expect(client.login).toHaveBeenCalledWith({ redirectUri: "http://localhost:3000/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps authentication inert when explicitly disabled", async () => {
|
||||||
|
const createClient = vi.fn();
|
||||||
|
const authentication = await initializeAuthentication(
|
||||||
|
parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" }),
|
||||||
|
createClient
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(authentication.enabled).toBe(false);
|
||||||
|
await expect(authentication.getAccessToken()).resolves.toBeNull();
|
||||||
|
expect(createClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import Keycloak, {
|
||||||
|
type KeycloakConfig,
|
||||||
|
type KeycloakInitOptions,
|
||||||
|
type KeycloakLoginOptions,
|
||||||
|
type KeycloakLogoutOptions,
|
||||||
|
type KeycloakTokenParsed
|
||||||
|
} from "keycloak-js";
|
||||||
|
import type { RuntimeConfig } from "@/shared/config/env";
|
||||||
|
|
||||||
|
const TOKEN_MIN_VALIDITY_SECONDS = 30;
|
||||||
|
|
||||||
|
export type AuthenticatedUser = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessTokenProvider = () => Promise<string | null>;
|
||||||
|
|
||||||
|
export type Authentication = {
|
||||||
|
enabled: boolean;
|
||||||
|
user: AuthenticatedUser | null;
|
||||||
|
getAccessToken: AccessTokenProvider;
|
||||||
|
logout?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KeycloakClient = {
|
||||||
|
token?: string;
|
||||||
|
tokenParsed?: KeycloakTokenParsed;
|
||||||
|
idTokenParsed?: KeycloakTokenParsed;
|
||||||
|
onTokenExpired?: () => void;
|
||||||
|
init: (options: KeycloakInitOptions) => Promise<boolean>;
|
||||||
|
login: (options?: KeycloakLoginOptions) => Promise<void>;
|
||||||
|
logout: (options?: KeycloakLogoutOptions) => Promise<void>;
|
||||||
|
updateToken: (minValidity: number) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KeycloakClientFactory = (config: KeycloakConfig) => KeycloakClient;
|
||||||
|
|
||||||
|
export async function initializeAuthentication(
|
||||||
|
config: RuntimeConfig,
|
||||||
|
createClient: KeycloakClientFactory = (keycloakConfig) => new Keycloak(keycloakConfig)
|
||||||
|
): Promise<Authentication> {
|
||||||
|
if (config.TJWATER_AUTH_MODE === "disabled") {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
user: null,
|
||||||
|
getAccessToken: async () => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloak = createClient(
|
||||||
|
toKeycloakConfig(
|
||||||
|
config.TJWATER_KEYCLOAK_ISSUER,
|
||||||
|
config.TJWATER_KEYCLOAK_CLIENT_ID
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const redirectUri = `${window.location.origin}/`;
|
||||||
|
const login = () => keycloak.login({ redirectUri });
|
||||||
|
const authenticated = await keycloak.init({
|
||||||
|
onLoad: "login-required",
|
||||||
|
flow: "standard",
|
||||||
|
pkceMethod: "S256"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
await login();
|
||||||
|
throw new Error("Keycloak authentication was not completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
try {
|
||||||
|
await keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS);
|
||||||
|
} catch (error) {
|
||||||
|
await login();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
keycloak.onTokenExpired = () => {
|
||||||
|
void refreshAccessToken().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
user: toAuthenticatedUser(keycloak.idTokenParsed ?? keycloak.tokenParsed),
|
||||||
|
getAccessToken: async () => {
|
||||||
|
await refreshAccessToken();
|
||||||
|
if (!keycloak.token) {
|
||||||
|
throw new Error("Keycloak access token is unavailable");
|
||||||
|
}
|
||||||
|
return keycloak.token;
|
||||||
|
},
|
||||||
|
logout: () => keycloak.logout({ redirectUri })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toKeycloakConfig(issuer: string, clientId: string): KeycloakConfig {
|
||||||
|
const url = new URL(issuer);
|
||||||
|
const realmMarker = "/realms/";
|
||||||
|
const markerIndex = url.pathname.lastIndexOf(realmMarker);
|
||||||
|
const realm = decodeURIComponent(url.pathname.slice(markerIndex + realmMarker.length));
|
||||||
|
const serverPath = url.pathname.slice(0, markerIndex).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `${url.origin}${serverPath}`,
|
||||||
|
realm,
|
||||||
|
clientId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAuthenticatedUser(token: KeycloakTokenParsed | undefined): AuthenticatedUser {
|
||||||
|
const name = readClaim(token, "name") ?? readClaim(token, "preferred_username") ?? readClaim(token, "sub") ?? "已认证用户";
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role: readClaim(token, "email") ?? "统一认证用户"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readClaim(token: KeycloakTokenParsed | undefined, key: string) {
|
||||||
|
const value = token?.[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ describe("runtime frontend configuration", () => {
|
|||||||
it("uses container-provided values without relying on Vite build variables", () => {
|
it("uses container-provided values without relying on Vite build variables", () => {
|
||||||
expect(
|
expect(
|
||||||
parseRuntimeConfig({
|
parseRuntimeConfig({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater/",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||||
@@ -14,6 +17,9 @@ describe("runtime frontend configuration", () => {
|
|||||||
TJWATER_ENABLE_MSW: "false"
|
TJWATER_ENABLE_MSW: "false"
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/realms/tjwater",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
|
||||||
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
TJWATER_GEOSERVER_WORKSPACE: "project-a",
|
||||||
@@ -24,12 +30,18 @@ describe("runtime frontend configuration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("applies typed defaults for optional feature flags", () => {
|
it("applies typed defaults for optional feature flags", () => {
|
||||||
expect(parseRuntimeConfig({})).toMatchObject({
|
expect(parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled" })).toMatchObject({
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
|
||||||
TJWATER_ENABLE_DEV_PANEL: false,
|
TJWATER_ENABLE_DEV_PANEL: false,
|
||||||
TJWATER_ENABLE_MSW: false
|
TJWATER_ENABLE_MSW: false
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires a Realm issuer when authentication is enabled", () => {
|
||||||
|
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "required" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects invalid runtime URLs before the application starts", () => {
|
it("rejects invalid runtime URLs before the application starts", () => {
|
||||||
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
||||||
});
|
});
|
||||||
@@ -39,6 +51,16 @@ describe("runtime frontend configuration", () => {
|
|||||||
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
||||||
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
||||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||||
expect(() => parseRuntimeConfig({ [key]: value })).toThrow();
|
expect(() => parseRuntimeConfig({ TJWATER_AUTH_MODE: "disabled", [key]: value })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a Keycloak URL that does not identify a Realm", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseRuntimeConfig({
|
||||||
|
TJWATER_AUTH_MODE: "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: "https://auth.example.test/",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater"
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,14 +22,53 @@ const browserHttpUrl = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtimeConfigSchema = z.object({
|
const keycloakIssuer = z
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
.string()
|
||||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
.trim()
|
||||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
.default("")
|
||||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
.superRefine((value, context) => {
|
||||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
if (!value) return;
|
||||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
const parsed = browserHttpUrl.safeParse(value);
|
||||||
});
|
if (!parsed.success) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须是安全的 HTTP(S) 地址" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.search || !/\/realms\/[^/]+\/?$/.test(url.pathname)) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "Keycloak issuer 必须指向具体 Realm" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transform((value) => value.replace(/\/+$/, ""));
|
||||||
|
|
||||||
|
const runtimeConfigSchema = z
|
||||||
|
.object({
|
||||||
|
TJWATER_AUTH_MODE: z.enum(["required", "disabled"]).default("required"),
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: keycloakIssuer,
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
|
||||||
|
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||||
|
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||||
|
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
||||||
|
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||||
|
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||||
|
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||||
|
})
|
||||||
|
.superRefine((config, context) => {
|
||||||
|
if (config.TJWATER_AUTH_MODE !== "required") return;
|
||||||
|
if (!config.TJWATER_KEYCLOAK_ISSUER) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["TJWATER_KEYCLOAK_ISSUER"],
|
||||||
|
message: "启用认证时必须配置 Keycloak issuer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!config.TJWATER_KEYCLOAK_CLIENT_ID) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["TJWATER_KEYCLOAK_CLIENT_ID"],
|
||||||
|
message: "启用认证时必须配置 Keycloak client ID"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var __TJWATER_CONFIG__: unknown;
|
var __TJWATER_CONFIG__: unknown;
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "disabled" };
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ async function prepareWorkbench(page: Page) {
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
contentType: "application/javascript",
|
contentType: "application/javascript",
|
||||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
type MockAgentApiOptions = {
|
type MockAgentApiOptions = {
|
||||||
|
model?: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: "bolt" | "sparkle";
|
||||||
|
};
|
||||||
sessions?: Array<Record<string, unknown>>;
|
sessions?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
|
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
|
||||||
|
const model = options.model ?? {
|
||||||
|
id: "test/model",
|
||||||
|
label: "测试模型",
|
||||||
|
description: "Playwright 浏览器测试",
|
||||||
|
icon: "bolt" as const
|
||||||
|
};
|
||||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||||
const pathname = new URL(route.request().url()).pathname;
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
|
||||||
@@ -37,15 +49,8 @@ export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}
|
|||||||
if (pathname.endsWith("/models")) {
|
if (pathname.endsWith("/models")) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
default_model: "test/model",
|
default_model: model.id,
|
||||||
models: [
|
models: [model]
|
||||||
{
|
|
||||||
id: "test/model",
|
|
||||||
label: "测试模型",
|
|
||||||
description: "Playwright 浏览器测试",
|
|
||||||
icon: "bolt"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { mockAgentApi } from "./support/mock-agent-api";
|
||||||
|
|
||||||
const EMPTY_RASTER_TILE = Buffer.from(
|
const EMPTY_RASTER_TILE = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
@@ -116,10 +117,19 @@ test.describe("neutral blue mist workbench", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function prepareDeterministicWorkbench(page: Page) {
|
async function prepareDeterministicWorkbench(page: Page) {
|
||||||
|
await mockAgentApi(page, {
|
||||||
|
model: {
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
label: "快速",
|
||||||
|
description: "快速响应",
|
||||||
|
icon: "bolt"
|
||||||
|
}
|
||||||
|
});
|
||||||
await page.route("**/runtime-config.js", async (route) => {
|
await page.route("**/runtime-config.js", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
contentType: "application/javascript",
|
contentType: "application/javascript",
|
||||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
|
||||||
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater",
|
||||||
@@ -148,7 +158,9 @@ async function prepareDeterministicWorkbench(page: Page) {
|
|||||||
|
|
||||||
async function openWorkbench(page: Page) {
|
async function openWorkbench(page: Page) {
|
||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.locator("main").evaluate((element) => {
|
const workbench = page.locator("main[data-basemap-tone]");
|
||||||
|
await workbench.waitFor();
|
||||||
|
await workbench.evaluate((element) => {
|
||||||
element.setAttribute("data-visual-test", "true");
|
element.setAttribute("data-visual-test", "true");
|
||||||
});
|
});
|
||||||
await page.addStyleTag({
|
await page.addStyleTag({
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const RUNTIME_CONFIG_PATH = "/runtime-config.js";
|
|||||||
|
|
||||||
function renderRuntimeConfig(values: Record<string, string>) {
|
function renderRuntimeConfig(values: Record<string, string>) {
|
||||||
const config = {
|
const config = {
|
||||||
|
TJWATER_AUTH_MODE: values.TJWATER_AUTH_MODE || "required",
|
||||||
|
TJWATER_KEYCLOAK_ISSUER: values.TJWATER_KEYCLOAK_ISSUER || "",
|
||||||
|
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
|
||||||
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
|
||||||
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",
|
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater",
|
||||||
|
|||||||
Reference in New Issue
Block a user