const fs = require('fs'); const express = require('express'); const next = require('next'); const dotenv = require('dotenv'); dotenv.config(); const dev = process.env.NODE_ENV !== 'production'; const port = Number(process.env.PORT || 3200); const app = next({ dev, dir: __dirname }); const handle = app.getRequestHandler(); const sessions = new Map(); const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?'; const zodiacProfiles = { aries: { name: '白羊座', fusion: '你身上有很直接的行动火花,关键是把冲劲变成稳定推进。' }, taurus: { name: '金牛座', fusion: '你重视确定感,先小步验证会比一直等待更有力量。' }, gemini: { name: '双子座', fusion: '你思路灵活,先定主线后扩展,效率会明显提升。' }, cancer: { name: '巨蟹座', fusion: '你情感细腻,先稳住自己,才能更好照顾关系和目标。' }, leo: { name: '狮子座', fusion: '你有担当感,外在发力的同时要记得给内在补能。' }, virgo: { name: '处女座', fusion: '你重视质量,先完成草稿再优化,比等待完美更快。' }, libra: { name: '天秤座', fusion: '你擅长平衡,关键在于练习有边界的取舍。' }, scorpio: { name: '天蝎座', fusion: '你并不是慢,而是要确认值不值得投入;一旦启动就很有穿透力。' }, sagittarius: { name: '射手座', fusion: '你有探索力,把热情绑定阶段目标会更稳。' }, capricorn: { name: '摩羯座', fusion: '你擅长长期推进,记得把恢复机制纳入执行系统。' }, aquarius: { name: '水瓶座', fusion: '你的独立视角很珍贵,落地后才会变成现实影响。' }, pisces: { name: '双鱼座', fusion: '你的感受力是天赋,先稳情绪再行动会更顺。' }, }; function detectZodiac(month, day) { if (!month || !day) return null; const md = month * 100 + day; if (md >= 321 && md <= 419) return 'aries'; if (md >= 420 && md <= 520) return 'taurus'; if (md >= 521 && md <= 621) return 'gemini'; if (md >= 622 && md <= 722) return 'cancer'; if (md >= 723 && md <= 822) return 'leo'; if (md >= 823 && md <= 922) return 'virgo'; if (md >= 923 && md <= 1023) return 'libra'; if (md >= 1024 && md <= 1122) return 'scorpio'; if (md >= 1123 && md <= 1221) return 'sagittarius'; if ((md >= 1222 && md <= 1231) || (md >= 101 && md <= 119)) return 'capricorn'; if (md >= 120 && md <= 218) return 'aquarius'; if (md >= 219 && md <= 320) return 'pisces'; return null; } function extractBirthday(text) { if (!text) return null; const cleaned = String(text).replace(/\s+/g, ''); let m = cleaned.match(/(1[0-2]|[1-9])月([0-2]?\d|3[01])(?:日|号)?/); if (!m) m = cleaned.match(/(1[0-2]|0?[1-9])[\/-](3[01]|[12]?\d)/); if (!m) return null; const month = Number(m[1]); const day = Number(m[2]); if (month < 1 || month > 12 || day < 1 || day > 31) return null; return { month, day }; } function sanitizeReply(text) { if (!text) return ''; // 移除所有控制字符(包括Unicode控制字符),保留普通换行和空格 return text .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // ASCII控制字符 .replace(/[\u0080-\u009F]/g, '') // C1控制字符 .replace(/\u0000/g, '') // null字节 .replace(/[\uFFF0-\uFFFF]/g, '') // Unicode特殊符 .replace(/\n{3,}/g, '\n\n') // 多余换行压缩 .trim(); } function parseJSONFromText(text) { if (!text) return null; try { return JSON.parse(text); } catch { const block = text.match(/```json\s*([\s\S]*?)```/i); if (block) { try { return JSON.parse(block[1]); } catch { return null; } } const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start >= 0 && end > start) { try { return JSON.parse(text.slice(start, end + 1)); } catch { return null; } } return null; } } function loadSystemPrompt() { const p = '/root/Projects/dochub-next/content-private/project-lingjing/system-prompt-v1.md'; try { return fs.readFileSync(p, 'utf8').slice(0, 12000); } catch { return '你是灵镜,一个温暖、自然的一问一答助手。'; } } async function callYcapis(input, maxOutputTokens = 500) { const key = process.env.YCAPIS_API_KEY; if (!key) return null; try { const res = await fetch('https://ycapis.com/v1/responses', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}`, }, body: JSON.stringify({ model: 'gpt-5.3-codex', input, max_output_tokens: maxOutputTokens, }), }); if (!res.ok) return null; const data = await res.json(); const raw = data?.output?.[0]?.content?.[0]?.text || null; return raw ? sanitizeReply(raw) : null; } catch { return null; } } function fallbackReport(session) { const zodiacSign = session?.birthday ? detectZodiac(session.birthday.month, session.birthday.day) : null; return { soulTags: ['敏感但清醒', '想改变', '有行动潜力'], currentState: { title: '在转折前的蓄力期', summary: '你已经明确感受到当下状态需要调整,也愿意正视问题。现在最关键的不是再想更多,而是把注意力集中到一个可执行的小行动上,用真实动作带动状态回升。', intensity: 68, }, fiveDim: { scores: { xinli: 66, xingli: 58, ganzhi: 74, dongjian: 70, dingli: 62 }, evidence: { xinli: ['「用户说:我最近总是拖着。」'], xingli: ['「用户说:明明知道该动了但就是不想。」'], ganzhi: ['「用户说:我会在意别人怎么看。」'], dongjian: ['「用户说:我知道问题不只是懒。」'], dingli: ['「用户说:我还是想把这件事做好。」'], }, interpretation: '你有不错的洞察和感受力,当前主要卡点在启动与持续。只要先把行动门槛降下来,整体状态会明显改善。', }, personalityReading: [ { point: '你并不逃避现实', quote: '「用户说:我知道该动了。」', explain: '你对问题是看见的,这意味着你具备修正能力。' }, { point: '你容易被压力拖慢', quote: '「用户说:就是不想动。」', explain: '不是没能力,而是启动前消耗了太多心理能量。' }, { point: '你仍有稳定的目标感', quote: '「用户说:还是想做好。」', explain: '内在方向感还在,只需要更轻的执行路径。' }, ], potentialBlindspots: { potentials: ['你能快速发现问题本质,适合做复盘和策略规划。', '你对关系和情绪变化敏感,沟通潜力高。'], blindspots: ['你会在开始前反复确认,导致行动延迟。', '你容易把阶段波动当成长期失败。'], }, mbti: { type: 'INFP', typeName: '调停者', description: '从你的表达看,你更像先在心里确认价值感,再进入行动。你重视感受和意义,一旦目标与你的内在认同对齐,执行会明显变稳。', }, zodiac: zodiacSign ? { sign: zodiacSign, name: zodiacProfiles[zodiacSign].name, fusionText: zodiacProfiles[zodiacSign].fusion } : null, presentSignal: { signalName: '启动阻力正在放大', urgency: 'high', trigger: '当你连续两天都在想“再等等”时', meaning: '这说明你已经进入了“想做但不敢开头”的循环,越拖越重。', riskIfMissed: '如果继续拖延,你会把短期卡点误判成长期无力。', }, pivotAction: { onePivot: '今天只做一件15分钟就能完成的小任务,并立即收尾。', threeStarts: ['把目标拆成今天版本,不超过3步。', '设置固定开工时间,先做再评估。', '完成后写一句复盘:我做到了什么。'], }, closingLine: '你真正缺的不是能力,而是一次不等完美就开始的动作。', }; } async function generateReportForSession(sessionId) { const session = sessions.get(sessionId); if (!session) return null; if (session.reportStatus === 'generating') return null; if (session.report) return session.report; session.reportStatus = 'generating'; session.reportError = ''; const birthday = session.birthday || null; const transcript = session.answers.map((a, i) => ({ q: session.questions[i] || `第${i + 1}题`, a })); const reportSystemPrompt = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。 要求: 1. 必须返回严格的JSON格式,不要有任何多余文字 2. 每个结论必须引用用户原话,格式:「用户说:……」 3. 用大白话,禁止专业术语(心流、人格原型、依恋模式等) 4. 五维评分直接输出0-100整数 5. MBTI根据对话综合判断,不要死套测试题 6. 如果没有生日信息,zodiac字段必须是null 返回以下JSON结构: { "soulTags": ["标签1", "标签2", "标签3"], "currentState": { "title": "当下状态标题", "summary": "150-200字描述", "intensity": 65 }, "fiveDim": { "scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 }, "evidence": { "xinli": ["「用户说:……」"], "xingli": ["「用户说:……」"], "ganzhi": ["「用户说:……」"], "dongjian": ["「用户说:……」"], "dingli": ["「用户说:……」"] }, "interpretation": "100-150字整体解读" }, "personalityReading": [ { "point": "核心特点", "quote": "「用户说:……」", "explain": "50-80字解释" }, { "point": "核心特点2", "quote": "「用户说:……」", "explain": "50-80字解释" }, { "point": "核心特点3", "quote": "「用户说:……」", "explain": "50-80字解释" } ], "potentialBlindspots": { "potentials": ["潜能1(50字)", "潜能2(50字)"], "blindspots": ["盲区1(50字)", "盲区2(50字)"] }, "mbti": { "type": "INFP", "typeName": "调停者", "description": "150-200字基于对话的MBTI解读" }, "zodiac": null, "presentSignal": { "signalName": "信号名称", "urgency": "high", "trigger": "当……出现时", "meaning": "这意味着……(80字)", "riskIfMissed": "如果错过……(50字)" }, "pivotAction": { "onePivot": "今天就能做的一个主行动", "threeStarts": ["第一件事", "第二件事", "第三件事"] }, "closingLine": "一句话总结,适合截图分享" }`; const userInput = { birthday, zodiacHint: birthday ? detectZodiac(birthday.month, birthday.day) : null, transcript, }; const text = await callYcapis([ { role: 'system', content: reportSystemPrompt }, { role: 'user', content: JSON.stringify(userInput) }, ], 4000); let parsed = parseJSONFromText(text); if (!parsed) parsed = fallbackReport(session); if (birthday) { const sign = detectZodiac(birthday.month, birthday.day); if (sign && (!parsed.zodiac || parsed.zodiac === null)) { parsed.zodiac = { sign, name: zodiacProfiles[sign].name, fusionText: zodiacProfiles[sign].fusion }; } } else { parsed.zodiac = null; } session.report = parsed; session.reportStatus = 'done'; return parsed; } app.prepare().then(() => { const server = express(); server.use(express.json({ limit: '2mb' })); server.post('/api/session/new', (req, res) => { const sessionId = `lgj_${Date.now().toString(36)}`; sessions.set(sessionId, { index: 0, answers: [], questions: [], skips: 0, prompt: loadSystemPrompt(), report: null, reportStatus: 'not_started', reportError: '', birthday: null, }); res.json({ sessionId, opening, total: 30 }); }); server.post('/api/chat', async (req, res) => { const { sessionId, answer } = req.body || {}; const s = sessions.get(sessionId); if (!s) return res.status(404).json({ error: 'session_not_found' }); const userAnswer = (answer || '').trim(); if (userAnswer) { s.answers.push(userAnswer); const b = extractBirthday(userAnswer); if (b) s.birthday = b; s.skips = userAnswer === '跳过' ? s.skips + 1 : 0; s.index += 1; } const done = s.index >= 30; if (done) { if (s.reportStatus !== 'generating' && !s.report) { generateReportForSession(sessionId).catch((err) => { s.reportStatus = 'error'; s.reportError = err?.message || 'generate_failed'; }); } return res.json({ done: true, reply: '你已经完成全部对话。接下来我会为你生成专属灵镜报告。', sessionId }); } const progress = s.index > 0 && s.index % 6 === 0 ? `(你已经完成第${Math.ceil(s.index / 6)}/5阶段了。)` : ''; const birthdayHint = s.index === 27 ? '这一题请自然地加一句:顺便问一下,你是几月几号生的?' : ''; const aiReply = await callYcapis([ { role: 'system', content: s.prompt }, { role: 'assistant', content: opening }, ...s.answers.flatMap((a, i) => [ { role: 'assistant', content: s.questions[i] || '' }, { role: 'user', content: a }, ]).filter((m) => m.content), { role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}请先接住用户,再继续下一问。只问一个问题,口语化、大白话。当前第${s.index + 1}题/30。${birthdayHint}` }, ], 300); const fallback = s.index === 27 ? '我听懂了。顺便问一下,你是几月几号生的?' : '嗯,我听到了。我们继续,最近这几天有什么小事让你心情变好?'; const nextQuestion = aiReply || fallback; s.questions.push(nextQuestion); return res.json({ done: false, reply: sanitizeReply(nextQuestion), index: s.index + 1, total: 30, 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) => { const { sessionId } = req.body || {}; const s = sessions.get(sessionId); if (!s) return res.status(404).json({ error: 'session_not_found' }); if (s.report) return res.json({ ok: true, status: 'done', report: s.report }); if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' }); try { const report = await generateReportForSession(sessionId); return res.json({ ok: true, status: 'done', report }); } catch (e) { s.reportStatus = 'error'; s.reportError = e?.message || 'generate_failed'; return res.status(500).json({ ok: false, status: 'error', error: s.reportError }); } }); server.get('/api/report', (req, res) => { const s = sessions.get(req.query.sessionId); if (!s) return res.status(404).json({ status: 'error', error: 'session_not_found' }); if (s.report) return res.json({ status: 'done', report: s.report }); if (s.reportStatus === 'generating') return res.json({ status: 'generating' }); if (s.reportStatus === 'error') return res.status(500).json({ status: 'error', error: s.reportError || 'generate_failed' }); return res.json({ status: 'not_started' }); }); server.use((req, res) => handle(req, res)); server.listen(port, () => { console.log(`Lingjing listening on ${port}`); }); });