feat: AI 打字机流式输出、小程序脚本主页布局及灵感卡片优化
- life-script: 新增 aiRuntime 打字机流式服务,PathView/ScriptView/TimelineView 接入打字机效果 - mini-program: ScriptView 重构为打字机输出 + 卡片式灵感列表,主页布局优化 - web: aiRuntime 服务新增流式输出支持 - chat store: 消息状态管理和打字机流式渲染支持 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
const createRequestId = () => `life-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const parseSseFrame = (frame) => {
|
||||
const event = { type: 'message', data: '' };
|
||||
frame.split(/\r?\n/).forEach((line) => {
|
||||
@@ -14,6 +18,46 @@ const parseSseFrame = (frame) => {
|
||||
}
|
||||
};
|
||||
|
||||
const authHeaders = () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const queryRuntimeResult = async (requestId) => {
|
||||
const response = await fetch(`${API_BASE_URL}/ai/runtime/result?requestId=${encodeURIComponent(requestId)}`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.ok && payload?.code === 200 && payload.data) {
|
||||
return payload.data;
|
||||
}
|
||||
throw new Error(payload?.message || 'AI 结果还在生成中');
|
||||
};
|
||||
|
||||
const recoverRuntimeOutput = async (requestId, { onDelta, onDone }) => {
|
||||
const maxAttempts = 150;
|
||||
for (let index = 0; index < maxAttempts; index++) {
|
||||
try {
|
||||
const log = await queryRuntimeResult(requestId);
|
||||
const output = String(log.outputText || '').trim();
|
||||
if (output) {
|
||||
onDelta?.(output, output, { type: 'delta', metadata: { recovered: true, requestId } });
|
||||
onDone?.({ type: 'done', metadata: { recovered: true, requestId, source: 'call-log' } }, output);
|
||||
return output;
|
||||
}
|
||||
if (log.status === 'failed') {
|
||||
throw new Error(log.errorMessage || 'AI 生成失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (index === maxAttempts - 1) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error('AI 生成结果暂时没有返回');
|
||||
};
|
||||
|
||||
export const streamAiScene = async ({
|
||||
sceneCode,
|
||||
inputs = {},
|
||||
@@ -22,26 +66,79 @@ export const streamAiScene = async ({
|
||||
onDone,
|
||||
onError
|
||||
}) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ sceneCode, inputs })
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const message = `AI流式请求失败(${response.status})`;
|
||||
onError?.(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const requestId = String(inputs.requestId || createRequestId());
|
||||
const requestInputs = { ...inputs, requestId };
|
||||
const controller = new AbortController();
|
||||
let buffer = '';
|
||||
let output = '';
|
||||
let closed = false;
|
||||
let recovered = false;
|
||||
let recoveryTimer;
|
||||
let recoveryPromise;
|
||||
|
||||
const clearRecoveryTimer = () => {
|
||||
if (recoveryTimer) {
|
||||
clearTimeout(recoveryTimer);
|
||||
recoveryTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const recoverOnce = () => {
|
||||
if (!recoveryPromise) {
|
||||
recoveryPromise = recoverRuntimeOutput(requestId, { onDelta, onDone });
|
||||
}
|
||||
return recoveryPromise;
|
||||
};
|
||||
|
||||
const completeFromRecoveredOutput = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const recoveredOutput = await recoverOnce();
|
||||
if (closed) return;
|
||||
output = recoveredOutput;
|
||||
recovered = true;
|
||||
closed = true;
|
||||
clearRecoveryTimer();
|
||||
controller.abort();
|
||||
} catch {
|
||||
if (!closed) recoveryPromise = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
recoveryTimer = setTimeout(() => {
|
||||
completeFromRecoveredOutput();
|
||||
}, 8000);
|
||||
|
||||
const finishRecovered = (event, message) => {
|
||||
if (!output.trim()) return false;
|
||||
recovered = true;
|
||||
closed = true;
|
||||
clearRecoveryTimer();
|
||||
onDone?.({
|
||||
type: 'done',
|
||||
metadata: {
|
||||
...(event?.metadata || {}),
|
||||
recovered: true,
|
||||
warningCode: event?.code,
|
||||
warningMessage: message || event?.message
|
||||
}
|
||||
}, output);
|
||||
return true;
|
||||
};
|
||||
|
||||
const recoverOrThrow = async (message, event) => {
|
||||
if (finishRecovered(event, message)) return;
|
||||
try {
|
||||
output = await recoverOnce();
|
||||
recovered = true;
|
||||
closed = true;
|
||||
clearRecoveryTimer();
|
||||
} catch (error) {
|
||||
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回';
|
||||
onError?.(finalMessage, event);
|
||||
throw new Error(finalMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const consumeText = (text) => {
|
||||
buffer += text;
|
||||
@@ -57,22 +154,61 @@ export const streamAiScene = async ({
|
||||
output += delta;
|
||||
onDelta?.(delta, output, event);
|
||||
} else if (event.type === 'done') {
|
||||
closed = true;
|
||||
clearRecoveryTimer();
|
||||
onDone?.(event, output);
|
||||
} else if (event.type === 'error') {
|
||||
const message = event.message || event.code || 'AI流式请求失败';
|
||||
onError?.(message, event);
|
||||
throw new Error(message);
|
||||
throw Object.assign(new Error(event.message || event.code || 'AI 流式请求失败'), { event });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
consumeText(decoder.decode(value, { stream: true }));
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders()
|
||||
},
|
||||
body: JSON.stringify({ sceneCode, requestId, inputs: requestInputs }),
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (!closed) await recoverOrThrow(error?.message);
|
||||
return { output };
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
await recoverOrThrow(`AI 流式请求失败(${response.status})`);
|
||||
return { output };
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
consumeText(decoder.decode(value, { stream: true }));
|
||||
if (closed || recovered) break;
|
||||
}
|
||||
if (!closed && !recovered) {
|
||||
consumeText(decoder.decode());
|
||||
if (buffer.trim()) consumeText('\n\n');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
await recoverOrThrow(error?.message, error?.event);
|
||||
}
|
||||
} finally {
|
||||
clearRecoveryTimer();
|
||||
}
|
||||
|
||||
if (!output.trim() && !closed) {
|
||||
await recoverOrThrow('AI 生成结果暂时没有返回');
|
||||
}
|
||||
consumeText(decoder.decode());
|
||||
if (buffer.trim()) consumeText('\n\n');
|
||||
return { output };
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GlassCard, GlassButton } from '../components/ui';
|
||||
import Modal from '../components/Modal';
|
||||
import useStore from '../store/useStore';
|
||||
import { generatePath } from '../services/ai';
|
||||
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||||
|
||||
/**
|
||||
* PathView 组件
|
||||
@@ -17,6 +18,7 @@ const PathView = ({ onGoToScript }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [streamPath, setStreamPath] = useState('');
|
||||
const pathWriter = useTypewriterStream({ interval: 18, step: 1 });
|
||||
|
||||
const selectedScript = getSelectedScript();
|
||||
|
||||
@@ -36,15 +38,22 @@ const PathView = ({ onGoToScript }) => {
|
||||
if (!selectedScript) return;
|
||||
|
||||
setIsLoading(true);
|
||||
pathWriter.reset();
|
||||
|
||||
try {
|
||||
setStreamPath('');
|
||||
const path = await generatePath(selectedScript.content, {
|
||||
onDelta: (_delta, output) => setStreamPath(output)
|
||||
onDelta: (_delta, output) => {
|
||||
setStreamPath(output);
|
||||
pathWriter.push(output);
|
||||
}
|
||||
});
|
||||
pathWriter.finish(path);
|
||||
await pathWriter.waitForDone();
|
||||
await setPath(path, selectedScriptId);
|
||||
setStreamPath('');
|
||||
} catch (error) {
|
||||
pathWriter.fail('路径生成失败,请稍后重试');
|
||||
console.error('Failed to generate path:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -82,7 +91,7 @@ const PathView = ({ onGoToScript }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const visiblePath = streamPath || selectedPath;
|
||||
const visiblePath = isLoading ? pathWriter.visibleText : selectedPath;
|
||||
const pathSteps = parsePathSteps(visiblePath);
|
||||
|
||||
// 无剧本时显示提示
|
||||
@@ -164,7 +173,9 @@ const PathView = ({ onGoToScript }) => {
|
||||
))
|
||||
) : (
|
||||
<div className="py-20 text-center text-white/20 italic font-serif">
|
||||
等待开启人生导航...
|
||||
{isLoading
|
||||
? (pathWriter.isWaiting ? '正在分析剧本,拆解路径...' : '正在逐字生成路径...')
|
||||
: '等待开启人生导航...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Modal from '../components/Modal';
|
||||
import useStore from '../store/useStore';
|
||||
import { scriptStyles, scriptLengths } from '../utils/constants';
|
||||
import { generateEpicScript } from '../services/ai';
|
||||
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||||
|
||||
/**
|
||||
* ScriptView 组件
|
||||
@@ -39,6 +40,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
||||
const [length, setLength] = useState(scriptLengths[0].value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [streamContent, setStreamContent] = useState('');
|
||||
const scriptWriter = useTypewriterStream({ interval: 18, step: 1 });
|
||||
|
||||
// 编辑模态框状态
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
@@ -62,14 +64,22 @@ const ScriptView = ({ onOpenProfile }) => {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
scriptWriter.reset();
|
||||
|
||||
try {
|
||||
setStreamContent('');
|
||||
const content = await generateEpicScript(
|
||||
{ theme, style, length, character: registrationData },
|
||||
lifeEvents,
|
||||
{ onDelta: (_delta, output) => setStreamContent(output) }
|
||||
{
|
||||
onDelta: (_delta, output) => {
|
||||
setStreamContent(output);
|
||||
scriptWriter.push(output);
|
||||
}
|
||||
}
|
||||
);
|
||||
scriptWriter.finish(content);
|
||||
await scriptWriter.waitForDone();
|
||||
await addScript({
|
||||
theme,
|
||||
style,
|
||||
@@ -82,6 +92,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
||||
setTheme('');
|
||||
setStreamContent('');
|
||||
} catch (error) {
|
||||
scriptWriter.fail('生成失败,请稍后重试');
|
||||
console.error('Failed to generate script:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -121,14 +132,22 @@ const ScriptView = ({ onOpenProfile }) => {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
scriptWriter.reset();
|
||||
|
||||
try {
|
||||
setStreamContent('');
|
||||
const content = await generateEpicScript(
|
||||
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
|
||||
lifeEvents,
|
||||
{ onDelta: (_delta, output) => setStreamContent(output) }
|
||||
{
|
||||
onDelta: (_delta, output) => {
|
||||
setStreamContent(output);
|
||||
scriptWriter.push(output);
|
||||
}
|
||||
}
|
||||
);
|
||||
scriptWriter.finish(content);
|
||||
await scriptWriter.waitForDone();
|
||||
await updateScript({
|
||||
id: editingScript.id,
|
||||
theme: editForm.theme,
|
||||
@@ -142,6 +161,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
||||
closeEditModal();
|
||||
setStreamContent('');
|
||||
} catch (error) {
|
||||
scriptWriter.fail('生成失败,请稍后重试');
|
||||
console.error('Failed to update script:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -306,18 +326,21 @@ const ScriptView = ({ onOpenProfile }) => {
|
||||
{/* 右侧剧本展示区 */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="h-full">
|
||||
{isLoading && streamContent ? (
|
||||
{isLoading ? (
|
||||
<GlassCard className="h-full overflow-y-auto custom-scrollbar border-orange-200/20 shadow-2xl animate-fade-in" padding="lg">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
|
||||
<div>
|
||||
<h4 className="text-2xl font-serif text-orange-200">{theme}</h4>
|
||||
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">正在生成</p>
|
||||
<h4 className="text-2xl font-serif text-orange-200">{theme || editForm.theme}</h4>
|
||||
<p className="text-[10px] text-white/40 mt-1 tracking-widest">
|
||||
{scriptWriter.isWaiting ? '正在理解你的创作目标' : scriptWriter.isDraining ? '正在收束最后一句' : '正在逐字生成剧本'}
|
||||
</p>
|
||||
</div>
|
||||
<BookOpen className="text-white/20" />
|
||||
<Loader2 className="text-orange-200/60 animate-spin" />
|
||||
</div>
|
||||
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm">
|
||||
{streamContent}
|
||||
{scriptWriter.visibleText || '故事正在生成,请稍候...'}
|
||||
{(scriptWriter.isStreaming || scriptWriter.isDraining) && <span className="text-orange-200 animate-pulse">|</span>}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components
|
||||
import Modal from '../components/Modal';
|
||||
import useStore from '../store/useStore';
|
||||
import { analyzeLifeEvent } from '../services/ai';
|
||||
import useTypewriterStream from '../hooks/useTypewriterStream';
|
||||
|
||||
/**
|
||||
* 格式化 AI 反馈内容的组件
|
||||
@@ -93,6 +94,7 @@ const TimelineView = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [streamFeedback, setStreamFeedback] = useState('');
|
||||
const feedbackWriter = useTypewriterStream({ interval: 18, step: 1 });
|
||||
|
||||
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
||||
const [editingEventId, setEditingEventId] = useState(null);
|
||||
@@ -114,6 +116,7 @@ const TimelineView = () => {
|
||||
setEditingEventId(null);
|
||||
setEventForm({ title: '', time: '', content: '' });
|
||||
setStreamFeedback('');
|
||||
feedbackWriter.reset();
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -129,6 +132,7 @@ const TimelineView = () => {
|
||||
content: event.content || ''
|
||||
});
|
||||
setStreamFeedback(event.aiFeedback || '');
|
||||
feedbackWriter.reset();
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -140,6 +144,7 @@ const TimelineView = () => {
|
||||
setEditingEventId(null);
|
||||
setEventForm({ title: '', time: '', content: '' });
|
||||
setStreamFeedback('');
|
||||
feedbackWriter.reset();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -152,12 +157,18 @@ const TimelineView = () => {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
feedbackWriter.reset();
|
||||
|
||||
try {
|
||||
setStreamFeedback('');
|
||||
const aiFeedback = await analyzeLifeEvent(eventForm, {
|
||||
onDelta: (_delta, output) => setStreamFeedback(output)
|
||||
onDelta: (_delta, output) => {
|
||||
setStreamFeedback(output);
|
||||
feedbackWriter.push(output);
|
||||
}
|
||||
});
|
||||
feedbackWriter.finish(aiFeedback);
|
||||
await feedbackWriter.waitForDone();
|
||||
|
||||
if (editingEventId) {
|
||||
// 编辑模式:调用更新接口
|
||||
@@ -177,6 +188,7 @@ const TimelineView = () => {
|
||||
// 重置表单并关闭模态框
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
feedbackWriter.fail('AI 疗愈反馈生成失败,请稍后重试');
|
||||
console.error('Failed to save event:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -323,13 +335,17 @@ const TimelineView = () => {
|
||||
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
|
||||
rows={5}
|
||||
/>
|
||||
{streamFeedback && (
|
||||
{(streamFeedback || isLoading) && (
|
||||
<div className="ai-glow-card p-4 rounded-2xl bg-orange-200/[0.02] border border-orange-200/5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles className="w-3 h-3 text-orange-200" />
|
||||
<span className="text-[9px] uppercase tracking-[0.2em] text-orange-200/60 font-bold">实时疗愈反馈</span>
|
||||
<span className="text-[9px] tracking-[0.2em] text-orange-200/60 font-bold">
|
||||
{feedbackWriter.isWaiting ? '正在理解这段经历' : feedbackWriter.isDraining ? '正在收束反馈' : '实时疗愈反馈'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs italic text-white/50 leading-loose whitespace-pre-wrap">{streamFeedback}</p>
|
||||
{feedbackWriter.visibleText
|
||||
? <FeedbackContent content={feedbackWriter.visibleText} />
|
||||
: <p className="text-xs italic text-white/40 leading-loose">AI 正在梳理你的生命轨迹,请稍候...</p>}
|
||||
</div>
|
||||
)}
|
||||
<GlassButton
|
||||
|
||||
Reference in New Issue
Block a user