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'); 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 sessionsDir = path.join(__dirname, 'data', 'sessions'); fs.mkdirSync(sessionsDir, { recursive: true }); 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 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; 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 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(); return data?.output?.[0]?.content?.[0]?.text || 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 = 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]?.text || 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": "当下状态标题(10字内)", "summary": "150字左右的描述", "intensity": 65 }, "fiveDim": { "scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 }, "interpretation": "100字左右的五维解读" }, "personalityReading": [ { "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" }, { "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" }, { "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" } ], "potentialBlindspots": { "potentials": ["潜能1", "潜能2", "潜能3"], "blindspots": ["盲区1", "盲区2", "盲区3"] }, "mbti": { "type": "INFP", "typeName": "调停者", "description": "100字左右的MBTI解读" }, "zodiac": null, "presentSignal": { "signalName": "信号名称(6字内)", "urgency": "medium", "trigger": "是什么触发了这个信号", "meaning": "这个信号意味着什么", "riskIfMissed": "错过的代价" }, "pivotAction": { "onePivot": "一句话说清楚最该做的一件事", "threeStarts": ["第一步", "第二步", "第三步"] }, "closingLine": "一句收束金句,20字内" }`; const text = await callYcapis([ { role: 'system', content: reportSystemPrompt }, { role: 'user', content: JSON.stringify({ birthday, transcript }) }, ], 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'; 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', async (req, res) => { const sessionId = `lgj_${Date.now().toString(36)}`; 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 = await getSession(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; 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 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]?.text || s.questions[i] || '' }, { role: 'user', content: a }, ]).filter((m) => m.content), { role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}当前第${s.index + 1}题/30。当前统计:choice=${s.choiceCount}, fixed=${s.fixedCount}, lastType=${s.lastQuestionType}。请按规则产出下一题。`, }, ], 320); const fallbackText = '我听到了。我们继续,你最近哪件事最让你有成就感?'; const payload = normalizeQuestionPayload(ds || fallbackText, s); const textForHistory = payload.question || payload.reply || ''; s.questions.push({ type: payload.type, text: textForHistory, options: payload.options || [] }); saveSession(sessionId, s); 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.post('/api/report', async (req, res) => { const { sessionId } = req.body || {}; 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' }); 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'; saveSession(sessionId, s); return res.status(500).json({ ok: false, status: 'error', error: s.reportError }); } }); 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' }); 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}`); }); });