feat: 后端代理层(Jev 透传 + ASR 预留端点),前端支持代理模式
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# TypeSafe AI System One (Jev) API key,服务端专用。
|
||||
# 复制本文件为 .env 并填入真实 key;key 只存在于服务端,绝不下发到前端。
|
||||
TYPESAFE_API_KEY=
|
||||
|
||||
# 代理服务监听端口(默认 8787)
|
||||
# PORT=8787
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
@@ -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 在请求结束后即弃。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+352
@@ -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: {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: [], // 占位:后续接声学特征提取(语速/停顿/音高/能量等)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user