diff --git a/.dockerignore b/.dockerignore index 526a792..3b16a3e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ node_modules +.git +.next dist +dist-server coverage playwright-report test-results diff --git a/.env.example b/.env.example index 6f2aa1e..609d64c 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,8 @@ DRAINAGE_MAPBOX_ACCESS_TOKEN= DRAINAGE_MAP_URL=https://geoserver.waternetwork.cn/geoserver DRAINAGE_GEOSERVER_WORKSPACE=wenzhou DRAINAGE_AGENT_API_BASE_URL=http://127.0.0.1:8787 -DRAINAGE_TTS_API_URL=/api/tts/edge DRAINAGE_ENABLE_DEV_PANEL=false DRAINAGE_ENABLE_MSW=false -# Optional development proxy target. This value is not exposed to browser code. -AGENT_API_INTERNAL_BASE_URL=http://127.0.0.1:8787 -TTS_API_INTERNAL_BASE_URL=http://127.0.0.1:8790 +# 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 4664e45..2a020c9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .next dist +dist-server coverage playwright-report test-results diff --git a/AGENTS.md b/AGENTS.md index 48e9967..b810c1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,12 @@ drainage-network WebGIS. Runtime TypeScript lives under `src/`. - `src/mocks/`: MSW handlers and fixtures. - `tests/browser/`: product-specific Playwright regressions. - `public/`: static drainage and SCADA assets plus the runtime-config placeholder. +- `server/`: same-origin Edge TTS adapter used by Vite and the production container. -Do not add Next.js APIs or route handlers. Backend and BFF behavior belongs in -the Agent service or another service. Browser-safe GeoServer reads may happen -directly when CORS is enabled. +Do not add Next.js APIs or general BFF routes. Agent behavior belongs in the +Agent service; the local `server/` exception is limited to the same-origin Edge +TTS network adapter. Browser-safe GeoServer reads may happen directly when CORS +is enabled. ## Commands @@ -81,9 +83,8 @@ runtime configuration, or map interactions. Name tests after behavior. ## Configuration and Security Browser configuration is injected through `/runtime-config.js` and validated in -`src/shared/config/env.ts`. Use `DRAINAGE_*` variables. Legacy -`NEXT_PUBLIC_*` values are accepted by the Vite development adapter only to ease -local migration. +`src/shared/config/env.ts`. Use `DRAINAGE_*` variables; legacy `NEXT_PUBLIC_*` +names are not supported. Do not commit secrets, `.env.local`, generated `dist/`, dependency folders, Playwright artifacts, tokens, or session dumps. Keep server-only proxy targets diff --git a/Caddyfile b/Caddyfile index 394f5dc..aa6152e 100644 --- a/Caddyfile +++ b/Caddyfile @@ -8,9 +8,12 @@ @immutableAssets path /assets/* header @immutableAssets Cache-Control "public, max-age=31536000, immutable" - @tts path /api/tts/edge - reverse_proxy @tts {$TTS_API_INTERNAL_BASE_URL:http://127.0.0.1:8790} + handle /api/tts/edge { + reverse_proxy 127.0.0.1:8790 + } - try_files {path} /index.html - file_server + handle { + try_files {path} /index.html + file_server + } } diff --git a/Dockerfile b/Dockerfile index a2e6b32..e1bece0 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/drainage-frontend-entrypoint COPY --from=build /app/dist /srv +COPY --from=build /app/dist-server/edge-tts-server.cjs /usr/local/lib/drainage-frontend/edge-tts-server.cjs RUN chmod 755 /usr/local/bin/drainage-frontend-entrypoint EXPOSE 80 diff --git a/README.md b/README.md index fedc243..376a7b4 100644 --- a/README.md +++ b/README.md @@ -26,23 +26,17 @@ pnpm dev ## 运行时配置 -浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 前缀。开发环境可在 `.env.local` 中设置: +浏览器配置通过 `/runtime-config.js` 注入,不使用 `VITE_` 或 `NEXT_PUBLIC_` 前缀。开发环境可在 `.env.local` 中设置: -| 变量 | 默认值 | 说明 | -| ------------------------------ | --------------------------------------------- | -------------------------------------- | -| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | -| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | -| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 | -| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 | -| `DRAINAGE_TTS_API_URL` | `/api/tts/edge` | 语音合成接口地址 | -| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | -| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | -| `AGENT_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8787` | Vite 开发代理访问的 Agent 服务地址 | -| `TTS_API_INTERNAL_BASE_URL` | `http://127.0.0.1:8790` | Vite/Caddy 代理访问的独立 TTS 服务地址 | - -Vite 开发适配器会兼容现有 `.env.local` 中的 `NEXT_PUBLIC_*` 变量,生产容器应使用 `DRAINAGE_*` 变量。 - -排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。Agent 流式接口使用 `DRAINAGE_AGENT_API_BASE_URL`。语音合成依赖独立 TTS 服务;默认站内路径会由 Vite 或容器内 Caddy 转发到 `TTS_API_INTERNAL_BASE_URL`。 +| 变量 | 默认值 | 说明 | +| ------------------------------ | --------------------------------------------- | ------------------------------------ | +| `DRAINAGE_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | +| `DRAINAGE_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | +| `DRAINAGE_GEOSERVER_WORKSPACE` | `wenzhou` | 排水数据工作区 | +| `DRAINAGE_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent 服务地址 | +| `DRAINAGE_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | +| `DRAINAGE_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | +排水要素定位在浏览器中直接请求 GeoServer WFS,因此部署环境需保留 GeoServer CORS。所有 Agent 请求由浏览器直接访问 `DRAINAGE_AGENT_API_BASE_URL`。语音播放请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 ## 常用命令 @@ -60,7 +54,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-drainage-frontend . diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0946ee7..21c77a1 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,7 +6,6 @@ runtime_config=$(jq -cn \ --arg mapUrl "${DRAINAGE_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ --arg geoserverWorkspace "${DRAINAGE_GEOSERVER_WORKSPACE:-wenzhou}" \ --arg agentApiBaseUrl "${DRAINAGE_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \ - --arg ttsApiUrl "${DRAINAGE_TTS_API_URL:-/api/tts/edge}" \ --arg enableDevPanel "${DRAINAGE_ENABLE_DEV_PANEL:-false}" \ --arg enableMsw "${DRAINAGE_ENABLE_MSW:-false}" \ '{ @@ -14,11 +13,26 @@ runtime_config=$(jq -cn \ DRAINAGE_MAP_URL: $mapUrl, DRAINAGE_GEOSERVER_WORKSPACE: $geoserverWorkspace, DRAINAGE_AGENT_API_BASE_URL: $agentApiBaseUrl, - DRAINAGE_TTS_API_URL: $ttsApiUrl, DRAINAGE_ENABLE_DEV_PANEL: $enableDevPanel, DRAINAGE_ENABLE_MSW: $enableMsw }') printf 'globalThis.__DRAINAGE_CONFIG__ = %s;\n' "$runtime_config" > /srv/runtime-config.js -exec "$@" +node /usr/local/lib/drainage-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 329d987..c8ec2d7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,14 @@ import globals from "globals"; export default [ { - ignores: [".next/**", "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 bf19a10..6a7dc11 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "packageManager": "pnpm@11.8.0", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview --host 0.0.0.0", + "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", 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/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 5cb0478..c621b74 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -17,12 +17,13 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { expect(runtimeConfig).toMatchObject({ DRAINAGE_MAP_URL: expect.any(String), DRAINAGE_GEOSERVER_WORKSPACE: expect.any(String), - DRAINAGE_AGENT_API_BASE_URL: expect.any(String), - DRAINAGE_TTS_API_URL: expect.any(String) + DRAINAGE_AGENT_API_BASE_URL: expect.any(String) }); expect(Object.keys(runtimeConfig as Record)).not.toContainEqual( expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/) ); + expect(runtimeConfig).not.toHaveProperty("DRAINAGE_TTS_API_URL"); + expect(runtimeConfig).not.toHaveProperty("EDGE_TTS_VOICE"); const mapAsset = await page.request.get("/icons/scada-integrated-monitoring.png"); expect(mapAsset.ok()).toBe(true); @@ -71,7 +72,9 @@ test("preserves compact typography in header and agent controls", async ({ page await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); - const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first(); + const headerControl = page + .locator("header.acrylic-navigation button.font-semibold:visible") + .first(); const approvalControl = page.getByRole("button", { name: "权限批准模式" }); const suggestion = page.locator("[aria-label=推荐问题] button").first(); diff --git a/src/features/agent/components/agent-collapsed-rail.tsx b/src/features/agent/components/agent-collapsed-rail.tsx index 994759c..b06774a 100644 --- a/src/features/agent/components/agent-collapsed-rail.tsx +++ b/src/features/agent/components/agent-collapsed-rail.tsx @@ -17,7 +17,7 @@ export function AgentCollapsedRail({ statusLabel = "Agent 已就绪", onExpand }: AgentCollapsedRailProps) { - const expandLabel = `展开排水助手面板,当前状态:${statusLabel}`; + const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`; return (