feat: MVP 工程脚手架——剧情引擎、Act 1 三章内容、Jev 服务层、画像报告与分享卡、Tauri 壳

This commit is contained in:
Jayway Liu
2026-10-03 10:05:10 +08:00
parent f57eabe12c
commit bf9d7dad5b
54 changed files with 8040 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react';
import type { EventStore, StoryEvent } from '../../engine/eventStore';
import type { JevClient } from '../../services/jev';
import { BADGE_MAP, PROFILE_DIMENSIONS } from './badges';
import { ShareCard } from './ShareCard';
export interface DimensionScore {
id: string;
label: string;
score: number;
max: number;
confidence: number;
}
interface Props {
store: EventStore;
jev: JevClient;
onReplay: () => void;
}
/** 把 append-only 事件轨迹汇总为喂给 Jev 的 state 文本 */
function buildState(events: StoryEvent[]): string {
const lines = events.map((e) => {
if (e.kind === 'choice') {
return `[${e.pointId}] 选择「${e.optionLabel}」(维度: ${e.dimensions.join('/') || '字段采集'})`;
}
const t = e.transcript === null ? '(沉默)' : `(讲述: ${e.transcript})`;
return `[${e.pointId}] 讲述${t} 情绪签名: ${e.emotionSignature ?? '未知'}`;
});
return `用户在互动剧情《苍梧疑冢》第一幕中的行为轨迹(按时间排序,含初刷与重玩轨迹):\n${lines.join('\n')}`;
}
/** Act 1 结束后的画像报告页:Jev 一次调用并行问 7 个维度 score */
export function ReportPage({ store, jev, onReplay }: Props) {
const [scores, setScores] = useState<DimensionScore[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const events = await store.list();
const state = buildState(events);
// 7 维 score 问题,criteria 为 1-10 级有序数组
const criteria = Array.from({ length: 10 }, (_, i) => String(i + 1));
const questions = Object.fromEntries(
PROFILE_DIMENSIONS.map((d) => [
d.id,
{
type: 'score' as const,
description: `根据用户行为轨迹,评估其「${d.label}」维度水平:${d.description}`,
criteria,
},
]),
);
try {
const answers = await jev.ask(state, questions);
if (cancelled) return;
setScores(
PROFILE_DIMENSIONS.map((d) => {
const a = answers[d.id];
return a?.type === 'score'
? { id: d.id, label: d.label, score: a.score, max: a.max, confidence: a.confidence }
: { id: d.id, label: d.label, score: 0, max: 10, confidence: 0 };
}),
);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
})();
return () => {
cancelled = true;
};
}, [store, jev]);
if (error) {
return (
<div className="report">
<h1>画像报告生成失败</h1>
<p>{error}</p>
<button className="primary" onClick={onReplay}>
回到剧情
</button>
</div>
);
}
if (!scores) {
return (
<div className="report">
<h1>档案馆正在整理你的记忆……</h1>
<p>Jev 正在评估 7 个维度。</p>
</div>
);
}
// 勋章 = 置信度最高的维度
const top = [...scores].sort((a, b) => b.confidence - a.confidence)[0];
const badge = BADGE_MAP[top.id];
return (
<div className="report">
<h1>人格画像报告 · Act 1</h1>
<section className="badge-banner">
<div className="badge-medal">勋</div>
<div>
<h2>{badge.title}</h2>
<p>{badge.flavor}</p>
<p className="badge-note">
(取自实测置信度最高的维度:{top.label},置信度 {Math.round(top.confidence * 100)}%)
</p>
</div>
</section>
<section className="dimension-list">
{scores.map((s) => (
<div key={s.id} className="dimension-row">
<span className="dimension-label">{s.label}</span>
<div className="dimension-bar">
<div className="dimension-fill" style={{ width: `${(s.score / s.max) * 100}%` }} />
</div>
<span className="dimension-value">
{s.score}/{s.max} · 置信度 {Math.round(s.confidence * 100)}%
</span>
</div>
))}
</section>
<ShareCard badgeDimension={top.id} scores={scores} />
<button className="secondary" onClick={onReplay}>
重玩 Act 1(新轨迹,历史保留)
</button>
</div>
);
}