feat: v2.0封版——完整报告页(9模块+动画+MBTI+星座),waiting页,修复报告生成时序
This commit is contained in:
parent
b88877e799
commit
0211f6a148
@ -246,7 +246,8 @@ export default function ChatPage() {
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.done) {
|
if (d.done) {
|
||||||
setStage('done');
|
setStage('done');
|
||||||
setTimeout(() => router.push('/waiting'), 1200);
|
const targetSid = d.sessionId || sid;
|
||||||
|
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setQuestion(d.reply);
|
setQuestion(d.reply);
|
||||||
|
|||||||
@ -1,30 +1,317 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Starfield from '../../components/Starfield';
|
||||||
import Shell from '../../components/Shell';
|
|
||||||
|
|
||||||
export default function ReportPreviewPage() {
|
const ZODIAC_SYMBOL = {
|
||||||
const [data, setData] = useState(null);
|
aries: '♈', taurus: '♉', gemini: '♊', cancer: '♋', leo: '♌', virgo: '♍', libra: '♎', scorpio: '♏', sagittarius: '♐', capricorn: '♑', aquarius: '♒', pisces: '♓',
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
function mbtiColor(type = '') {
|
||||||
const sid = localStorage.getItem('lingjing_sid');
|
const t = type.toUpperCase();
|
||||||
if (!sid) return;
|
if (['INTJ', 'INTP', 'ENTJ', 'ENTP'].includes(t)) return 'text-violet-300 border-violet-400/50';
|
||||||
fetch(`/api/report/preview?sessionId=${encodeURIComponent(sid)}`)
|
if (['INFJ', 'INFP', 'ENFJ', 'ENFP'].includes(t)) return 'text-emerald-300 border-emerald-400/50';
|
||||||
.then((r) => r.json())
|
if (['ISTJ', 'ISFJ', 'ESTJ', 'ESFJ'].includes(t)) return 'text-sky-300 border-sky-400/50';
|
||||||
.then(setData);
|
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 (
|
return (
|
||||||
<Shell title="报告预览(免费版)" subtitle="先看核心摘要,完整版可继续查看。">
|
<svg viewBox="0 0 320 320" className="mx-auto w-full max-w-[380px]">
|
||||||
<div className="card space-y-4 p-6 text-sm">
|
{[20, 40, 60, 80, 100].map((step) => {
|
||||||
<p className="font-medium">{data?.userSnapshot?.summary || '你现在处在“想改变,但还没找到最顺手路径”的阶段。'}</p>
|
const rr = r * step / 100;
|
||||||
<p>{data?.highlight?.content || '你对变化是有行动意愿的,只是容易在选择上分散精力。'}</p>
|
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(' ');
|
||||||
<p className="text-neutral-600">{data?.teaser?.lockedHint || '完整版将告诉你哪条路线最适合你现在的节奏。'}</p>
|
return <polygon key={step} points={ring} fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="1" />;
|
||||||
<Link href="/report-full" className="btn-primary">
|
})}
|
||||||
查看完整报告(演示)
|
{labels.map((l, i) => {
|
||||||
</Link>
|
const a = -Math.PI / 2 + Math.PI * 2 * i / 5;
|
||||||
</div>
|
return (
|
||||||
</Shell>
|
<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)=>JSX,拿到visible状态做子元素动画
|
||||||
|
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">
|
||||||
|
“{data.closingLine}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,137 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Shell from '../../components/Shell';
|
import Starfield from '../../components/Starfield';
|
||||||
|
|
||||||
|
const MESSAGES = [
|
||||||
|
'正在整理你的回答线索……',
|
||||||
|
'正在校准你的五维镜像……',
|
||||||
|
'正在提炼你的当下信号……',
|
||||||
|
'正在生成你的支点行动……',
|
||||||
|
];
|
||||||
|
|
||||||
export default function WaitingPage() {
|
export default function WaitingPage() {
|
||||||
const [progress, setProgress] = useState(8);
|
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => setIdx((n) => (n + 1) % MESSAGES.length), 3000);
|
||||||
setProgress((p) => Math.min(p + 12, 100));
|
return () => clearInterval(t);
|
||||||
}, 600);
|
}, []);
|
||||||
const done = setTimeout(() => {
|
|
||||||
const sid = typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') : '';
|
useEffect(() => {
|
||||||
router.push(`/report-preview?sid=${encodeURIComponent(sid || '')}`);
|
if (!sid) return;
|
||||||
}, 5000);
|
localStorage.setItem('lingjing_sid', sid);
|
||||||
return () => {
|
|
||||||
clearInterval(t);
|
let alive = true;
|
||||||
clearTimeout(done);
|
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) { // 45次x2秒=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 (
|
return (
|
||||||
<Shell title="正在生成你的灵镜报告" subtitle="正在整理你的回答与关键线索,请稍候。">
|
<main className="relative min-h-screen overflow-hidden bg-[#05030f] text-white">
|
||||||
<div className="card p-6">
|
<Starfield className="absolute inset-0 h-full w-full" animated />
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-neutral-200">
|
<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">
|
||||||
<div className="h-full rounded-full bg-black transition-all" style={{ width: `${progress}%` }} />
|
<h1 className="text-3xl font-semibold">灵镜正在为你生成报告</h1>
|
||||||
</div>
|
<p className="mt-6 text-lg text-white/80">{MESSAGES[idx]}</p>
|
||||||
<p className="mt-3 text-sm text-neutral-600">{progress}%</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>
|
</div>
|
||||||
</Shell>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
components/Starfield.jsx
Normal file
78
components/Starfield.jsx
Normal 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
428
server.js
@ -1,5 +1,4 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const next = require('next');
|
const next = require('next');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
@ -15,49 +14,99 @@ const sessions = new Map();
|
|||||||
|
|
||||||
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
|
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
|
||||||
|
|
||||||
const questionBank = [
|
const zodiacProfiles = {
|
||||||
'你今年大概在哪个年龄段?比如20岁以下、20-29、30-39这样。',
|
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';
|
||||||
'接下来3个月,你最想改善的一件事是什么?',
|
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() {
|
function loadSystemPrompt() {
|
||||||
const p = '/root/Projects/dochub-next/content-private/project-lingjing/system-prompt-v1.md';
|
const p = '/root/Projects/dochub-next/content-private/project-lingjing/system-prompt-v1.md';
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(p, 'utf8').slice(0, 8000);
|
return fs.readFileSync(p, 'utf8').slice(0, 12000);
|
||||||
} catch {
|
} catch {
|
||||||
return '你是灵镜,一个温暖、自然的一问一答助手。';
|
return '你是灵镜,一个温暖、自然的一问一答助手。';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callAI(messages) {
|
async function callYcapis(input, maxOutputTokens = 500) {
|
||||||
const key = process.env.YCAPIS_API_KEY;
|
const key = process.env.YCAPIS_API_KEY;
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
try {
|
try {
|
||||||
@ -69,60 +118,184 @@ async function callAI(messages) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'gpt-5.3-codex',
|
model: 'gpt-5.3-codex',
|
||||||
input: messages.map(m => ({ role: m.role, content: m.content })),
|
input,
|
||||||
max_output_tokens: 300,
|
max_output_tokens: maxOutputTokens,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewReport(session) {
|
function fallbackReport(session) {
|
||||||
|
const zodiacSign = session?.birthday ? detectZodiac(session.birthday.month, session.birthday.day) : null;
|
||||||
return {
|
return {
|
||||||
version: 'free_v1',
|
soulTags: ['敏感但清醒', '想改变', '有行动潜力'],
|
||||||
reportType: 'free',
|
currentState: {
|
||||||
userSnapshot: { summary: '你当前状态是:有改变意愿,但需要更聚焦的行动节奏。' },
|
title: '在转折前的蓄力期',
|
||||||
highlight: { title: '你的亮点', content: '你能清楚表达自己的真实感受,这会让你更快找到方向。' },
|
summary: '你已经明确感受到当下状态需要调整,也愿意正视问题。现在最关键的不是再想更多,而是把注意力集中到一个可执行的小行动上,用真实动作带动状态回升。',
|
||||||
evidence: [{ quote: session.answers[0] || '(示例)', meaning: '你愿意打开自己,这是成长的起点。' }],
|
intensity: 68,
|
||||||
currentBlock: { title: '当前阻碍', content: '你容易想太多再行动,导致动力被消耗。' },
|
},
|
||||||
oneActionThisWeek: { title: '本周动作', content: '选一件最小任务,限定30分钟,今天就开始。' },
|
fiveDim: {
|
||||||
teaser: { lockedHint: '完整版会给你稳妥/成长/冲刺三条路线和30天动作计划。', upgradeText: '继续查看完整报告' },
|
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) {
|
async function generateReportForSession(sessionId) {
|
||||||
return {
|
const session = sessions.get(sessionId);
|
||||||
version: 'pro_v1',
|
if (!session) return null;
|
||||||
reportType: 'pro',
|
if (session.reportStatus === 'generating') return null;
|
||||||
profileSummary: {
|
if (session.report) return session.report;
|
||||||
oneLineDiagnosis: '你是行动意愿强、但容易被多目标分散的人。先聚焦一个主线,会明显提速。',
|
|
||||||
currentStage: '调整期',
|
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: [
|
"interpretation": "100-150字整体解读"
|
||||||
{ name: '自我觉察', description: '你能描述自己的状态变化,便于快速纠偏。' },
|
},
|
||||||
{ name: '现实感', description: '你会考虑真实条件,不容易盲目冲动。' },
|
"personalityReading": [
|
||||||
{ name: '执行意愿', description: '你愿意从小步开始,这对长期成长很关键。' },
|
{ "point": "核心特点", "quote": "「用户说:……」", "explain": "50-80字解释" },
|
||||||
],
|
{ "point": "核心特点2", "quote": "「用户说:……」", "explain": "50-80字解释" },
|
||||||
routes: [
|
{ "point": "核心特点3", "quote": "「用户说:……」", "explain": "50-80字解释" }
|
||||||
{ routeType: 'stable', title: '稳妥线', fitReason: '先稳住节奏与作息,每周完成固定小目标。' },
|
],
|
||||||
{ routeType: 'growth', title: '成长线', fitReason: '围绕一个能力做30天刻意练习,建立优势杠杆。' },
|
"potentialBlindspots": {
|
||||||
{ routeType: 'sprint', title: '冲刺线', fitReason: '聚焦一件高价值目标,用短周期冲刺验证上限。' },
|
"potentials": ["潜能1(50字)", "潜能2(50字)"],
|
||||||
],
|
"blindspots": ["盲区1(50字)", "盲区2(50字)"]
|
||||||
sourceAnswers: session.answers,
|
},
|
||||||
|
"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(() => {
|
app.prepare().then(() => {
|
||||||
const server = express();
|
const server = express();
|
||||||
server.use(express.json({ limit: '1mb' }));
|
server.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
server.post('/api/session/new', (req, res) => {
|
server.post('/api/session/new', (req, res) => {
|
||||||
const sessionId = `lgj_${Date.now().toString(36)}`;
|
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 });
|
res.json({ sessionId, opening, total: 30 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -134,46 +307,131 @@ app.prepare().then(() => {
|
|||||||
const userAnswer = (answer || '').trim();
|
const userAnswer = (answer || '').trim();
|
||||||
if (userAnswer) {
|
if (userAnswer) {
|
||||||
s.answers.push(userAnswer);
|
s.answers.push(userAnswer);
|
||||||
|
const b = extractBirthday(userAnswer);
|
||||||
|
if (b) s.birthday = b;
|
||||||
s.skips = userAnswer === '跳过' ? s.skips + 1 : 0;
|
s.skips = userAnswer === '跳过' ? s.skips + 1 : 0;
|
||||||
s.index += 1;
|
s.index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const done = s.index >= 30;
|
const done = s.index >= 30;
|
||||||
if (done) {
|
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 progress = s.index > 0 && s.index % 6 === 0 ? `(你已经完成第${Math.ceil(s.index / 6)}/5阶段了。)` : '';
|
||||||
|
const birthdayHint = s.index === 27 ? '这一题请自然地加一句:顺便问一下,你是几月几号生的?' : '';
|
||||||
|
|
||||||
// 全部交给AI动态生成,不用硬编码题库
|
const aiReply = await callYcapis([
|
||||||
const ds = await callAI([
|
|
||||||
{ role: 'system', content: s.prompt },
|
{ role: 'system', content: s.prompt },
|
||||||
{ role: 'assistant', content: opening },
|
{ role: 'assistant', content: opening },
|
||||||
...s.answers.flatMap((a, i) => [
|
...s.answers.flatMap((a, i) => [
|
||||||
{ role: 'assistant', content: s.questions[i] || '' },
|
{ role: 'assistant', content: s.questions[i] || '' },
|
||||||
{ role: 'user', content: a }
|
{ role: 'user', content: a },
|
||||||
]).filter(m => m.content),
|
]).filter((m) => m.content),
|
||||||
{ role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}请用温暖口语接住他的回答,然后继续问下一个问题。注意:只问一个问题,用大白话,不用专业术语。当前已问第${s.index}题,共30题。` }
|
{ role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}请先接住用户,再继续下一问。只问一个问题,口语化、大白话。当前第${s.index + 1}题/30。${birthdayHint}` },
|
||||||
]);
|
], 300);
|
||||||
|
|
||||||
const aiReply = ds || '嗯,我听到了。我们继续,你现在主要在做什么?上学、上班、还是在休整?';
|
const fallback = s.index === 27
|
||||||
|
? '我听懂了。顺便问一下,你是几月几号生的?'
|
||||||
|
: '嗯,我听到了。我们继续,最近这几天有什么小事让你心情变好?';
|
||||||
|
|
||||||
// 记录AI问的问题
|
const nextQuestion = aiReply || fallback;
|
||||||
s.questions.push(aiReply);
|
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);
|
server.get('/api/report/mock', (req, res) => {
|
||||||
if (!s) return res.json(previewReport({ answers: [] }));
|
const mockReport = {
|
||||||
return res.json(previewReport(s));
|
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);
|
const s = sessions.get(req.query.sessionId);
|
||||||
if (!s) return res.json(fullReport({ answers: [] }));
|
if (!s) return res.status(404).json({ status: 'error', error: 'session_not_found' });
|
||||||
return res.json(fullReport(s));
|
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));
|
server.use((req, res) => handle(req, res));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user