diff --git a/.env.example b/.env.example index f2656b7..fc467c7 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ # TypeSafe AI System One (Jev) API key。 # 复制本文件为 .env 并填入真实 key;缺失时前端自动降级为 MockJevClient(确定性伪随机返分)。 VITE_TYPESAFE_API_KEY= + +# 后端代理地址(见 server/ 子包)。配置后前端走代理、不再需要上面的 key; +# 未配置时回退为直连模式(浏览器可能遇到 CORS)。 +# VITE_JEV_PROXY_URL=http://localhost:8787 +VITE_JEV_PROXY_URL= diff --git a/README.md b/README.md index 8d14971..27e7b9a 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,21 @@ src-tauri/ Tauri 2 壳(iOS/Android/桌面共用) NPC 口吻追问,至多 1-2 句),MVP 未实现。 - **美术全部占位**:CSS 色块 + 渐变,无图片/视频素材;cutscene 节点是占位渲染。 - **自适应支线路由未实现**:story-framework 第 6 节的低置信度维度插支线属于后续章节工作。 + +## 后端代理 + +`server/` 是独立子包(Node 22 + Hono,自己的 lockfile,与前端互不干扰): + +```bash +pnpm --dir server install +cp server/.env.example server/.env # 填 TYPESAFE_API_KEY(key 只在服务端) +pnpm --dir server dev # 默认 http://localhost:8787 +pnpm --dir server smoke # 冒烟:代理调真实 Jev + /api/asr 自测 +``` + +- `POST /api/jev` 透传 systemone(状态码原样返回),解决浏览器直连的 CORS 问题; + 前端在根 `.env` 设 `VITE_JEV_PROXY_URL=http://localhost:8787` 即切代理模式, + 此时前端不需要 `VITE_TYPESAFE_API_KEY`,未配置时回退直连(见 `src/services/jev.ts`)。 +- `POST /api/asr` 为移动端 ASR 预留:provider 可插拔(当前 Mock 占位),音频遵守 + ADR-0004"特征提取后即焚"不落盘,返回 `{ transcript, prosody }`。 +- 详见 `server/README.md`。 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..4fdad58 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,6 @@ +# TypeSafe AI System One (Jev) API key,服务端专用。 +# 复制本文件为 .env 并填入真实 key;key 只存在于服务端,绝不下发到前端。 +TYPESAFE_API_KEY= + +# 代理服务监听端口(默认 8787) +# PORT=8787 diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..a9528d0 --- /dev/null +++ b/server/README.md @@ -0,0 +1,56 @@ +# 后端代理层(server/) + +为浏览器/Tauri 前端代理 TypeSafe AI(Jev)与 ASR 请求。key 只存在于服务端, +前端不需要 `VITE_TYPESAFE_API_KEY`,也解决了浏览器直连 `api.typesafe.ai` 的 CORS 问题。 + +独立子包:自己的 `package.json` 与 lockfile,与根仓库前端互不干扰。Node ≥ 22。 + +## 怎么跑 + +```bash +pnpm --dir server install +cp server/.env.example server/.env # 填入 TYPESAFE_API_KEY +pnpm --dir server dev # tsx watch,默认 http://localhost:8787 +pnpm --dir server smoke # 冒烟:代理调一次真实 Jev + /api/asr 自测 +``` + +前端侧在根 `.env` 里加一行即切换到代理模式(此时前端无需 key): + +``` +VITE_JEV_PROXY_URL=http://localhost:8787 +``` + +## 环境变量 + +| 变量 | 说明 | +| --- | --- | +| `TYPESAFE_API_KEY` | Jev API key,**只在服务端**,绝不下发前端、不打印日志 | +| `PORT` | 监听端口,默认 `8787` | + +## 路由 + +- `POST /api/jev`:请求体原样透传到 `https://api.typesafe.ai/v1/systemone`, + 上游状态码(401/422/429/529 等)与响应体原样返回。 +- `POST /api/asr`:multipart/form-data,文件字段名 `audio`,返回 + `{ transcript, prosody }`。`prosody` 暂为空数组占位,后续接声学特征提取 + (语速/停顿/音高/能量等,见 ADR-0004)。 +- `GET /healthz`:存活检查。 + +CORS 仅放行 `http://localhost:1420`(Tauri dev)与 `http://localhost:5173`(Vite dev)。 + +## 接入真实 ASR provider + +当前 `/api/asr` 走 `MockAsrProvider`(占位转写)。换真实 provider 只需两步: + +1. 在 `src/asr/provider.ts` 新增实现 `AsrProvider` 接口的类,输入是 + `input.data`(内存 buffer,**不要写盘**——ADR-0004"特征提取后即焚"): + - **阿里云 NLS**:一句话识别 RESTful API,`input.data` 直接作请求体, + appkey/token 从 `process.env` 读(加到 `server/.env`); + - **讯飞语音听写**:WebSocket/REST 端点,同样以 `input.data` 为输入; + - **Whisper 兼容端点**(OpenAI / Groq / 自托管 faster-whisper): + `fetch(url, { method: 'POST', body: FormData(file=Blob(input.data)) })`, + 取返回 JSON 的 `text` 字段。 +2. 在 `src/routes/asr.ts` 把 `new MockAsrProvider()` 换成新 provider 实例。 + +韵律特征提取(ADR-0004 的 (b) 类产物)接入点同上:在 provider 内对 +`input.data` 提取统计向量填入 `prosody`,音频 buffer 在请求结束后即弃。 diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..d2d7262 --- /dev/null +++ b/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "jev-dating-match-server", + "private": true, + "version": "0.0.0", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "smoke": "tsx scripts/smoke.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.19.0", + "hono": "^4.10.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.20.0", + "typescript": "^5.7.0" + } +} diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 0000000..93f4b6b --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,352 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.12) + hono: + specifier: ^4.10.0 + version: 4.13.12 + devDependencies: + '@types/node': + specifier: ^22.10.0 + version: 22.20.5 + tsx: + specifier: ^4.20.0 + version: 4.23.15 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.13.12: + resolution: {integrity: sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==} + engines: {node: '>=16.9.0'} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/node-server@1.19.17(hono@4.13.12)': + dependencies: + hono: 4.13.12 + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + fsevents@2.3.3: + optional: true + + hono@4.13.12: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/server/scripts/smoke.ts b/server/scripts/smoke.ts new file mode 100644 index 0000000..cd66b9e --- /dev/null +++ b/server/scripts/smoke.ts @@ -0,0 +1,76 @@ +/** + * 代理冒烟(pnpm --dir server smoke): + * 通过本地代理调一次真实 Jev(1 个 score 问题,控制 token), + * 再用一段程序生成的 WAV 测 /api/asr(Mock provider)。 + * key 只存在于服务端,本脚本不接触、不打印 key。 + * 先启动服务:pnpm --dir server dev + */ +const BASE = process.env.PROXY_URL ?? 'http://localhost:8787'; + +// --- 1. /api/jev:1 个 score 问题 --- +const jevRes = await fetch(`${BASE}/api/jev`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'jev-latest', + state: '用户在互动剧情中选择「直接进城,赌一把」,并向同伴坦言「我们是去记录的,不是去拿的」。', + questions: { + risk: { + type: 'score', + instructions: '根据用户行为评估其「风险偏好」维度水平', + criteria: ['非常保守', '较为保守', '中等', '较为冒险', '非常冒险'], + }, + }, + }), +}); +console.log(`[1] POST /api/jev → ${jevRes.status}`); +const jevBody = (await jevRes.json()) as { + answers?: Record; + usage?: { input_tokens: number; output_tokens: number }; +}; +if (!jevRes.ok) { + console.error(' 上游错误:', JSON.stringify(jevBody)); + process.exit(1); +} +const risk = jevBody.answers?.risk; +console.log(` risk: score=${risk?.score} confidence=${risk?.confidence}`); +console.log(` usage: input=${jevBody.usage?.input_tokens} output=${jevBody.usage?.output_tokens} tokens`); + +// --- 2. /api/asr:程序生成的 0.5s 静音 WAV,走 Mock provider --- +const wav = makeSilentWav(0.5); +const form = new FormData(); +form.append('audio', new Blob([wav], { type: 'audio/wav' }), 'smoke.wav'); +const asrRes = await fetch(`${BASE}/api/asr`, { method: 'POST', body: form }); +console.log(`[2] POST /api/asr → ${asrRes.status}`); +console.log(' ', JSON.stringify(await asrRes.json())); + +// --- 3. CORS 预检:模拟 Tauri 开发端口来源 --- +const preflight = await fetch(`${BASE}/api/jev`, { + method: 'OPTIONS', + headers: { Origin: 'http://localhost:1420', 'Access-Control-Request-Method': 'POST' }, +}); +console.log(`[3] OPTIONS /api/jev (Origin: localhost:1420) → ${preflight.status}, allow-origin: ${preflight.headers.get('access-control-allow-origin')}`); + +console.log('\n冒烟完成'); + +/** 生成一段 16-bit PCM 单声道静音 WAV(44 字节头 + 数据) */ +function makeSilentWav(seconds: number): Uint8Array { + const rate = 16000; + const n = Math.floor(rate * seconds); + const buf = new ArrayBuffer(44 + n * 2); + const v = new DataView(buf); + const writeStr = (off: number, s: string) => [...s].forEach((ch, i) => v.setUint8(off + i, ch.charCodeAt(0))); + writeStr(0, 'RIFF'); + v.setUint32(4, 36 + n * 2, true); + writeStr(8, 'WAVEfmt '); + v.setUint32(16, 16, true); + v.setUint16(20, 1, true); // PCM + v.setUint16(22, 1, true); // mono + v.setUint32(24, rate, true); + v.setUint32(28, rate * 2, true); + v.setUint16(32, 2, true); + v.setUint16(34, 16, true); + writeStr(36, 'data'); + v.setUint32(40, n * 2, true); + return new Uint8Array(buf); +} diff --git a/server/src/asr/provider.ts b/server/src/asr/provider.ts new file mode 100644 index 0000000..f0f6cb0 --- /dev/null +++ b/server/src/asr/provider.ts @@ -0,0 +1,49 @@ +/** + * ASR provider 可插拔接口(ADR-0004:音频"特征提取后即焚")。 + * + * 音频只存在于处理窗口内的内存 buffer,绝不落盘、绝不入库。 + * 删除前允许提取两类产物: + * (a) transcript —— ASR 转写文本; + * (b) prosody —— 韵律/声学特征(语速、停顿分布、音高变化范围、能量、 + * 填充词频率、情绪唤醒度等),以不可逆统计向量形式存储, + * 不含声纹。当前为占位空数组,后续接声学特征提取管线。 + */ +export interface AsrInput { + /** 音频字节,仅存活于本次请求的处理窗口内 */ + data: Uint8Array; + contentType: string; + filename?: string; +} + +export interface AsrResult { + transcript: string; + /** 韵律特征占位:后续接声学特征提取后填充(见 ADR-0004"敏感数据处理") */ + prosody: unknown[]; +} + +export interface AsrProvider { + transcribe(input: AsrInput): Promise; +} + +/** + * 占位实现:不做真实识别,返回固定占位文本,用于联调前后端管线。 + * + * === 接入真实 provider 只需替换本类 === + * - 阿里云 NLS(一句话识别/录音文件识别):在 transcribe() 内用 + * input.data 调 NLS RESTful API(appkey + token 从 server/.env 读取), + * 把返回的 result 字段填入 transcript; + * - 讯飞语音听写:改用讯飞 WebSocket/REST 端点,同样以 input.data 为输入; + * - Whisper 兼容端点(OpenAI / Groq / 自托管 faster-whisper): + * fetch(WHISPER_URL, { method: 'POST', body: FormData(file=Blob(input.data)) }), + * 取 JSON 的 text 字段。 + * 三种接入的输入都是 input.data(内存 buffer),都不需要把音频写盘—— + * 保持 ADR-0004 的"即焚"语义。 + */ +export class MockAsrProvider implements AsrProvider { + async transcribe(input: AsrInput): Promise { + return { + transcript: `[mock-asr] 占位转写(收到 ${input.data.byteLength} 字节 ${input.contentType || '未知类型'} 音频,未做真实识别)`, + prosody: [], // 占位:后续接声学特征提取(语速/停顿/音高/能量等) + }; + } +} diff --git a/server/src/env.ts b/server/src/env.ts new file mode 100644 index 0000000..cc35a46 --- /dev/null +++ b/server/src/env.ts @@ -0,0 +1,20 @@ +/** + * 极简 .env 加载(不引 dotenv,与根仓库 scripts/verify-live.ts 风格一致)。 + * 只解析 KEY=VALUE 行,忽略注释与空行;任何输出都不打印 value。 + */ +import { readFileSync } from 'node:fs'; + +export function loadEnvFile(path: URL): void { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return; // .env 不存在时静默跳过(允许用真实环境变量注入) + } + for (const line of raw.split('\n')) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/); + if (m && !m[2].startsWith('#') && process.env[m[1]] === undefined) { + process.env[m[1]] = m[2]; + } + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..f1e134f --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,33 @@ +/** + * 后端代理层入口(Node 22 + Hono)。 + * - POST /api/jev 透传 TypeSafe AI systemone(key 只在服务端) + * - POST /api/asr 音频转写占位(provider 可插拔,音频即焚) + * CORS 仅放行 Vite 开发端口 5173 与 Tauri 开发端口 1420。 + */ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { loadEnvFile } from './env.js'; +import { jevRoute } from './routes/jev.js'; +import { asrRoute } from './routes/asr.js'; + +loadEnvFile(new URL('../.env', import.meta.url)); + +const app = new Hono(); + +app.use( + '*', + cors({ + origin: ['http://localhost:1420', 'http://localhost:5173'], + }), +); + +app.route('/api/jev', jevRoute); +app.route('/api/asr', asrRoute); +app.get('/healthz', (c) => c.json({ ok: true })); + +const port = Number(process.env.PORT ?? 8787); +serve({ fetch: app.fetch, port }, (info) => { + console.log(`[server] 代理已启动: http://localhost:${info.port}`); + console.log(`[server] TYPESAFE_API_KEY ${process.env.TYPESAFE_API_KEY ? '已加载(内容不打印)' : '未配置,/api/jev 将返回 503'}`); +}); diff --git a/server/src/routes/asr.ts b/server/src/routes/asr.ts new file mode 100644 index 0000000..8bd681f --- /dev/null +++ b/server/src/routes/asr.ts @@ -0,0 +1,30 @@ +/** + * ASR 路由:POST /api/asr(multipart/form-data,文件字段名 "audio")。 + * 为移动端 ASR 预留(Tauri WKWebView 不支持 Web Speech API 的环境走这里)。 + * + * ADR-0004 约束:音频处理完即弃—— + * 只在内存中传给 provider,不写临时文件、不进任何存储; + * 响应只含 { transcript, prosody },不含音频或可还原声音的内容。 + */ +import { Hono } from 'hono'; +import { MockAsrProvider, type AsrProvider } from '../asr/provider.js'; + +// 接入真实 provider 时改为 new AliyunNlsProvider() / new WhisperProvider(...) 等, +// 构造参数(appkey、token、endpoint)从 process.env 读取。 +const provider: AsrProvider = new MockAsrProvider(); + +export const asrRoute = new Hono().post('/', async (c) => { + const form = await c.req.parseBody(); + const file = form['audio']; + if (!(file instanceof File)) { + return c.json({ error: '缺少 multipart 文件字段 "audio"' }, 400); + } + const data = new Uint8Array(await file.arrayBuffer()); + // data 仅在本请求作用域内存活,函数返回后由 GC 回收;不落盘(ADR-0004)。 + const result = await provider.transcribe({ + data, + contentType: file.type, + filename: file.name, + }); + return c.json(result); +}); diff --git a/server/src/routes/jev.ts b/server/src/routes/jev.ts new file mode 100644 index 0000000..40959cc --- /dev/null +++ b/server/src/routes/jev.ts @@ -0,0 +1,31 @@ +/** + * Jev 代理路由:POST /api/jev + * 透传 systemone 请求到 https://api.typesafe.ai/v1/systemone, + * key 从服务端环境变量 TYPESAFE_API_KEY 读取(不下发到前端)。 + * 上游状态码(401/422/429/529 等)与响应体原样透传回前端。 + * key 永不写入日志或响应。 + */ +import { Hono } from 'hono'; + +const UPSTREAM_URL = 'https://api.typesafe.ai/v1/systemone'; + +export const jevRoute = new Hono().post('/', async (c) => { + const apiKey = process.env.TYPESAFE_API_KEY; + if (!apiKey) { + return c.json({ error: 'server 未配置 TYPESAFE_API_KEY(见 server/README.md)' }, 503); + } + const body = await c.req.text(); // 请求体原样透传,不解析不修改 + const upstream = await fetch(UPSTREAM_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body, + }); + const text = await upstream.text(); + return new Response(text, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('content-type') ?? 'application/json' }, + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..68204c0 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "scripts"] +} diff --git a/src/services/jev.ts b/src/services/jev.ts index e760105..c194540 100644 --- a/src/services/jev.ts +++ b/src/services/jev.ts @@ -64,21 +64,30 @@ export interface JevClient { const API_URL = 'https://api.typesafe.ai/v1/systemone'; -/** 直连 REST 的实现。浏览器调用可能遇到 CORS,正式环境需走后端代理(见 README)。 */ +/** + * REST 实现,两种模式: + * - 代理模式(优先):传 proxyUrl,请求发到 `${proxyUrl}/api/jev`,key 在服务端, + * 前端不带 Authorization(见 server/ 子包); + * - 直连模式(fallback):传 apiKey,直连 api.typesafe.ai,浏览器可能遇到 CORS。 + */ export class HttpJevClient implements JevClient { - private apiKey: string; + private apiKey?: string; + private proxyUrl?: string; - constructor(apiKey: string) { + constructor(apiKey?: string, proxyUrl?: string) { this.apiKey = apiKey; + this.proxyUrl = proxyUrl; } async ask(state: unknown, questions: Record): Promise> { - const res = await fetch(API_URL, { + const url = this.proxyUrl ? `${this.proxyUrl}/api/jev` : API_URL; + const headers: Record = { 'Content-Type': 'application/json' }; + if (!this.proxyUrl && this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}`; + } + const res = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify({ model: 'jev-latest', state, questions } satisfies JevRequest), }); if (!res.ok) { @@ -156,14 +165,17 @@ export class MockJevClient implements JevClient { let singleton: JevClient | null = null; -/** key 缺失时自动降级 Mock 并提示。 */ +/** 有代理走代理,有 key 直连,两者都缺时降级 Mock 并提示。 */ export function getJevClient(): JevClient { if (singleton) return singleton; + const proxyUrl = import.meta.env.VITE_JEV_PROXY_URL; const key = import.meta.env.VITE_TYPESAFE_API_KEY; - if (key) { + if (proxyUrl) { + singleton = new HttpJevClient(undefined, proxyUrl); + } else if (key) { singleton = new HttpJevClient(key); } else { - console.warn('[jev] 未配置 VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)'); + console.warn('[jev] 未配置 VITE_JEV_PROXY_URL / VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)'); singleton = new MockJevClient(); } return singleton;