feat: v3.0封版——混合题型气泡UI,Session文件持久化,报告生成修复,已完成自动跳报告页

This commit is contained in:
root 2026-02-23 15:51:30 +00:00
parent 0211f6a148
commit 3340b6ace3
3 changed files with 298 additions and 249 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules/
.next/ .next/
.env .env
*.tar.gz *.tar.gz
data/

View File

@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
// Canvas
function Starfield() { function Starfield() {
const canvasRef = useRef(null); const canvasRef = useRef(null);
useEffect(() => { useEffect(() => {
@ -62,16 +61,9 @@ function Starfield() {
window.removeEventListener('resize', resize); window.removeEventListener('resize', resize);
}; };
}, []); }, []);
return ( return <canvas ref={canvasRef} className="fixed inset-0 z-0" style={{ background: '#05030f' }} />;
<canvas
ref={canvasRef}
className="fixed inset-0 z-0"
style={{ background: '#05030f' }}
/>
);
} }
//
function FadeText({ text, visible, className = '' }) { function FadeText({ text, visible, className = '' }) {
return ( return (
<div <div
@ -83,10 +75,8 @@ function FadeText({ text, visible, className = '' }) {
); );
} }
//
function Intro({ onDone }) { function Intro({ onDone }) {
const [phase, setPhase] = useState(0); const [phase, setPhase] = useState(0);
// 0: 1: 2: 3: 4: 5:
useEffect(() => { useEffect(() => {
const timers = [ const timers = [
setTimeout(() => setPhase(1), 600), setTimeout(() => setPhase(1), 600),
@ -100,24 +90,10 @@ function Intro({ onDone }) {
return ( return (
<div className="relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8 text-center"> <div className="relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8 text-center">
<FadeText <FadeText text="灵镜" visible={phase === 1 || phase === 2} className="text-5xl font-light tracking-[0.3em] text-white/90" />
text="灵镜" <FadeText text="一场温柔但有穿透力的对话" visible={phase === 3 || phase === 4} className="text-lg font-light tracking-widest text-white/60" />
visible={phase === 1 || phase === 2} <div className="transition-all duration-1000" style={{ opacity: phase >= 5 ? 1 : 0, transform: phase >= 5 ? 'translateY(0)' : 'translateY(12px)' }}>
className="text-5xl font-light tracking-[0.3em] text-white/90" <button onClick={onDone} style={{ color: 'white', textDecoration: 'underline', textUnderlineOffset: '4px', fontSize: '0.875rem', letterSpacing: '0.2em', background: 'none', border: 'none', cursor: 'pointer' }}>
/>
<FadeText
text="一场温柔但有穿透力的对话"
visible={phase === 3 || phase === 4}
className="text-lg font-light tracking-widest text-white/60"
/>
<div
className="transition-all duration-1000"
style={{ opacity: phase >= 5 ? 1 : 0, transform: phase >= 5 ? 'translateY(0)' : 'translateY(12px)' }}
>
<button
onClick={onDone}
style={{ color: 'white', textDecoration: 'underline', textUnderlineOffset: '4px', fontSize: '0.875rem', letterSpacing: '0.2em', background: 'none', border: 'none', cursor: 'pointer' }}
>
开始探索 开始探索
</button> </button>
</div> </div>
@ -125,34 +101,50 @@ function Intro({ onDone }) {
); );
} }
// const fixedOptions = {
function QuestionView({ question, onAnswer, questionIndex, total }) { fixed_gender: ['男生', '女生', '不想说'],
fixed_age: ['20以下', '20-25', '26-30', '31-40', '40以上'],
fixed_birth_month: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
fixed_birth_day: Array.from({ length: 31 }, (_, i) => `${i + 1}`),
};
function QuestionView({ prompt, onAnswer }) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [leaving, setLeaving] = useState(false); const [leaving, setLeaving] = useState(false);
const [picked, setPicked] = useState('');
const inputRef = useRef(null); const inputRef = useRef(null);
useEffect(() => { useEffect(() => {
setVisible(false); setVisible(false);
setInput(''); setInput('');
setLeaving(false); setLeaving(false);
setPicked('');
const t = setTimeout(() => { const t = setTimeout(() => {
setVisible(true); setVisible(true);
if ((prompt?.type || 'text') === 'text') {
setTimeout(() => inputRef.current?.focus(), 600); setTimeout(() => inputRef.current?.focus(), 600);
}
}, 100); }, 100);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [question]); }, [prompt]);
const submit = () => { const submit = (val) => {
if (!input.trim()) return; const answer = (val || '').trim();
if (!answer) return;
setLeaving(true); setLeaving(true);
setTimeout(() => onAnswer(input.trim()), 800); setTimeout(() => onAnswer(answer), 700);
}; };
const type = prompt?.type || 'text';
const isChoice = type === 'choice' || type.startsWith('fixed_');
const question = prompt?.question || prompt?.reply || '';
const options = type === 'choice' ? (prompt.options || []) : (fixedOptions[type] || []);
const compact = type === 'fixed_birth_day';
return ( return (
<div className="chat-page relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8"> <div className="chat-page relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8">
<div className="w-full max-w-md space-y-8"> <div className="w-full max-w-2xl space-y-8">
{/* 问题 */}
<div <div
className="text-center text-lg font-light leading-relaxed tracking-wide text-white/85 transition-all duration-700" className="text-center text-lg font-light leading-relaxed tracking-wide text-white/85 transition-all duration-700"
style={{ style={{
@ -163,13 +155,41 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
{question} {question}
</div> </div>
{/* 输入 */} {isChoice ? (
<div
className="flex flex-wrap justify-center gap-3 transition-all duration-700"
style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
>
{options.map((op, i) => {
const selected = picked === op;
const othersFade = picked && picked !== op;
return (
<button
key={op}
type="button"
onClick={() => {
if (picked) return;
setPicked(op);
const ms = selected ? 300 : 300;
setTimeout(() => submit(op), ms);
}}
className={`rounded-full border border-white/30 text-white/90 transition-all duration-300 ${compact ? 'px-3 py-1.5 text-sm' : 'px-5 py-2.5 text-base'} ${selected ? 'bg-white/25' : 'bg-white/10 hover:bg-white/20'}`}
style={{
animation: `bubbleFloat ${2 + (i % 3) * 0.5}s ease-in-out infinite`,
animationDelay: `${i * 0.12}s`,
opacity: selected ? 0 : othersFade ? 0 : 1,
transform: selected ? 'scale(1.3)' : othersFade ? 'scale(0.8)' : 'scale(1)',
}}
>
{op}
</button>
);
})}
</div>
) : (
<div <div
className="transition-all duration-700" className="transition-all duration-700"
style={{ style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
opacity: visible && !leaving ? 1 : 0,
transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)',
}}
> >
<textarea <textarea
ref={inputRef} ref={inputRef}
@ -179,7 +199,7 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
submit(); submit(input);
} }
}} }}
placeholder="输入你的回答,或输入 跳过" placeholder="输入你的回答,或输入 跳过"
@ -188,55 +208,65 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<button <button
type="button" type="button"
onClick={submit} onClick={() => submit(input)}
disabled={!input.trim()} disabled={!input.trim()}
className="continue-btn !text-white underline underline-offset-4 text-sm tracking-widest disabled:opacity-100" className="continue-btn !text-white underline underline-offset-4 text-sm tracking-widest disabled:opacity-100"
style={{ style={{ color: '#fff', background: 'none', border: 'none', cursor: input.trim() ? 'pointer' : 'not-allowed', letterSpacing: '0.1em' }}
color: '#fff',
background: 'none',
border: 'none',
cursor: input.trim() ? 'pointer' : 'not-allowed',
letterSpacing: '0.1em'
}}
> >
继续 继续
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
<style>{`
@keyframes bubbleFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
`}</style>
</div> </div>
); );
} }
//
export default function ChatPage() { export default function ChatPage() {
const [stage, setStage] = useState('intro'); // intro | chat | waiting | loading | done const [stage, setStage] = useState('intro');
const [question, setQuestion] = useState(''); const [prompt, setPrompt] = useState({ type: 'text', reply: '' });
const [qIndex, setQIndex] = useState(0);
const [total, setTotal] = useState(30);
const [sid, setSid] = useState(''); const [sid, setSid] = useState('');
const router = useRouter(); const router = useRouter();
// sessiondone
useEffect(() => {
const savedSid = localStorage.getItem('lingjing_sid');
if (!savedSid) return;
fetch(`/api/report?sessionId=${encodeURIComponent(savedSid)}`)
.then(r => r.json())
.then(d => {
if (d?.status === 'done') {
router.replace(`/report-preview?sessionId=${encodeURIComponent(savedSid)}`);
}
})
.catch(() => {});
}, [router]);
const startChat = async () => { const startChat = async () => {
setStage('loading'); setStage('loading');
try { try {
const r = await fetch('/api/session/new', { method: 'POST' }); const r = await fetch('/api/session/new', { method: 'POST' });
const d = await r.json(); const d = await r.json();
setSid(d.sessionId); const sessionId = d.sessionId || '';
localStorage.setItem('lingjing_sid', d.sessionId); setSid(sessionId);
setQuestion(d.opening); localStorage.setItem('lingjing_sid', sessionId);
setQIndex(1); setPrompt({ type: 'text', reply: d.opening || d.reply || '' });
setTotal(d.total || 30);
setStage('chat'); setStage('chat');
} catch { } catch {
setStage('chat'); setStage('chat');
setQuestion('开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?'); setPrompt({ type: 'text', reply: '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?' });
setQIndex(1);
} }
}; };
const handleAnswer = async (answer) => { const handleAnswer = async (answer) => {
setStage('waiting'); // setStage('waiting');
try { try {
const r = await fetch('/api/chat', { const r = await fetch('/api/chat', {
method: 'POST', method: 'POST',
@ -250,12 +280,16 @@ export default function ChatPage() {
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200); setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
return; return;
} }
setQuestion(d.reply); if (d.type === 'choice') {
setQIndex((i) => i + 1); setPrompt({ type: 'choice', question: d.question, options: d.options || [] });
} else if (d.type && d.type.startsWith('fixed_')) {
setPrompt({ type: d.type, question: d.question });
} else {
setPrompt({ type: 'text', reply: d.reply || d.question || '我听到了,我们继续。' });
}
setStage('chat'); setStage('chat');
} catch { } catch {
setQuestion('网络有点问题,我们继续。你刚才的回答我已经记住了。'); setPrompt({ type: 'text', reply: '网络有点问题,我们继续。你刚才的回答我已经记住了。' });
setQIndex((i) => i + 1);
setStage('chat'); setStage('chat');
} }
}; };
@ -266,18 +300,10 @@ export default function ChatPage() {
{stage === 'intro' && <Intro onDone={startChat} />} {stage === 'intro' && <Intro onDone={startChat} />}
{stage === 'waiting' && ( {stage === 'waiting' && (
<div className="relative z-10 flex h-screen h-dvh items-center justify-center"> <div className="relative z-10 flex h-screen h-dvh items-center justify-center">
<div <div className="text-base tracking-[0.3em] text-white/60" style={{ animation: 'breathe 2.4s ease-in-out infinite' }}>
className="text-base tracking-[0.3em] text-white/60"
style={{ animation: 'breathe 2.4s ease-in-out infinite' }}
>
探索中 探索中
</div> </div>
<style>{` <style>{`@keyframes breathe { 0%,100%{opacity:0.2;} 50%{opacity:0.9;} }`}</style>
@keyframes breathe {
0%, 100% { opacity: 0.2; }
50% { opacity: 0.9; }
}
`}</style>
</div> </div>
)} )}
{stage === 'loading' && ( {stage === 'loading' && (
@ -285,14 +311,7 @@ export default function ChatPage() {
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在连接...</div> <div className="text-sm tracking-widest text-white/30 animate-pulse">正在连接...</div>
</div> </div>
)} )}
{stage === 'chat' && ( {stage === 'chat' && <QuestionView prompt={prompt} onAnswer={handleAnswer} />}
<QuestionView
question={question}
onAnswer={handleAnswer}
questionIndex={qIndex}
total={total}
/>
)}
{stage === 'done' && ( {stage === 'done' && (
<div className="relative z-10 flex h-screen h-dvh items-center justify-center"> <div className="relative z-10 flex h-screen h-dvh items-center justify-center">
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在生成你的报告...</div> <div className="text-sm tracking-widest text-white/30 animate-pulse">正在生成你的报告...</div>

303
server.js
View File

@ -1,4 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const fsP = require('fs/promises');
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');
@ -11,6 +13,8 @@ const app = next({ dev, dir: __dirname });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
const sessions = new Map(); const sessions = new Map();
const sessionsDir = path.join(__dirname, 'data', 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?'; const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
@ -29,6 +33,48 @@ const zodiacProfiles = {
pisces: { name: '双鱼座', fusion: '你的感受力是天赋,先稳情绪再行动会更顺。' }, pisces: { name: '双鱼座', fusion: '你的感受力是天赋,先稳情绪再行动会更顺。' },
}; };
function defaultSession() {
return {
index: 0,
answers: [],
questions: [],
skips: 0,
prompt: loadSystemPrompt(),
report: null,
reportStatus: 'not_started',
reportError: '',
birthday: null,
profile: { gender: '', ageRange: '' },
choiceCount: 0,
fixedCount: 0,
lastQuestionType: 'text',
};
}
async function saveSession(sid, sessionObj) {
const p = path.join(sessionsDir, `${sid}.json`);
fsP.writeFile(p, JSON.stringify(sessionObj), 'utf8').catch(() => {});
}
async function loadSession(sid) {
try {
const p = path.join(sessionsDir, `${sid}.json`);
const raw = await fsP.readFile(p, 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
async function getSession(sid) {
if (sessions.has(sid)) return sessions.get(sid);
const loaded = await loadSession(sid);
if (!loaded) return null;
const merged = { ...defaultSession(), ...loaded };
sessions.set(sid, merged);
return merged;
}
function detectZodiac(month, day) { function detectZodiac(month, day) {
if (!month || !day) return null; if (!month || !day) return null;
const md = month * 100 + day; const md = month * 100 + day;
@ -59,18 +105,6 @@ function extractBirthday(text) {
return { month, day }; 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) { function parseJSONFromText(text) {
if (!text) return null; if (!text) return null;
try { try {
@ -124,8 +158,7 @@ async function callYcapis(input, maxOutputTokens = 500) {
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
const raw = data?.output?.[0]?.content?.[0]?.text || null; return data?.output?.[0]?.content?.[0]?.text || null;
return raw ? sanitizeReply(raw) : null;
} catch { } catch {
return null; return null;
} }
@ -182,16 +215,17 @@ function fallbackReport(session) {
} }
async function generateReportForSession(sessionId) { async function generateReportForSession(sessionId) {
const session = sessions.get(sessionId); const session = await getSession(sessionId);
if (!session) return null; if (!session) return null;
if (session.reportStatus === 'generating') return null; if (session.reportStatus === 'generating') return null;
if (session.report) return session.report; if (session.report) return session.report;
session.reportStatus = 'generating'; session.reportStatus = 'generating';
session.reportError = ''; session.reportError = '';
saveSession(sessionId, session);
const birthday = session.birthday || null; const birthday = session.birthday || null;
const transcript = session.answers.map((a, i) => ({ q: session.questions[i] || `${i + 1}`, a })); const transcript = session.answers.map((a, i) => ({ q: session.questions[i]?.text || session.questions[i] || `${i + 1}`, a }));
const reportSystemPrompt = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。 const reportSystemPrompt = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。
@ -203,63 +237,50 @@ async function generateReportForSession(sessionId) {
5. MBTI根据对话综合判断不要死套测试题 5. MBTI根据对话综合判断不要死套测试题
6. 如果没有生日信息zodiac字段必须是null 6. 如果没有生日信息zodiac字段必须是null
返回以下JSON结构 严格返回以下JSON结构不能缺字段
{ {
"soulTags": ["标签1", "标签2", "标签3"], "soulTags": ["标签1", "标签2", "标签3"],
"currentState": { "currentState": {
"title": "当下状态标题", "title": "当下状态标题10字内",
"summary": "150-200字描述", "summary": "150左右的描述",
"intensity": 65 "intensity": 65
}, },
"fiveDim": { "fiveDim": {
"scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 }, "scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 },
"evidence": { "interpretation": "100字左右的五维解读"
"xinli": ["「用户说:……」"],
"xingli": ["「用户说:……」"],
"ganzhi": ["「用户说:……」"],
"dongjian": ["「用户说:……」"],
"dingli": ["「用户说:……」"]
},
"interpretation": "100-150字整体解读"
}, },
"personalityReading": [ "personalityReading": [
{ "point": "核心特点", "quote": "「用户说:……」", "explain": "50-80字解释" }, { "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" },
{ "point": "核心特点2", "quote": "「用户说:……」", "explain": "50-80字解释" }, { "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" },
{ "point": "核心特点3", "quote": "「用户说:……」", "explain": "50-80字解释" } { "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" }
], ],
"potentialBlindspots": { "potentialBlindspots": {
"potentials": ["潜能150字", "潜能250字"], "potentials": ["潜能1", "潜能2", "潜能3"],
"blindspots": ["盲区150字", "盲区250字"] "blindspots": ["盲区1", "盲区2", "盲区3"]
}, },
"mbti": { "mbti": {
"type": "INFP", "type": "INFP",
"typeName": "调停者", "typeName": "调停者",
"description": "150-200字基于对话的MBTI解读" "description": "100字左右的MBTI解读"
}, },
"zodiac": null, "zodiac": null,
"presentSignal": { "presentSignal": {
"signalName": "信号名称", "signalName": "信号名称6字内",
"urgency": "high", "urgency": "medium",
"trigger": "当……出现时", "trigger": "是什么触发了这个信号",
"meaning": "这意味着……80字", "meaning": "这个信号意味着什么",
"riskIfMissed": "如果错过……50字" "riskIfMissed": "错过的代价"
}, },
"pivotAction": { "pivotAction": {
"onePivot": "今天就能做的一个主行动", "onePivot": "一句话说清楚最该做的一件事",
"threeStarts": ["第一件事", "第二件事", "第三件事"] "threeStarts": ["第一步", "第二步", "第三步"]
}, },
"closingLine": "一句话总结,适合截图分享" "closingLine": "一句收束金句20字内"
}`; }`;
const userInput = {
birthday,
zodiacHint: birthday ? detectZodiac(birthday.month, birthday.day) : null,
transcript,
};
const text = await callYcapis([ const text = await callYcapis([
{ role: 'system', content: reportSystemPrompt }, { role: 'system', content: reportSystemPrompt },
{ role: 'user', content: JSON.stringify(userInput) }, { role: 'user', content: JSON.stringify({ birthday, transcript }) },
], 4000); ], 4000);
let parsed = parseJSONFromText(text); let parsed = parseJSONFromText(text);
@ -276,32 +297,64 @@ async function generateReportForSession(sessionId) {
session.report = parsed; session.report = parsed;
session.reportStatus = 'done'; session.reportStatus = 'done';
saveSession(sessionId, session);
return parsed; return parsed;
} }
function normalizeQuestionPayload(aiText, session) {
const parsed = parseJSONFromText(aiText);
const idx = session.index;
if (parsed && parsed.type) {
const type = parsed.type;
const isFixed = ['fixed_gender', 'fixed_age', 'fixed_birth_month', 'fixed_birth_day'].includes(type);
if (isFixed) {
// fixed_birth_day 允许紧跟 fixed_birth_month月日必须连续
const prevIsMonth = session.lastQuestionType === 'fixed_birth_month';
const isBirthDay = type === 'fixed_birth_day';
const blockConsecutive = session.lastQuestionType.startsWith('fixed_') && !(prevIsMonth && isBirthDay);
if (idx < 3 || blockConsecutive) {
return { type: 'text', reply: parsed.question || '我听到了,我们继续聊聊你最近的状态。' };
}
session.fixedCount += 1;
session.lastQuestionType = type;
return { type, question: parsed.question || '顺便问一下。' };
}
if (type === 'choice') {
const options = Array.isArray(parsed.options) ? parsed.options.slice(0, 4).filter(Boolean) : [];
if (options.length >= 2) {
session.choiceCount += 1;
session.lastQuestionType = 'choice';
return {
type: 'choice',
question: parsed.question || '你更接近下面哪种情况?',
options,
};
}
}
}
session.lastQuestionType = 'text';
return { type: 'text', reply: aiText || '我听到了。我们继续。' };
}
app.prepare().then(() => { app.prepare().then(() => {
const server = express(); const server = express();
server.use(express.json({ limit: '2mb' })); server.use(express.json({ limit: '2mb' }));
server.post('/api/session/new', (req, res) => { server.post('/api/session/new', async (req, res) => {
const sessionId = `lgj_${Date.now().toString(36)}`; const sessionId = `lgj_${Date.now().toString(36)}`;
sessions.set(sessionId, { const s = defaultSession();
index: 0, sessions.set(sessionId, s);
answers: [], saveSession(sessionId, s);
questions: [], res.json({ sessionId, opening, total: 30, type: 'text', reply: opening });
skips: 0,
prompt: loadSystemPrompt(),
report: null,
reportStatus: 'not_started',
reportError: '',
birthday: null,
});
res.json({ sessionId, opening, total: 30 });
}); });
server.post('/api/chat', async (req, res) => { server.post('/api/chat', async (req, res) => {
const { sessionId, answer } = req.body || {}; const { sessionId, answer } = req.body || {};
const s = sessions.get(sessionId); const s = await getSession(sessionId);
if (!s) return res.status(404).json({ error: 'session_not_found' }); if (!s) return res.status(404).json({ error: 'session_not_found' });
const userAnswer = (answer || '').trim(); const userAnswer = (answer || '').trim();
@ -309,108 +362,83 @@ app.prepare().then(() => {
s.answers.push(userAnswer); s.answers.push(userAnswer);
const b = extractBirthday(userAnswer); const b = extractBirthday(userAnswer);
if (b) s.birthday = b; if (b) s.birthday = b;
if (s.lastQuestionType === 'fixed_gender') s.profile.gender = userAnswer;
if (s.lastQuestionType === 'fixed_age') s.profile.ageRange = userAnswer;
if (s.lastQuestionType === 'fixed_birth_month' && !s.birthday) {
const m = userAnswer.match(/(1[0-2]|[1-9])/);
if (m) s.birthday = { month: Number(m[1]), day: 1 };
}
if (s.lastQuestionType === 'fixed_birth_day' && s.birthday && s.birthday.month) {
const d = userAnswer.match(/(3[01]|[12]?\d)/);
if (d) s.birthday.day = Number(d[1]);
}
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) {
saveSession(sessionId, s);
if (s.reportStatus !== 'generating' && !s.report) { if (s.reportStatus !== 'generating' && !s.report) {
generateReportForSession(sessionId).catch((err) => { generateReportForSession(sessionId).catch((err) => {
s.reportStatus = 'error'; s.reportStatus = 'error';
s.reportError = err?.message || 'generate_failed'; s.reportError = err?.message || 'generate_failed';
saveSession(sessionId, s);
}); });
} }
return res.json({ done: true, reply: '你已经完成全部对话。接下来我会为你生成专属灵镜报告。', sessionId }); 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 ? '这一题请自然地加一句:顺便问一下,你是几月几号生的?' : '';
const aiReply = await callYcapis([ const ds = await callYcapis([
{ role: 'system', content: s.prompt }, { role: 'system', content: `${s.prompt}
你必须遵守出题规则
- 30题里约10题返回选择题
- 选择题返回JSON{"type":"choice","question":"...","options":["A","B","C","D"]}
- 开放题返回纯文字问题
- 适当时机返回固定采集题JSON
{"type":"fixed_gender","question":"顺便问一下,你是"}
{"type":"fixed_age","question":"你大概是哪个年龄段的"}
{"type":"fixed_birth_month","question":"你是几月份出生的"}
{"type":"fixed_birth_day","question":"几号生日"}
- 固定采集题不要放在前3题
- 不要连续两题都是固定采集题
- 如果上题是 fixed_birth_month优先下一题用 fixed_birth_day` },
{ 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]?.text || 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 + 1}题/30。${birthdayHint}` }, {
], 300); role: 'user',
content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}当前第${s.index + 1}题/30。当前统计choice=${s.choiceCount}, fixed=${s.fixedCount}, lastType=${s.lastQuestionType}。请按规则产出下一题。`,
},
], 320);
const fallback = s.index === 27 const fallbackText = '我听到了。我们继续,你最近哪件事最让你有成就感?';
? '我听懂了。顺便问一下,你是几月几号生的?' const payload = normalizeQuestionPayload(ds || fallbackText, s);
: '嗯,我听到了。我们继续,最近这几天有什么小事让你心情变好?';
const nextQuestion = aiReply || fallback; const textForHistory = payload.question || payload.reply || '';
s.questions.push(nextQuestion); s.questions.push({ type: payload.type, text: textForHistory, options: payload.options || [] });
saveSession(sessionId, s);
return res.json({ done: false, reply: sanitizeReply(nextQuestion), index: s.index + 1, total: 30, sessionId }); if (payload.type === 'choice') {
}); return res.json({ done: false, type: 'choice', question: payload.question, options: payload.options, index: s.index + 1, sessionId });
}
if (payload.type.startsWith('fixed_')) {
return res.json({ done: false, type: payload.type, question: payload.question, index: s.index + 1, sessionId });
}
// 测试用:直接返回模拟报告数据 return res.json({ done: false, type: 'text', reply: payload.reply, index: s.index + 1, sessionId });
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.post('/api/report', async (req, res) => { server.post('/api/report', async (req, res) => {
const { sessionId } = req.body || {}; const { sessionId } = req.body || {};
const s = sessions.get(sessionId); const s = await getSession(sessionId);
if (!s) return res.status(404).json({ error: 'session_not_found' }); 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.report) return res.json({ ok: true, status: 'done', report: s.report });
if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' }); if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' });
@ -421,12 +449,13 @@ app.prepare().then(() => {
} catch (e) { } catch (e) {
s.reportStatus = 'error'; s.reportStatus = 'error';
s.reportError = e?.message || 'generate_failed'; s.reportError = e?.message || 'generate_failed';
saveSession(sessionId, s);
return res.status(500).json({ ok: false, status: 'error', error: s.reportError }); return res.status(500).json({ ok: false, status: 'error', error: s.reportError });
} }
}); });
server.get('/api/report', (req, res) => { server.get('/api/report', async (req, res) => {
const s = sessions.get(req.query.sessionId); const s = await getSession(req.query.sessionId);
if (!s) return res.status(404).json({ status: 'error', error: 'session_not_found' }); 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.report) return res.json({ status: 'done', report: s.report });
if (s.reportStatus === 'generating') return res.json({ status: 'generating' }); if (s.reportStatus === 'generating') return res.json({ status: 'generating' });