feat: v3.0封版——混合题型气泡UI,Session文件持久化,报告生成修复,已完成自动跳报告页
This commit is contained in:
parent
0211f6a148
commit
3340b6ace3
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ node_modules/
|
||||
.next/
|
||||
.env
|
||||
*.tar.gz
|
||||
data/
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// ── 星空Canvas ──────────────────────────────────────────
|
||||
function Starfield() {
|
||||
const canvasRef = useRef(null);
|
||||
useEffect(() => {
|
||||
@ -62,16 +61,9 @@ function Starfield() {
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 z-0"
|
||||
style={{ background: '#05030f' }}
|
||||
/>
|
||||
);
|
||||
return <canvas ref={canvasRef} className="fixed inset-0 z-0" style={{ background: '#05030f' }} />;
|
||||
}
|
||||
|
||||
// ── 淡入淡出文字组件 ──────────────────────────────────────
|
||||
function FadeText({ text, visible, className = '' }) {
|
||||
return (
|
||||
<div
|
||||
@ -83,10 +75,8 @@ function FadeText({ text, visible, className = '' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 开场序列 ──────────────────────────────────────────────
|
||||
function Intro({ onDone }) {
|
||||
const [phase, setPhase] = useState(0);
|
||||
// 0: 黑 → 1: 灵镜出现 → 2: 灵镜消失 → 3: 副标题出现 → 4: 副标题消失 → 5: 按钮出现
|
||||
useEffect(() => {
|
||||
const timers = [
|
||||
setTimeout(() => setPhase(1), 600),
|
||||
@ -100,24 +90,10 @@ function Intro({ onDone }) {
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8 text-center">
|
||||
<FadeText
|
||||
text="灵镜"
|
||||
visible={phase === 1 || phase === 2}
|
||||
className="text-5xl font-light tracking-[0.3em] text-white/90"
|
||||
/>
|
||||
<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' }}
|
||||
>
|
||||
<FadeText text="灵镜" visible={phase === 1 || phase === 2} className="text-5xl font-light tracking-[0.3em] text-white/90" />
|
||||
<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>
|
||||
</div>
|
||||
@ -125,34 +101,50 @@ function Intro({ onDone }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 单问题沉浸视图 ────────────────────────────────────────
|
||||
function QuestionView({ question, onAnswer, questionIndex, total }) {
|
||||
const fixedOptions = {
|
||||
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 [input, setInput] = useState('');
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [picked, setPicked] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(false);
|
||||
setInput('');
|
||||
setLeaving(false);
|
||||
setPicked('');
|
||||
const t = setTimeout(() => {
|
||||
setVisible(true);
|
||||
if ((prompt?.type || 'text') === 'text') {
|
||||
setTimeout(() => inputRef.current?.focus(), 600);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearTimeout(t);
|
||||
}, [question]);
|
||||
}, [prompt]);
|
||||
|
||||
const submit = () => {
|
||||
if (!input.trim()) return;
|
||||
const submit = (val) => {
|
||||
const answer = (val || '').trim();
|
||||
if (!answer) return;
|
||||
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 (
|
||||
<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
|
||||
className="text-center text-lg font-light leading-relaxed tracking-wide text-white/85 transition-all duration-700"
|
||||
style={{
|
||||
@ -163,13 +155,41 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
|
||||
{question}
|
||||
</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
|
||||
className="transition-all duration-700"
|
||||
style={{
|
||||
opacity: visible && !leaving ? 1 : 0,
|
||||
transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)',
|
||||
}}
|
||||
style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@ -179,7 +199,7 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
submit(input);
|
||||
}
|
||||
}}
|
||||
placeholder="输入你的回答,或输入 跳过"
|
||||
@ -188,55 +208,65 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
onClick={() => submit(input)}
|
||||
disabled={!input.trim()}
|
||||
className="continue-btn !text-white underline underline-offset-4 text-sm tracking-widest disabled:opacity-100"
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: input.trim() ? 'pointer' : 'not-allowed',
|
||||
letterSpacing: '0.1em'
|
||||
}}
|
||||
style={{ color: '#fff', background: 'none', border: 'none', cursor: input.trim() ? 'pointer' : 'not-allowed', letterSpacing: '0.1em' }}
|
||||
>
|
||||
继续 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes bubbleFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主页面 ────────────────────────────────────────────────
|
||||
export default function ChatPage() {
|
||||
const [stage, setStage] = useState('intro'); // intro | chat | waiting | loading | done
|
||||
const [question, setQuestion] = useState('');
|
||||
const [qIndex, setQIndex] = useState(0);
|
||||
const [total, setTotal] = useState(30);
|
||||
const [stage, setStage] = useState('intro');
|
||||
const [prompt, setPrompt] = useState({ type: 'text', reply: '' });
|
||||
const [sid, setSid] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
// 启动时检查:如果已有session且报告done,直接跳报告页
|
||||
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 () => {
|
||||
setStage('loading');
|
||||
try {
|
||||
const r = await fetch('/api/session/new', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
setSid(d.sessionId);
|
||||
localStorage.setItem('lingjing_sid', d.sessionId);
|
||||
setQuestion(d.opening);
|
||||
setQIndex(1);
|
||||
setTotal(d.total || 30);
|
||||
const sessionId = d.sessionId || '';
|
||||
setSid(sessionId);
|
||||
localStorage.setItem('lingjing_sid', sessionId);
|
||||
setPrompt({ type: 'text', reply: d.opening || d.reply || '' });
|
||||
setStage('chat');
|
||||
} catch {
|
||||
setStage('chat');
|
||||
setQuestion('开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?');
|
||||
setQIndex(1);
|
||||
setPrompt({ type: 'text', reply: '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswer = async (answer) => {
|
||||
setStage('waiting'); // 显示探索中
|
||||
setStage('waiting');
|
||||
try {
|
||||
const r = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
@ -250,12 +280,16 @@ export default function ChatPage() {
|
||||
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
|
||||
return;
|
||||
}
|
||||
setQuestion(d.reply);
|
||||
setQIndex((i) => i + 1);
|
||||
if (d.type === 'choice') {
|
||||
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');
|
||||
} catch {
|
||||
setQuestion('网络有点问题,我们继续。你刚才的回答我已经记住了。');
|
||||
setQIndex((i) => i + 1);
|
||||
setPrompt({ type: 'text', reply: '网络有点问题,我们继续。你刚才的回答我已经记住了。' });
|
||||
setStage('chat');
|
||||
}
|
||||
};
|
||||
@ -266,18 +300,10 @@ export default function ChatPage() {
|
||||
{stage === 'intro' && <Intro onDone={startChat} />}
|
||||
{stage === 'waiting' && (
|
||||
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
||||
<div
|
||||
className="text-base tracking-[0.3em] text-white/60"
|
||||
style={{ animation: 'breathe 2.4s ease-in-out infinite' }}
|
||||
>
|
||||
<div className="text-base tracking-[0.3em] text-white/60" style={{ animation: 'breathe 2.4s ease-in-out infinite' }}>
|
||||
探索中……
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
`}</style>
|
||||
<style>{`@keyframes breathe { 0%,100%{opacity:0.2;} 50%{opacity:0.9;} }`}</style>
|
||||
</div>
|
||||
)}
|
||||
{stage === 'loading' && (
|
||||
@ -285,14 +311,7 @@ export default function ChatPage() {
|
||||
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在连接...</div>
|
||||
</div>
|
||||
)}
|
||||
{stage === 'chat' && (
|
||||
<QuestionView
|
||||
question={question}
|
||||
onAnswer={handleAnswer}
|
||||
questionIndex={qIndex}
|
||||
total={total}
|
||||
/>
|
||||
)}
|
||||
{stage === 'chat' && <QuestionView prompt={prompt} onAnswer={handleAnswer} />}
|
||||
{stage === 'done' && (
|
||||
<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>
|
||||
|
||||
303
server.js
303
server.js
@ -1,4 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const fsP = require('fs/promises');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const next = require('next');
|
||||
const dotenv = require('dotenv');
|
||||
@ -11,6 +13,8 @@ const app = next({ dev, dir: __dirname });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
const sessions = new Map();
|
||||
const sessionsDir = path.join(__dirname, 'data', 'sessions');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
|
||||
|
||||
@ -29,6 +33,48 @@ const zodiacProfiles = {
|
||||
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) {
|
||||
if (!month || !day) return null;
|
||||
const md = month * 100 + day;
|
||||
@ -59,18 +105,6 @@ function extractBirthday(text) {
|
||||
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 {
|
||||
@ -124,8 +158,7 @@ async function callYcapis(input, maxOutputTokens = 500) {
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const raw = data?.output?.[0]?.content?.[0]?.text || null;
|
||||
return raw ? sanitizeReply(raw) : null;
|
||||
return data?.output?.[0]?.content?.[0]?.text || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -182,16 +215,17 @@ function fallbackReport(session) {
|
||||
}
|
||||
|
||||
async function generateReportForSession(sessionId) {
|
||||
const session = sessions.get(sessionId);
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.reportStatus === 'generating') return null;
|
||||
if (session.report) return session.report;
|
||||
|
||||
session.reportStatus = 'generating';
|
||||
session.reportError = '';
|
||||
saveSession(sessionId, session);
|
||||
|
||||
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 = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。
|
||||
|
||||
@ -203,63 +237,50 @@ async function generateReportForSession(sessionId) {
|
||||
5. MBTI根据对话综合判断,不要死套测试题
|
||||
6. 如果没有生日信息,zodiac字段必须是null
|
||||
|
||||
返回以下JSON结构:
|
||||
严格返回以下JSON结构(不能缺字段):
|
||||
{
|
||||
"soulTags": ["标签1", "标签2", "标签3"],
|
||||
"currentState": {
|
||||
"title": "当下状态标题",
|
||||
"summary": "150-200字描述",
|
||||
"title": "当下状态标题(10字内)",
|
||||
"summary": "150字左右的描述",
|
||||
"intensity": 65
|
||||
},
|
||||
"fiveDim": {
|
||||
"scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 },
|
||||
"evidence": {
|
||||
"xinli": ["「用户说:……」"],
|
||||
"xingli": ["「用户说:……」"],
|
||||
"ganzhi": ["「用户说:……」"],
|
||||
"dongjian": ["「用户说:……」"],
|
||||
"dingli": ["「用户说:……」"]
|
||||
},
|
||||
"interpretation": "100-150字整体解读"
|
||||
"interpretation": "100字左右的五维解读"
|
||||
},
|
||||
"personalityReading": [
|
||||
{ "point": "核心特点", "quote": "「用户说:……」", "explain": "50-80字解释" },
|
||||
{ "point": "核心特点2", "quote": "「用户说:……」", "explain": "50-80字解释" },
|
||||
{ "point": "核心特点3", "quote": "「用户说:……」", "explain": "50-80字解释" }
|
||||
{ "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" },
|
||||
{ "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" },
|
||||
{ "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" }
|
||||
],
|
||||
"potentialBlindspots": {
|
||||
"potentials": ["潜能1(50字)", "潜能2(50字)"],
|
||||
"blindspots": ["盲区1(50字)", "盲区2(50字)"]
|
||||
"potentials": ["潜能1", "潜能2", "潜能3"],
|
||||
"blindspots": ["盲区1", "盲区2", "盲区3"]
|
||||
},
|
||||
"mbti": {
|
||||
"type": "INFP",
|
||||
"typeName": "调停者",
|
||||
"description": "150-200字基于对话的MBTI解读"
|
||||
"description": "100字左右的MBTI解读"
|
||||
},
|
||||
"zodiac": null,
|
||||
"presentSignal": {
|
||||
"signalName": "信号名称",
|
||||
"urgency": "high",
|
||||
"trigger": "当……出现时",
|
||||
"meaning": "这意味着……(80字)",
|
||||
"riskIfMissed": "如果错过……(50字)"
|
||||
"signalName": "信号名称(6字内)",
|
||||
"urgency": "medium",
|
||||
"trigger": "是什么触发了这个信号",
|
||||
"meaning": "这个信号意味着什么",
|
||||
"riskIfMissed": "错过的代价"
|
||||
},
|
||||
"pivotAction": {
|
||||
"onePivot": "今天就能做的一个主行动",
|
||||
"threeStarts": ["第一件事", "第二件事", "第三件事"]
|
||||
"onePivot": "一句话说清楚最该做的一件事",
|
||||
"threeStarts": ["第一步", "第二步", "第三步"]
|
||||
},
|
||||
"closingLine": "一句话总结,适合截图分享"
|
||||
"closingLine": "一句收束金句,20字内"
|
||||
}`;
|
||||
|
||||
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) },
|
||||
{ role: 'user', content: JSON.stringify({ birthday, transcript }) },
|
||||
], 4000);
|
||||
|
||||
let parsed = parseJSONFromText(text);
|
||||
@ -276,32 +297,64 @@ async function generateReportForSession(sessionId) {
|
||||
|
||||
session.report = parsed;
|
||||
session.reportStatus = 'done';
|
||||
saveSession(sessionId, session);
|
||||
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(() => {
|
||||
const server = express();
|
||||
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)}`;
|
||||
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 });
|
||||
const s = defaultSession();
|
||||
sessions.set(sessionId, s);
|
||||
saveSession(sessionId, s);
|
||||
res.json({ sessionId, opening, total: 30, type: 'text', reply: opening });
|
||||
});
|
||||
|
||||
server.post('/api/chat', async (req, res) => {
|
||||
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' });
|
||||
|
||||
const userAnswer = (answer || '').trim();
|
||||
@ -309,108 +362,83 @@ app.prepare().then(() => {
|
||||
s.answers.push(userAnswer);
|
||||
const b = extractBirthday(userAnswer);
|
||||
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.index += 1;
|
||||
}
|
||||
|
||||
const done = s.index >= 30;
|
||||
if (done) {
|
||||
saveSession(sessionId, s);
|
||||
if (s.reportStatus !== 'generating' && !s.report) {
|
||||
generateReportForSession(sessionId).catch((err) => {
|
||||
s.reportStatus = 'error';
|
||||
s.reportError = err?.message || 'generate_failed';
|
||||
saveSession(sessionId, s);
|
||||
});
|
||||
}
|
||||
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 ? '这一题请自然地加一句:顺便问一下,你是几月几号生的?' : '';
|
||||
|
||||
const aiReply = await callYcapis([
|
||||
{ role: 'system', content: s.prompt },
|
||||
const ds = await callYcapis([
|
||||
{ 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 },
|
||||
...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 },
|
||||
]).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;
|
||||
s.questions.push(nextQuestion);
|
||||
const textForHistory = payload.question || payload.reply || '';
|
||||
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 });
|
||||
}
|
||||
|
||||
// 测试用:直接返回模拟报告数据
|
||||
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 });
|
||||
return res.json({ done: false, type: 'text', reply: payload.reply, index: s.index + 1, sessionId });
|
||||
});
|
||||
|
||||
server.post('/api/report', async (req, res) => {
|
||||
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.report) return res.json({ ok: true, status: 'done', report: s.report });
|
||||
if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' });
|
||||
@ -421,12 +449,13 @@ app.prepare().then(() => {
|
||||
} catch (e) {
|
||||
s.reportStatus = 'error';
|
||||
s.reportError = e?.message || 'generate_failed';
|
||||
saveSession(sessionId, s);
|
||||
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);
|
||||
server.get('/api/report', async (req, res) => {
|
||||
const s = await getSession(req.query.sessionId);
|
||||
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' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user