);
}
-// ── 主页面 ────────────────────────────────────────────────
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' &&
正在生成你的报告...
diff --git a/server.js b/server.js
index b3df160..96c83f3 100644
--- a/server.js
+++ b/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' });