lingjing/server.js

472 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}`);
});
});