feat: 后端代理层(Jev 透传 + ASR 预留端点),前端支持代理模式
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user