183 lines
6.6 KiB
TypeScript
183 lines
6.6 KiB
TypeScript
/**
|
||
* Jev 服务层 —— TypeSafe AI 的 System One 模型。
|
||
* REST: POST https://api.typesafe.ai/v1/systemone
|
||
* Header: Authorization: Bearer <TYPESAFE_API_KEY>
|
||
* 请求: { model: "jev-latest", state: <文本或JSON>, questions: { <id>: 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<string, string>;
|
||
}
|
||
|
||
/** 打分题,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<string, JevQuestion>;
|
||
}
|
||
|
||
export type JevAnswer =
|
||
| { type: 'noul'; probability: number; confidence: number }
|
||
| { type: 'choice'; choice: string; probabilities: Record<string, number>; confidence: number }
|
||
| {
|
||
type: 'score';
|
||
/** 概率加权浮点分数,范围 [0, criteria.length-1],可落在两级之间 */
|
||
score: number;
|
||
confidence: number;
|
||
legend: Record<string, string>;
|
||
probabilities: Record<string, number>;
|
||
};
|
||
|
||
export interface JevClient {
|
||
ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>>;
|
||
}
|
||
|
||
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<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
|
||
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,
|
||
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<string, JevAnswer> };
|
||
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 {
|
||
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
|
||
const stateKey = JSON.stringify(state) ?? '';
|
||
const answers: Record<string, JevAnswer> = {};
|
||
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<string, number> = {};
|
||
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<string, number> = {};
|
||
const legend: Record<string, string> = {};
|
||
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();
|
||
}
|
||
return singleton;
|
||
}
|