feat: align frontend runtime and Edge TTS

This commit is contained in:
2026-07-22 15:01:25 +08:00
parent d2c278f0ea
commit 699a0bced4
43 changed files with 2000 additions and 73 deletions
+4
View File
@@ -1,8 +1,12 @@
node_modules
.git
.next
dist
dist-server
coverage
playwright-report
test-results
.env
.env.*
*.log
.DS_Store
+2 -2
View File
@@ -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
+3
View File
@@ -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
+4 -2
View File
@@ -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.
+8 -2
View File
@@ -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
}
}
+2 -1
View File
@@ -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
+4 -3
View File
@@ -24,7 +24,7 @@ cp .env.example .env.local
pnpm dev
```
开发服务器默认监听所有网络接口。打开终端中 Vite 输出的地址即可访问工作台
开发服务器默认地址为 <http://127.0.0.1:5173>
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 .
+17 -1
View File
@@ -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"
+8 -1
View File
@@ -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,
{
+11 -3
View File
@@ -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"
]
}
}
+13 -7
View File
@@ -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 }
}
}
]
});
+47
View File
@@ -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:
+361
View File
@@ -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<Client | undefined>}
*/
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<Response>}
*/
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<Transferable>} transferrables
* @returns {Promise<any>}
*/
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,
}
}
+24
View File
@@ -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);
+102
View File
@@ -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<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((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<void>((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<EdgeTtsSynthesizer>(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<EdgeTtsSynthesizer>(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<EdgeTtsSynthesizer>(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<Uint8Array>((resolve) => {
releaseFirst = resolve;
});
const synthesize = vi.fn<EdgeTtsSynthesizer>(() => 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<EdgeTtsSynthesizer>(() => new Promise(() => undefined));
const baseUrl = await startServer({ synthesize, timeoutMs: 5 });
expect((await postSpeech(baseUrl, { text: "超时测试" })).status).toBe(504);
});
});
+210
View File
@@ -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<Uint8Array>;
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<EdgeTtsPayload> {
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<Uint8Array> {
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<string, RateBucket>();
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<typeof setTimeout> | undefined;
try {
const audio = await Promise.race([
synthesisTask,
new Promise<never>((_, 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);
}
};
}
+3 -3
View File
@@ -22,13 +22,13 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
expect(Object.keys(runtimeConfig as Record<string, unknown>)).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);
});
@@ -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 (
<aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5",
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
)}
>
<button
type="button"
aria-label="展开排水助手面板"
title="展开排水助手面板"
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
>
<AgentPersona className="h-12 w-12" state={personaState} />
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg border-0! transition-transform group-hover:translate-x-0.5">
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
</span>
</button>
</aside>
@@ -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,
@@ -58,7 +58,11 @@ export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
const shouldRenderAnimation = !failed && visible;
return (
<span ref={containerRef} className={cn("relative block shrink-0", className)}>
<span
ref={containerRef}
aria-label="Agent 状态"
className={cn("relative block shrink-0", className)}
>
{shouldRenderAnimation ? (
<Suspense fallback={<PersonaFallback />}>
<LazyPersona
@@ -20,6 +20,13 @@ describe("toTrustedMapAction", () => {
})
).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();
});
});
+30 -7
View File
@@ -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<string, string> = {
};
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 = [
@@ -152,7 +152,7 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}></button>
</PanelSection>
<PanelSection title="全网水流" description="按管拓扑方向显示静态或动画流纹。">
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
<div className="grid grid-cols-2 gap-2">
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}></button>
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}></button>
@@ -124,7 +124,7 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
{
id: "calculate-energy",
title: "计算单位电耗",
detail: "按当前窗口水量折算单位水电耗和效率偏差。"
detail: "按当前窗口水量折算单位水电耗和效率偏差。"
},
{
id: "compare-efficiency",
@@ -92,7 +92,7 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
{
id: "pump-energy",
title: "泵站能耗检查",
summary: "检查泵组负载、单位水电耗、启停频率和供压稳定性。",
summary: "检查泵组负载、单位水电耗、启停频率和供压稳定性。",
intervalMinutes: 60,
durationMinutes: 10
}
@@ -709,16 +709,16 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
return [
{
id: "energy-deviation",
label: "单位水电耗偏差",
label: "单位水电耗偏差",
value: energyDeviation.toString(),
unit: "%",
threshold: "关注 > 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 [
"关注单位水电耗和启停频率,判断是否存在泵组组合不经济。",
"关注单位水电耗和启停频率,判断是否存在泵组组合不经济。",
"若电耗偏差持续升高,建议比较相邻泵组效率曲线并优化启停策略。",
"调整前需确认供压稳定性,避免节能动作引发末端低压。"
];
@@ -23,5 +23,5 @@ export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
export const WORKBENCH_USER: WorkbenchUser = {
name: "调度员",
role: "水运行调度中心"
role: "水运行调度中心"
};
@@ -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<AgentUiMessage>({
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: {
@@ -902,7 +902,7 @@ export function MapWorkbenchPage() {
ref={mapContainerRef}
className="map-grid"
style={{ position: "absolute", inset: 0 }}
aria-label="水管网地图"
aria-label="水管网地图"
/>
<WorkbenchTopBar
@@ -969,7 +969,11 @@ export function MapWorkbenchPage() {
</div>
</>
) : (
<AgentCollapsedRail personaState={agent.personaState} onExpand={agent.expandPanel} />
<AgentCollapsedRail
personaState={agent.personaState}
statusLabel={agent.statusLabel}
onExpand={agent.expandPanel}
/>
)}
</div>
@@ -33,7 +33,7 @@ const PROPERTY_LABELS: Record<string, string> = {
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<string, string> = {
device_icon_code: "设备图标编码",
active: "运行状态",
external_id: "设备编号",
junction_id: "关联检查井",
junction_id: "关联节点",
kind: "设备类型",
location_source: "位置来源",
site_external_id: "站点编号",
@@ -98,7 +98,7 @@ export function createAlertQueueConversationPrompt({
});
return [
"请作为水管网调度 Agent,汇总当前待复核工况队列。",
"请作为水管网调度 Agent,汇总当前待复核工况队列。",
"",
"目标:",
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
+8
View File
@@ -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();
});
});
+19 -3
View File
@@ -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)
});
+35
View File
@@ -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([]);
});
});
+111
View File
@@ -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-");
}
}
+77
View File
@@ -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);
});
@@ -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<string, unknown>) => {
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<typeof setInterval> | 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<void>((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<void>((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<HTMLElement>(".agent-panel-message");
const wrapper = message?.parentElement?.parentElement;
const scroll = document.querySelector<HTMLElement>(".agent-conversation-scroll");
const plan = Array.from(document.querySelectorAll<HTMLElement>(".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<HTMLOListElement>('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<HTMLElement>('[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<HTMLElement>(".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();
}
@@ -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<Array<{ transcript: string }> & { 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("检查城南泵站水位");
});
+27
View File
@@ -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);
});
+19
View File
@@ -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);
});
@@ -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");
}
@@ -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);
});
+1 -1
View File
@@ -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": []
}
+2 -10
View File
@@ -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<string, string>) {
function runtimeConfigPlugin(values: Record<string, string>): 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<string, string>): 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,