feat: v2.0封版——完整报告页(9模块+动画+MBTI+星座),waiting页,修复报告生成时序

This commit is contained in:
root 2026-02-23 14:24:39 +00:00
parent b88877e799
commit 0211f6a148
5 changed files with 855 additions and 129 deletions

View File

@ -246,7 +246,8 @@ export default function ChatPage() {
const d = await r.json();
if (d.done) {
setStage('done');
setTimeout(() => router.push('/waiting'), 1200);
const targetSid = d.sessionId || sid;
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
return;
}
setQuestion(d.reply);

View File

@ -1,30 +1,317 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Shell from '../../components/Shell';
import { useEffect, useMemo, useRef, useState } from 'react';
import Starfield from '../../components/Starfield';
export default function ReportPreviewPage() {
const [data, setData] = useState(null);
const ZODIAC_SYMBOL = {
aries: '♈', taurus: '♉', gemini: '♊', cancer: '♋', leo: '♌', virgo: '♍', libra: '♎', scorpio: '♏', sagittarius: '♐', capricorn: '♑', aquarius: '♒', pisces: '♓',
};
useEffect(() => {
const sid = localStorage.getItem('lingjing_sid');
if (!sid) return;
fetch(`/api/report/preview?sessionId=${encodeURIComponent(sid)}`)
.then((r) => r.json())
.then(setData);
}, []);
function mbtiColor(type = '') {
const t = type.toUpperCase();
if (['INTJ', 'INTP', 'ENTJ', 'ENTP'].includes(t)) return 'text-violet-300 border-violet-400/50';
if (['INFJ', 'INFP', 'ENFJ', 'ENFP'].includes(t)) return 'text-emerald-300 border-emerald-400/50';
if (['ISTJ', 'ISFJ', 'ESTJ', 'ESFJ'].includes(t)) return 'text-sky-300 border-sky-400/50';
return 'text-orange-300 border-orange-400/50';
}
function Radar({ scores }) {
const labels = ['心力', '行力', '感知', '洞见', '定力'];
const values = [scores?.xinli || 0, scores?.xingli || 0, scores?.ganzhi || 0, scores?.dongjian || 0, scores?.dingli || 0];
const cx = 160; const cy = 160; const r = 110;
const points = values.map((v, i) => {
const a = -Math.PI / 2 + (Math.PI * 2 * i) / 5;
return [cx + Math.cos(a) * (r * v / 100), cy + Math.sin(a) * (r * v / 100)];
});
const polygon = points.map((p) => p.join(',')).join(' ');
return (
<Shell title="报告预览(免费版)" subtitle="先看核心摘要,完整版可继续查看。">
<div className="card space-y-4 p-6 text-sm">
<p className="font-medium">{data?.userSnapshot?.summary || '你现在处在“想改变,但还没找到最顺手路径”的阶段。'}</p>
<p>{data?.highlight?.content || '你对变化是有行动意愿的,只是容易在选择上分散精力。'}</p>
<p className="text-neutral-600">{data?.teaser?.lockedHint || '完整版将告诉你哪条路线最适合你现在的节奏。'}</p>
<Link href="/report-full" className="btn-primary">
查看完整报告演示
</Link>
</div>
</Shell>
<svg viewBox="0 0 320 320" className="mx-auto w-full max-w-[380px]">
{[20, 40, 60, 80, 100].map((step) => {
const rr = r * step / 100;
const ring = labels.map((_, i) => { const a = -Math.PI / 2 + Math.PI * 2 * i / 5; return `${cx + Math.cos(a) * rr},${cy + Math.sin(a) * rr}`; }).join(' ');
return <polygon key={step} points={ring} fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="1" />;
})}
{labels.map((l, i) => {
const a = -Math.PI / 2 + Math.PI * 2 * i / 5;
return (
<g key={l}>
<line x1={cx} y1={cy} x2={cx + Math.cos(a) * r} y2={cy + Math.sin(a) * r} stroke="rgba(255,255,255,0.15)" />
<text x={cx + Math.cos(a) * (r + 18)} y={cy + Math.sin(a) * (r + 18)} textAnchor="middle" dominantBaseline="middle" fill="rgba(255,255,255,0.75)" fontSize="11">{l}</text>
</g>
);
})}
<polygon points={polygon} fill="rgba(104,130,255,0.35)" stroke="rgba(152,176,255,0.9)" strokeWidth="2" />
{points.map((p, i) => (
<g key={labels[i]}>
<circle cx={p[0]} cy={p[1]} r="3" fill="white" />
<text x={p[0]} y={p[1] - 8} textAnchor="middle" fill="rgba(255,255,255,0.85)" fontSize="10">{values[i]}</text>
</g>
))}
</svg>
);
}
// Section: children(vis)=>JSXvisible
function Section({ title, children }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ threshold: 0.12 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<section ref={ref} className="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm"
style={{ opacity: visible ? 1 : 0, transform: visible ? 'translateY(0)' : 'translateY(28px)', transition: 'opacity 0.7s ease, transform 0.7s ease' }}>
<h2 className="mb-3 text-lg font-semibold text-white">{title}</h2>
{typeof children === 'function' ? children(visible) : children}
</section>
);
}
export default function ReportPreviewPage() {
const [querySid, setQuerySid] = useState('');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const headerRef = useRef(null);
const [headerVisible, setHeaderVisible] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
setQuerySid(new URLSearchParams(window.location.search).get('sessionId') || '');
}, []);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const obs = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setHeaderVisible(true); obs.disconnect(); } }, { threshold: 0.1 });
obs.observe(el);
return () => obs.disconnect();
}, []);
const isMock = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('mock') === '1';
const sid = useMemo(() => querySid || (typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') || '' : ''), [querySid]);
const load = async () => {
setLoading(true); setError('');
try {
const url = isMock ? '/api/report/mock' : `/api/report?sessionId=${encodeURIComponent(sid)}`;
if (!isMock && !sid) { setError('缺少 sessionId'); setLoading(false); return; }
// 60
for (let attempt = 0; attempt < 30; attempt++) {
const r = await fetch(url);
const d = await r.json();
if (d?.status === 'done' && d?.report) {
setData(d.report);
return;
}
if (d?.status === 'error') throw new Error(d?.error || '生成失败');
// generating not_started 2
if (attempt < 29) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
throw new Error('报告生成超时,请重试');
} catch (e) { setError(e.message || '加载失败'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, [sid]);
return (
<main className="relative min-h-screen overflow-hidden bg-[#05030f] text-white">
<Starfield className="absolute inset-0 h-[320px] w-full opacity-90" animated={false} />
<div className="relative z-10 mx-auto w-full max-w-2xl px-4 py-10">
<header ref={headerRef} className="mb-8 text-center"
style={{ opacity: headerVisible ? 1 : 0, transform: headerVisible ? 'translateY(0)' : 'translateY(-20px)', transition: 'opacity 0.8s ease, transform 0.8s ease' }}>
<p className="text-xs tracking-[0.3em] text-white/35">LINGJING REPORT</p>
<h1 className="mt-2 text-3xl font-light tracking-widest">你的灵镜报告</h1>
</header>
{loading ? (
<div className="text-center">
<p className="text-white/50 tracking-widest" style={{ animation: 'breathe 2.4s ease-in-out infinite' }}>正在生成你的灵镜报告</p>
<p className="mt-4 text-xs text-white/30">通常需要30-60</p>
<style>{`@keyframes breathe { 0%,100% { opacity: 0.3; } 50% { opacity: 0.9; } }`}</style>
</div>
) : null}
{error ? (
<div className="mx-auto max-w-xl rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-red-300">{error}</p>
<button className="mt-4 rounded-xl border border-white/20 px-4 py-2 text-sm" onClick={load}>重试</button>
</div>
) : null}
{!loading && !error && data ? (
<div className="space-y-5">
{/* ① 灵魂标签 */}
<Section title="灵魂标签">
{(vis) => (
<div className="flex flex-wrap justify-center gap-2">
{(data.soulTags || []).map((t, i) => (
<span key={i} className="rounded-full border border-white/30 px-4 py-1.5 text-sm text-white/90"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'scale(1)' : 'scale(0.8)', transition: `opacity 0.4s ease ${0.2 + i * 0.1}s, transform 0.4s cubic-bezier(0.34,1.56,0.64,1) ${0.2 + i * 0.1}s` }}>
{t}
</span>
))}
</div>
)}
</Section>
{/* ② 当下处境 */}
<Section title="当下处境">
{(vis) => (
<>
<h3 className="text-base font-medium text-white/95">{data.currentState?.title}</h3>
<p className="mt-2 text-sm leading-7 text-white/80">{data.currentState?.summary}</p>
<div className="mt-4">
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-sky-400 via-fuchsia-400 to-orange-400"
style={{ width: vis ? `${Math.max(0, Math.min(100, data.currentState?.intensity || 0))}%` : '0%', transition: 'width 1.4s cubic-bezier(0.4,0,0.2,1) 0.3s' }} />
</div>
<p className="mt-1.5 text-xs text-white/40">状态强度 {data.currentState?.intensity || 0} / 100</p>
</div>
</>
)}
</Section>
{/* ③ 五维镜像 */}
<Section title="五维镜像">
{() => (
<>
<Radar scores={data.fiveDim?.scores} />
<p className="mt-3 text-sm leading-7 text-white/80">{data.fiveDim?.interpretation}</p>
</>
)}
</Section>
{/* ④ 性格解读 */}
<Section title="性格解读">
{(vis) => (
<div className="space-y-3">
{(data.personalityReading || []).map((item, i) => (
<article key={i} className="rounded-xl border border-white/10 bg-black/20 p-4"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateX(0)' : 'translateX(-20px)', transition: `opacity 0.6s ease ${i * 0.15}s, transform 0.6s ease ${i * 0.15}s` }}>
<h3 className="text-sm font-semibold text-white">{item.point}</h3>
<blockquote className="mt-2 border-l-2 border-white/25 pl-3 text-sm italic text-white/55">{item.quote}</blockquote>
<p className="mt-2 text-sm leading-7 text-white/80">{item.explain}</p>
</article>
))}
</div>
)}
</Section>
{/* ⑤ 潜能与盲区 */}
<Section title="潜能与盲区">
{(vis) => (
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-emerald-400/30 bg-emerald-500/10 p-4"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateX(0)' : 'translateX(-16px)', transition: 'opacity 0.6s ease 0.1s, transform 0.6s ease 0.1s' }}>
<h3 className="text-sm font-semibold text-emerald-200 mb-2"> 潜能</h3>
<ul className="space-y-2">{(data.potentialBlindspots?.potentials || []).map((p, i) => <li key={i} className="text-sm text-white/80 leading-6">{p}</li>)}</ul>
</div>
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 p-4"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateX(0)' : 'translateX(16px)', transition: 'opacity 0.6s ease 0.2s, transform 0.6s ease 0.2s' }}>
<h3 className="text-sm font-semibold text-amber-200 mb-2"> 盲区</h3>
<ul className="space-y-2">{(data.potentialBlindspots?.blindspots || []).map((p, i) => <li key={i} className="text-sm text-white/80 leading-6">{p}</li>)}</ul>
</div>
</div>
)}
</Section>
{/* ⑥ MBTI */}
<Section title="MBTI 人格">
{(vis) => (
<>
<div className="flex items-center gap-4 mb-3">
<div className={`rounded-2xl border-2 px-5 py-3 text-center ${mbtiColor(data.mbti?.type)}`}
style={{ opacity: vis ? 1 : 0, transform: vis ? 'scale(1)' : 'scale(0.6)', transition: 'opacity 0.5s ease 0.1s, transform 0.6s cubic-bezier(0.34,1.56,0.64,1) 0.1s' }}>
<p className="text-2xl font-bold tracking-widest">{data.mbti?.type}</p>
<p className="text-xs mt-0.5 opacity-70">{data.mbti?.typeName}</p>
</div>
</div>
<p className="text-sm leading-7 text-white/80">{data.mbti?.description}</p>
</>
)}
</Section>
{/* ⑦ 星座共鸣(可选) */}
{data.zodiac ? (
<Section title="星座共鸣">
{(vis) => (
<>
<div className="flex items-center gap-4 mb-3">
<span className="text-5xl leading-none"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'rotate(0deg) scale(1)' : 'rotate(-20deg) scale(0.5)', transition: 'opacity 0.6s ease 0.15s, transform 0.7s cubic-bezier(0.34,1.56,0.64,1) 0.15s' }}>
{ZODIAC_SYMBOL[data.zodiac.sign] || '✦'}
</span>
<div>
<p className="text-lg font-semibold">{data.zodiac.name}</p>
<p className="text-xs text-white/45 mt-0.5 italic">{data.zodiac.lingjingLine}</p>
</div>
</div>
<p className="text-sm leading-7 text-white/80">{data.zodiac.fusionText}</p>
</>
)}
</Section>
) : null}
{/* ⑧ 当下信号 */}
<Section title="当下信号">
{() => (
<>
<div className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/20 p-4 mb-3">
<p className="text-base font-semibold">{data.presentSignal?.signalName}</p>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${data.presentSignal?.urgency === 'high' ? 'bg-red-500/20 text-red-300' : data.presentSignal?.urgency === 'medium' ? 'bg-yellow-500/20 text-yellow-300' : 'bg-sky-500/20 text-sky-300'}`}>
{data.presentSignal?.urgency === 'high' ? '紧迫' : data.presentSignal?.urgency === 'medium' ? '适时' : '从容'}
</span>
</div>
<div className="space-y-2.5 text-sm text-white/80">
<p><span className="text-white/45 mr-1">触发信号</span>{data.presentSignal?.trigger}</p>
<p><span className="text-white/45 mr-1">含义</span>{data.presentSignal?.meaning}</p>
<p><span className="text-white/45 mr-1">错过代价</span>{data.presentSignal?.riskIfMissed}</p>
</div>
</>
)}
</Section>
{/* ⑨ 支点行动 + 收束金句 */}
<Section title="支点行动">
{(vis) => (
<>
<div className="rounded-xl border border-indigo-400/40 bg-indigo-500/10 p-4 text-white font-medium leading-7 mb-4">
{data.pivotAction?.onePivot}
</div>
<div className="grid gap-3 md:grid-cols-3">
{(data.pivotAction?.threeStarts || []).map((a, i) => (
<div key={i} className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-white/85"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateY(0)' : 'translateY(16px)', transition: `opacity 0.5s ease ${i * 0.12}s, transform 0.5s ease ${i * 0.12}s` }}>
<span className="text-white/30 text-xs mr-1.5">0{i + 1}</span>{a}
</div>
))}
</div>
<div className="mt-12 mb-4 text-center px-4"
style={{ opacity: vis ? 1 : 0, transition: 'opacity 1.4s ease 0.6s' }}>
<p className="text-xl font-light tracking-wide text-white/85 leading-relaxed">
&ldquo;{data.closingLine}&rdquo;
</p>
</div>
</>
)}
</Section>
</div>
) : null}
</div>
</main>
);
}

View File

@ -1,35 +1,137 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import Shell from '../../components/Shell';
import Starfield from '../../components/Starfield';
const MESSAGES = [
'正在整理你的回答线索……',
'正在校准你的五维镜像……',
'正在提炼你的当下信号……',
'正在生成你的支点行动……',
];
export default function WaitingPage() {
const [progress, setProgress] = useState(8);
const router = useRouter();
const [querySid, setQuerySid] = useState('');
const [idx, setIdx] = useState(0);
const [timedOut, setTimedOut] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (typeof window === 'undefined') return;
const sidFromQuery = new URLSearchParams(window.location.search).get('sessionId') || '';
setQuerySid(sidFromQuery);
}, []);
const sid = useMemo(() => {
return querySid || (typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') || '' : '');
}, [querySid]);
useEffect(() => {
const t = setInterval(() => {
setProgress((p) => Math.min(p + 12, 100));
}, 600);
const done = setTimeout(() => {
const sid = typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') : '';
router.push(`/report-preview?sid=${encodeURIComponent(sid || '')}`);
}, 5000);
return () => {
clearInterval(t);
clearTimeout(done);
const t = setInterval(() => setIdx((n) => (n + 1) % MESSAGES.length), 3000);
return () => clearInterval(t);
}, []);
useEffect(() => {
if (!sid) return;
localStorage.setItem('lingjing_sid', sid);
let alive = true;
const timeout = setTimeout(() => setTimedOut(true), 90000); // 90
const run = async () => {
try {
const r = await fetch('/api/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: sid }),
});
const d = await r.json();
if (!alive) return;
if (d?.ok || d?.status === 'done') {
router.replace(`/report-preview?sessionId=${encodeURIComponent(sid)}`);
return;
}
if (d?.status === 'generating') {
poll();
return;
}
setError(d?.error || '生成失败,请重试');
} catch {
if (alive) setError('生成失败,请重试');
}
};
}, [router]);
const poll = async () => {
let notStartedCount = 0;
for (let i = 0; i < 45; i += 1) { // 45x2=90
await new Promise((r) => setTimeout(r, 2000));
if (!alive) return;
try {
const g = await fetch(`/api/report?sessionId=${encodeURIComponent(sid)}`);
const d = await g.json();
if (d?.status === 'done') {
router.replace(`/report-preview?sessionId=${encodeURIComponent(sid)}`);
return;
}
if (d?.status === 'error') {
setError(d?.error || '生成失败,请重试');
return;
}
if (d?.status === 'not_started') {
notStartedCount += 1;
// 5
if (notStartedCount >= 5) {
fetch('/api/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sid }) }).catch(() => {});
notStartedCount = 0;
}
}
} catch {
// ignore single poll failure
}
}
setError('报告生成超时,请重试');
};
run();
return () => {
alive = false;
clearTimeout(timeout);
};
}, [router, sid]);
const retry = () => {
setTimedOut(false);
setError('');
window.location.reload();
};
return (
<Shell title="正在生成你的灵镜报告" subtitle="正在整理你的回答与关键线索,请稍候。">
<div className="card p-6">
<div className="h-2 w-full overflow-hidden rounded-full bg-neutral-200">
<div className="h-full rounded-full bg-black transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="mt-3 text-sm text-neutral-600">{progress}%</p>
<main className="relative min-h-screen overflow-hidden bg-[#05030f] text-white">
<Starfield className="absolute inset-0 h-full w-full" animated />
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-2xl flex-col items-center justify-center px-6 text-center">
<h1 className="text-3xl font-semibold">灵镜正在为你生成报告</h1>
<p className="mt-6 text-lg text-white/80">{MESSAGES[idx]}</p>
<p className="mt-12 text-sm text-white/60">已保存你的对话可放心离开</p>
{timedOut && !error ? (
<div className="mt-8 flex gap-3">
<button onClick={retry} className="rounded-xl border border-white/20 bg-white/10 px-4 py-2 text-sm">继续等待</button>
<button onClick={() => router.push('/')} className="rounded-xl border border-white/20 bg-transparent px-4 py-2 text-sm">稍后查看</button>
</div>
) : null}
{error ? (
<div className="mt-8 space-y-3">
<p className="text-sm text-red-300">{error}</p>
<div className="flex gap-3">
<button onClick={retry} className="rounded-xl border border-white/20 bg-white/10 px-4 py-2 text-sm">重新生成</button>
<button onClick={() => router.push('/')} className="rounded-xl border border-white/20 bg-transparent px-4 py-2 text-sm">稍后查看</button>
</div>
</div>
) : null}
</div>
</Shell>
</main>
);
}

78
components/Starfield.jsx Normal file
View File

@ -0,0 +1,78 @@
'use client';
import { useEffect, useRef } from 'react';
export default function Starfield({ className = '', animated = false }) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let raf = null;
let width = 0;
let height = 0;
let stars = [];
const createStars = () => {
const count = Math.max(90, Math.floor((width * height) / 16000));
stars = Array.from({ length: count }, () => ({
x: Math.random() * width,
y: Math.random() * height,
r: Math.random() * 1.4 + 0.4,
a: Math.random() * 0.7 + 0.2,
v: Math.random() * 0.08 + 0.02,
}));
};
const resize = () => {
const dpr = window.devicePixelRatio || 1;
width = canvas.clientWidth;
height = canvas.clientHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
createStars();
draw();
};
const draw = () => {
ctx.clearRect(0, 0, width, height);
const bg = ctx.createLinearGradient(0, 0, 0, height);
bg.addColorStop(0, '#0b0720');
bg.addColorStop(1, '#05030f');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);
for (const s of stars) {
ctx.beginPath();
ctx.fillStyle = `rgba(220,225,255,${s.a})`;
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
};
const tick = () => {
for (const s of stars) {
s.a += (Math.random() - 0.5) * s.v;
if (s.a < 0.15) s.a = 0.15;
if (s.a > 0.95) s.a = 0.95;
}
draw();
raf = window.requestAnimationFrame(tick);
};
resize();
window.addEventListener('resize', resize);
if (animated) raf = window.requestAnimationFrame(tick);
return () => {
window.removeEventListener('resize', resize);
if (raf) window.cancelAnimationFrame(raf);
};
}, [animated]);
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
}

428
server.js
View File

@ -1,5 +1,4 @@
const fs = require('fs');
const path = require('path');
const express = require('express');
const next = require('next');
const dotenv = require('dotenv');
@ -15,49 +14,99 @@ const sessions = new Map();
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
const questionBank = [
'你今年大概在哪个年龄段比如20岁以下、20-29、30-39这样。',
'你是男生还是女生呀?',
'你现在主要在做什么?上学、上班、自己做事,还是在休息调整?',
'最近这几天,有没有一件小事让你心情变好?',
'如果只说一件事,你现在最发愁的是什么?',
'最近一周,你心情大多数时候是轻松、一般,还是有点压着?',
'你更容易从一个人待着恢复,还是和人聊天后恢复?',
'忙完一天后,你最想做什么来放松?',
'什么场景最容易让你觉得被掏空?',
'你做什么事时最容易忘记时间?',
'最近一次你状态特别好的那天,发生了什么?',
'最近一次你状态特别差的那天,发生了什么?',
'遇到新任务,你习惯先列计划,还是先做再调整?',
'你做决定时更看重稳妥还是可能性更大?',
'你拖延通常是因为不会做、怕做错,还是没兴趣?',
'有压力时你会先自己扛,还是找人聊聊?',
'你更喜欢一次做一件事,还是好几件事一起推?',
'过去一个月,你最满意的一次决定是什么?',
'和亲近的人有分歧时,你更常沉默、解释,还是直接顶回去?',
'你会不会因为怕别人失望,就先答应再后悔?',
'别人一句话让你不舒服时,你通常会说出来吗?',
'最近一次你明明很累但还在撑的场景是什么?',
'你觉得自己做得不够好的念头,最近常出现吗?',
'如果给现在的自己一句鼓励,你最想说什么?',
'接下来3个月你最想改善的一件事是什么',
'你觉得最大的拦路点是什么?',
'如果只做一个小动作,哪件事你愿意这周就开始?',
'谁能给你一点支持?你愿不愿意主动开口?',
'你希望我给你稳一点的方案还是冲一点的方案?',
'今天聊完,你最想先记住的一句话是什么?',
];
const zodiacProfiles = {
aries: { name: '白羊座', fusion: '你身上有很直接的行动火花,关键是把冲劲变成稳定推进。' },
taurus: { name: '金牛座', fusion: '你重视确定感,先小步验证会比一直等待更有力量。' },
gemini: { name: '双子座', fusion: '你思路灵活,先定主线后扩展,效率会明显提升。' },
cancer: { name: '巨蟹座', fusion: '你情感细腻,先稳住自己,才能更好照顾关系和目标。' },
leo: { name: '狮子座', fusion: '你有担当感,外在发力的同时要记得给内在补能。' },
virgo: { name: '处女座', fusion: '你重视质量,先完成草稿再优化,比等待完美更快。' },
libra: { name: '天秤座', fusion: '你擅长平衡,关键在于练习有边界的取舍。' },
scorpio: { name: '天蝎座', fusion: '你并不是慢,而是要确认值不值得投入;一旦启动就很有穿透力。' },
sagittarius: { name: '射手座', fusion: '你有探索力,把热情绑定阶段目标会更稳。' },
capricorn: { name: '摩羯座', fusion: '你擅长长期推进,记得把恢复机制纳入执行系统。' },
aquarius: { name: '水瓶座', fusion: '你的独立视角很珍贵,落地后才会变成现实影响。' },
pisces: { name: '双鱼座', fusion: '你的感受力是天赋,先稳情绪再行动会更顺。' },
};
function detectZodiac(month, day) {
if (!month || !day) return null;
const md = month * 100 + day;
if (md >= 321 && md <= 419) return 'aries';
if (md >= 420 && md <= 520) return 'taurus';
if (md >= 521 && md <= 621) return 'gemini';
if (md >= 622 && md <= 722) return 'cancer';
if (md >= 723 && md <= 822) return 'leo';
if (md >= 823 && md <= 922) return 'virgo';
if (md >= 923 && md <= 1023) return 'libra';
if (md >= 1024 && md <= 1122) return 'scorpio';
if (md >= 1123 && md <= 1221) return 'sagittarius';
if ((md >= 1222 && md <= 1231) || (md >= 101 && md <= 119)) return 'capricorn';
if (md >= 120 && md <= 218) return 'aquarius';
if (md >= 219 && md <= 320) return 'pisces';
return null;
}
function extractBirthday(text) {
if (!text) return null;
const cleaned = String(text).replace(/\s+/g, '');
let m = cleaned.match(/(1[0-2]|[1-9])月([0-2]?\d|3[01])(?:日|号)?/);
if (!m) m = cleaned.match(/(1[0-2]|0?[1-9])[\/-](3[01]|[12]?\d)/);
if (!m) return null;
const month = Number(m[1]);
const day = Number(m[2]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
return { month, day };
}
function sanitizeReply(text) {
if (!text) return '';
// 移除所有控制字符包括Unicode控制字符保留普通换行和空格
return text
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // ASCII控制字符
.replace(/[\u0080-\u009F]/g, '') // C1控制字符
.replace(/\u0000/g, '') // null字节
.replace(/[\uFFF0-\uFFFF]/g, '') // Unicode特殊符
.replace(/\n{3,}/g, '\n\n') // 多余换行压缩
.trim();
}
function parseJSONFromText(text) {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
const block = text.match(/```json\s*([\s\S]*?)```/i);
if (block) {
try {
return JSON.parse(block[1]);
} catch {
return null;
}
}
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
return JSON.parse(text.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
}
function loadSystemPrompt() {
const p = '/root/Projects/dochub-next/content-private/project-lingjing/system-prompt-v1.md';
try {
return fs.readFileSync(p, 'utf8').slice(0, 8000);
return fs.readFileSync(p, 'utf8').slice(0, 12000);
} catch {
return '你是灵镜,一个温暖、自然的一问一答助手。';
}
}
async function callAI(messages) {
async function callYcapis(input, maxOutputTokens = 500) {
const key = process.env.YCAPIS_API_KEY;
if (!key) return null;
try {
@ -69,60 +118,184 @@ async function callAI(messages) {
},
body: JSON.stringify({
model: 'gpt-5.3-codex',
input: messages.map(m => ({ role: m.role, content: m.content })),
max_output_tokens: 300,
input,
max_output_tokens: maxOutputTokens,
}),
});
if (!res.ok) return null;
const data = await res.json();
return data?.output?.[0]?.content?.[0]?.text || null;
const raw = data?.output?.[0]?.content?.[0]?.text || null;
return raw ? sanitizeReply(raw) : null;
} catch {
return null;
}
}
function previewReport(session) {
function fallbackReport(session) {
const zodiacSign = session?.birthday ? detectZodiac(session.birthday.month, session.birthday.day) : null;
return {
version: 'free_v1',
reportType: 'free',
userSnapshot: { summary: '你当前状态是:有改变意愿,但需要更聚焦的行动节奏。' },
highlight: { title: '你的亮点', content: '你能清楚表达自己的真实感受,这会让你更快找到方向。' },
evidence: [{ quote: session.answers[0] || '(示例)', meaning: '你愿意打开自己,这是成长的起点。' }],
currentBlock: { title: '当前阻碍', content: '你容易想太多再行动,导致动力被消耗。' },
oneActionThisWeek: { title: '本周动作', content: '选一件最小任务限定30分钟今天就开始。' },
teaser: { lockedHint: '完整版会给你稳妥/成长/冲刺三条路线和30天动作计划。', upgradeText: '继续查看完整报告' },
soulTags: ['敏感但清醒', '想改变', '有行动潜力'],
currentState: {
title: '在转折前的蓄力期',
summary: '你已经明确感受到当下状态需要调整,也愿意正视问题。现在最关键的不是再想更多,而是把注意力集中到一个可执行的小行动上,用真实动作带动状态回升。',
intensity: 68,
},
fiveDim: {
scores: { xinli: 66, xingli: 58, ganzhi: 74, dongjian: 70, dingli: 62 },
evidence: {
xinli: ['「用户说:我最近总是拖着。」'],
xingli: ['「用户说:明明知道该动了但就是不想。」'],
ganzhi: ['「用户说:我会在意别人怎么看。」'],
dongjian: ['「用户说:我知道问题不只是懒。」'],
dingli: ['「用户说:我还是想把这件事做好。」'],
},
interpretation: '你有不错的洞察和感受力,当前主要卡点在启动与持续。只要先把行动门槛降下来,整体状态会明显改善。',
},
personalityReading: [
{ point: '你并不逃避现实', quote: '「用户说:我知道该动了。」', explain: '你对问题是看见的,这意味着你具备修正能力。' },
{ point: '你容易被压力拖慢', quote: '「用户说:就是不想动。」', explain: '不是没能力,而是启动前消耗了太多心理能量。' },
{ point: '你仍有稳定的目标感', quote: '「用户说:还是想做好。」', explain: '内在方向感还在,只需要更轻的执行路径。' },
],
potentialBlindspots: {
potentials: ['你能快速发现问题本质,适合做复盘和策略规划。', '你对关系和情绪变化敏感,沟通潜力高。'],
blindspots: ['你会在开始前反复确认,导致行动延迟。', '你容易把阶段波动当成长期失败。'],
},
mbti: {
type: 'INFP',
typeName: '调停者',
description: '从你的表达看,你更像先在心里确认价值感,再进入行动。你重视感受和意义,一旦目标与你的内在认同对齐,执行会明显变稳。',
},
zodiac: zodiacSign ? { sign: zodiacSign, name: zodiacProfiles[zodiacSign].name, fusionText: zodiacProfiles[zodiacSign].fusion } : null,
presentSignal: {
signalName: '启动阻力正在放大',
urgency: 'high',
trigger: '当你连续两天都在想“再等等”时',
meaning: '这说明你已经进入了“想做但不敢开头”的循环,越拖越重。',
riskIfMissed: '如果继续拖延,你会把短期卡点误判成长期无力。',
},
pivotAction: {
onePivot: '今天只做一件15分钟就能完成的小任务并立即收尾。',
threeStarts: ['把目标拆成今天版本不超过3步。', '设置固定开工时间,先做再评估。', '完成后写一句复盘:我做到了什么。'],
},
closingLine: '你真正缺的不是能力,而是一次不等完美就开始的动作。',
};
}
function fullReport(session) {
return {
version: 'pro_v1',
reportType: 'pro',
profileSummary: {
oneLineDiagnosis: '你是行动意愿强、但容易被多目标分散的人。先聚焦一个主线,会明显提速。',
currentStage: '调整期',
async function generateReportForSession(sessionId) {
const session = sessions.get(sessionId);
if (!session) return null;
if (session.reportStatus === 'generating') return null;
if (session.report) return session.report;
session.reportStatus = 'generating';
session.reportError = '';
const birthday = session.birthday || null;
const transcript = session.answers.map((a, i) => ({ q: session.questions[i] || `${i + 1}`, a }));
const reportSystemPrompt = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。
要求
1. 必须返回严格的JSON格式不要有任何多余文字
2. 每个结论必须引用用户原话格式用户说
3. 用大白话禁止专业术语心流人格原型依恋模式等
4. 五维评分直接输出0-100整数
5. MBTI根据对话综合判断不要死套测试题
6. 如果没有生日信息zodiac字段必须是null
返回以下JSON结构
{
"soulTags": ["标签1", "标签2", "标签3"],
"currentState": {
"title": "当下状态标题",
"summary": "150-200字描述",
"intensity": 65
},
"fiveDim": {
"scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 },
"evidence": {
"xinli": ["「用户说:……」"],
"xingli": ["「用户说:……」"],
"ganzhi": ["「用户说:……」"],
"dongjian": ["「用户说:……」"],
"dingli": ["「用户说:……」"]
},
strengths: [
{ name: '自我觉察', description: '你能描述自己的状态变化,便于快速纠偏。' },
{ name: '现实感', description: '你会考虑真实条件,不容易盲目冲动。' },
{ name: '执行意愿', description: '你愿意从小步开始,这对长期成长很关键。' },
],
routes: [
{ routeType: 'stable', title: '稳妥线', fitReason: '先稳住节奏与作息,每周完成固定小目标。' },
{ routeType: 'growth', title: '成长线', fitReason: '围绕一个能力做30天刻意练习建立优势杠杆。' },
{ routeType: 'sprint', title: '冲刺线', fitReason: '聚焦一件高价值目标,用短周期冲刺验证上限。' },
],
sourceAnswers: session.answers,
"interpretation": "100-150字整体解读"
},
"personalityReading": [
{ "point": "核心特点", "quote": "「用户说:……」", "explain": "50-80字解释" },
{ "point": "核心特点2", "quote": "「用户说:……」", "explain": "50-80字解释" },
{ "point": "核心特点3", "quote": "「用户说:……」", "explain": "50-80字解释" }
],
"potentialBlindspots": {
"potentials": ["潜能150字", "潜能250字"],
"blindspots": ["盲区150字", "盲区250字"]
},
"mbti": {
"type": "INFP",
"typeName": "调停者",
"description": "150-200字基于对话的MBTI解读"
},
"zodiac": null,
"presentSignal": {
"signalName": "信号名称",
"urgency": "high",
"trigger": "当……出现时",
"meaning": "这意味着……80字",
"riskIfMissed": "如果错过……50字"
},
"pivotAction": {
"onePivot": "今天就能做的一个主行动",
"threeStarts": ["第一件事", "第二件事", "第三件事"]
},
"closingLine": "一句话总结,适合截图分享"
}`;
const userInput = {
birthday,
zodiacHint: birthday ? detectZodiac(birthday.month, birthday.day) : null,
transcript,
};
const text = await callYcapis([
{ role: 'system', content: reportSystemPrompt },
{ role: 'user', content: JSON.stringify(userInput) },
], 4000);
let parsed = parseJSONFromText(text);
if (!parsed) parsed = fallbackReport(session);
if (birthday) {
const sign = detectZodiac(birthday.month, birthday.day);
if (sign && (!parsed.zodiac || parsed.zodiac === null)) {
parsed.zodiac = { sign, name: zodiacProfiles[sign].name, fusionText: zodiacProfiles[sign].fusion };
}
} else {
parsed.zodiac = null;
}
session.report = parsed;
session.reportStatus = 'done';
return parsed;
}
app.prepare().then(() => {
const server = express();
server.use(express.json({ limit: '1mb' }));
server.use(express.json({ limit: '2mb' }));
server.post('/api/session/new', (req, res) => {
const sessionId = `lgj_${Date.now().toString(36)}`;
sessions.set(sessionId, { index: 0, answers: [], questions: [], skips: 0, prompt: loadSystemPrompt() });
sessions.set(sessionId, {
index: 0,
answers: [],
questions: [],
skips: 0,
prompt: loadSystemPrompt(),
report: null,
reportStatus: 'not_started',
reportError: '',
birthday: null,
});
res.json({ sessionId, opening, total: 30 });
});
@ -134,46 +307,131 @@ app.prepare().then(() => {
const userAnswer = (answer || '').trim();
if (userAnswer) {
s.answers.push(userAnswer);
const b = extractBirthday(userAnswer);
if (b) s.birthday = b;
s.skips = userAnswer === '跳过' ? s.skips + 1 : 0;
s.index += 1;
}
const done = s.index >= 30;
if (done) {
return res.json({ done: true, reply: '你已经完成全部对话。接下来我会为你生成专属灵镜报告。' });
if (s.reportStatus !== 'generating' && !s.report) {
generateReportForSession(sessionId).catch((err) => {
s.reportStatus = 'error';
s.reportError = err?.message || 'generate_failed';
});
}
return res.json({ done: true, reply: '你已经完成全部对话。接下来我会为你生成专属灵镜报告。', sessionId });
}
const progress = s.index > 0 && s.index % 6 === 0 ? `(你已经完成第${Math.ceil(s.index / 6)}/5阶段了。` : '';
const birthdayHint = s.index === 27 ? '这一题请自然地加一句:顺便问一下,你是几月几号生的?' : '';
// 全部交给AI动态生成不用硬编码题库
const ds = await callAI([
const aiReply = await callYcapis([
{ role: 'system', content: s.prompt },
{ role: 'assistant', content: opening },
...s.answers.flatMap((a, i) => [
{ role: 'assistant', content: s.questions[i] || '' },
{ role: 'user', content: a }
]).filter(m => m.content),
{ role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}用温暖口语接住他的回答,然后继续问下一个问题。注意:只问一个问题,用大白话,不用专业术语。当前已问第${s.index}共30题。` }
]);
{ role: 'user', content: a },
]).filter((m) => m.content),
{ role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}先接住用户,再继续下一问。只问一个问题,口语化、大白话。当前第${s.index + 1}题/30。${birthdayHint}` },
], 300);
const aiReply = ds || '嗯,我听到了。我们继续,你现在主要在做什么?上学、上班、还是在休整?';
const fallback = s.index === 27
? '我听懂了。顺便问一下,你是几月几号生的?'
: '嗯,我听到了。我们继续,最近这几天有什么小事让你心情变好?';
// 记录AI问的问题
s.questions.push(aiReply);
const nextQuestion = aiReply || fallback;
s.questions.push(nextQuestion);
return res.json({ done: false, reply: aiReply, index: s.index + 1, total: 30 });
return res.json({ done: false, reply: sanitizeReply(nextQuestion), index: s.index + 1, total: 30, sessionId });
});
server.get('/api/report/preview', (req, res) => {
const s = sessions.get(req.query.sessionId);
if (!s) return res.json(previewReport({ answers: [] }));
return res.json(previewReport(s));
// 测试用:直接返回模拟报告数据
server.get('/api/report/mock', (req, res) => {
const mockReport = {
soulTags: ['慢热型行动者', '内驱力深藏', '边界感极强', '高敏感思考者'],
currentState: {
title: '蓄力期——你正在等一个真正值得出手的时机',
summary: '你现在处在一种"心里明白,但脚还没动"的状态。不是没有想法,而是对自己的要求很高,不愿意随便交差。从你说的话里能感觉到,你有很清晰的内在标准,但这个标准有时候也在拖住你。你不是懒,你是在等一个"感觉对了"的信号——问题是,这个信号可能需要你先动起来才会出现。',
intensity: 68
},
fiveDim: {
scores: { xinli: 74, xingli: 52, ganzhi: 83, dongjian: 71, dingli: 78 },
evidence: {
xinli: ['「用户说:我情绪上还好,就是有点麻木」'],
xingli: ['「用户说:明明知道该动了但就是不想」'],
ganzhi: ['「用户说:我很容易感受到别人的情绪变化」'],
dongjian: ['「用户说:我总是能看到别人看不到的问题」'],
dingli: ['「用户说:我很少轻易改变一个已经想清楚的决定」']
},
interpretation: '你的感知力和定力是真正的优势——你能读到别人读不到的信号,也能在别人动摇时保持方向。行力偏低不是能力问题,而是启动门槛较高,需要找到"值得动"的理由。'
},
personalityReading: [
{ point: '你是先想清楚再动的人,但"想清楚"这个标准有时候没有终点', quote: '「用户说:我需要把事情在脑子里过一遍才能开始」', explain: '这种习惯让你少走弯路,但也容易让你卡在起点。真正的清晰往往是行动带来的,不是想出来的。' },
{ point: '你对"真实"有很高要求,不愿意做表面文章', quote: '「用户说:我不喜欢应付,要么就好好做,要么就不做」', explain: '这是你的核心特质,也是别人信任你的原因。但对自己也需要留一点余地——不是所有事都值得全力以赴。' },
{ point: '你的感受比你表达出来的更丰富', quote: '「用户说:有些话说出来也没用,就算了」', explain: '你习惯内化而不是外化,这让你看起来比实际上更平静。找到一两个可以真正倾诉的出口,会帮你减轻很多无形的重量。' }
],
potentialBlindspots: {
potentials: ['系统性思维:你天然会从整体视角看问题,这在需要策略规划的场景里价值极高', '高信任感:你说话算数,别人感受得到,这是很稀缺的特质'],
blindspots: ['完美主义陷阱:你对质量的追求有时候会变成推迟启动的理由', '情绪内化过度:你处理内心感受的效率比表达的效率高,但压着不说会积累']
},
mbti: {
type: 'INFP',
typeName: '调停者',
description: '从你的回答里我看到一个典型INFP的样子内心有很清晰的价值标准对"意义感"极度敏感不愿意做自己不认同的事。你的行动力不强不是因为懒而是因为你在等一件真正值得投入的事。INFP最大的挑战是把内在的丰富世界和外部现实连接起来——而你现在正在经历这个过程。'
},
zodiac: {
sign: 'capricorn',
name: '摩羯座',
symbol: '♑',
coreTraits: '你目标感强,能长期扛压并稳定推进。',
blindSpot: '你容易把效率放在感受前面,久了会内耗。',
lingjingLine: '你会登山,也要记得补氧。',
supportKey: '在执行系统里加入固定的恢复机制。',
userOverlay: '你说"明明知道该动了但就是不想",这恰恰是摩羯特有的状态——不是没有方向,而是对结果的要求太高,在没有把握之前宁可按住自己。摩羯的你,一旦认定方向就能走得很远,但你现在需要的不是再想更久,而是给自己一个"够好即可出发"的许可。',
fusionText: '摩羯的你,天生有一种扛得住的韧性——别人撑不下去的时候,你还在。你说"明明知道该动了但就是不想",这不是拖延,这是摩羯式的自我保护:不确定就不动,动了就要做好。这种严格其实是你的优势,但在当下这个阶段,它需要稍微松一松。你不需要等到完全准备好,摩羯登山不是一步到顶,而是一步一步往上走,补氧再走。'
},
presentSignal: {
signalName: '行动窗口正在开启',
urgency: 'high',
trigger: '当你发现自己开始主动搜索某件事、反复想某个方向的时候',
meaning: '那不是随机的念头,那是你内在已经准备好了的信号。你的"想清楚"过程已经完成了很大一部分,现在缺的只是第一步的启动。',
riskIfMissed: '再等下去,这个窗口不会等你——它会变成"当初要是做就好了"的遗憾。'
},
pivotAction: {
onePivot: '今天找出一件你已经想了超过两周但还没开始的事给它设定一个30分钟的启动时间',
threeStarts: ['把脑子里的那件事写下来,哪怕一句话', '找到它最小的第一步是什么不超过15分钟能完成的', '告诉一个人你要开始做这件事(说出来会形成承诺感)']
},
closingLine: '你不是还没准备好,你只是还没决定出发。'
};
res.json({ status: 'done', report: mockReport });
});
server.get('/api/report/full', (req, res) => {
server.post('/api/report', async (req, res) => {
const { sessionId } = req.body || {};
const s = sessions.get(sessionId);
if (!s) return res.status(404).json({ error: 'session_not_found' });
if (s.report) return res.json({ ok: true, status: 'done', report: s.report });
if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' });
try {
const report = await generateReportForSession(sessionId);
return res.json({ ok: true, status: 'done', report });
} catch (e) {
s.reportStatus = 'error';
s.reportError = e?.message || 'generate_failed';
return res.status(500).json({ ok: false, status: 'error', error: s.reportError });
}
});
server.get('/api/report', (req, res) => {
const s = sessions.get(req.query.sessionId);
if (!s) return res.json(fullReport({ answers: [] }));
return res.json(fullReport(s));
if (!s) return res.status(404).json({ status: 'error', error: 'session_not_found' });
if (s.report) return res.json({ status: 'done', report: s.report });
if (s.reportStatus === 'generating') return res.json({ status: 'generating' });
if (s.reportStatus === 'error') return res.status(500).json({ status: 'error', error: s.reportError || 'generate_failed' });
return res.json({ status: 'not_started' });
});
server.use((req, res) => handle(req, res));