443 lines
21 KiB
JavaScript
443 lines
21 KiB
JavaScript
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}`);
|
||
});
|
||
});
|