lingjing/server.js

443 lines
21 KiB
JavaScript
Raw 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 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": ["潜能150字", "潜能250字"],
"blindspots": ["盲区150字", "盲区250字"]
},
"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}`);
});
});