/** * Jev 服务层 —— TypeSafe AI 的 System One 模型。 * REST: POST https://api.typesafe.ai/v1/systemone * Header: Authorization: Bearer * 请求: { model: "jev-latest", state: <文本或JSON>, questions: { : Question } } * * 以下 schema 按真实接口 curl 实测对齐(2026-10-03): * - Question 的文本字段是 instructions; * - choice 的 criteria 是 { 选项: 描述 } 的 map(选项数 ≤255); * - score 的 criteria 是 2-10 级有序数组,索引从 0 开始(min=0, max=criteria.length-1); * - score 答案是概率加权浮点值,可落在两级之间;legend 为 { 索引字符串: 等级描述 }。 * * 实测响应样例: * {"model":"jev-1.13.0","answers":{"risk":{"type":"score","score":1.46,"confidence":0.62, * "legend":{"0":"非常保守",...},"probabilities":{"0":0.0,"1":0.55,...}}, * "hobby":{"type":"choice","choice":"outdoor","confidence":0.99, * "probabilities":{"social":0.0,"outdoor":0.99,...}}}, * "usage":{"input_tokens":446,"output_tokens":57}} */ /** 是非概率问题 */ export interface NoulQuestion { type: 'noul'; instructions: string; } /** 选择题(≤255 选项),criteria 为 { 选项: 描述 } 的 map */ export interface ChoiceQuestion { type: 'choice'; instructions: string; criteria: Record; } /** 打分题,criteria 为 2-10 级有序数组(索引 0 起,由低到高) */ export interface ScoreQuestion { type: 'score'; instructions: string; criteria: string[]; } export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion; export interface JevRequest { model: 'jev-latest'; state: unknown; questions: Record; } export type JevAnswer = | { type: 'noul'; probability: number; confidence: number } | { type: 'choice'; choice: string; probabilities: Record; confidence: number } | { type: 'score'; /** 概率加权浮点分数,范围 [0, criteria.length-1],可落在两级之间 */ score: number; confidence: number; legend: Record; probabilities: Record; }; export interface JevClient { adaptiveScoring?: boolean; ask(state: unknown, questions: Record): Promise>; } const API_URL = 'https://api.typesafe.ai/v1/systemone'; /** * REST 实现,两种模式: * - 代理模式(优先):传 proxyUrl,请求发到 `${proxyUrl}/api/jev`,key 在服务端, * 前端不带 Authorization(见 server/ 子包); * - 直连模式(fallback):传 apiKey,直连 api.typesafe.ai,浏览器可能遇到 CORS。 */ export class HttpJevClient implements JevClient { private apiKey?: string; private proxyUrl?: string; constructor(apiKey?: string, proxyUrl?: string) { this.apiKey = apiKey; this.proxyUrl = proxyUrl; } async ask(state: unknown, questions: Record): Promise> { 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, { signal: AbortSignal.timeout(15000), method: 'POST', headers, body: JSON.stringify({ model: 'jev-latest', state, questions } satisfies JevRequest), }); if (!res.ok) { throw new Error(`Jev API ${res.status}: ${await res.text()}`); } const body = (await res.json()) as { answers: Record }; return body.answers; } } /** 简单确定性 hash(FNV-1a),Mock 用它让同一 (state, question) 永远得到同一答案。 */ function hash(input: string): number { let h = 0x811c9dc5; for (let i = 0; i < input.length; i++) { h ^= input.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } /** 由 hash 生成 [0,1) 的确定性伪随机序列 */ function prng(seed: number, n: number): number[] { const out: number[] = []; let s = seed; for (let i = 0; i < n; i++) { s = hash(String(s) + ':' + i); out.push(s / 0xffffffff); } return out; } /** 无 key 开发用:确定性伪随机返分(同一输入永远同一输出,输出形状与真实 API 对齐)。 */ export class MockJevClient implements JevClient { adaptiveScoring = true; async ask(state: unknown, questions: Record): Promise> { const stateKey = JSON.stringify(state) ?? ''; const answers: Record = {}; for (const [id, q] of Object.entries(questions)) { const seed = hash(stateKey + '|' + id); const confidence = Math.round((0.35 + prng(seed, 1)[0] * 0.6) * 100) / 100; if (q.type === 'noul') { answers[id] = { type: 'noul', probability: Math.round(prng(seed, 2)[1] * 100) / 100, confidence }; } else if (q.type === 'choice') { const options = Object.keys(q.criteria); const r = prng(seed, options.length); const total = r.reduce((a, b) => a + b, 0) || 1; const probabilities: Record = {}; let choice = options[0]; let best = -1; options.forEach((opt, i) => { probabilities[opt] = Math.round((r[i] / total) * 100) / 100; if (r[i] > best) { best = r[i]; choice = opt; } }); answers[id] = { type: 'choice', choice, probabilities, confidence }; } else { const r = prng(seed, q.criteria.length); const total = r.reduce((a, b) => a + b, 0) || 1; const probabilities: Record = {}; const legend: Record = {}; let score = 0; q.criteria.forEach((level, i) => { const p = r[i] / total; probabilities[String(i)] = Math.round(p * 100) / 100; legend[String(i)] = level; score += i * p; }); answers[id] = { type: 'score', score: Math.round(score * 100) / 100, confidence, legend, probabilities }; } } return answers; } } let singleton: JevClient | null = null; /** 有代理走代理,有 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 (proxyUrl) { singleton = new HttpJevClient(undefined, proxyUrl); } else if (key) { singleton = new HttpJevClient(key); } else { console.warn('[jev] 未配置 VITE_JEV_PROXY_URL / VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)'); singleton = new MockJevClient(); singleton.adaptiveScoring = false; } return singleton; }