feat: v3.0封版——混合题型气泡UI,Session文件持久化,报告生成修复,已完成自动跳报告页
This commit is contained in:
parent
0211f6a148
commit
3340b6ace3
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ node_modules/
|
|||||||
.next/
|
.next/
|
||||||
.env
|
.env
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
data/
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// ── 星空Canvas ──────────────────────────────────────────
|
|
||||||
function Starfield() {
|
function Starfield() {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -62,16 +61,9 @@ function Starfield() {
|
|||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return <canvas ref={canvasRef} className="fixed inset-0 z-0" style={{ background: '#05030f' }} />;
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="fixed inset-0 z-0"
|
|
||||||
style={{ background: '#05030f' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 淡入淡出文字组件 ──────────────────────────────────────
|
|
||||||
function FadeText({ text, visible, className = '' }) {
|
function FadeText({ text, visible, className = '' }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -83,10 +75,8 @@ function FadeText({ text, visible, className = '' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 开场序列 ──────────────────────────────────────────────
|
|
||||||
function Intro({ onDone }) {
|
function Intro({ onDone }) {
|
||||||
const [phase, setPhase] = useState(0);
|
const [phase, setPhase] = useState(0);
|
||||||
// 0: 黑 → 1: 灵镜出现 → 2: 灵镜消失 → 3: 副标题出现 → 4: 副标题消失 → 5: 按钮出现
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timers = [
|
const timers = [
|
||||||
setTimeout(() => setPhase(1), 600),
|
setTimeout(() => setPhase(1), 600),
|
||||||
@ -100,24 +90,10 @@ function Intro({ onDone }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8 text-center">
|
<div className="relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8 text-center">
|
||||||
<FadeText
|
<FadeText text="灵镜" visible={phase === 1 || phase === 2} className="text-5xl font-light tracking-[0.3em] text-white/90" />
|
||||||
text="灵镜"
|
<FadeText text="一场温柔但有穿透力的对话" visible={phase === 3 || phase === 4} className="text-lg font-light tracking-widest text-white/60" />
|
||||||
visible={phase === 1 || phase === 2}
|
<div className="transition-all duration-1000" style={{ opacity: phase >= 5 ? 1 : 0, transform: phase >= 5 ? 'translateY(0)' : 'translateY(12px)' }}>
|
||||||
className="text-5xl font-light tracking-[0.3em] text-white/90"
|
<button onClick={onDone} style={{ color: 'white', textDecoration: 'underline', textUnderlineOffset: '4px', fontSize: '0.875rem', letterSpacing: '0.2em', background: 'none', border: 'none', cursor: 'pointer' }}>
|
||||||
/>
|
|
||||||
<FadeText
|
|
||||||
text="一场温柔但有穿透力的对话"
|
|
||||||
visible={phase === 3 || phase === 4}
|
|
||||||
className="text-lg font-light tracking-widest text-white/60"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="transition-all duration-1000"
|
|
||||||
style={{ opacity: phase >= 5 ? 1 : 0, transform: phase >= 5 ? 'translateY(0)' : 'translateY(12px)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onDone}
|
|
||||||
style={{ color: 'white', textDecoration: 'underline', textUnderlineOffset: '4px', fontSize: '0.875rem', letterSpacing: '0.2em', background: 'none', border: 'none', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
开始探索
|
开始探索
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -125,34 +101,50 @@ function Intro({ onDone }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 单问题沉浸视图 ────────────────────────────────────────
|
const fixedOptions = {
|
||||||
function QuestionView({ question, onAnswer, questionIndex, total }) {
|
fixed_gender: ['男生', '女生', '不想说'],
|
||||||
|
fixed_age: ['20以下', '20-25', '26-30', '31-40', '40以上'],
|
||||||
|
fixed_birth_month: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||||||
|
fixed_birth_day: Array.from({ length: 31 }, (_, i) => `${i + 1}日`),
|
||||||
|
};
|
||||||
|
|
||||||
|
function QuestionView({ prompt, onAnswer }) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
const [picked, setPicked] = useState('');
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
setInput('');
|
setInput('');
|
||||||
setLeaving(false);
|
setLeaving(false);
|
||||||
|
setPicked('');
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
|
if ((prompt?.type || 'text') === 'text') {
|
||||||
setTimeout(() => inputRef.current?.focus(), 600);
|
setTimeout(() => inputRef.current?.focus(), 600);
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [question]);
|
}, [prompt]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = (val) => {
|
||||||
if (!input.trim()) return;
|
const answer = (val || '').trim();
|
||||||
|
if (!answer) return;
|
||||||
setLeaving(true);
|
setLeaving(true);
|
||||||
setTimeout(() => onAnswer(input.trim()), 800);
|
setTimeout(() => onAnswer(answer), 700);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const type = prompt?.type || 'text';
|
||||||
|
const isChoice = type === 'choice' || type.startsWith('fixed_');
|
||||||
|
const question = prompt?.question || prompt?.reply || '';
|
||||||
|
const options = type === 'choice' ? (prompt.options || []) : (fixedOptions[type] || []);
|
||||||
|
const compact = type === 'fixed_birth_day';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-page relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8">
|
<div className="chat-page relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-2xl space-y-8">
|
||||||
{/* 问题 */}
|
|
||||||
<div
|
<div
|
||||||
className="text-center text-lg font-light leading-relaxed tracking-wide text-white/85 transition-all duration-700"
|
className="text-center text-lg font-light leading-relaxed tracking-wide text-white/85 transition-all duration-700"
|
||||||
style={{
|
style={{
|
||||||
@ -163,13 +155,41 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
|
|||||||
{question}
|
{question}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 输入 */}
|
{isChoice ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap justify-center gap-3 transition-all duration-700"
|
||||||
|
style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
|
||||||
|
>
|
||||||
|
{options.map((op, i) => {
|
||||||
|
const selected = picked === op;
|
||||||
|
const othersFade = picked && picked !== op;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={op}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (picked) return;
|
||||||
|
setPicked(op);
|
||||||
|
const ms = selected ? 300 : 300;
|
||||||
|
setTimeout(() => submit(op), ms);
|
||||||
|
}}
|
||||||
|
className={`rounded-full border border-white/30 text-white/90 transition-all duration-300 ${compact ? 'px-3 py-1.5 text-sm' : 'px-5 py-2.5 text-base'} ${selected ? 'bg-white/25' : 'bg-white/10 hover:bg-white/20'}`}
|
||||||
|
style={{
|
||||||
|
animation: `bubbleFloat ${2 + (i % 3) * 0.5}s ease-in-out infinite`,
|
||||||
|
animationDelay: `${i * 0.12}s`,
|
||||||
|
opacity: selected ? 0 : othersFade ? 0 : 1,
|
||||||
|
transform: selected ? 'scale(1.3)' : othersFade ? 'scale(0.8)' : 'scale(1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className="transition-all duration-700"
|
className="transition-all duration-700"
|
||||||
style={{
|
style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
|
||||||
opacity: visible && !leaving ? 1 : 0,
|
|
||||||
transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@ -179,7 +199,7 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit(input);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入你的回答,或输入 跳过"
|
placeholder="输入你的回答,或输入 跳过"
|
||||||
@ -188,55 +208,65 @@ function QuestionView({ question, onAnswer, questionIndex, total }) {
|
|||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => submit(input)}
|
||||||
disabled={!input.trim()}
|
disabled={!input.trim()}
|
||||||
className="continue-btn !text-white underline underline-offset-4 text-sm tracking-widest disabled:opacity-100"
|
className="continue-btn !text-white underline underline-offset-4 text-sm tracking-widest disabled:opacity-100"
|
||||||
style={{
|
style={{ color: '#fff', background: 'none', border: 'none', cursor: input.trim() ? 'pointer' : 'not-allowed', letterSpacing: '0.1em' }}
|
||||||
color: '#fff',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: input.trim() ? 'pointer' : 'not-allowed',
|
|
||||||
letterSpacing: '0.1em'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
继续 →
|
继续 →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes bubbleFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-8px); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 主页面 ────────────────────────────────────────────────
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const [stage, setStage] = useState('intro'); // intro | chat | waiting | loading | done
|
const [stage, setStage] = useState('intro');
|
||||||
const [question, setQuestion] = useState('');
|
const [prompt, setPrompt] = useState({ type: 'text', reply: '' });
|
||||||
const [qIndex, setQIndex] = useState(0);
|
|
||||||
const [total, setTotal] = useState(30);
|
|
||||||
const [sid, setSid] = useState('');
|
const [sid, setSid] = useState('');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 启动时检查:如果已有session且报告done,直接跳报告页
|
||||||
|
useEffect(() => {
|
||||||
|
const savedSid = localStorage.getItem('lingjing_sid');
|
||||||
|
if (!savedSid) return;
|
||||||
|
fetch(`/api/report?sessionId=${encodeURIComponent(savedSid)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d?.status === 'done') {
|
||||||
|
router.replace(`/report-preview?sessionId=${encodeURIComponent(savedSid)}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const startChat = async () => {
|
const startChat = async () => {
|
||||||
setStage('loading');
|
setStage('loading');
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/session/new', { method: 'POST' });
|
const r = await fetch('/api/session/new', { method: 'POST' });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
setSid(d.sessionId);
|
const sessionId = d.sessionId || '';
|
||||||
localStorage.setItem('lingjing_sid', d.sessionId);
|
setSid(sessionId);
|
||||||
setQuestion(d.opening);
|
localStorage.setItem('lingjing_sid', sessionId);
|
||||||
setQIndex(1);
|
setPrompt({ type: 'text', reply: d.opening || d.reply || '' });
|
||||||
setTotal(d.total || 30);
|
|
||||||
setStage('chat');
|
setStage('chat');
|
||||||
} catch {
|
} catch {
|
||||||
setStage('chat');
|
setStage('chat');
|
||||||
setQuestion('开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?');
|
setPrompt({ type: 'text', reply: '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?' });
|
||||||
setQIndex(1);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAnswer = async (answer) => {
|
const handleAnswer = async (answer) => {
|
||||||
setStage('waiting'); // 显示探索中
|
setStage('waiting');
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/chat', {
|
const r = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -250,12 +280,16 @@ export default function ChatPage() {
|
|||||||
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
|
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setQuestion(d.reply);
|
if (d.type === 'choice') {
|
||||||
setQIndex((i) => i + 1);
|
setPrompt({ type: 'choice', question: d.question, options: d.options || [] });
|
||||||
|
} else if (d.type && d.type.startsWith('fixed_')) {
|
||||||
|
setPrompt({ type: d.type, question: d.question });
|
||||||
|
} else {
|
||||||
|
setPrompt({ type: 'text', reply: d.reply || d.question || '我听到了,我们继续。' });
|
||||||
|
}
|
||||||
setStage('chat');
|
setStage('chat');
|
||||||
} catch {
|
} catch {
|
||||||
setQuestion('网络有点问题,我们继续。你刚才的回答我已经记住了。');
|
setPrompt({ type: 'text', reply: '网络有点问题,我们继续。你刚才的回答我已经记住了。' });
|
||||||
setQIndex((i) => i + 1);
|
|
||||||
setStage('chat');
|
setStage('chat');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -266,18 +300,10 @@ export default function ChatPage() {
|
|||||||
{stage === 'intro' && <Intro onDone={startChat} />}
|
{stage === 'intro' && <Intro onDone={startChat} />}
|
||||||
{stage === 'waiting' && (
|
{stage === 'waiting' && (
|
||||||
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
||||||
<div
|
<div className="text-base tracking-[0.3em] text-white/60" style={{ animation: 'breathe 2.4s ease-in-out infinite' }}>
|
||||||
className="text-base tracking-[0.3em] text-white/60"
|
|
||||||
style={{ animation: 'breathe 2.4s ease-in-out infinite' }}
|
|
||||||
>
|
|
||||||
探索中……
|
探索中……
|
||||||
</div>
|
</div>
|
||||||
<style>{`
|
<style>{`@keyframes breathe { 0%,100%{opacity:0.2;} 50%{opacity:0.9;} }`}</style>
|
||||||
@keyframes breathe {
|
|
||||||
0%, 100% { opacity: 0.2; }
|
|
||||||
50% { opacity: 0.9; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stage === 'loading' && (
|
{stage === 'loading' && (
|
||||||
@ -285,14 +311,7 @@ export default function ChatPage() {
|
|||||||
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在连接...</div>
|
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在连接...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stage === 'chat' && (
|
{stage === 'chat' && <QuestionView prompt={prompt} onAnswer={handleAnswer} />}
|
||||||
<QuestionView
|
|
||||||
question={question}
|
|
||||||
onAnswer={handleAnswer}
|
|
||||||
questionIndex={qIndex}
|
|
||||||
total={total}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{stage === 'done' && (
|
{stage === 'done' && (
|
||||||
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
||||||
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在生成你的报告...</div>
|
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在生成你的报告...</div>
|
||||||
|
|||||||
303
server.js
303
server.js
@ -1,4 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const fsP = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const next = require('next');
|
const next = require('next');
|
||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
@ -11,6 +13,8 @@ const app = next({ dev, dir: __dirname });
|
|||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
const sessionsDir = path.join(__dirname, 'data', 'sessions');
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
|
const opening = '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?';
|
||||||
|
|
||||||
@ -29,6 +33,48 @@ const zodiacProfiles = {
|
|||||||
pisces: { 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) {
|
function detectZodiac(month, day) {
|
||||||
if (!month || !day) return null;
|
if (!month || !day) return null;
|
||||||
const md = month * 100 + day;
|
const md = month * 100 + day;
|
||||||
@ -59,18 +105,6 @@ function extractBirthday(text) {
|
|||||||
return { month, day };
|
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) {
|
function parseJSONFromText(text) {
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
try {
|
try {
|
||||||
@ -124,8 +158,7 @@ async function callYcapis(input, maxOutputTokens = 500) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const raw = data?.output?.[0]?.content?.[0]?.text || null;
|
return data?.output?.[0]?.content?.[0]?.text || null;
|
||||||
return raw ? sanitizeReply(raw) : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -182,16 +215,17 @@ function fallbackReport(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateReportForSession(sessionId) {
|
async function generateReportForSession(sessionId) {
|
||||||
const session = sessions.get(sessionId);
|
const session = await getSession(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
if (session.reportStatus === 'generating') return null;
|
if (session.reportStatus === 'generating') return null;
|
||||||
if (session.report) return session.report;
|
if (session.report) return session.report;
|
||||||
|
|
||||||
session.reportStatus = 'generating';
|
session.reportStatus = 'generating';
|
||||||
session.reportError = '';
|
session.reportError = '';
|
||||||
|
saveSession(sessionId, session);
|
||||||
|
|
||||||
const birthday = session.birthday || null;
|
const birthday = session.birthday || null;
|
||||||
const transcript = session.answers.map((a, i) => ({ q: session.questions[i] || `第${i + 1}题`, a }));
|
const transcript = session.answers.map((a, i) => ({ q: session.questions[i]?.text || session.questions[i] || `第${i + 1}题`, a }));
|
||||||
|
|
||||||
const reportSystemPrompt = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。
|
const reportSystemPrompt = `你是灵镜报告生成器。根据用户的完整对话记录,生成一份深度灵魂洞察报告。
|
||||||
|
|
||||||
@ -203,63 +237,50 @@ async function generateReportForSession(sessionId) {
|
|||||||
5. MBTI根据对话综合判断,不要死套测试题
|
5. MBTI根据对话综合判断,不要死套测试题
|
||||||
6. 如果没有生日信息,zodiac字段必须是null
|
6. 如果没有生日信息,zodiac字段必须是null
|
||||||
|
|
||||||
返回以下JSON结构:
|
严格返回以下JSON结构(不能缺字段):
|
||||||
{
|
{
|
||||||
"soulTags": ["标签1", "标签2", "标签3"],
|
"soulTags": ["标签1", "标签2", "标签3"],
|
||||||
"currentState": {
|
"currentState": {
|
||||||
"title": "当下状态标题",
|
"title": "当下状态标题(10字内)",
|
||||||
"summary": "150-200字描述",
|
"summary": "150字左右的描述",
|
||||||
"intensity": 65
|
"intensity": 65
|
||||||
},
|
},
|
||||||
"fiveDim": {
|
"fiveDim": {
|
||||||
"scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 },
|
"scores": { "xinli": 72, "xingli": 58, "ganzhi": 81, "dongjian": 69, "dingli": 74 },
|
||||||
"evidence": {
|
"interpretation": "100字左右的五维解读"
|
||||||
"xinli": ["「用户说:……」"],
|
|
||||||
"xingli": ["「用户说:……」"],
|
|
||||||
"ganzhi": ["「用户说:……」"],
|
|
||||||
"dongjian": ["「用户说:……」"],
|
|
||||||
"dingli": ["「用户说:……」"]
|
|
||||||
},
|
|
||||||
"interpretation": "100-150字整体解读"
|
|
||||||
},
|
},
|
||||||
"personalityReading": [
|
"personalityReading": [
|
||||||
{ "point": "核心特点", "quote": "「用户说:……」", "explain": "50-80字解释" },
|
{ "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" },
|
||||||
{ "point": "核心特点2", "quote": "「用户说:……」", "explain": "50-80字解释" },
|
{ "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" },
|
||||||
{ "point": "核心特点3", "quote": "「用户说:……」", "explain": "50-80字解释" }
|
{ "point": "特质标题", "quote": "「用户说:……」", "explain": "解读文字" }
|
||||||
],
|
],
|
||||||
"potentialBlindspots": {
|
"potentialBlindspots": {
|
||||||
"potentials": ["潜能1(50字)", "潜能2(50字)"],
|
"potentials": ["潜能1", "潜能2", "潜能3"],
|
||||||
"blindspots": ["盲区1(50字)", "盲区2(50字)"]
|
"blindspots": ["盲区1", "盲区2", "盲区3"]
|
||||||
},
|
},
|
||||||
"mbti": {
|
"mbti": {
|
||||||
"type": "INFP",
|
"type": "INFP",
|
||||||
"typeName": "调停者",
|
"typeName": "调停者",
|
||||||
"description": "150-200字基于对话的MBTI解读"
|
"description": "100字左右的MBTI解读"
|
||||||
},
|
},
|
||||||
"zodiac": null,
|
"zodiac": null,
|
||||||
"presentSignal": {
|
"presentSignal": {
|
||||||
"signalName": "信号名称",
|
"signalName": "信号名称(6字内)",
|
||||||
"urgency": "high",
|
"urgency": "medium",
|
||||||
"trigger": "当……出现时",
|
"trigger": "是什么触发了这个信号",
|
||||||
"meaning": "这意味着……(80字)",
|
"meaning": "这个信号意味着什么",
|
||||||
"riskIfMissed": "如果错过……(50字)"
|
"riskIfMissed": "错过的代价"
|
||||||
},
|
},
|
||||||
"pivotAction": {
|
"pivotAction": {
|
||||||
"onePivot": "今天就能做的一个主行动",
|
"onePivot": "一句话说清楚最该做的一件事",
|
||||||
"threeStarts": ["第一件事", "第二件事", "第三件事"]
|
"threeStarts": ["第一步", "第二步", "第三步"]
|
||||||
},
|
},
|
||||||
"closingLine": "一句话总结,适合截图分享"
|
"closingLine": "一句收束金句,20字内"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const userInput = {
|
|
||||||
birthday,
|
|
||||||
zodiacHint: birthday ? detectZodiac(birthday.month, birthday.day) : null,
|
|
||||||
transcript,
|
|
||||||
};
|
|
||||||
|
|
||||||
const text = await callYcapis([
|
const text = await callYcapis([
|
||||||
{ role: 'system', content: reportSystemPrompt },
|
{ role: 'system', content: reportSystemPrompt },
|
||||||
{ role: 'user', content: JSON.stringify(userInput) },
|
{ role: 'user', content: JSON.stringify({ birthday, transcript }) },
|
||||||
], 4000);
|
], 4000);
|
||||||
|
|
||||||
let parsed = parseJSONFromText(text);
|
let parsed = parseJSONFromText(text);
|
||||||
@ -276,32 +297,64 @@ async function generateReportForSession(sessionId) {
|
|||||||
|
|
||||||
session.report = parsed;
|
session.report = parsed;
|
||||||
session.reportStatus = 'done';
|
session.reportStatus = 'done';
|
||||||
|
saveSession(sessionId, session);
|
||||||
return parsed;
|
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(() => {
|
app.prepare().then(() => {
|
||||||
const server = express();
|
const server = express();
|
||||||
server.use(express.json({ limit: '2mb' }));
|
server.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
server.post('/api/session/new', (req, res) => {
|
server.post('/api/session/new', async (req, res) => {
|
||||||
const sessionId = `lgj_${Date.now().toString(36)}`;
|
const sessionId = `lgj_${Date.now().toString(36)}`;
|
||||||
sessions.set(sessionId, {
|
const s = defaultSession();
|
||||||
index: 0,
|
sessions.set(sessionId, s);
|
||||||
answers: [],
|
saveSession(sessionId, s);
|
||||||
questions: [],
|
res.json({ sessionId, opening, total: 30, type: 'text', reply: opening });
|
||||||
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) => {
|
server.post('/api/chat', async (req, res) => {
|
||||||
const { sessionId, answer } = req.body || {};
|
const { sessionId, answer } = req.body || {};
|
||||||
const s = sessions.get(sessionId);
|
const s = await getSession(sessionId);
|
||||||
if (!s) return res.status(404).json({ error: 'session_not_found' });
|
if (!s) return res.status(404).json({ error: 'session_not_found' });
|
||||||
|
|
||||||
const userAnswer = (answer || '').trim();
|
const userAnswer = (answer || '').trim();
|
||||||
@ -309,108 +362,83 @@ app.prepare().then(() => {
|
|||||||
s.answers.push(userAnswer);
|
s.answers.push(userAnswer);
|
||||||
const b = extractBirthday(userAnswer);
|
const b = extractBirthday(userAnswer);
|
||||||
if (b) s.birthday = b;
|
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.skips = userAnswer === '跳过' ? s.skips + 1 : 0;
|
||||||
s.index += 1;
|
s.index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const done = s.index >= 30;
|
const done = s.index >= 30;
|
||||||
if (done) {
|
if (done) {
|
||||||
|
saveSession(sessionId, s);
|
||||||
if (s.reportStatus !== 'generating' && !s.report) {
|
if (s.reportStatus !== 'generating' && !s.report) {
|
||||||
generateReportForSession(sessionId).catch((err) => {
|
generateReportForSession(sessionId).catch((err) => {
|
||||||
s.reportStatus = 'error';
|
s.reportStatus = 'error';
|
||||||
s.reportError = err?.message || 'generate_failed';
|
s.reportError = err?.message || 'generate_failed';
|
||||||
|
saveSession(sessionId, s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.json({ done: true, reply: '你已经完成全部对话。接下来我会为你生成专属灵镜报告。', sessionId });
|
return res.json({ done: true, reply: '你已经完成全部对话。接下来我会为你生成专属灵镜报告。', sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = s.index > 0 && s.index % 6 === 0 ? `(你已经完成第${Math.ceil(s.index / 6)}/5阶段了。)` : '';
|
const progress = s.index > 0 && s.index % 6 === 0 ? `(你已经完成第${Math.ceil(s.index / 6)}/5阶段了。)` : '';
|
||||||
const birthdayHint = s.index === 27 ? '这一题请自然地加一句:顺便问一下,你是几月几号生的?' : '';
|
|
||||||
|
|
||||||
const aiReply = await callYcapis([
|
const ds = await callYcapis([
|
||||||
{ role: 'system', content: s.prompt },
|
{ 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 },
|
{ role: 'assistant', content: opening },
|
||||||
...s.answers.flatMap((a, i) => [
|
...s.answers.flatMap((a, i) => [
|
||||||
{ role: 'assistant', content: s.questions[i] || '' },
|
{ role: 'assistant', content: s.questions[i]?.text || s.questions[i] || '' },
|
||||||
{ role: 'user', content: a },
|
{ role: 'user', content: a },
|
||||||
]).filter((m) => m.content),
|
]).filter((m) => m.content),
|
||||||
{ role: 'user', content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}请先接住用户,再继续下一问。只问一个问题,口语化、大白话。当前第${s.index + 1}题/30。${birthdayHint}` },
|
{
|
||||||
], 300);
|
role: 'user',
|
||||||
|
content: `[系统提示] 用户刚才回答了:"${userAnswer}"。${progress}当前第${s.index + 1}题/30。当前统计:choice=${s.choiceCount}, fixed=${s.fixedCount}, lastType=${s.lastQuestionType}。请按规则产出下一题。`,
|
||||||
|
},
|
||||||
|
], 320);
|
||||||
|
|
||||||
const fallback = s.index === 27
|
const fallbackText = '我听到了。我们继续,你最近哪件事最让你有成就感?';
|
||||||
? '我听懂了。顺便问一下,你是几月几号生的?'
|
const payload = normalizeQuestionPayload(ds || fallbackText, s);
|
||||||
: '嗯,我听到了。我们继续,最近这几天有什么小事让你心情变好?';
|
|
||||||
|
|
||||||
const nextQuestion = aiReply || fallback;
|
const textForHistory = payload.question || payload.reply || '';
|
||||||
s.questions.push(nextQuestion);
|
s.questions.push({ type: payload.type, text: textForHistory, options: payload.options || [] });
|
||||||
|
saveSession(sessionId, s);
|
||||||
|
|
||||||
return res.json({ done: false, reply: sanitizeReply(nextQuestion), index: s.index + 1, total: 30, sessionId });
|
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.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) => {
|
server.post('/api/report', async (req, res) => {
|
||||||
const { sessionId } = req.body || {};
|
const { sessionId } = req.body || {};
|
||||||
const s = sessions.get(sessionId);
|
const s = await getSession(sessionId);
|
||||||
if (!s) return res.status(404).json({ error: 'session_not_found' });
|
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.report) return res.json({ ok: true, status: 'done', report: s.report });
|
||||||
if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' });
|
if (s.reportStatus === 'generating') return res.json({ ok: true, status: 'generating' });
|
||||||
@ -421,12 +449,13 @@ app.prepare().then(() => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
s.reportStatus = 'error';
|
s.reportStatus = 'error';
|
||||||
s.reportError = e?.message || 'generate_failed';
|
s.reportError = e?.message || 'generate_failed';
|
||||||
|
saveSession(sessionId, s);
|
||||||
return res.status(500).json({ ok: false, status: 'error', error: s.reportError });
|
return res.status(500).json({ ok: false, status: 'error', error: s.reportError });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.get('/api/report', (req, res) => {
|
server.get('/api/report', async (req, res) => {
|
||||||
const s = sessions.get(req.query.sessionId);
|
const s = await getSession(req.query.sessionId);
|
||||||
if (!s) return res.status(404).json({ status: 'error', error: 'session_not_found' });
|
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.report) return res.json({ status: 'done', report: s.report });
|
||||||
if (s.reportStatus === 'generating') return res.json({ status: 'generating' });
|
if (s.reportStatus === 'generating') return res.json({ status: 'generating' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user