feat: 后端代理层(Jev 透传 + ASR 预留端点),前端支持代理模式

This commit is contained in:
Jayway Liu
2026-10-03 12:55:00 +08:00
parent bed4d734ca
commit 6117ef894f
15 changed files with 740 additions and 11 deletions
+5
View File
@@ -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=
+18
View File
@@ -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`。
+6
View File
@@ -0,0 +1,6 @@
# TypeSafe AI System One (Jev) API key,服务端专用。
# 复制本文件为 .env 并填入真实 key;key 只存在于服务端,绝不下发到前端。
TYPESAFE_API_KEY=
# 代理服务监听端口(默认 8787)
# PORT=8787
+2
View File
@@ -0,0 +1,2 @@
node_modules
.env
+56
View File
@@ -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 在请求结束后即弃。
+24
View File
@@ -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"
}
}
+352
View File
@@ -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: {}
+76
View File
@@ -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<string, { type: string; score?: number; confidence?: number }>;
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);
}
+49
View File
@@ -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<AsrResult>;
}
/**
* 占位实现:不做真实识别,返回固定占位文本,用于联调前后端管线。
*
* === 接入真实 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<AsrResult> {
return {
transcript: `[mock-asr] 占位转写(收到 ${input.data.byteLength} 字节 ${input.contentType || '未知类型'} 音频,未做真实识别)`,
prosody: [], // 占位:后续接声学特征提取(语速/停顿/音高/能量等)
};
}
}
+20
View File
@@ -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];
}
}
}
+33
View File
@@ -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'}`);
});
+30
View File
@@ -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);
});
+31
View File
@@ -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' },
});
});
+15
View File
@@ -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"]
}
+23 -11
View File
@@ -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<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
const res = await fetch(API_URL, {
const url = this.proxyUrl ? `${this.proxyUrl}/api/jev` : API_URL;
const headers: Record<string, string> = { '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;