feat: MVP 工程脚手架——剧情引擎、Act 1 三章内容、Jev 服务层、画像报告与分享卡、Tauri 壳
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 } }
|
||||
* 响应: answers[<id>] 含分数/概率分布/confidence(0-1)
|
||||
*/
|
||||
|
||||
/** 是非概率问题 */
|
||||
export interface NoulQuestion {
|
||||
type: 'noul';
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 选择题(≤255 选项),criteria 为 map */
|
||||
export interface ChoiceQuestion {
|
||||
type: 'choice';
|
||||
description: string;
|
||||
options: string[];
|
||||
criteria?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 打分题,criteria 为 2-10 级有序数组(从低到高) */
|
||||
export interface ScoreQuestion {
|
||||
type: 'score';
|
||||
description: 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'; selected: string; distribution: Record<string, number>; confidence: number }
|
||||
| { type: 'score'; score: number; min: number; max: number; confidence: 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 的实现。浏览器调用可能遇到 CORS,正式环境需走后端代理(见 README)。 */
|
||||
export class HttpJevClient implements JevClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async ask(state: unknown, questions: Record<string, JevQuestion>): Promise<Record<string, JevAnswer>> {
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
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 开发用:确定性伪随机返分(同一输入永远同一输出,便于联调)。 */
|
||||
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 r = prng(seed, q.options.length + 1);
|
||||
const weights = r.slice(0, q.options.length);
|
||||
const total = weights.reduce((a, b) => a + b, 0) || 1;
|
||||
const distribution: Record<string, number> = {};
|
||||
let selected = q.options[0];
|
||||
let best = -1;
|
||||
q.options.forEach((opt, i) => {
|
||||
distribution[opt] = Math.round((weights[i] / total) * 100) / 100;
|
||||
if (weights[i] > best) {
|
||||
best = weights[i];
|
||||
selected = opt;
|
||||
}
|
||||
});
|
||||
answers[id] = { type: 'choice', selected, distribution, confidence };
|
||||
} else {
|
||||
const min = 1;
|
||||
const max = q.criteria.length;
|
||||
const score = min + Math.floor(prng(seed, 1)[0] * max);
|
||||
answers[id] = { type: 'score', score, min, max, confidence };
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: JevClient | null = null;
|
||||
|
||||
/** key 缺失时自动降级 Mock 并提示。 */
|
||||
export function getJevClient(): JevClient {
|
||||
if (singleton) return singleton;
|
||||
const key = import.meta.env.VITE_TYPESAFE_API_KEY as string | undefined;
|
||||
if (key) {
|
||||
singleton = new HttpJevClient(key);
|
||||
} else {
|
||||
console.warn('[jev] 未配置 VITE_TYPESAFE_API_KEY,降级为 MockJevClient(确定性伪随机返分)');
|
||||
singleton = new MockJevClient();
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
Reference in New Issue
Block a user