472 lines
19 KiB
JavaScript
472 lines
19 KiB
JavaScript
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}`);
|
||
});
|
||
});
|