diff --git a/.dockerignore b/.dockerignore index 526a792..2f3f320 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,12 @@ node_modules +.git +.next dist +dist-server coverage playwright-report test-results .env .env.* *.log +.DS_Store diff --git a/.env.example b/.env.example index 7ef4234..7bbd71e 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,5 @@ TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 TJWATER_ENABLE_DEV_PANEL=false TJWATER_ENABLE_MSW=false -# Optional development proxy target; never exposed to browser code. -AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787 +# Optional server-only Edge TTS voice. Never exposed through runtime-config.js. +EDGE_TTS_VOICE=zh-CN-XiaoxiaoNeural diff --git a/.gitignore b/.gitignore index 21c9142..5ed1afe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules +.next dist +dist-server coverage playwright-report test-results @@ -7,4 +9,5 @@ test-results .env.* !.env.example *.log +*.tsbuildinfo *:Zone.Identifier diff --git a/AGENTS.md b/AGENTS.md index 1413695..8d25257 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ under `src/`. - `src/shared/`: reusable UI, config, styles, and utilities. - `src/mocks/`: MSW handlers and fixtures for backend-independent development. - `src/test/`: Vitest setup. +- `server/`: same-origin Edge TTS adapter used by Vite and the production container. ## Commands @@ -31,5 +32,6 @@ Use TypeScript and React function components. Prefer explicit exported types at module boundaries. Keep Agent dynamic UI rendering schema-driven: Agent output must be validated before it reaches React components. -Do not add Next.js APIs or server routes to this frontend. Backend/BFF behavior -belongs in the Agent service or a separate service. +Do not add Next.js APIs or general BFF routes to this frontend. Agent behavior +belongs in the Agent service; the local `server/` exception is limited to the +same-origin Edge TTS network adapter. diff --git a/Caddyfile b/Caddyfile index 5cb3793..aa6152e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -8,6 +8,12 @@ @immutableAssets path /assets/* header @immutableAssets Cache-Control "public, max-age=31536000, immutable" - try_files {path} /index.html - file_server + handle /api/tts/edge { + reverse_proxy 127.0.0.1:8790 + } + + handle { + try_files {path} /index.html + file_server + } } diff --git a/Dockerfile b/Dockerfile index a7d06f0..a4c1d03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,11 @@ RUN pnpm build FROM caddy:2.10.2-alpine -RUN apk add --no-cache jq +RUN apk add --no-cache jq nodejs COPY Caddyfile /etc/caddy/Caddyfile COPY docker-entrypoint.sh /usr/local/bin/tjwater-frontend-entrypoint COPY --from=build /app/dist /srv +COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/tjwater-frontend/edge-tts-server.cjs RUN chmod 755 /usr/local/bin/tjwater-frontend-entrypoint EXPOSE 80 diff --git a/README.md b/README.md index 2609dce..06e58ff 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ cp .env.example .env.local pnpm dev ``` -开发服务器默认监听所有网络接口。打开终端中 Vite 输出的地址即可访问工作台。 +开发服务器默认地址为 。 Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力时,请同时启动对应的 Agent 后端。 @@ -40,10 +40,11 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 | `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | | `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | | `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | -| `AGENT_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8787` | Vite 开发代理访问的 Agent 服务地址,仅开发环境使用 | `.env.local` 已被 Git 忽略,请勿提交令牌或生产环境配置。 +Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 + ## 常用命令 ```bash @@ -60,7 +61,7 @@ pnpm test:browser # 运行 Playwright 浏览器测试 ## Docker -生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时会根据环境变量生成 `/runtime-config.js`。 +生产镜像使用 Node.js 构建静态文件,并通过 Caddy 提供服务。容器启动时会根据环境变量生成 `/runtime-config.js`,同时启动 Edge TTS 适配器。 ```bash docker build -t next-tjwater . diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 750b655..6bac895 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -19,4 +19,20 @@ runtime_config=$(jq -cn \ printf 'globalThis.__TJWATER_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js -exec "$@" +node /usr/local/lib/tjwater-frontend/edge-tts-server.cjs & +tts_pid=$! + +"$@" & +app_pid=$! + +terminate() { + kill "$app_pid" "$tts_pid" 2>/dev/null || true +} +trap terminate INT TERM + +status=0 +wait -n "$app_pid" "$tts_pid" || status=$? +terminate +wait "$app_pid" 2>/dev/null || true +wait "$tts_pid" 2>/dev/null || true +exit "$status" diff --git a/eslint.config.js b/eslint.config.js index e34cc87..c8ec2d7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,14 @@ import globals from "globals"; export default [ { - ignores: ["dist/**", "node_modules/**", "playwright-report/**", "test-results/**"] + ignores: [ + ".next/**", + "dist/**", + "dist-server/**", + "node_modules/**", + "playwright-report/**", + "test-results/**" + ] }, js.configs.recommended, { diff --git a/package.json b/package.json index 3b5a265..dc6c7fe 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "type": "module", "packageManager": "pnpm@11.8.0", "scripts": { - "dev": "vite --host 0.0.0.0", - "build": "tsc --noEmit && vite build", - "preview": "vite preview --host 0.0.0.0", + "dev": "vite", + "build": "tsc --noEmit && vite build && pnpm build:tts", + "build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs", + "preview": "vite preview", "typecheck": "tsc --noEmit", "lint": "eslint .", "format": "prettier --write .", @@ -41,6 +42,7 @@ "cmdk": "^1.1.1", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", + "edge-tts-ts": "^1.0.0", "katex": "^0.17.0", "lucide-react": "^0.468.0", "maplibre-gl": "^4.7.1", @@ -73,6 +75,7 @@ "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", + "esbuild": "^0.28.0", "globals": "^15.14.0", "jsdom": "^25.0.1", "msw": "^2.6.8", @@ -83,5 +86,10 @@ "typescript": "^7.0.0", "vite": "^7.0.0", "vitest": "^4.1.9" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/playwright.config.ts b/playwright.config.ts index 0c48583..8f32457 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,23 +1,29 @@ import { defineConfig, devices } from "@playwright/test"; +const testPort = process.env.PLAYWRIGHT_PORT || "5191"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`; + export default defineConfig({ - testDir: "./src", - testMatch: ["**/*.e2e.ts"], + testDir: ".", + testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"], fullyParallel: true, reporter: "html", use: { - baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5173", + baseURL, trace: "on-first-retry" }, webServer: { - command: "pnpm dev", - url: "http://127.0.0.1:5173", - reuseExistingServer: true + command: `TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 pnpm dev --port ${testPort} --strictPort`, + url: baseURL, + reuseExistingServer: false }, projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] } + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 900 } + } } ] }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a164bf8..f0fe7df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: echarts-for-react: specifier: ^3.0.2 version: 3.0.6(echarts@6.1.0)(react@19.2.7) + edge-tts-ts: + specifier: ^1.0.0 + version: 1.0.0 katex: specifier: ^0.17.0 version: 0.17.0 @@ -165,6 +168,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.0.2 version: 4.3.1(@swc/helpers@0.5.23)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)) + esbuild: + specifier: ^0.28.0 + version: 0.28.1 eslint: specifier: ^9.17.0 version: 9.39.5(jiti@2.7.0) @@ -2212,6 +2218,10 @@ packages: resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} engines: {node: '>=12.20.0'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -2480,6 +2490,10 @@ packages: echarts@6.1.0: resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + edge-tts-ts@1.0.0: + resolution: {integrity: sha512-327gpuN0VjvMsuvbizqabzqMiYpdHb0Slt8/hyf+ridtSkOGN68oogLpFnm8KznunbCnoo3DWMTAn7j6sd3WrA==} + hasBin: true + electron-to-chromium@1.5.393: resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} @@ -2924,6 +2938,11 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3708,6 +3727,9 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + sound-play@1.1.0: + resolution: {integrity: sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3948,6 +3970,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@13.0.2: + resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} + hasBin: true + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true @@ -6108,6 +6134,8 @@ snapshots: table-layout: 4.1.1 typical: 7.3.0 + commander@14.0.3: {} + commander@7.2.0: {} commander@8.3.0: {} @@ -6390,6 +6418,17 @@ snapshots: tslib: 2.3.0 zrender: 6.1.0 + edge-tts-ts@1.0.0: + dependencies: + commander: 14.0.3 + isomorphic-ws: 5.0.0(ws@8.21.1) + sound-play: 1.1.0 + uuid: 13.0.2 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + electron-to-chromium@1.5.393: {} emoji-regex@8.0.0: {} @@ -6892,6 +6931,10 @@ snapshots: isexe@3.1.5: {} + isomorphic-ws@5.0.0(ws@8.21.1): + dependencies: + ws: 8.21.1 + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -7964,6 +8007,8 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + sound-play@1.1.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -8223,6 +8268,8 @@ snapshots: dependencies: react: 19.2.7 + uuid@13.0.2: {} + uuid@14.0.1: {} vfile-location@5.0.3: diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/server/edge-tts-server.ts b/server/edge-tts-server.ts new file mode 100644 index 0000000..ce3645c --- /dev/null +++ b/server/edge-tts-server.ts @@ -0,0 +1,24 @@ +import { createServer } from "node:http"; +import { createEdgeTtsMiddleware } from "./edge-tts-service"; + +const host = "127.0.0.1"; +const port = 8790; +const handleEdgeTts = createEdgeTtsMiddleware({ trustProxy: true }); + +const server = createServer((request, response) => { + void handleEdgeTts(request, response, () => { + response.statusCode = 404; + response.end("Not Found"); + }); +}); + +server.listen(port, host, () => { + console.log(`[Agent TTS] Edge TTS adapter listening on http://${host}:${port}`); +}); + +function closeServer() { + server.close(() => process.exit(0)); +} + +process.on("SIGINT", closeServer); +process.on("SIGTERM", closeServer); diff --git a/server/edge-tts-service.test.ts b/server/edge-tts-service.test.ts new file mode 100644 index 0000000..f2d7dc1 --- /dev/null +++ b/server/edge-tts-service.test.ts @@ -0,0 +1,102 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createEdgeTtsMiddleware, + type EdgeTtsMiddlewareOptions, + type EdgeTtsSynthesizer +} from "./edge-tts-service"; + +const servers: ReturnType[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))) + ); +}); + +async function startServer(options: EdgeTtsMiddlewareOptions) { + const middleware = createEdgeTtsMiddleware(options); + const server = createServer((request, response) => { + void middleware(request, response, () => { + response.statusCode = 404; + response.end(); + }); + }); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +function postSpeech(baseUrl: string, body: unknown, headers = true) { + return fetch(`${baseUrl}/api/tts/edge`, { + method: "POST", + ...(headers ? { headers: { "Content-Type": "application/json" } } : {}), + body: typeof body === "string" ? body : JSON.stringify(body) + }); +} + +describe("Edge TTS adapter", () => { + it("returns synthesized MP3 audio with the server-owned voice", async () => { + const synthesize = vi.fn(async () => Uint8Array.from([73, 68, 51])); + const baseUrl = await startServer({ synthesize, defaultVoice: "zh-CN-XiaoxiaoNeural" }); + + const response = await postSpeech(baseUrl, { text: " 朗读调度结果 " }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("audio/mpeg"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.from([73, 68, 51])); + expect(synthesize).toHaveBeenCalledWith("朗读调度结果", "zh-CN-XiaoxiaoNeural"); + }); + + it("rejects invalid, unsafe and oversized requests before synthesis", async () => { + const synthesize = vi.fn(async () => Uint8Array.from([1])); + const baseUrl = await startServer({ synthesize }); + + expect((await postSpeech(baseUrl, "not-json")).status).toBe(400); + expect((await postSpeech(baseUrl, { text: "测试" }, false)).status).toBe(415); + expect((await postSpeech(baseUrl, { text: "测试", voice: "unsafe" })).status).toBe(400); + expect((await postSpeech(baseUrl, { text: "排".repeat(521) })).status).toBe(413); + expect(synthesize).not.toHaveBeenCalled(); + }); + + it("limits requests by client and releases the window", async () => { + let currentTime = 1_000; + const synthesize = vi.fn(async () => Uint8Array.from([1])); + const baseUrl = await startServer({ + synthesize, + maxRequestsPerWindow: 1, + rateWindowMs: 1_000, + now: () => currentTime + }); + + expect((await postSpeech(baseUrl, { text: "第一次" })).status).toBe(200); + expect((await postSpeech(baseUrl, { text: "第二次" })).status).toBe(429); + currentTime += 1_000; + expect((await postSpeech(baseUrl, { text: "新窗口" })).status).toBe(200); + }); + + it("caps concurrent synthesis work", async () => { + let releaseFirst: ((audio: Uint8Array) => void) | undefined; + const firstAudio = new Promise((resolve) => { + releaseFirst = resolve; + }); + const synthesize = vi.fn(() => firstAudio); + const baseUrl = await startServer({ synthesize, maxConcurrent: 1 }); + + const firstRequest = postSpeech(baseUrl, { text: "第一段" }); + await vi.waitFor(() => expect(synthesize).toHaveBeenCalledTimes(1)); + expect((await postSpeech(baseUrl, { text: "第二段" })).status).toBe(429); + releaseFirst?.(Uint8Array.from([1])); + expect((await firstRequest).status).toBe(200); + }); + + it("returns a gateway timeout without releasing the occupied slot early", async () => { + const synthesize = vi.fn(() => new Promise(() => undefined)); + const baseUrl = await startServer({ synthesize, timeoutMs: 5 }); + + expect((await postSpeech(baseUrl, { text: "超时测试" })).status).toBe(504); + }); +}); diff --git a/server/edge-tts-service.ts b/server/edge-tts-service.ts new file mode 100644 index 0000000..13a34f7 --- /dev/null +++ b/server/edge-tts-service.ts @@ -0,0 +1,210 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Communicate } from "edge-tts-ts"; + +const EDGE_TTS_PATH = "/api/tts/edge"; +const DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"; +const MAX_REQUEST_BYTES = 8 * 1024; +const MAX_TEXT_LENGTH = 520; +const MAX_AUDIO_BYTES = 5 * 1024 * 1024; +const DEFAULT_MAX_CONCURRENT = 8; +const DEFAULT_RATE_LIMIT = 60; +const DEFAULT_RATE_WINDOW_MS = 60_000; +const DEFAULT_TIMEOUT_MS = 30_000; + +interface EdgeTtsPayload { + text?: unknown; + voice?: unknown; +} + +interface RateBucket { + count: number; + startedAt: number; +} + +export type EdgeTtsSynthesizer = (text: string, voice: string) => Promise; + +export type EdgeTtsMiddlewareOptions = { + defaultVoice?: string; + maxConcurrent?: number; + maxRequestsPerWindow?: number; + now?: () => number; + rateWindowMs?: number; + synthesize?: EdgeTtsSynthesizer; + timeoutMs?: number; + trustProxy?: boolean; +}; + +function sendJson(response: ServerResponse, status: number, message: string) { + response.statusCode = status; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.end(JSON.stringify({ error: message })); +} + +async function readJson(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let byteLength = 0; + + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += buffer.byteLength; + if (byteLength > MAX_REQUEST_BYTES) { + throw new RangeError("请求内容过大"); + } + chunks.push(buffer); + } + + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new SyntaxError("请求内容必须是 JSON 对象"); + } + return payload as EdgeTtsPayload; +} + +function isLoopback(address: string | undefined) { + return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1"; +} + +function getClientKey(request: IncomingMessage, trustProxy: boolean) { + const remoteAddress = request.socket.remoteAddress; + if (trustProxy && isLoopback(remoteAddress)) { + const forwardedFor = request.headers["x-forwarded-for"]; + const firstAddress = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(",", 1)[0]; + if (firstAddress?.trim()) return firstAddress.trim(); + } + return remoteAddress || "unknown"; +} + +export async function synthesizeEdgeSpeech(text: string, voice: string): Promise { + const communicate = new Communicate(text, { voice }); + const chunks: Uint8Array[] = []; + let byteLength = 0; + + for await (const chunk of communicate.stream()) { + if (chunk.type !== "audio") continue; + chunks.push(chunk.data); + byteLength += chunk.data.byteLength; + } + + if (!byteLength) throw new Error("语音服务返回了空音频"); + + const audio = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + audio.set(chunk, offset); + offset += chunk.byteLength; + } + return audio; +} + +export function createEdgeTtsMiddleware(options: EdgeTtsMiddlewareOptions = {}) { + const synthesize = options.synthesize ?? synthesizeEdgeSpeech; + const defaultVoice = options.defaultVoice ?? process.env.EDGE_TTS_VOICE ?? DEFAULT_VOICE; + const maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + const maxRequestsPerWindow = options.maxRequestsPerWindow ?? DEFAULT_RATE_LIMIT; + const rateWindowMs = options.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const trustProxy = options.trustProxy ?? false; + const now = options.now ?? Date.now; + const rateBuckets = new Map(); + let activeRequests = 0; + + return async (request: IncomingMessage, response: ServerResponse, next?: () => void) => { + if (request.url?.split("?", 1)[0] !== EDGE_TTS_PATH) { + next?.(); + return; + } + if (request.method !== "POST") { + response.setHeader("Allow", "POST"); + sendJson(response, 405, "仅支持 POST 请求"); + return; + } + if (!request.headers["content-type"]?.toLowerCase().startsWith("application/json")) { + sendJson(response, 415, "请求必须使用 application/json"); + return; + } + + const currentTime = now(); + const clientKey = getClientKey(request, trustProxy); + const existingBucket = rateBuckets.get(clientKey); + const bucket = !existingBucket || currentTime - existingBucket.startedAt >= rateWindowMs + ? { count: 0, startedAt: currentTime } + : existingBucket; + bucket.count += 1; + rateBuckets.set(clientKey, bucket); + if (rateBuckets.size > 1_024) { + for (const [key, value] of rateBuckets) { + if (currentTime - value.startedAt >= rateWindowMs) rateBuckets.delete(key); + } + } + if (bucket.count > maxRequestsPerWindow) { + response.setHeader("Retry-After", String(Math.max(1, Math.ceil(rateWindowMs / 1_000)))); + sendJson(response, 429, "语音请求过于频繁,请稍后重试"); + return; + } + + let payload: EdgeTtsPayload; + try { + payload = await readJson(request); + } catch (error) { + sendJson(response, error instanceof RangeError ? 413 : 400, "请求内容不是有效 JSON"); + return; + } + + if (Object.prototype.hasOwnProperty.call(payload, "voice")) { + sendJson(response, 400, "语音角色只能由服务端配置"); + return; + } + const text = typeof payload.text === "string" ? payload.text.trim() : ""; + if (!text) { + sendJson(response, 400, "缺少待朗读文本"); + return; + } + if (text.length > MAX_TEXT_LENGTH) { + sendJson(response, 413, `单次文本不能超过 ${MAX_TEXT_LENGTH} 字`); + return; + } + if (activeRequests >= maxConcurrent) { + response.setHeader("Retry-After", "1"); + sendJson(response, 429, "语音服务繁忙,请稍后重试"); + return; + } + + activeRequests += 1; + const synthesisTask = Promise.resolve().then(() => synthesize(text, defaultVoice)); + void synthesisTask.finally(() => { + activeRequests -= 1; + }).catch(() => undefined); + + let timeout: ReturnType | undefined; + try { + const audio = await Promise.race([ + synthesisTask, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new DOMException("语音服务响应超时", "TimeoutError")), timeoutMs); + }) + ]); + if (!audio.byteLength) { + sendJson(response, 502, "语音服务返回了空音频"); + return; + } + if (audio.byteLength > MAX_AUDIO_BYTES) { + sendJson(response, 502, "语音服务返回的音频过大"); + return; + } + response.statusCode = 200; + response.setHeader("Content-Type", "audio/mpeg"); + response.setHeader("Cache-Control", "no-store"); + response.end(Buffer.from(audio)); + } catch (error) { + if (error instanceof DOMException && error.name === "TimeoutError") { + sendJson(response, 504, "语音服务响应超时"); + return; + } + console.error("[Agent TTS] 语音生成失败", error); + sendJson(response, 502, "语音生成失败"); + } finally { + if (timeout) clearTimeout(timeout); + } + }; +} diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 2cc7e20..dbd6bc0 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -22,13 +22,13 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { expect(Object.keys(runtimeConfig as Record)).not.toContainEqual( expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/) ); + expect(runtimeConfig).not.toHaveProperty("TJWATER_TTS_API_URL"); + expect(runtimeConfig).not.toHaveProperty("EDGE_TTS_VOICE"); const mapAsset = await page.request.get("/map/valve.png"); expect(mapAsset.ok()).toBe(true); expect(mapAsset.headers()["content-type"]).toBe("image/png"); - await expect(page.locator('[aria-label="Agent 命令面板"] canvas').first()).toBeVisible({ - timeout: 25_000 - }); + await expect(page.getByLabel("Agent 状态").first()).toBeVisible(); await expect.poll(() => riveRequests.length, { timeout: 25_000 }).toBe(1); }); diff --git a/src/features/agent/components/agent-collapsed-rail.tsx b/src/features/agent/components/agent-collapsed-rail.tsx index 127d419..b06774a 100644 --- a/src/features/agent/components/agent-collapsed-rail.tsx +++ b/src/features/agent/components/agent-collapsed-rail.tsx @@ -8,28 +8,35 @@ import { AgentPersona } from "./agent-persona"; type AgentCollapsedRailProps = { personaState?: PersonaState; + statusLabel?: string; onExpand: () => void; }; -export function AgentCollapsedRail({ personaState, onExpand }: AgentCollapsedRailProps) { +export function AgentCollapsedRail({ + personaState, + statusLabel = "Agent 已就绪", + onExpand +}: AgentCollapsedRailProps) { + const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`; + return ( diff --git a/src/features/agent/components/agent-operational-brief.tsx b/src/features/agent/components/agent-operational-brief.tsx index b25d121..2121b74 100644 --- a/src/features/agent/components/agent-operational-brief.tsx +++ b/src/features/agent/components/agent-operational-brief.tsx @@ -104,7 +104,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认"; const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成"; const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500"; - const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势"; + const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势"; return { task, diff --git a/src/features/agent/components/agent-persona.tsx b/src/features/agent/components/agent-persona.tsx index 25f8097..e9ddb97 100644 --- a/src/features/agent/components/agent-persona.tsx +++ b/src/features/agent/components/agent-persona.tsx @@ -58,7 +58,11 @@ export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) { const shouldRenderAnimation = !failed && visible; return ( - + {shouldRenderAnimation ? ( }> { }) ).toBeNull(); expect(toTrustedMapAction("apply_layer_style", {})).toBeNull(); + expect( + toTrustedMapAction("apply_layer_style", { + layer_group_id: "pipes", + layer_id: "scada", + visible: true + }) + ).toBeNull(); }); it("rejects out-of-range coordinates and zoom", () => { @@ -83,6 +90,7 @@ describe("toTrustedMapAction", () => { it("accepts supply layer visibility and scada locate actions", () => { expect(toTrustedMapAction("apply_layer_style", { layer_id: "scada", visible: false })).toMatchObject({ type: "apply_layer_style", + layerGroupId: "scada", layerId: "scada", visible: false }); @@ -92,4 +100,20 @@ describe("toTrustedMapAction", () => { layer: "geo_scada" }); }); + + it("rejects unknown layers and accepts known visibility controls", () => { + expect(toTrustedMapAction("locate_features", { ids: ["P1"], layer: "geo_unknown" })).toBeNull(); + expect(toTrustedMapAction("apply_layer_style", { layer_id: "pipes", visible: true })).toMatchObject({ + type: "apply_layer_style", + layerGroupId: "pipes", + layerId: "pipes", + visible: true + }); + expect(toTrustedMapAction("apply_layer_style", { layer_group_id: "simulation", visible: false })).toMatchObject({ + type: "apply_layer_style", + layerGroupId: "simulation", + visible: false + }); + expect(toTrustedMapAction("apply_layer_style", { layer_id: "conduits", visible: true })).toBeNull(); + }); }); diff --git a/src/features/agent/ui-envelope/map-actions.ts b/src/features/agent/ui-envelope/map-actions.ts index 94a8c78..70ae5a1 100644 --- a/src/features/agent/ui-envelope/map-actions.ts +++ b/src/features/agent/ui-envelope/map-actions.ts @@ -14,7 +14,7 @@ export type TrustedMapAction = } | { type: "apply_layer_style"; - layerGroupId?: string; + layerGroupId: string; layerId?: string; visible?: boolean; fallbackText?: string; @@ -33,12 +33,14 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) { const featureIds = readLocateIds(params); if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null; + const layer = + stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ?? + LEGACY_LOCATE_ACTION_LAYERS[action]; + if (layer && !ALLOWED_LOCATE_LAYERS.has(layer)) return null; return { type: "locate_features", featureIds, - layer: - stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ?? - LEGACY_LOCATE_ACTION_LAYERS[action], + layer, fallbackText }; } @@ -66,11 +68,18 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText if (action === "apply_layer_style") { const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId); const layerId = stringValue(params.layer_id ?? params.layerId); + const resolvedLayerGroupId = layerGroupId ?? layerId; const visible = booleanValue(params.visible); - if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null; + if ( + !resolvedLayerGroupId || + visible === undefined || + (layerGroupId && !ALLOWED_LAYER_GROUP_IDS.has(layerGroupId)) || + (layerId && !ALLOWED_LAYER_IDS.has(layerId)) || + (layerGroupId && layerId && layerGroupId !== layerId) + ) return null; return { type: "apply_layer_style", - layerGroupId, + layerGroupId: resolvedLayerGroupId, layerId, visible, fallbackText @@ -165,7 +174,21 @@ const LEGACY_LOCATE_ACTION_LAYERS: Record = { }; const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/; -const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes", "valves", "reservoirs", "scada"]); +const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"]; +const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS); +const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]); +const ALLOWED_LOCATE_LAYERS = new Set([ + ...SUPPLY_SOURCE_IDS, + "pumps", + "tanks", + "geo_junctions_mat", + "geo_pipes_mat", + "geo_valves", + "geo_reservoirs", + "geo_scada", + "geo_pumps", + "geo_tanks" +]); const WEB_MERCATOR_RADIUS = 6378137; const LOCATE_ID_PARAM_KEYS = [ diff --git a/src/features/workbench/components/map-dev-panel.tsx b/src/features/workbench/components/map-dev-panel.tsx index 4764c47..3f1eade 100644 --- a/src/features/workbench/components/map-dev-panel.tsx +++ b/src/features/workbench/components/map-dev-panel.tsx @@ -152,7 +152,7 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose - +
diff --git a/src/features/workbench/data/running-workflows.ts b/src/features/workbench/data/running-workflows.ts index 0a9e691..c6d86d7 100644 --- a/src/features/workbench/data/running-workflows.ts +++ b/src/features/workbench/data/running-workflows.ts @@ -124,7 +124,7 @@ export const runningWorkflowDefinitions: Record 10%,异常 > 15%", status: getMetricRisk(energyDeviation, 10, 15), - description: "本轮单位排水电耗相对同窗基线的偏差。" + description: "本轮单位供水电耗相对同窗基线的偏差。" }, { id: "unit-energy", - label: "单位排水电耗", + label: "单位供水电耗", value: unitEnergy.toString(), unit: "kWh/m3", baseline: "同窗经济运行区间 0.28-0.34", @@ -780,10 +780,10 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] { action: "泵组组合优化", target: "1# 泵站 3# / 4# 泵组", command: "保持 3# 泵运行,延后 4# 泵启动 10 分钟,避免低效并联区间", - verification: "复核单位排水电耗是否低于 0.34kWh/m3" + verification: "复核单位供水电耗是否低于 0.34kWh/m3" }, { - action: "高区排水边界收敛", + action: "高区供水边界收敛", target: "高区联络阀 HV-204", command: "开度下调 3%,减少高区向中区回流", verification: "确认高区末端压力不低于 0.22MPa" @@ -819,7 +819,7 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] { { action: "阀门开度回退", target: "华山路沿线阀门组 VG-11", - command: "将昨日临时开度回退 2%,恢复常规排水边界", + command: "将昨日临时开度回退 2%,恢复常规供水边界", verification: "观察华山路和人民路支线压差不超过 2.5m" }, { @@ -911,7 +911,7 @@ function createGuidanceItems( if (task.id === "pump-energy") { return [ - "关注单位排水电耗和启停频率,判断是否存在泵组组合不经济。", + "关注单位供水电耗和启停频率,判断是否存在泵组组合不经济。", "若电耗偏差持续升高,建议比较相邻泵组效率曲线并优化启停策略。", "调整前需确认供压稳定性,避免节能动作引发末端低压。" ]; diff --git a/src/features/workbench/data/workbench-session.ts b/src/features/workbench/data/workbench-session.ts index ba06a95..df399f2 100644 --- a/src/features/workbench/data/workbench-session.ts +++ b/src/features/workbench/data/workbench-session.ts @@ -23,5 +23,5 @@ export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [ export const WORKBENCH_USER: WorkbenchUser = { name: "调度员", - role: "排水运行调度中心" + role: "供水运行调度中心" }; diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts index e314c4c..0d4fd7a 100644 --- a/src/features/workbench/hooks/use-workbench-agent.ts +++ b/src/features/workbench/hooks/use-workbench-agent.ts @@ -6,6 +6,7 @@ import { DefaultChatTransport } from "ai"; import useSWR from "swr"; import useSWRImmutable from "swr/immutable"; import type { PersonaState } from "@/shared/ai-elements/persona"; +import { env } from "@/shared/config/env"; import { showMapNotice } from "@/features/map/core/components/notice-actions"; import type { AgentApprovalMode, @@ -209,7 +210,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben const transport = useMemo( () => new DefaultChatTransport({ - api: "/api/v1/agent/chat/stream", + api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`, prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) { return { body: { diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index b7f71bb..3bfb45c 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -902,7 +902,7 @@ export function MapWorkbenchPage() { ref={mapContainerRef} className="map-grid" style={{ position: "absolute", inset: 0 }} - aria-label="排水管网地图" + aria-label="供水管网地图" /> ) : ( - + )}
diff --git a/src/features/workbench/utils/feature-properties.ts b/src/features/workbench/utils/feature-properties.ts index 4fa0a21..b90f75c 100644 --- a/src/features/workbench/utils/feature-properties.ts +++ b/src/features/workbench/utils/feature-properties.ts @@ -33,7 +33,7 @@ const PROPERTY_LABELS: Record = { sensor_name: "传感器名称", swmm_node: "SWMM 节点", topology_order: "拓扑序", - distance_to_wwtp_m: "距污水厂距离", + distance_to_wwtp_m: "距处理厂距离", upstream_node_count: "上游节点数", upstream_sensors: "上游传感器", downstream_path: "下游路径", @@ -103,7 +103,7 @@ const PROPERTY_LABELS: Record = { device_icon_code: "设备图标编码", active: "运行状态", external_id: "设备编号", - junction_id: "关联检查井", + junction_id: "关联节点", kind: "设备类型", location_source: "位置来源", site_external_id: "站点编号", diff --git a/src/features/workbench/utils/scheduled-condition-prompts.ts b/src/features/workbench/utils/scheduled-condition-prompts.ts index bc9472c..a976533 100644 --- a/src/features/workbench/utils/scheduled-condition-prompts.ts +++ b/src/features/workbench/utils/scheduled-condition-prompts.ts @@ -98,7 +98,7 @@ export function createAlertQueueConversationPrompt({ }); return [ - "请作为排水管网调度 Agent,汇总当前待复核工况队列。", + "请作为供水管网调度 Agent,汇总当前待复核工况队列。", "", "目标:", "1. 汇总这一组待复核工况之间的关联关系和处理顺序。", diff --git a/src/shared/config/env.test.ts b/src/shared/config/env.test.ts index 7995e39..70cc775 100644 --- a/src/shared/config/env.test.ts +++ b/src/shared/config/env.test.ts @@ -33,4 +33,12 @@ describe("runtime frontend configuration", () => { it("rejects invalid runtime URLs before the application starts", () => { expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow(); }); + + it.each([ + ["TJWATER_MAP_URL", "file:///etc/passwd"], + ["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"], + ["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"] + ])("rejects unsafe browser runtime address %s", (key, value) => { + expect(() => parseRuntimeConfig({ [key]: value })).toThrow(); + }); }); diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index c6c5cfe..c1df264 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -6,11 +6,27 @@ const runtimeBoolean = (defaultValue: boolean) => .default(defaultValue ? "true" : "false") .transform((value) => value === "true"); +const browserHttpUrl = z + .string() + .url() + .superRefine((value, context) => { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "仅支持 HTTP(S) 地址" }); + } + if (url.username || url.password) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "浏览器运行时地址不能包含凭据" }); + } + if (url.hash) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "浏览器运行时地址不能包含片段" }); + } + }); + const runtimeConfigSchema = z.object({ TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""), - TJWATER_MAP_URL: z.string().url().default("https://geoserver.waternetwork.cn/geoserver"), - TJWATER_GEOSERVER_WORKSPACE: z.string().min(1).default("tjwater"), - TJWATER_AGENT_API_BASE_URL: z.string().url().default("http://127.0.0.1:8787"), + 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) }); diff --git a/src/test/supply-domain.test.ts b/src/test/supply-domain.test.ts new file mode 100644 index 0000000..d400597 --- /dev/null +++ b/src/test/supply-domain.test.ts @@ -0,0 +1,35 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const featuresRoot = join(process.cwd(), "src/features"); +const forbiddenDrainageTerms = [ + "排水管网", + "单位排水电耗", + "排水边界", + "距污水厂距离", + "关联检查井", + "管渠拓扑" +] as const; + +function listRuntimeFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const target = join(directory, entry.name); + if (entry.isDirectory()) return listRuntimeFiles(target); + if (!/\.(ts|tsx)$/.test(entry.name) || /\.(test|e2e)\.(ts|tsx)$/.test(entry.name)) return []; + return [target]; + }); +} + +describe("supply domain boundary", () => { + it("does not reintroduce drainage-only operating language", () => { + const violations = listRuntimeFiles(featuresRoot).flatMap((file) => { + const source = readFileSync(file, "utf8"); + return forbiddenDrainageTerms + .filter((term) => source.includes(term)) + .map((term) => `${file.replace(`${featuresRoot}/`, "")}: ${term}`); + }); + + expect(violations).toEqual([]); + }); +}); diff --git a/tests/browser/agent-acrylic.e2e.ts b/tests/browser/agent-acrylic.e2e.ts new file mode 100644 index 0000000..5d109a3 --- /dev/null +++ b/tests/browser/agent-acrylic.e2e.ts @@ -0,0 +1,111 @@ +import { expect, test, type Locator } from "playwright/test"; + +test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(250); + + const topBar = page.locator("header.acrylic-navigation"); + const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); + const agentHeader = agentPanel.locator(".agent-panel-header"); + + await expect(topBar).toBeVisible(); + await expect(agentPanel).toBeVisible(); + await expect(agentHeader).toBeVisible(); + + await expectAcrylicSurface(topBar); + await expectAcrylicSurface(agentPanel); + await expectTransparentAcrylicContext(agentHeader); + await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation")); + await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band")); +}); + +async function expectAcrylicSurface(shell: Locator) { + const composition = await shell.evaluate((element) => { + const style = getComputedStyle(element); + const filteredDescendants: string[] = []; + + for (const descendant of element.querySelectorAll("*")) { + if (getComputedStyle(descendant).backdropFilter !== "none") { + filteredDescendants.push(descendant.className); + } + } + + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor, + filteredDescendants, + transform: style.transform, + willChange: style.willChange + }; + }); + + expect(composition.backdropFilter).toContain("blur("); + expect(getAlpha(composition.backgroundColor)).toBeLessThan(1); + expect(composition.filteredDescendants).toEqual([]); + expect(composition.transform).toBe("none"); + expect(composition.willChange).toBe("auto"); +} + +function getAlpha(color: string) { + const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/); + + return match ? Number(match[1]) : 1; +} + +async function expectTransparentAcrylicContext(header: Locator) { + const style = await header.evaluate((element) => { + const computedStyle = getComputedStyle(element); + + return { + backdropFilter: computedStyle.backdropFilter, + backgroundColor: computedStyle.backgroundColor, + className: element.className, + webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter") + }; + }); + + expect(style.backdropFilter).toBe("none"); + expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)"); + expect(["", "none"]).toContain(style.webkitBackdropFilter); + expect(String(style.className)).not.toContain("acrylic-"); +} + +async function expectLightweightContextSurfaces(surfaces: Locator) { + const surfaceStyles = await surfaces.evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor + }; + }) + ); + + expect(surfaceStyles.length).toBeGreaterThan(0); + for (const style of surfaceStyles) { + expect(style.backdropFilter).toBe("none"); + expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12); + } +} + +async function expectSolidInternalSurfaces(surfaces: Locator) { + const surfaceStyles = await surfaces.evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element); + + return { + backdropFilter: style.backdropFilter, + className: element.className, + webkitBackdropFilter: style.getPropertyValue("-webkit-backdrop-filter") + }; + }) + ); + + expect(surfaceStyles.length).toBeGreaterThan(0); + for (const style of surfaceStyles) { + expect(style.backdropFilter).toBe("none"); + expect(["", "none"]).toContain(style.webkitBackdropFilter); + expect(String(style.className)).not.toContain("acrylic-"); + } +} diff --git a/tests/browser/agent-panel-resize.e2e.ts b/tests/browser/agent-panel-resize.e2e.ts new file mode 100644 index 0000000..0f096ff --- /dev/null +++ b/tests/browser/agent-panel-resize.e2e.ts @@ -0,0 +1,77 @@ +import { expect, test } from "playwright/test"; + +test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const panel = page.locator('aside[aria-label="Agent 命令面板"]'); + const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" }); + await expect(panel).toBeVisible(); + await expect(resizeHandle).toBeVisible(); + + const handleBox = await resizeHandle.boundingBox(); + expect(handleBox).not.toBeNull(); + + await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2, { steps: 10 }); + await page.mouse.up(); + + await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720); + + await resizeHandle.focus(); + await page.keyboard.press("Home"); + await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460); + await page.keyboard.press("ArrowRight"); + await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476); +}); + +test("collapsed Agent rail is compact and expands as one clear action", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: "折叠 Agent 面板" }).click(); + + const rail = page.locator('aside[aria-label="Agent 折叠栏"]'); + const expandButton = page.getByRole("button", { name: /展开 Agent 助手面板/ }); + await expect(rail).toBeVisible(); + await expect(rail).toHaveCSS("width", "72px"); + await expect(expandButton).toHaveAttribute("title", /当前|正在/); + + await expandButton.click(); + await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); +}); + +test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("separator", { name: "调整 Agent 面板宽度" })).toBeHidden(); +}); + +test.describe("mobile touch layout", () => { + test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } }); + + test("mobile Agent toggle opens the conversation panel by touch", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const openButton = page.getByRole("button", { name: "打开 Agent 面板" }); + const buttonBox = await openButton.boundingBox(); + expect(buttonBox).not.toBeNull(); + + await page.touchscreen.tap(buttonBox!.x + buttonBox!.width / 2, buttonBox!.y + buttonBox!.height / 2); + + await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0); + }); +}); + +test("mobile alert summary opens the Agent conversation panel", async ({ page }) => { + await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00")); + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: /查看异常处置面板/ }).click(); + await page.getByRole("button", { name: "工况汇总" }).click(); + + await expect(page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toHaveCount(0); +}); diff --git a/tests/browser/agent-streaming-stability.e2e.ts b/tests/browser/agent-streaming-stability.e2e.ts new file mode 100644 index 0000000..5464195 --- /dev/null +++ b/tests/browser/agent-streaming-stability.e2e.ts @@ -0,0 +1,601 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { expect, test } from "playwright/test"; + +const STREAM_CHUNK_SIZE = 18; +const STREAM_CHUNK_DELAY_MS = 20; +const PROGRESS_GEOMETRY_TOLERANCE_PX = 0.1; + +let streamServer: Server; +let streamServerUrl: string; +let appendProgress: (() => void) | null = null; +let failProgress: (() => void) | null = null; +let releaseStream: (() => void) | null = null; + +test.beforeAll(async () => { + streamServer = createServer((_request, response) => { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + "X-Vercel-AI-UI-Message-Stream": "v1" + }); + + const content = createLongMarkdownResponse(); + const chunks = splitIntoCodePointChunks(content, STREAM_CHUNK_SIZE); + const send = (chunk: Record) => { + response.write(`data: ${JSON.stringify(chunk)}\n\n`); + }; + + send({ type: "start", messageId: "assistant-stream-stability" }); + send({ + type: "data-progress", + id: "inspect-context", + data: { + id: "inspect-context", + phase: "planning", + status: "running", + title: "正在分析运行上下文", + detail: "核对泵站、液位与降雨过程。", + started_at: Date.now() + } + }); + send({ + type: "data-progress", + id: "inspect-rainfall", + data: { + id: "inspect-rainfall", + phase: "evidence", + status: "completed", + title: "正在核对降雨过程与汇水区响应关系", + detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。", + started_at: Date.now() + } + }); + send({ + type: "data-progress", + id: "inspect-level", + data: { + id: "inspect-level", + phase: "evidence", + status: "completed", + title: "正在核对液位连续性", + detail: "排除缺测与异常峰值。", + started_at: Date.now() + } + }); + send({ + type: "data-todo_update", + id: "stream-stability-plan", + data: { + session_id: "stream-stability-session", + message_id: "assistant-stream-stability", + created_at: Date.now(), + todos: [ + { id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" }, + { id: "inspect-level", content: "核对液位计连续性", status: "pending" }, + { id: "assess-overflow", content: "判断溢流风险", status: "pending" } + ] + } + }); + send({ type: "text-start", id: "answer" }); + + let index = 0; + let timer: ReturnType | null = null; + const sendNextTextChunk = () => { + const delta = chunks[index]; + if (delta !== undefined) { + index += 1; + send({ type: "text-delta", id: "answer", delta }); + send({ + type: "data-stream_token", + data: { + session_id: "stream-stability-session", + message_id: "assistant-stream-stability", + content: delta + }, + transient: true + }); + return; + } + + if (timer) { + clearInterval(timer); + timer = null; + } + send({ + type: "data-progress", + id: "assess-risk", + data: { + id: "assess-risk", + phase: "analysis", + status: "completed", + title: "溢流风险判断完成", + detail: "已完成风险点位排序。", + ended_at: Date.now() + } + }); + send({ type: "text-end", id: "answer" }); + send({ type: "finish", finishReason: "stop" }); + response.end(); + }; + + while (index < 3 && index < chunks.length) { + sendNextTextChunk(); + } + appendProgress = () => { + send({ + type: "data-progress", + id: "assess-risk", + data: { + id: "assess-risk", + phase: "analysis", + status: "running", + title: "正在判断溢流风险", + detail: "结合流量突变与液位趋势形成判断。", + started_at: Date.now() + } + }); + }; + + failProgress = () => { + send({ + type: "data-progress", + id: "inspect-level", + data: { + id: "inspect-level", + phase: "evidence", + status: "error", + title: "液位连续性核对失败", + detail: "监测数据读取超时。", + ended_at: Date.now() + } + }); + }; + + releaseStream = () => { + if (!timer) { + timer = setInterval(sendNextTextChunk, STREAM_CHUNK_DELAY_MS); + } + }; + + response.on("close", () => { + if (timer) { + clearInterval(timer); + } + }); + }); + + await new Promise((resolve) => streamServer.listen(0, "127.0.0.1", resolve)); + const address = streamServer.address() as AddressInfo; + streamServerUrl = `http://127.0.0.1:${address.port}/stream`; +}); + +test.afterAll(async () => { + await new Promise((resolve, reject) => { + streamServer.close((error) => (error ? reject(error) : resolve())); + }); +}); + +test("stream completion and the plan card remain positionally stable", async ({ page }) => { + await page.route("**/api/v1/agent/chat/**", async (route) => { + const path = new URL(route.request().url()).pathname; + + if (path.endsWith("/stream")) { + await route.continue({ url: streamServerUrl }); + return; + } + + const body = path.endsWith("/models") + ? { + default_model: "test/model", + models: [ + { + id: "test/model", + label: "测试模型", + description: "流式稳定性测试", + icon: "bolt" + } + ] + } + : path.endsWith("/sessions") + ? { sessions: [] } + : {}; + + await route.fulfill({ json: body }); + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect( + page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first() + ).toContainText("测试模型"); + const prompt = page + .getByPlaceholder(/输入调度问题/) + .filter({ visible: true }) + .first(); + await expect(prompt).toBeVisible(); + await prompt.fill("检查流式响应稳定性"); + const sendButton = page + .getByRole("button", { name: "发送 Agent 指令" }) + .filter({ visible: true }) + .first(); + await expect(sendButton).toBeEnabled(); + + await page.evaluate(() => { + type StreamSample = { + clientHeight: number; + planItemTransform: string | null; + planSectionTransform: string | null; + planTransform: string | null; + scrollHeight: number; + scrollTop: number; + time: number; + transform: string; + }; + + const samples: StreamSample[] = []; + const startedAt = performance.now(); + const state = window as typeof window & { + __agentStreamSamples?: StreamSample[]; + __agentStreamSamplingDone?: boolean; + }; + state.__agentStreamSamples = samples; + state.__agentStreamSamplingDone = false; + + const sample = () => { + const message = document.querySelector(".agent-panel-message"); + const wrapper = message?.parentElement?.parentElement; + const scroll = document.querySelector(".agent-conversation-scroll"); + const plan = Array.from(document.querySelectorAll(".agent-panel-nested")).find( + (element) => element.textContent?.includes("计划") + ); + const planWrapper = plan?.parentElement; + const planSection = planWrapper?.parentElement; + const planItem = plan?.querySelector("li"); + + if (message && wrapper && scroll) { + samples.push({ + clientHeight: scroll.clientHeight, + planItemTransform: planItem ? getComputedStyle(planItem).transform : null, + planSectionTransform: planSection ? getComputedStyle(planSection).transform : null, + planTransform: planWrapper ? getComputedStyle(planWrapper).transform : null, + scrollHeight: scroll.scrollHeight, + scrollTop: scroll.scrollTop, + time: performance.now() - startedAt, + transform: getComputedStyle(wrapper).transform + }); + } + + if (!state.__agentStreamSamplingDone) { + requestAnimationFrame(sample); + } + }; + + requestAnimationFrame(sample); + }); + + await sendButton.click(); + const liveProgress = page.getByRole("list", { name: "最近执行进度" }); + await expect(liveProgress.locator("li")).toHaveCount(3); + await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible(); + await expect(liveProgress).toHaveCSS("height", "156px"); + await expect.poll(() => liveProgress.evaluate((list) => list.style.height)).toBe("auto"); + + await page.evaluate(() => { + const state = window as typeof window & { + __agentMinVisibleProgressRows?: number; + __agentProgressGeometry?: { + maxContainerHeight: number; + maxListHeight: number; + minContainerHeight: number; + minListHeight: number; + }; + __agentProgressWindowSampleDone?: boolean; + }; + const startedAt = performance.now(); + state.__agentMinVisibleProgressRows = Number.POSITIVE_INFINITY; + state.__agentProgressGeometry = { + maxContainerHeight: Number.NEGATIVE_INFINITY, + maxListHeight: Number.NEGATIVE_INFINITY, + minContainerHeight: Number.POSITIVE_INFINITY, + minListHeight: Number.POSITIVE_INFINITY + }; + state.__agentProgressWindowSampleDone = false; + + const sample = () => { + const list = document.querySelector('ol[aria-label="最近执行进度"]'); + const rows = list?.children.length ?? 0; + state.__agentMinVisibleProgressRows = Math.min( + state.__agentMinVisibleProgressRows ?? rows, + rows + ); + if (list && state.__agentProgressGeometry) { + const listHeight = list.getBoundingClientRect().height; + const containerHeight = list.parentElement?.getBoundingClientRect().height ?? 0; + state.__agentProgressGeometry.minListHeight = Math.min( + state.__agentProgressGeometry.minListHeight, + listHeight + ); + state.__agentProgressGeometry.maxListHeight = Math.max( + state.__agentProgressGeometry.maxListHeight, + listHeight + ); + state.__agentProgressGeometry.minContainerHeight = Math.min( + state.__agentProgressGeometry.minContainerHeight, + containerHeight + ); + state.__agentProgressGeometry.maxContainerHeight = Math.max( + state.__agentProgressGeometry.maxContainerHeight, + containerHeight + ); + } + if (performance.now() - startedAt < 400) { + requestAnimationFrame(sample); + } else { + state.__agentProgressWindowSampleDone = true; + } + }; + + requestAnimationFrame(sample); + }); + triggerProgressAppend(); + await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible(); + await expect(liveProgress.locator("li")).toHaveCount(3); + await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { __agentProgressWindowSampleDone?: boolean }) + .__agentProgressWindowSampleDone ?? false + ) + ) + .toBe(true); + expect( + await page.evaluate( + () => + (window as typeof window & { __agentMinVisibleProgressRows?: number }) + .__agentMinVisibleProgressRows + ) + ).toBeGreaterThanOrEqual(3); + const progressGeometry = await page.evaluate( + () => + ( + window as typeof window & { + __agentProgressGeometry?: { + maxContainerHeight: number; + maxListHeight: number; + minContainerHeight: number; + minListHeight: number; + }; + } + ).__agentProgressGeometry + ); + expect(progressGeometry).toBeDefined(); + expect(progressGeometry!.maxListHeight - progressGeometry!.minListHeight).toBeLessThanOrEqual( + PROGRESS_GEOMETRY_TOLERANCE_PX + ); + expect( + progressGeometry!.maxContainerHeight - progressGeometry!.minContainerHeight + ).toBeLessThanOrEqual(PROGRESS_GEOMETRY_TOLERANCE_PX); + + const liveProgressRowHeights = await liveProgress + .locator("li") + .evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().height)); + expect(liveProgressRowHeights.every((height) => Math.abs(height - 48) <= 0.01)).toBe(true); + await expect(liveProgress.locator("li").first().locator("span.min-w-0 > span").first()).toHaveCSS( + "white-space", + "nowrap" + ); + const progressToggle = page + .getByRole("button", { name: /执行进度/ }) + .filter({ visible: true }) + .first(); + await expect(progressToggle).toBeEnabled(); + await progressToggle.click(); + const expandedLiveProgress = page.getByRole("list", { name: "全部执行进度" }); + await expect(expandedLiveProgress.locator("li")).toHaveCount(4); + await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible(); + await expect( + expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true }) + ).toHaveCSS("white-space", "normal"); + await progressToggle.click(); + await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3); + + await page.evaluate(() => { + const state = window as typeof window & { __agentFailureMotionObserved?: boolean }; + const startedAt = performance.now(); + state.__agentFailureMotionObserved = false; + + const sample = () => { + const badge = document.querySelector('[data-progress-status="error"]'); + if (badge && getComputedStyle(badge).transform !== "none") { + state.__agentFailureMotionObserved = true; + } + if (!state.__agentFailureMotionObserved && performance.now() - startedAt < 1_000) { + requestAnimationFrame(sample); + } + }; + + requestAnimationFrame(sample); + }); + triggerProgressFailure(); + await expect(liveProgress.getByText("液位连续性核对失败", { exact: true })).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { __agentFailureMotionObserved?: boolean }) + .__agentFailureMotionObserved ?? false + ) + ) + .toBe(true); + resumeStream(); + + await expect( + page.getByText("分析完成", { exact: true }).filter({ visible: true }).first() + ).toBeVisible({ + timeout: 10_000 + }); + await expect(progressToggle).toContainText("共 4 条"); + await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); + await expect( + page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }) + ).toHaveCount(0); + await expect + .poll(async () => + page.evaluate(() => { + const scroll = document.querySelector(".agent-conversation-scroll"); + return scroll + ? Math.abs(scroll.scrollHeight - scroll.clientHeight - scroll.scrollTop) + : Number.POSITIVE_INFINITY; + }) + ) + .toBeLessThanOrEqual(2); + await expect + .poll(() => + page.evaluate(() => { + const samples = ( + window as typeof window & { + __agentStreamSamples?: Array<{ + clientHeight: number; + scrollHeight: number; + scrollTop: number; + }>; + } + ).__agentStreamSamples; + const sample = samples?.at(-1); + return sample + ? Math.abs(sample.scrollHeight - sample.clientHeight - sample.scrollTop) + : Number.POSITIVE_INFINITY; + }) + ) + .toBeLessThanOrEqual(2); + await page.evaluate(() => { + (window as typeof window & { __agentStreamSamplingDone?: boolean }).__agentStreamSamplingDone = + true; + }); + + const samples = await page.evaluate( + () => + ( + window as typeof window & { + __agentStreamSamples?: Array<{ + clientHeight: number; + planItemTransform: string | null; + planSectionTransform: string | null; + planTransform: string | null; + scrollHeight: number; + scrollTop: number; + time: number; + transform: string; + }>; + } + ).__agentStreamSamples ?? [] + ); + + expect(samples.length).toBeGreaterThan(5); + const finalSample = samples.at(-1); + expect(finalSample).toBeDefined(); + expect(finalSample).toMatchObject({ + planItemTransform: "none", + planSectionTransform: "none", + planTransform: "none", + transform: "none" + }); + expect( + Math.abs(finalSample!.scrollHeight - finalSample!.clientHeight - finalSample!.scrollTop) + ).toBeLessThanOrEqual(2); + + await progressToggle.click(); + const expandedProgress = page.getByRole("list", { name: "全部执行进度" }); + await expect(expandedProgress.locator("li")).toHaveCount(4); + await expect( + page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first() + ).toBeVisible(); + const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", { + exact: true + }); + await expect(expandedLongTitle).toHaveCSS("white-space", "normal"); + await expect(expandedProgress.locator("li").nth(1)).not.toHaveCSS("height", "48px"); + + await progressToggle.click(); + await prompt.fill("验证展开状态在流结束后保持"); + await sendButton.click(); + + const latestProgressToggle = page + .getByRole("button", { name: /执行进度/ }) + .filter({ visible: true }) + .last(); + const latestProgressRegion = latestProgressToggle.locator(".."); + const latestExpandedProgress = latestProgressRegion.getByRole("list", { + name: "全部执行进度", + }); + await expect(latestProgressToggle).toContainText("最近 3 条"); + triggerProgressAppend(); + await expect( + page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true }) + ).toBeVisible(); + await latestProgressToggle.click(); + await expect(latestProgressToggle).toContainText("全部 4 条"); + resumeStream(); + + await expect(latestProgressToggle).toContainText("全部 4 条", { timeout: 10_000 }); + await expect(latestExpandedProgress).toBeVisible(); + await expect( + latestExpandedProgress.getByText("溢流风险判断完成", { exact: true }) + ).toBeVisible(); +}); + +function createLongMarkdownResponse() { + let content = "## 诊断结果\n\n"; + for (let index = 1; index <= 6; index += 1) { + content += `### 区域 ${index}\n\n`; + content += + "当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n"; + content += "- 检查泵站运行状态与实时流量。\n"; + content += "- 核对液位计连续性和异常峰值。\n"; + content += "- 结合降雨过程判断溢流风险。\n\n"; + } + return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`; +} + +function splitIntoCodePointChunks(content: string, chunkSize: number) { + const codePoints = Array.from(content); + const chunks: string[] = []; + for (let index = 0; index < codePoints.length; index += chunkSize) { + chunks.push(codePoints.slice(index, index + chunkSize).join("")); + } + return chunks; +} + +function resumeStream() { + if (!releaseStream) { + throw new Error("Stream checkpoint was not reached"); + } + + const resume = releaseStream; + releaseStream = null; + resume(); +} + +function triggerProgressAppend() { + if (!appendProgress) { + throw new Error("Progress append checkpoint was not reached"); + } + + const append = appendProgress; + appendProgress = null; + append(); +} + +function triggerProgressFailure() { + if (!failProgress) { + throw new Error("Progress failure checkpoint was not reached"); + } + + const fail = failProgress; + failProgress = null; + fail(); +} diff --git a/tests/browser/agent-voice-hydration.e2e.ts b/tests/browser/agent-voice-hydration.e2e.ts new file mode 100644 index 0000000..dbbff12 --- /dev/null +++ b/tests/browser/agent-voice-hydration.e2e.ts @@ -0,0 +1,85 @@ +import { expect, test } from "playwright/test"; + +test("speech capability detection preserves the server hydration tree", async ({ page }) => { + const hydrationErrors: string[] = []; + + page.on("console", (message) => { + if (message.type() === "error" && /hydration|server rendered html/i.test(message.text())) { + hydrationErrors.push(message.text()); + } + }); + + await page.addInitScript(() => { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + value: class SpeechRecognition {} + }); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible(); + await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible(); + expect(hydrationErrors).toEqual([]); +}); + +test("speech recognition failure clears the active animation and explains the error", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + value: class SpeechRecognition { + onerror: ((event: { error: string }) => void) | null = null; + onend: (() => void) | null = null; + + start() { + window.setTimeout(() => { + this.onerror?.({ error: "not-allowed" }); + this.onend?.(); + }, 20); + } + + stop() { + this.onend?.(); + } + + abort() {} + } + }); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: "语音输入" }).click(); + + await expect(page.getByRole("button", { name: "语音输入" })).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator('[data-slot="voice-input-pulse"]')).toHaveCount(0); + await expect(page.getByText("无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试")).toBeVisible(); +}); + +test("speech recognition writes final transcript into the prompt", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, "SpeechRecognition", { + configurable: true, + value: class SpeechRecognition { + onresult: ((event: { resultIndex: number; results: Array & { isFinal: boolean }> }) => void) | null = null; + onend: (() => void) | null = null; + + start() { + window.setTimeout(() => { + const result = Object.assign([{ transcript: "检查城南泵站水位" }], { isFinal: true }); + this.onresult?.({ resultIndex: 0, results: [result] }); + }, 20); + } + + stop() { + this.onend?.(); + } + + abort() {} + } + }); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: "语音输入" }).click(); + + await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位"); +}); diff --git a/tests/browser/map-dev-panel.e2e.ts b/tests/browser/map-dev-panel.e2e.ts new file mode 100644 index 0000000..c4e697d --- /dev/null +++ b/tests/browser/map-dev-panel.e2e.ts @@ -0,0 +1,27 @@ +import { expect, test } from "playwright/test"; + +test("Dev Panel opens on desktop, owns the right workspace, and closes with Escape", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => Boolean(window.__waterNetworkMap)); + const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" }); + await expect(trigger).toBeVisible(); + await expect(page.getByRole("navigation", { name: "地图工具" })).toBeVisible(); + await trigger.click(); + + const panel = page.getByTestId("map-dev-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toHaveCSS("width", "400px"); + await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden(); + await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible(); + await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); +}); + +test("Dev entry is absent below the desktop breakpoint", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden(); + await expect(page.getByTestId("map-dev-panel")).toHaveCount(0); +}); diff --git a/tests/browser/map-notice-position.e2e.ts b/tests/browser/map-notice-position.e2e.ts new file mode 100644 index 0000000..70ad9e5 --- /dev/null +++ b/tests/browser/map-notice-position.e2e.ts @@ -0,0 +1,19 @@ +import { expect, test } from "playwright/test"; + +test("map notice is centered in the mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: "打开用户菜单" }).click(); + await page.getByRole("menuitem", { name: /查看运行状态/ }).click(); + + const notice = page.locator('[role="status"]').filter({ hasText: "数据状态" }).first(); + await expect(notice).toBeVisible(); + + const box = await notice.boundingBox(); + expect(box).not.toBeNull(); + + const leftInset = Math.round(box!.x); + const rightInset = Math.round(375 - box!.x - box!.width); + expect(Math.abs(leftInset - rightInset)).toBeLessThanOrEqual(1); +}); diff --git a/tests/browser/scheduled-condition-acrylic.e2e.ts b/tests/browser/scheduled-condition-acrylic.e2e.ts new file mode 100644 index 0000000..5a08039 --- /dev/null +++ b/tests/browser/scheduled-condition-acrylic.e2e.ts @@ -0,0 +1,61 @@ +import { expect, test, type Locator } from "playwright/test"; + +test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const panel = page.getByRole("region", { name: "工况任务", exact: true }); + await expect(panel).toBeVisible(); + await page.waitForTimeout(250); + + await expectAcrylicComposition(panel); + await expect(panel).toHaveCSS("width", "432px"); + + await panel.getByRole("button", { name: "展开工况任务" }).click(); + await page.waitForTimeout(250); + + await expectAcrylicComposition(panel); + await expect(panel).toHaveCSS("width", "880px"); +}); + +async function expectAcrylicComposition(panel: Locator) { + const composition = await panel.evaluate((element) => { + const rootStyle = getComputedStyle(element); + const blockingAncestors: string[] = []; + const filteredDescendants: string[] = []; + + let ancestor = element.parentElement; + while (ancestor && ancestor.tagName !== "MAIN") { + const style = getComputedStyle(ancestor); + if ( + style.transform !== "none" || + style.filter !== "none" || + style.opacity !== "1" || + style.isolation !== "auto" || + style.willChange.includes("transform") + ) { + blockingAncestors.push(ancestor.className); + } + ancestor = ancestor.parentElement; + } + + for (const descendant of element.querySelectorAll("*")) { + if (getComputedStyle(descendant).backdropFilter !== "none") { + filteredDescendants.push(descendant.className); + } + } + + return { + backdropFilter: rootStyle.backdropFilter, + blockingAncestors, + filteredDescendants, + transform: rootStyle.transform, + willChange: rootStyle.willChange + }; + }); + + expect(composition.backdropFilter).toContain("blur("); + expect(composition.blockingAncestors).toEqual([]); + expect(composition.filteredDescendants).toEqual([]); + expect(composition.transform).toBe("none"); + expect(composition.willChange).toBe("auto"); +} diff --git a/tests/browser/workbench-top-bar-mobile.e2e.ts b/tests/browser/workbench-top-bar-mobile.e2e.ts new file mode 100644 index 0000000..40769c8 --- /dev/null +++ b/tests/browser/workbench-top-bar-mobile.e2e.ts @@ -0,0 +1,34 @@ +import { expect, test } from "playwright/test"; + +test("top bar uses compact controls on narrow mobile viewports", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect(page.locator('meta[name="viewport"]')).toHaveAttribute( + "content", + /width=device-width/ + ); + await expect(page.getByRole("button", { name: "切换模拟方案" })).toBeHidden(); + await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden(); + await expect(page.getByRole("button", { name: /查看异常处置面板/ })).toBeVisible(); +}); + +test("top bar keeps text horizontal at the desktop breakpoint", async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 844 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + const header = page.locator("header").first(); + await expect(header).toBeVisible(); + await expect.poll(async () => Math.round((await header.boundingBox())?.height ?? 0)).toBe(56); + + await expect.poll(async () => header.evaluate((element) => { + return Array.from(element.querySelectorAll("span")) + .filter((span) => { + const style = getComputedStyle(span); + const rect = span.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }) + .map((span) => span.textContent?.trim()) + .filter((text) => text === "任务浮条" || text === "工况任务" || text === "异常处置").length; + })).toBe(0); +}); diff --git a/tsconfig.json b/tsconfig.json index 3d1a04a..42d589c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "@/*": ["./src/*"] } }, - "include": ["src", "vite.config.ts", "eslint.config.js", "playwright.config.ts"], + "include": ["src", "server", "vite.config.ts", "eslint.config.js", "playwright.config.ts"], "references": [] } diff --git a/vite.config.ts b/vite.config.ts index 1d464fb..d3d1e8d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; import { defineConfig, loadEnv, type Plugin, type PreviewServer, type ViteDevServer } from "vite"; import { fileURLToPath, URL } from "node:url"; +import { createEdgeTtsMiddleware } from "./server/edge-tts-service"; const RUNTIME_CONFIG_PATH = "/runtime-config.js"; @@ -20,6 +21,7 @@ function renderRuntimeConfig(values: Record) { function runtimeConfigPlugin(values: Record): Plugin { const installMiddleware = (server: ViteDevServer | PreviewServer) => { + server.middlewares.use(createEdgeTtsMiddleware()); server.middlewares.use((request, response, next) => { if (request.url?.split("?", 1)[0] !== RUNTIME_CONFIG_PATH) { next(); @@ -42,8 +44,6 @@ function runtimeConfigPlugin(values: Record): Plugin { export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); - const agentBaseUrl = - env.AGENT_API_INTERNAL_BASE_URL || env.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787"; return { plugins: [runtimeConfigPlugin(env), react(), tailwindcss()], @@ -52,14 +52,6 @@ export default defineConfig(({ mode }) => { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, - server: { - proxy: { - "/api/v1/agent/chat": { - target: agentBaseUrl, - changeOrigin: true - } - } - }, test: { environment: "jsdom", globals: true,