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:
2026-05-24 18:35:33 +08:00
parent c900f56174
commit 64476eee6d
21 changed files with 1474 additions and 205 deletions
+14 -3
View File
@@ -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>
+30 -7
View File
@@ -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>
+20 -4
View File
@@ -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