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
+163 -27
View File
@@ -1,5 +1,9 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'; 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 parseSseFrame = (frame) => {
const event = { type: 'message', data: '' }; const event = { type: 'message', data: '' };
frame.split(/\r?\n/).forEach((line) => { 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 ({ export const streamAiScene = async ({
sceneCode, sceneCode,
inputs = {}, inputs = {},
@@ -22,26 +66,79 @@ export const streamAiScene = async ({
onDone, onDone,
onError onError
}) => { }) => {
const token = localStorage.getItem('access_token'); const requestId = String(inputs.requestId || createRequestId());
const response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, { const requestInputs = { ...inputs, requestId };
method: 'POST', const controller = new AbortController();
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');
let buffer = ''; let buffer = '';
let output = ''; 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) => { const consumeText = (text) => {
buffer += text; buffer += text;
@@ -57,22 +154,61 @@ export const streamAiScene = async ({
output += delta; output += delta;
onDelta?.(delta, output, event); onDelta?.(delta, output, event);
} else if (event.type === 'done') { } else if (event.type === 'done') {
closed = true;
clearRecoveryTimer();
onDone?.(event, output); onDone?.(event, output);
} else if (event.type === 'error') { } else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败'; throw Object.assign(new Error(event.message || event.code || 'AI 流式请求失败'), { event });
onError?.(message, event);
throw new Error(message);
} }
}); });
}; };
while (true) { let response;
const { value, done } = await reader.read(); try {
if (done) break; response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, {
consumeText(decoder.decode(value, { stream: true })); 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 }; return { output };
}; };
+14 -3
View File
@@ -5,6 +5,7 @@ import { GlassCard, GlassButton } from '../components/ui';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import useStore from '../store/useStore'; import useStore from '../store/useStore';
import { generatePath } from '../services/ai'; import { generatePath } from '../services/ai';
import useTypewriterStream from '../hooks/useTypewriterStream';
/** /**
* PathView 组件 * PathView 组件
@@ -17,6 +18,7 @@ const PathView = ({ onGoToScript }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [streamPath, setStreamPath] = useState(''); const [streamPath, setStreamPath] = useState('');
const pathWriter = useTypewriterStream({ interval: 18, step: 1 });
const selectedScript = getSelectedScript(); const selectedScript = getSelectedScript();
@@ -36,15 +38,22 @@ const PathView = ({ onGoToScript }) => {
if (!selectedScript) return; if (!selectedScript) return;
setIsLoading(true); setIsLoading(true);
pathWriter.reset();
try { try {
setStreamPath(''); setStreamPath('');
const path = await generatePath(selectedScript.content, { 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); await setPath(path, selectedScriptId);
setStreamPath(''); setStreamPath('');
} catch (error) { } catch (error) {
pathWriter.fail('路径生成失败,请稍后重试');
console.error('Failed to generate path:', error); console.error('Failed to generate path:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -82,7 +91,7 @@ const PathView = ({ onGoToScript }) => {
}); });
}; };
const visiblePath = streamPath || selectedPath; const visiblePath = isLoading ? pathWriter.visibleText : selectedPath;
const pathSteps = parsePathSteps(visiblePath); const pathSteps = parsePathSteps(visiblePath);
// 无剧本时显示提示 // 无剧本时显示提示
@@ -164,7 +173,9 @@ const PathView = ({ onGoToScript }) => {
)) ))
) : ( ) : (
<div className="py-20 text-center text-white/20 italic font-serif"> <div className="py-20 text-center text-white/20 italic font-serif">
等待开启人生导航... {isLoading
? (pathWriter.isWaiting ? '正在分析剧本,拆解路径...' : '正在逐字生成路径...')
: '等待开启人生导航...'}
</div> </div>
)} )}
</div> </div>
+30 -7
View File
@@ -5,6 +5,7 @@ import Modal from '../components/Modal';
import useStore from '../store/useStore'; import useStore from '../store/useStore';
import { scriptStyles, scriptLengths } from '../utils/constants'; import { scriptStyles, scriptLengths } from '../utils/constants';
import { generateEpicScript } from '../services/ai'; import { generateEpicScript } from '../services/ai';
import useTypewriterStream from '../hooks/useTypewriterStream';
/** /**
* ScriptView 组件 * ScriptView 组件
@@ -39,6 +40,7 @@ const ScriptView = ({ onOpenProfile }) => {
const [length, setLength] = useState(scriptLengths[0].value); const [length, setLength] = useState(scriptLengths[0].value);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [streamContent, setStreamContent] = useState(''); const [streamContent, setStreamContent] = useState('');
const scriptWriter = useTypewriterStream({ interval: 18, step: 1 });
// 编辑模态框状态 // 编辑模态框状态
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@@ -62,14 +64,22 @@ const ScriptView = ({ onOpenProfile }) => {
} }
setIsLoading(true); setIsLoading(true);
scriptWriter.reset();
try { try {
setStreamContent(''); setStreamContent('');
const content = await generateEpicScript( const content = await generateEpicScript(
{ theme, style, length, character: registrationData }, { theme, style, length, character: registrationData },
lifeEvents, lifeEvents,
{ onDelta: (_delta, output) => setStreamContent(output) } {
onDelta: (_delta, output) => {
setStreamContent(output);
scriptWriter.push(output);
}
}
); );
scriptWriter.finish(content);
await scriptWriter.waitForDone();
await addScript({ await addScript({
theme, theme,
style, style,
@@ -82,6 +92,7 @@ const ScriptView = ({ onOpenProfile }) => {
setTheme(''); setTheme('');
setStreamContent(''); setStreamContent('');
} catch (error) { } catch (error) {
scriptWriter.fail('生成失败,请稍后重试');
console.error('Failed to generate script:', error); console.error('Failed to generate script:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -121,14 +132,22 @@ const ScriptView = ({ onOpenProfile }) => {
} }
setIsLoading(true); setIsLoading(true);
scriptWriter.reset();
try { try {
setStreamContent(''); setStreamContent('');
const content = await generateEpicScript( const content = await generateEpicScript(
{ theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData }, { theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData },
lifeEvents, lifeEvents,
{ onDelta: (_delta, output) => setStreamContent(output) } {
onDelta: (_delta, output) => {
setStreamContent(output);
scriptWriter.push(output);
}
}
); );
scriptWriter.finish(content);
await scriptWriter.waitForDone();
await updateScript({ await updateScript({
id: editingScript.id, id: editingScript.id,
theme: editForm.theme, theme: editForm.theme,
@@ -142,6 +161,7 @@ const ScriptView = ({ onOpenProfile }) => {
closeEditModal(); closeEditModal();
setStreamContent(''); setStreamContent('');
} catch (error) { } catch (error) {
scriptWriter.fail('生成失败,请稍后重试');
console.error('Failed to update script:', error); console.error('Failed to update script:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -306,18 +326,21 @@ const ScriptView = ({ onOpenProfile }) => {
{/* 右侧剧本展示区 */} {/* 右侧剧本展示区 */}
<div className="lg:col-span-8"> <div className="lg:col-span-8">
<div className="h-full"> <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"> <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="prose prose-invert max-w-none">
<div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5"> <div className="flex justify-between items-center mb-8 pb-4 border-b border-white/5">
<div> <div>
<h4 className="text-2xl font-serif text-orange-200">{theme}</h4> <h4 className="text-2xl font-serif text-orange-200">{theme || editForm.theme}</h4>
<p className="text-[10px] text-white/30 mt-1 uppercase tracking-widest">正在生成</p> <p className="text-[10px] text-white/40 mt-1 tracking-widest">
{scriptWriter.isWaiting ? '正在理解你的创作目标' : scriptWriter.isDraining ? '正在收束最后一句' : '正在逐字生成剧本'}
</p>
</div> </div>
<BookOpen className="text-white/20" /> <Loader2 className="text-orange-200/60 animate-spin" />
</div> </div>
<div className="text-white/70 leading-loose whitespace-pre-wrap space-y-6 text-sm"> <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>
</div> </div>
</GlassCard> </GlassCard>
+20 -4
View File
@@ -4,6 +4,7 @@ import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import useStore from '../store/useStore'; import useStore from '../store/useStore';
import { analyzeLifeEvent } from '../services/ai'; import { analyzeLifeEvent } from '../services/ai';
import useTypewriterStream from '../hooks/useTypewriterStream';
/** /**
* 格式化 AI 反馈内容的组件 * 格式化 AI 反馈内容的组件
@@ -93,6 +94,7 @@ const TimelineView = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [streamFeedback, setStreamFeedback] = useState(''); const [streamFeedback, setStreamFeedback] = useState('');
const feedbackWriter = useTypewriterStream({ interval: 18, step: 1 });
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID) // 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
const [editingEventId, setEditingEventId] = useState(null); const [editingEventId, setEditingEventId] = useState(null);
@@ -114,6 +116,7 @@ const TimelineView = () => {
setEditingEventId(null); setEditingEventId(null);
setEventForm({ title: '', time: '', content: '' }); setEventForm({ title: '', time: '', content: '' });
setStreamFeedback(''); setStreamFeedback('');
feedbackWriter.reset();
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -129,6 +132,7 @@ const TimelineView = () => {
content: event.content || '' content: event.content || ''
}); });
setStreamFeedback(event.aiFeedback || ''); setStreamFeedback(event.aiFeedback || '');
feedbackWriter.reset();
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -140,6 +144,7 @@ const TimelineView = () => {
setEditingEventId(null); setEditingEventId(null);
setEventForm({ title: '', time: '', content: '' }); setEventForm({ title: '', time: '', content: '' });
setStreamFeedback(''); setStreamFeedback('');
feedbackWriter.reset();
}; };
/** /**
@@ -152,12 +157,18 @@ const TimelineView = () => {
} }
setIsLoading(true); setIsLoading(true);
feedbackWriter.reset();
try { try {
setStreamFeedback(''); setStreamFeedback('');
const aiFeedback = await analyzeLifeEvent(eventForm, { 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) { if (editingEventId) {
// 编辑模式:调用更新接口 // 编辑模式:调用更新接口
@@ -177,6 +188,7 @@ const TimelineView = () => {
// 重置表单并关闭模态框 // 重置表单并关闭模态框
closeModal(); closeModal();
} catch (error) { } catch (error) {
feedbackWriter.fail('AI 疗愈反馈生成失败,请稍后重试');
console.error('Failed to save event:', error); console.error('Failed to save event:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -323,13 +335,17 @@ const TimelineView = () => {
onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))} onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))}
rows={5} 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="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"> <div className="flex items-center gap-2 mb-3">
<Sparkles className="w-3 h-3 text-orange-200" /> <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> </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> </div>
)} )}
<GlassButton <GlassButton
+4
View File
@@ -14,9 +14,13 @@ const hydrateSafeArea = () => {
statusBarHeight.value = windowInfo.statusBarHeight || 20 statusBarHeight.value = windowInfo.statusBarHeight || 20
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20 safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0 safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
uni.setStorageSync('windowWidth', windowInfo.windowWidth || 375)
uni.setStorageSync('statusBarHeight', statusBarHeight.value) uni.setStorageSync('statusBarHeight', statusBarHeight.value)
uni.setStorageSync('safeAreaTop', safeAreaTop.value) uni.setStorageSync('safeAreaTop', safeAreaTop.value)
uni.setStorageSync('safeAreaBottom', safeAreaBottom.value) uni.setStorageSync('safeAreaBottom', safeAreaBottom.value)
if (uni.getMenuButtonBoundingClientRect) {
uni.setStorageSync('menuButtonRect', uni.getMenuButtonBoundingClientRect())
}
} catch (error) { } catch (error) {
statusBarHeight.value = 20 statusBarHeight.value = 20
safeAreaTop.value = 20 safeAreaTop.value = 20
+3 -1
View File
@@ -2,7 +2,7 @@
<view class="detail-page"> <view class="detail-page">
<view class="space-bg"></view> <view class="space-bg"></view>
<view class="floating-nav"> <view class="floating-nav" :style="floatingTopStyle">
<view class="circle-btn" @click="goBack"> <view class="circle-btn" @click="goBack">
<view class="back-icon"></view> <view class="back-icon"></view>
</view> </view>
@@ -118,10 +118,12 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import Markdown from '../../components/Markdown.vue' import Markdown from '../../components/Markdown.vue'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
const store = useAppStore() const store = useAppStore()
const pagePath = '/pages/life-event/detail' const pagePath = '/pages/life-event/detail'
const { floatingTopStyle } = useMenuButtonSafeArea()
const safeAreaBottom = ref(0) const safeAreaBottom = ref(0)
const eventId = ref('') const eventId = ref('')
const cachedEvent = ref(null) const cachedEvent = ref(null)
+57 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<view class="record-page"> <view class="record-page">
<view class="space-bg"></view> <view class="space-bg"></view>
<view class="safe-top" :style="{ height: safeAreaTop + 10 + 'px' }"></view> <view class="safe-top" :style="{ height: capsuleTopReservePx + 'px' }"></view>
<view class="header"> <view class="header">
<view class="back-hit" @click="goBack"><view class="back-icon"></view></view> <view class="back-hit" @click="goBack"><view class="back-icon"></view></view>
@@ -125,9 +125,9 @@
:placeholder="contentPlaceholder" :placeholder="contentPlaceholder"
placeholder-class="placeholder" placeholder-class="placeholder"
/> />
<view class="ai-btn" @click="assistWrite"> <view class="ai-btn" :class="{ active: assisting }" @click="assistWrite">
<text class="sparkle"></text> <text class="sparkle"></text>
<text>AI 帮我写</text> <text>{{ assistButtonText }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -165,14 +165,18 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
const store = useAppStore() const store = useAppStore()
const pagePath = '/pages/life-event/form' const pagePath = '/pages/life-event/form'
const safeAreaTop = ref(20) const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const saving = ref(false) const saving = ref(false)
const assisting = ref(false)
const assistWriter = useTypewriterStream({ interval: 24, step: 1 })
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const form = reactive({ const form = reactive({
@@ -208,6 +212,17 @@ const seasonRange = [years, seasons.map(item => item.label)]
const tags = ref(['成长', '学习', '工作', '旅行', '感情', '家庭', '友情', '挑战', '突破', '收获', '感动', '迷茫']) const tags = ref(['成长', '学习', '工作', '旅行', '感情', '家庭', '友情', '挑战', '突破', '收获', '感动', '迷茫'])
const contentPlaceholder = '请详细记录这段经历的背景、发生的事情、你的感受和收获...' const contentPlaceholder = '请详细记录这段经历的背景、发生的事情、你的感受和收获...'
const assistButtonText = computed(() => {
if (assistWriter.isWaiting.value) return 'AI 正在理解'
if (assistWriter.isStreaming.value || assistWriter.isDraining.value) return 'AI 正在写入'
return 'AI 帮我写'
})
watch(() => assistWriter.visibleText.value, (text) => {
if (!text || !(assisting.value || assistWriter.isStreaming.value || assistWriter.isDraining.value)) return
form.content = text
})
const monthPickerValue = computed(() => { const monthPickerValue = computed(() => {
const [year, month] = (form.eventDateText || form.time).split('-') const [year, month] = (form.eventDateText || form.time).split('-')
return [ return [
@@ -225,8 +240,6 @@ const seasonPickerValue = computed(() => {
}) })
onMounted(() => { onMounted(() => {
const info = uni.getWindowInfo()
safeAreaTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
const pages = getCurrentPages() const pages = getCurrentPages()
const id = pages[pages.length - 1]?.options?.id || '' const id = pages[pages.length - 1]?.options?.id || ''
if (id) loadEvent(id) if (id) loadEvent(id)
@@ -336,16 +349,24 @@ const loadEvent = async (id) => {
} }
const assistWrite = async () => { const assistWrite = async () => {
if (assisting.value) return
assisting.value = true
assistWriter.reset()
const result = await store.assistEventWriting({ ...form }, { const result = await store.assistEventWriting({ ...form }, {
onDelta: (_delta, output) => { onDelta: (_delta, output) => {
form.content = output assistWriter.push(output)
} }
}) })
if (!result.success) { if (!result.success) {
assistWriter.fail(result.error || 'AI 帮写失败')
assisting.value = false
uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' }) uni.showToast({ title: result.error || 'AI 帮写失败', icon: 'none' })
return return
} }
if (result.data?.content) form.content = result.data.content if (result.data?.content) assistWriter.finish(result.data.content)
else assistWriter.finish(form.content)
await assistWriter.waitForDone()
assisting.value = false
if (Array.isArray(result.data?.tags)) { if (Array.isArray(result.data?.tags)) {
result.data.tags.forEach(tag => { result.data.tags.forEach(tag => {
if (!tags.value.includes(tag)) tags.value.push(tag) if (!tags.value.includes(tag)) tags.value.push(tag)
@@ -394,6 +415,10 @@ const buildAiFeedback = () => {
const goBack = () => { const goBack = () => {
uni.navigateBack() uni.navigateBack()
} }
onUnmounted(() => {
assistWriter.dispose()
})
</script> </script>
<style scoped> <style scoped>
@@ -800,10 +825,33 @@ const goBack = () => {
font-size: 23rpx; font-size: 23rpx;
} }
.ai-btn.active {
border-color: rgba(255, 216, 107, 0.5);
color: #ffe7a3;
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.16);
}
.sparkle { .sparkle {
color: #d783ff; color: #d783ff;
} }
.ai-btn.active .sparkle {
color: #ffd86b;
animation: sparklePulse 1s ease-in-out infinite;
}
@keyframes sparklePulse {
0%, 100% {
opacity: 0.45;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
.custom-tag { .custom-tag {
height: 50rpx; height: 50rpx;
padding: 0 20rpx; padding: 0 20rpx;
+1 -1
View File
@@ -142,7 +142,7 @@ const handleLogin = async () => {
if (result.success) { if (result.success) {
if (result.hasProfile) { if (result.hasProfile) {
uni.redirectTo({ url: '/pages/main/index' }) uni.redirectTo({ url: '/pages/main/index?tab=script' })
} else { } else {
uni.redirectTo({ url: '/pages/onboarding/index' }) uni.redirectTo({ url: '/pages/onboarding/index' })
} }
+55 -19
View File
@@ -1,5 +1,5 @@
<template> <template>
<view class="path-view"> <view class="path-view" :style="{ paddingTop: capsuleTopReservePx + 'px' }">
<text class="page-title">实现路径</text> <text class="page-title">实现路径</text>
<view v-if="!selectedScript" class="empty-state glass-card"> <view v-if="!selectedScript" class="empty-state glass-card">
@@ -10,7 +10,7 @@
<view v-else-if="!currentPath" class="empty-state glass-card"> <view v-else-if="!currentPath" class="empty-state glass-card">
<text class="empty-icon">🎯</text> <text class="empty-icon">🎯</text>
<text class="empty-text">等待开启人生导航...</text> <text class="empty-text">{{ pathEmptyText }}</text>
</view> </view>
<view v-else class="path-content"> <view v-else class="path-content">
@@ -41,14 +41,19 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted, watch } from 'vue' import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import * as lifePathService from '../../services/lifePath.js' import * as lifePathService from '../../services/lifePath.js'
import { streamAiScene } from '../../services/aiRuntime.js' import { streamAiScene } from '../../services/aiRuntime.js'
const store = useAppStore() const store = useAppStore()
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const pathData = ref(null) const pathData = ref(null)
const pathGenerating = ref(false)
const pathWriter = useTypewriterStream({ interval: 24, step: 1 })
const selectedScript = computed(() => { const selectedScript = computed(() => {
return store.scripts.find(s => s.isSelected) return store.scripts.find(s => s.isSelected)
@@ -60,6 +65,34 @@ const currentPath = computed(() => {
return null return null
}) })
const pathEmptyText = computed(() => {
if (pathWriter.isWaiting.value) return '正在分析剧本,拆解可执行路径...'
if (pathWriter.isStreaming.value || pathWriter.isDraining.value) return '正在生成路径节点...'
return '等待开启人生导航...'
})
const buildStreamPath = (scriptId, title, output) => ({
id: `stream-${scriptId}`,
scriptId,
title,
description: output,
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({
phase: `阶段${index + 1}`,
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
desc: line,
content: line,
done: index === 0
})),
progress: 8,
status: 'active'
})
watch(() => pathWriter.visibleText.value, (text) => {
if (!(pathGenerating.value || pathWriter.isStreaming.value || pathWriter.isDraining.value) || !selectedScript.value?.id || !text) return
const title = selectedScript.value.title ? `${selectedScript.value.title} · 实现路径` : '我的实现路径'
pathData.value = buildStreamPath(selectedScript.value.id, title, text)
})
const loadPath = async (scriptId) => { const loadPath = async (scriptId) => {
if (!scriptId) return if (!scriptId) return
try { try {
@@ -76,6 +109,8 @@ const createPlaceholderPath = async (scriptId) => {
const script = selectedScript.value || {} const script = selectedScript.value || {}
const title = script.title ? `${script.title} · 实现路径` : '我的实现路径' const title = script.title ? `${script.title} · 实现路径` : '我的实现路径'
let generatedText = '' let generatedText = ''
pathGenerating.value = true
pathWriter.reset()
try { try {
const streamRes = await streamAiScene({ const streamRes = await streamAiScene({
sceneCode: 'life_healing', sceneCode: 'life_healing',
@@ -86,26 +121,23 @@ const createPlaceholderPath = async (scriptId) => {
}, },
onDelta: (_delta, output) => { onDelta: (_delta, output) => {
generatedText = output generatedText = output
pathData.value = { pathWriter.push(output)
id: `stream-${scriptId}`, },
scriptId, onDone: (_event, output) => {
title, pathWriter.finish(output)
description: output, },
steps: output.split('\n').filter(Boolean).slice(0, 6).map((line, index) => ({ onError: (message) => {
phase: `阶段${index + 1}`, pathWriter.fail(message || '路径生成失败')
task: line.replace(/^\d+[.、]\s*/, '').slice(0, 28),
desc: line,
content: line,
done: index === 0
})),
progress: 8,
status: 'active'
}
} }
}) })
generatedText = streamRes.output || generatedText generatedText = streamRes.output || generatedText
pathWriter.finish(generatedText)
await pathWriter.waitForDone()
} catch (error) { } catch (error) {
pathWriter.fail(error?.message || '路径生成失败')
generatedText = '' generatedText = ''
} finally {
pathGenerating.value = false
} }
const steps = [ const steps = [
{ phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true }, { phase: '阶段1', task: '整理目标', desc: '把剧本中的关键目标拆成可以执行的小目标。', content: '把剧本中的关键目标拆成可以执行的小目标。', done: true },
@@ -128,7 +160,7 @@ const createPlaceholderPath = async (scriptId) => {
const res = await lifePathService.createPath({ const res = await lifePathService.createPath({
scriptId, scriptId,
title, title,
description: script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。', description: generatedText || script.summary || '根据选中的人生剧本生成的占位实现路径,后续可接入AI生成更细的行动计划。',
steps, steps,
progress: 8, progress: 8,
status: 'active' status: 'active'
@@ -164,6 +196,10 @@ onMounted(() => {
loadPath(selectedScript.value.id) loadPath(selectedScript.value.id)
} }
}) })
onUnmounted(() => {
pathWriter.dispose()
})
</script> </script>
<style scoped> <style scoped>
@@ -1,9 +1,9 @@
<template> <template>
<view class="detail-page"> <view class="detail-page">
<view class="space-bg"></view> <view class="space-bg"></view>
<view class="status-space" :style="{ height: statusBarHeight + 'px' }"></view> <view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
<view class="topbar"> <view class="topbar" :style="topbarStyle">
<button class="back-btn" @click="goBack"></button> <button class="back-btn" @click="goBack"></button>
<text class="top-title">人生剧本 </text> <text class="top-title">人生剧本 </text>
<button class="save-btn kos-pill" @click="selectCurrent">映射</button> <button class="save-btn kos-pill" @click="selectCurrent">映射</button>
@@ -67,14 +67,15 @@ import { useAppStore } from '../../stores/app.js'
import Markdown from '../../components/Markdown.vue' import Markdown from '../../components/Markdown.vue'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js' import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const store = useAppStore() const store = useAppStore()
const statusBarHeight = ref(20)
const activeTab = ref('content') const activeTab = ref('content')
const scriptId = ref('') const scriptId = ref('')
const script = ref(null) const script = ref(null)
const pagePath = '/pages/main/ScriptDetailView' const pagePath = '/pages/main/ScriptDetailView'
const ttsPlayer = useTtsPlayer({ pagePath }) const ttsPlayer = useTtsPlayer({ pagePath })
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
const fullContent = computed(() => script.value?.content || '暂无正文内容。') const fullContent = computed(() => script.value?.content || '暂无正文内容。')
const lengthText = computed(() => { const lengthText = computed(() => {
@@ -146,7 +147,6 @@ const goBack = () => {
} }
onMounted(async () => { onMounted(async () => {
statusBarHeight.value = uni.getStorageSync('statusBarHeight') || 20
const pages = getCurrentPages() const pages = getCurrentPages()
scriptId.value = pages[pages.length - 1]?.options?.id || '' scriptId.value = pages[pages.length - 1]?.options?.id || ''
await loadScript() await loadScript()
+668 -64
View File
@@ -1,5 +1,14 @@
<template> <template>
<view class="script-view"> <view class="script-view">
<view class="cosmic-background">
<view class="cosmic-stars layer-one"></view>
<view class="cosmic-stars layer-two"></view>
<view class="cosmic-planet planet-main"></view>
<view class="cosmic-planet planet-soft"></view>
<view class="meteor meteor-one"></view>
<view class="meteor meteor-two"></view>
<view class="meteor meteor-three"></view>
</view>
<view v-if="viewState === 'home'" class="wish-home"> <view v-if="viewState === 'home'" class="wish-home">
<view class="home-head"> <view class="home-head">
<view class="history-button" @click="openScriptLibrary"> <view class="history-button" @click="openScriptLibrary">
@@ -21,28 +30,26 @@
</view> </view>
<view class="hero-copy"> <view class="hero-copy">
<text class="hero-title">今天有什么</text> <text class="hero-title">今天有什么<text class="hero-highlight">心愿</text>想实现</text>
<text class="hero-title"><text class="hero-highlight">心愿</text>想实现</text>
</view> </view>
<view class="orb-wrap"> <view class="inspiration-section">
<view <view class="section-line">
class="mic-orb" <text class="section-title">灵感一下</text>
:class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }" <text class="refresh" @click="shuffleInspirations">换一换</text>
@touchstart.prevent="startVoicePress" </view>
@touchend.prevent="endVoicePress" <view class="recommend-grid">
@touchcancel.prevent="cancelVoicePress" <view
> v-for="item in recommendations"
<view class="mic-symbol"> :key="item.text"
<view class="mic-head"></view> class="recommend-card"
<view class="mic-stem"></view> @click="useRecommendation(item.text)"
<view class="mic-base"></view> >
<text class="recommend-text">{{ item.text }}</text>
</view> </view>
</view> </view>
</view> </view>
<text class="voice-copy">{{ voiceCopy }}</text>
<view class="wish-input-wrap"> <view class="wish-input-wrap">
<input <input
class="wish-input" class="wish-input"
@@ -68,44 +75,79 @@
/> />
</view> </view>
<view class="inspiration-section"> <view class="orb-wrap">
<view class="section-line"> <view
<text class="section-title">灵感一下</text> class="mic-orb"
<text class="refresh" @click="shuffleInspirations">换一换</text> :class="{ pressing: voiceState === 'pressing', recognizing: voiceState === 'recognizing' }"
</view> @touchstart.prevent="startVoicePress"
<view class="recommend-grid"> @touchend.prevent="endVoicePress"
<view @touchcancel.prevent="cancelVoicePress"
v-for="item in recommendations" >
:key="item.text" <view class="mic-symbol">
class="recommend-card" <view class="mic-head"></view>
@click="useRecommendation(item.text)" <view class="mic-stem"></view>
> <view class="mic-base"></view>
<text class="recommend-text">{{ item.text }}</text>
<text class="recommend-tag">{{ item.tag || item.category || '灵感' }}</text>
</view> </view>
</view> </view>
</view> </view>
<text class="voice-copy">{{ voiceCopy }}</text>
</view> </view>
<view v-else-if="viewState === 'generating'" class="generation-view"> <view v-else-if="viewState === 'generating'" class="generation-view">
<scroll-view
class="generation-scroll"
scroll-y
:scroll-into-view="generationScrollTarget"
:scroll-with-animation="true"
:enhanced="true"
:show-scrollbar="false"
@touchstart="pauseGenerationAutoScroll"
@scrolltolower="resumeGenerationAutoScroll"
>
<view class="generation-scroll-content">
<view class="conversation"> <view class="conversation">
<view class="chat-bubble user"> <view class="chat-bubble user">
<text>{{ wishText }}</text> <text>{{ wishText }}</text>
<text class="bubble-time">{{ currentMessageTime }}</text> <text class="bubble-time">{{ currentMessageTime }}</text>
</view> </view>
<view class="chat-bubble system"> <view class="chat-bubble system">
<text>心愿实现中</text> <text>{{ generationTitle }}</text>
<text v-if="streamContent" class="stream-preview">{{ streamContent }}</text> <view v-if="showThinkingDots" class="thinking-dots">
<view></view>
<view></view>
<view></view>
</view>
<text v-if="generationStatus === 'failed'" class="generation-error">{{ generationFailureCopy }}</text>
<text v-else-if="visibleStreamContent" class="stream-preview typing">{{ visibleStreamContent }}<text v-if="generating" class="typing-cursor">|</text></text>
<text class="bubble-time">{{ currentMessageTime }}</text> <text class="bubble-time">{{ currentMessageTime }}</text>
</view> </view>
</view> </view>
<view class="loading-orbit"> <view class="generation-loading">
<view class="loading-orbit" :class="{ streaming: generationStatus === 'streaming', failed: generationStatus === 'failed' }">
<view class="orbit-ring outer"></view>
<view class="orbit-ring inner"></view>
<view class="orbit-core"></view> <view class="orbit-core"></view>
</view> </view>
<text class="loading-copy">正在把你的心愿写成故事</text> <text class="loading-copy">{{ generationCopy }}</text>
<text v-if="generationStatus !== 'failed'" class="loading-subcopy">{{ generationSubcopy }}</text>
<view v-else class="generation-actions">
<button class="generation-action primary" @click="retryGeneration">再试一次</button>
<button class="generation-action secondary" @click="returnToEdit">返回修改</button>
</view>
</view>
<view :id="generationScrollAnchor" class="generation-scroll-anchor"></view>
</view>
</scroll-view>
</view> </view>
<view v-else class="result-view"> <scroll-view
v-else
class="result-view"
scroll-y
:enhanced="true"
:show-scrollbar="false"
>
<view class="conversation compact"> <view class="conversation compact">
<view class="chat-bubble user"> <view class="chat-bubble user">
<text>{{ wishText }}</text> <text>{{ wishText }}</text>
@@ -141,17 +183,18 @@
<button class="action-btn primary" @click="trackTtsClick">{{ ttsButtonText }}</button> <button class="action-btn primary" @click="trackTtsClick">{{ ttsButtonText }}</button>
</view> </view>
</view> </view>
</view> </scroll-view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
import * as socialImport from '../../services/socialImport.js' import * as socialImport from '../../services/socialImport.js'
import * as epicScriptService from '../../services/epicScript.js' import * as epicScriptService from '../../services/epicScript.js'
import { useTtsPlayer } from '../../composables/useTtsPlayer.js' import { useTtsPlayer } from '../../composables/useTtsPlayer.js'
import { useTypewriterStream } from '../../composables/useTypewriterStream.js'
import { transcribeAudio } from '../../services/asr.js' import { transcribeAudio } from '../../services/asr.js'
import { streamAiScene } from '../../services/aiRuntime.js' import { streamAiScene } from '../../services/aiRuntime.js'
@@ -166,6 +209,14 @@ const currentMessageTime = ref('')
const currentResultTime = ref('') const currentResultTime = ref('')
const streamContent = ref('') const streamContent = ref('')
const generating = ref(false) const generating = ref(false)
const streamWriter = useTypewriterStream({ interval: 24, step: 1 })
const generationStatus = ref('idle')
const generationError = ref('')
const generationHintIndex = ref(0)
const generationScrollAnchor = ref('generation-stream-anchor-a')
const generationScrollTarget = ref('')
const generationAutoFollow = ref(true)
const lastSubmitSource = ref('text')
const remainingCount = ref(3) const remainingCount = ref(3)
const style = ref('career') const style = ref('career')
const randomRecommendations = ref([]) const randomRecommendations = ref([])
@@ -175,6 +226,17 @@ const ttsPlayer = useTtsPlayer({ pagePath })
let recorderManager = null let recorderManager = null
let recordStartedAt = 0 let recordStartedAt = 0
let recordCancelled = false let recordCancelled = false
let generationHintTimer = null
let generationSlowTimer = null
let generationVerySlowTimer = null
let generationScrollTimer = null
const generationHints = [
'正在从你的心愿里寻找故事的起点',
'正在整理人生素材和情绪线索',
'正在安排一次更爽的命运转弯',
'正在让故事慢慢靠近你'
]
const fallbackRecommendations = [ const fallbackRecommendations = [
{ text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' }, { text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' },
@@ -211,6 +273,71 @@ const resultContent = computed(() => {
return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。' return currentResult.value?.content || currentResult.value?.summary || '故事正在生成,请稍后查看。'
}) })
const visibleStreamContent = computed(() => streamWriter.visibleText.value)
const scrollGenerationToLatest = () => {
if (viewState.value !== 'generating') return
if (!generationAutoFollow.value) return
if (generationScrollTimer) clearTimeout(generationScrollTimer)
generationScrollTimer = setTimeout(() => {
nextTick(() => {
generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a'
? 'generation-stream-anchor-b'
: 'generation-stream-anchor-a'
generationScrollTarget.value = generationScrollAnchor.value
})
}, 80)
}
const pauseGenerationAutoScroll = () => {
generationAutoFollow.value = false
}
const resumeGenerationAutoScroll = () => {
generationAutoFollow.value = true
scrollGenerationToLatest()
}
watch(visibleStreamContent, (text) => {
if (!text) return
scrollGenerationToLatest()
})
watch(generationStatus, () => {
scrollGenerationToLatest()
})
const generationTitle = computed(() => {
if (generationStatus.value === 'failed') return '心愿暂时没有抵达'
if (visibleStreamContent.value) return '故事正在展开'
return '心愿实现中……'
})
const showThinkingDots = computed(() => {
return generationStatus.value !== 'failed' && !visibleStreamContent.value
})
const generationCopy = computed(() => {
if (generationStatus.value === 'failed') return '这次生成没有顺利完成'
if (generationStatus.value === 'verySlow') return '这次需要久一点,仍在努力连接灵感'
if (generationStatus.value === 'slow') return '还在整理,请再给我一点时间'
if (streamWriter.isWaiting.value) return '正在理解你的心愿,整理人生素材'
if (streamWriter.isDraining.value) return '故事马上完成,正在收束最后一句'
if (streamWriter.isStreaming.value) return '正在把你的心愿写成故事'
return '正在把你的心愿写成故事'
})
const generationSubcopy = computed(() => {
if (generationStatus.value === 'verySlow') return '如果网络波动,稍后会给你温和提示,不会丢掉当前心愿。'
if (generationStatus.value === 'slow') return 'AI 还没有吐出第一句话,但请求仍在进行中。'
if (streamWriter.isStreaming.value || streamWriter.isDraining.value) return '已经收到内容,正在逐字展示给你。'
return generationHints[generationHintIndex.value]
})
const generationFailureCopy = computed(() => {
return generationError.value || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
})
const ttsButtonText = computed(() => { const ttsButtonText = computed(() => {
if (!currentResult.value?.id) return '生成保存后可语音播放' if (!currentResult.value?.id) return '生成保存后可语音播放'
return ttsPlayer.buttonText.value return ttsPlayer.buttonText.value
@@ -223,6 +350,54 @@ const formatMessageTime = () => {
return `${hours}:${minutes}` return `${hours}:${minutes}`
} }
const clearGenerationFeedbackTimers = () => {
if (generationHintTimer) {
clearInterval(generationHintTimer)
generationHintTimer = null
}
if (generationSlowTimer) {
clearTimeout(generationSlowTimer)
generationSlowTimer = null
}
if (generationVerySlowTimer) {
clearTimeout(generationVerySlowTimer)
generationVerySlowTimer = null
}
}
const startGenerationFeedback = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'waiting'
generationError.value = ''
generationHintIndex.value = 0
generationHintTimer = setInterval(() => {
if (generationStatus.value === 'failed' || visibleStreamContent.value) return
generationHintIndex.value = (generationHintIndex.value + 1) % generationHints.length
}, 3600)
generationSlowTimer = setTimeout(() => {
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
generationStatus.value = 'slow'
}
}, 8000)
generationVerySlowTimer = setTimeout(() => {
if (generating.value && !streamContent.value && generationStatus.value !== 'failed') {
generationStatus.value = 'verySlow'
}
}, 20000)
}
const markGenerationStreaming = () => {
if (generationStatus.value === 'failed') return
generationStatus.value = 'streaming'
}
const markGenerationFailed = (message) => {
clearGenerationFeedbackTimers()
generationStatus.value = 'failed'
generationError.value = message || '可能是网络慢了,或 AI 服务暂时没有回应。你可以直接再试一次,也可以返回修改心愿。'
streamWriter.fail(generationError.value)
}
const normalizeGeneratedScript = (data) => { const normalizeGeneratedScript = (data) => {
const script = data?.script || data || {} const script = data?.script || data || {}
const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null
@@ -387,12 +562,22 @@ const generateScriptByStream = async (text) => {
}, },
onDelta: (_delta, output) => { onDelta: (_delta, output) => {
streamContent.value = output streamContent.value = output
markGenerationStreaming()
streamWriter.push(output)
},
onDone: (_event, output) => {
streamWriter.finish(output)
},
onError: (message) => {
streamWriter.fail(message || 'AI 流式生成失败')
} }
}) })
const content = streamRes.output?.trim() const content = streamRes.output?.trim()
if (!content) { if (!content) {
throw new Error('AI 流式输出为空') throw new Error('AI 流式输出为空')
} }
streamWriter.finish(content)
await streamWriter.waitForDone()
const saveRes = await store.createScript({ const saveRes = await store.createScript({
title: text.length > 22 ? `${text.slice(0, 22)}...` : text, title: text.length > 22 ? `${text.slice(0, 22)}...` : text,
theme: text, theme: text,
@@ -424,6 +609,7 @@ const generateScriptByStream = async (text) => {
const submitWish = async (source = 'text') => { const submitWish = async (source = 'text') => {
const text = wishText.value.trim() const text = wishText.value.trim()
if (!text || generating.value) return if (!text || generating.value) return
lastSubmitSource.value = source
analytics.track('script_wish_submit', { analytics.track('script_wish_submit', {
source, source,
@@ -434,6 +620,11 @@ const submitWish = async (source = 'text') => {
generationStartedAt.value = Date.now() generationStartedAt.value = Date.now()
generating.value = true generating.value = true
streamContent.value = '' streamContent.value = ''
generationScrollTarget.value = ''
generationScrollAnchor.value = 'generation-stream-anchor-a'
generationAutoFollow.value = true
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset() ttsPlayer.reset()
viewState.value = 'generating' viewState.value = 'generating'
analytics.track('script_generation_progress_view', { analytics.track('script_generation_progress_view', {
@@ -445,6 +636,7 @@ const submitWish = async (source = 'text') => {
try { try {
res = await generateScriptByStream(text) res = await generateScriptByStream(text)
} catch (streamError) { } catch (streamError) {
markGenerationFailed(streamError?.message || 'AI 流式生成失败')
analytics.track('script_generate_stream_fail', { analytics.track('script_generate_stream_fail', {
source, source,
error: streamError?.message || 'stream_failed' error: streamError?.message || 'stream_failed'
@@ -456,6 +648,10 @@ const submitWish = async (source = 'text') => {
} }
generating.value = false generating.value = false
if (res.success) {
clearGenerationFeedbackTimers()
if (streamContent.value) streamWriter.finish(streamContent.value)
}
if (!res.success) { if (!res.success) {
analytics.track('script_generate_fail', { analytics.track('script_generate_fail', {
@@ -463,8 +659,7 @@ const submitWish = async (source = 'text') => {
error: res.error || 'unknown', error: res.error || 'unknown',
duration_ms: Date.now() - generationStartedAt.value duration_ms: Date.now() - generationStartedAt.value
}, { eventType: 'script', pagePath }) }, { eventType: 'script', pagePath })
viewState.value = 'home' markGenerationFailed(res.error || '生成失败')
uni.showToast({ title: res.error || '生成失败', icon: 'none' })
return return
} }
@@ -485,10 +680,36 @@ const submitWish = async (source = 'text') => {
length: currentResult.value.length || '' length: currentResult.value.length || ''
}, { eventType: 'script', pagePath }) }, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
viewState.value = 'result' viewState.value = 'result'
} }
const retryGeneration = () => {
if (generating.value) return
analytics.track('script_generation_retry_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
generationStatus.value = 'idle'
generationError.value = ''
submitWish(lastSubmitSource.value || 'text')
}
const returnToEdit = () => {
clearGenerationFeedbackTimers()
generating.value = false
generationStatus.value = 'idle'
generationError.value = ''
streamContent.value = ''
streamWriter.reset()
analytics.track('script_generation_back_edit_click', {
prompt_length: wishText.value.trim().length
}, { eventType: 'script', pagePath })
viewState.value = 'home'
}
const closeResult = () => { const closeResult = () => {
clearGenerationFeedbackTimers()
generationStatus.value = 'idle'
viewState.value = 'home' viewState.value = 'home'
currentResult.value = null currentResult.value = null
ttsPlayer.reset() ttsPlayer.reset()
@@ -534,6 +755,12 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
clearGenerationFeedbackTimers()
if (generationScrollTimer) {
clearTimeout(generationScrollTimer)
generationScrollTimer = null
}
streamWriter.dispose()
if (recorderManager && voiceState.value === 'pressing') { if (recorderManager && voiceState.value === 'pressing') {
recordCancelled = true recordCancelled = true
recorderManager.stop() recorderManager.stop()
@@ -543,14 +770,177 @@ onUnmounted(() => {
<style scoped> <style scoped>
.script-view { .script-view {
position: relative;
height: 100%;
min-height: 100%; min-height: 100%;
overflow: hidden;
color: #fff; color: #fff;
font-family: "PingFang SC", "Noto Sans SC", sans-serif; font-family: "PingFang SC", "Noto Sans SC", sans-serif;
} }
.cosmic-background {
position: absolute;
inset: -80rpx -80rpx;
z-index: 0;
pointer-events: none;
overflow: hidden;
}
.cosmic-background::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 22%, rgba(255, 216, 107, 0.06), transparent 30%),
radial-gradient(circle at 72% 6%, rgba(209, 138, 255, 0.11), transparent 24%),
linear-gradient(180deg, rgba(12, 4, 31, 0.1), rgba(5, 2, 13, 0.2));
}
.cosmic-stars {
position: absolute;
inset: 0;
opacity: 0.48;
}
.cosmic-stars.layer-one {
background:
radial-gradient(circle at 12% 18%, rgba(255, 255, 255, 0.52) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 32% 9%, rgba(255, 216, 107, 0.42) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 74% 32%, rgba(255, 255, 255, 0.36) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 88% 48%, rgba(209, 138, 255, 0.42) 0 2rpx, transparent 3rpx),
radial-gradient(circle at 24% 72%, rgba(255, 255, 255, 0.32) 0 2rpx, transparent 3rpx);
animation: starFloat 8s ease-in-out infinite;
}
.cosmic-stars.layer-two {
opacity: 0.32;
background:
radial-gradient(circle at 18% 42%, rgba(255, 255, 255, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 46% 28%, rgba(209, 138, 255, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 64% 70%, rgba(255, 216, 107, 0.5) 0 1rpx, transparent 2rpx),
radial-gradient(circle at 92% 18%, rgba(255, 255, 255, 0.42) 0 1rpx, transparent 2rpx);
animation: starFloat 11s ease-in-out infinite reverse;
}
.cosmic-planet {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.planet-main {
top: 118rpx;
right: -52rpx;
width: 188rpx;
height: 188rpx;
opacity: 0.38;
background:
radial-gradient(circle at 36% 28%, rgba(255, 231, 163, 0.96), rgba(209, 138, 255, 0.7) 36%, rgba(93, 38, 193, 0.78) 68%, rgba(23, 9, 56, 0.9));
box-shadow: 0 0 76rpx rgba(168, 85, 247, 0.32);
animation: planetDrift 9s ease-in-out infinite;
}
.planet-main::after {
content: '';
position: absolute;
left: -24rpx;
top: 78rpx;
width: 238rpx;
height: 34rpx;
border-radius: 50%;
border: 3rpx solid rgba(255, 216, 107, 0.18);
transform: rotate(-16deg);
}
.planet-soft {
left: -64rpx;
bottom: 180rpx;
width: 128rpx;
height: 128rpx;
opacity: 0.2;
background: radial-gradient(circle at 50% 42%, rgba(140, 68, 242, 0.78), rgba(30, 8, 76, 0.92));
box-shadow: 0 0 64rpx rgba(140, 68, 242, 0.28);
animation: planetDrift 12s ease-in-out infinite reverse;
}
.meteor {
position: absolute;
width: 132rpx;
height: 3rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), rgba(255, 216, 107, 0.18), transparent);
opacity: 0;
transform: rotate(-22deg);
animation: meteorFall 5.6s linear infinite;
}
.meteor-one {
top: 142rpx;
left: -160rpx;
}
.meteor-two {
top: 318rpx;
left: 120rpx;
width: 104rpx;
animation-delay: 1.8s;
}
.meteor-three {
top: 520rpx;
right: -120rpx;
width: 88rpx;
animation-delay: 3.4s;
}
@keyframes starFloat {
0%, 100% {
transform: translateY(0);
opacity: 0.36;
}
50% {
transform: translateY(18rpx);
opacity: 0.62;
}
}
@keyframes planetDrift {
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(-14rpx, 18rpx, 0) scale(1.03);
}
}
@keyframes meteorFall {
0% {
opacity: 0;
transform: translate3d(0, 0, 0) rotate(-22deg);
}
9% {
opacity: 0.62;
}
34% {
opacity: 0;
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
}
100% {
opacity: 0;
transform: translate3d(520rpx, 210rpx, 0) rotate(-22deg);
}
}
.wish-home, .wish-home,
.generation-view, .generation-view,
.result-view { .result-view {
position: relative;
z-index: 1;
min-height: 100%; min-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -562,6 +952,13 @@ onUnmounted(() => {
gap: 32rpx; gap: 32rpx;
} }
.result-view {
display: block;
height: 100%;
min-height: 0;
padding-bottom: 190rpx;
}
.home-head { .home-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -623,17 +1020,28 @@ onUnmounted(() => {
.hero-copy { .hero-copy {
margin-top: 12rpx; margin-top: 12rpx;
margin-bottom: 14rpx;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
white-space: nowrap;
overflow: visible;
} }
.hero-title { .hero-title {
display: block; display: inline-flex;
font-size: 76rpx; align-items: baseline;
flex-wrap: nowrap;
white-space: nowrap;
font-size: 56rpx;
font-weight: 800; font-weight: 800;
line-height: 1.26; line-height: 1.18;
letter-spacing: 0; letter-spacing: 0;
} }
.hero-highlight { .hero-highlight {
margin: 0 4rpx;
color: #d18aff; color: #d18aff;
text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52); text-shadow: 0 0 28rpx rgba(209, 138, 255, 0.52);
} }
@@ -822,40 +1230,58 @@ onUnmounted(() => {
.recommend-grid { .recommend-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx; gap: 14rpx;
} }
.recommend-card { .recommend-card {
min-height: 142rpx; min-height: 68rpx;
display: flex; display: flex;
flex-direction: column; align-items: center;
justify-content: space-between; min-width: 0;
gap: 16rpx; padding: 0 22rpx;
padding: 22rpx; border-radius: 24rpx;
border-radius: 36rpx;
background: linear-gradient(180deg, rgba(48, 24, 89, 0.78), rgba(32, 14, 62, 0.76)); background: linear-gradient(180deg, rgba(48, 24, 89, 0.78), rgba(32, 14, 62, 0.76));
border: 1rpx solid rgba(168, 85, 247, 0.22); border: 1rpx solid rgba(168, 85, 247, 0.22);
box-sizing: border-box; box-sizing: border-box;
} }
.recommend-text { .recommend-text {
font-size: 28rpx; width: 100%;
line-height: 40rpx; min-width: 0;
font-size: 26rpx;
line-height: 68rpx;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} overflow: hidden;
text-overflow: ellipsis;
.recommend-tag { white-space: nowrap;
align-self: flex-start;
padding: 5rpx 14rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #d18aff;
background: rgba(168, 85, 247, 0.22);
} }
.generation-view { .generation-view {
justify-content: center; flex: 1;
gap: 52rpx; height: 100%;
min-height: 0;
padding: 0;
}
.generation-scroll {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.generation-scroll-content {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 42rpx;
box-sizing: border-box;
padding: 40rpx 0 190rpx;
}
.generation-scroll-anchor {
width: 1rpx;
height: 28rpx;
} }
.conversation { .conversation {
@@ -892,14 +1318,78 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.thinking-dots {
display: flex;
align-items: center;
gap: 10rpx;
padding-top: 8rpx;
}
.thinking-dots view {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #ffd86b;
box-shadow: 0 0 18rpx rgba(255, 216, 107, 0.52);
animation: thinkingDot 1.2s ease-in-out infinite;
}
.thinking-dots view:nth-child(2) {
animation-delay: 0.18s;
background: #d18aff;
}
.thinking-dots view:nth-child(3) {
animation-delay: 0.36s;
background: #8c44f2;
}
@keyframes thinkingDot {
0%, 80%, 100% {
opacity: 0.38;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-8rpx);
}
}
.stream-preview { .stream-preview {
display: block; display: block;
max-height: 320rpx;
overflow: hidden;
color: rgba(255, 255, 255, 0.86); color: rgba(255, 255, 255, 0.86);
font-size: 28rpx; font-size: 28rpx;
line-height: 44rpx; line-height: 44rpx;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all;
}
.stream-preview.typing {
padding-top: 12rpx;
}
.typing-cursor {
color: #ffd86b;
animation: cursorBlink 0.9s steps(2, start) infinite;
}
.generation-error {
display: block;
color: rgba(255, 231, 163, 0.92);
font-size: 28rpx;
line-height: 44rpx;
white-space: pre-wrap;
}
@keyframes cursorBlink {
0%, 45% {
opacity: 1;
}
46%, 100% {
opacity: 0;
}
} }
.chat-bubble.done { .chat-bubble.done {
@@ -920,6 +1410,24 @@ onUnmounted(() => {
border-radius: 50%; border-radius: 50%;
border: 3rpx solid rgba(192, 132, 252, 0.28); border: 3rpx solid rgba(192, 132, 252, 0.28);
box-shadow: 0 0 44rpx rgba(168, 85, 247, 0.55); box-shadow: 0 0 44rpx rgba(168, 85, 247, 0.55);
animation: orbitBreath 2.4s ease-in-out infinite;
transition: opacity 0.2s ease, filter 0.2s ease;
}
.generation-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 28rpx;
}
.loading-orbit.streaming {
filter: saturate(1.2);
}
.loading-orbit.failed {
opacity: 0.52;
filter: grayscale(0.2);
} }
.loading-orbit::after { .loading-orbit::after {
@@ -932,6 +1440,22 @@ onUnmounted(() => {
border-radius: 50%; border-radius: 50%;
background: #ffd86b; background: #ffd86b;
box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.72); box-shadow: 0 0 24rpx rgba(255, 216, 107, 0.72);
animation: orbitLight 1.8s linear infinite;
transform-origin: -72rpx 70rpx;
}
.orbit-ring {
position: absolute;
inset: -22rpx;
border-radius: 50%;
border: 1rpx solid rgba(255, 216, 107, 0.16);
animation: ringPulse 2.8s ease-in-out infinite;
}
.orbit-ring.inner {
inset: 18rpx;
border-color: rgba(209, 138, 255, 0.2);
animation-delay: 0.7s;
} }
.orbit-core { .orbit-core {
@@ -939,6 +1463,41 @@ onUnmounted(() => {
inset: 42rpx; inset: 42rpx;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, #c084fc, #5c1bb0); background: radial-gradient(circle, #c084fc, #5c1bb0);
box-shadow: inset 0 0 22rpx rgba(255, 255, 255, 0.18);
}
@keyframes orbitBreath {
0%, 100% {
transform: scale(0.96);
box-shadow: 0 0 36rpx rgba(168, 85, 247, 0.42);
}
50% {
transform: scale(1.04);
box-shadow: 0 0 70rpx rgba(168, 85, 247, 0.66);
}
}
@keyframes orbitLight {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ringPulse {
0%, 100% {
opacity: 0.2;
transform: scale(0.92);
}
50% {
opacity: 0.78;
transform: scale(1.08);
}
} }
.loading-copy { .loading-copy {
@@ -947,6 +1506,51 @@ onUnmounted(() => {
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
} }
.loading-subcopy {
display: block;
max-width: 620rpx;
text-align: center;
padding: 18rpx 24rpx;
border-radius: 28rpx;
color: rgba(255, 231, 163, 0.78);
font-size: 24rpx;
line-height: 36rpx;
background: rgba(255, 216, 107, 0.07);
border: 1rpx solid rgba(255, 216, 107, 0.14);
}
.generation-actions {
display: flex;
justify-content: center;
gap: 18rpx;
}
.generation-action {
min-width: 176rpx;
height: 72rpx;
margin: 0;
padding: 0 28rpx;
border-radius: 999rpx;
font-size: 27rpx;
line-height: 72rpx;
}
.generation-action::after {
border: 0;
}
.generation-action.primary {
color: #1b0b31;
background: linear-gradient(145deg, #ffd86b, #d18aff);
box-shadow: 0 16rpx 36rpx rgba(209, 138, 255, 0.24);
}
.generation-action.secondary {
color: #e8ccff;
background: rgba(43, 19, 83, 0.68);
border: 1rpx solid rgba(168, 85, 247, 0.32);
}
.story-card { .story-card {
border-radius: 52rpx; border-radius: 52rpx;
padding: 34rpx; padding: 34rpx;
+12 -7
View File
@@ -6,7 +6,7 @@
<view class="stars"></view> <view class="stars"></view>
</view> </view>
<view class="safe-top" :style="{ height: safeAreaTop + 14 + 'px' }"></view> <view class="safe-top" :style="{ height: capsuleTopReservePx + 'px' }"></view>
<scroll-view class="content" scroll-y :enhanced="true" :show-scrollbar="false"> <scroll-view class="content" scroll-y :enhanced="true" :show-scrollbar="false">
<ScriptView v-if="activeTab === 'script'" /> <ScriptView v-if="activeTab === 'script'" />
@@ -48,31 +48,36 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import RecordView from './RecordView.vue' import RecordView from './RecordView.vue'
import ScriptView from './ScriptView.vue' import ScriptView from './ScriptView.vue'
import MineView from './MineView.vue' import MineView from './MineView.vue'
import MusicPlayer from '../../components/MusicPlayer.vue' import MusicPlayer from '../../components/MusicPlayer.vue'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const store = useAppStore() const store = useAppStore()
const activeTab = ref('script') const activeTab = ref('script')
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20)
const safeAreaBottom = ref(uni.getStorageSync('safeAreaBottom') || 0)
const pagePath = '/pages/main/index' const pagePath = '/pages/main/index'
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
const validTabs = ['script', 'record', 'mine']
const normalizeTab = (tab) => validTabs.includes(tab) ? tab : 'script'
const switchTab = (tab) => { const switchTab = (tab) => {
tab = normalizeTab(tab)
if (activeTab.value === tab) return if (activeTab.value === tab) return
analytics.track('page_leave', { tab: activeTab.value }, { eventType: 'page', pagePath }) analytics.track('page_leave', { tab: activeTab.value }, { eventType: 'page', pagePath })
activeTab.value = tab activeTab.value = tab
analytics.track('page_view', { tab }, { eventType: 'page', pagePath }) analytics.track('page_view', { tab }, { eventType: 'page', pagePath })
} }
onMounted(async () => { onLoad((options = {}) => {
const windowInfo = uni.getWindowInfo() activeTab.value = normalizeTab(options.tab)
safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20 })
safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0
onMounted(async () => {
const session = await store.restoreSession() const session = await store.restoreSession()
if (session.status !== store.SESSION_STATUS.AUTHENTICATED) { if (session.status !== store.SESSION_STATUS.AUTHENTICATED) {
uni.reLaunch({ url: '/pages/login/index' }) uni.reLaunch({ url: '/pages/login/index' })
+6 -6
View File
@@ -1,9 +1,9 @@
<template> <template>
<view class="profile-page"> <view class="profile-page">
<view class="space-bg"></view> <view class="space-bg"></view>
<view class="status-space" :style="{ height: statusBarHeight + 'px' }"></view> <view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
<view class="topbar"> <view class="topbar" :style="topbarStyle">
<text class="back" @click="goBack"></text> <text class="back" @click="goBack"></text>
<text class="title">编辑资料 <text class="gold"></text></text> <text class="title">编辑资料 <text class="gold"></text></text>
<text class="save" @click="saveProfile">保存</text> <text class="save" @click="saveProfile">保存</text>
@@ -185,9 +185,10 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useAppStore } from '../../stores/app.js' import { useAppStore } from '../../stores/app.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const store = useAppStore() const store = useAppStore()
const statusBarHeight = ref(20) const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
const isEdit = ref(false) const isEdit = ref(false)
const saving = ref(false) const saving = ref(false)
const birthday = ref('') const birthday = ref('')
@@ -321,17 +322,16 @@ const saveProfile = async () => {
uni.showToast({ title: '已保存', icon: 'success' }) uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => { setTimeout(() => {
if (isEdit.value) uni.navigateBack() if (isEdit.value) uni.navigateBack()
else uni.reLaunch({ url: '/pages/main/index' }) else uni.reLaunch({ url: '/pages/main/index?tab=script' })
}, 350) }, 350)
} }
const goBack = () => { const goBack = () => {
if (isEdit.value) uni.navigateBack() if (isEdit.value) uni.navigateBack()
else uni.reLaunch({ url: '/pages/main/index' }) else uni.reLaunch({ url: '/pages/main/index?tab=script' })
} }
onMounted(() => { onMounted(() => {
statusBarHeight.value = uni.getStorageSync('statusBarHeight') || 20
const pages = getCurrentPages() const pages = getCurrentPages()
isEdit.value = pages[pages.length - 1]?.options?.edit === '1' isEdit.value = pages[pages.length - 1]?.options?.edit === '1'
syncFromStore() syncFromStore()
+4 -9
View File
@@ -1,7 +1,7 @@
<template> <template>
<view class="profile-page"> <view class="profile-page">
<view class="top-safe" :style="{ height: safeAreaTop + 'px' }"></view> <view class="top-safe" :style="{ height: capsuleTopReservePx + 'px' }"></view>
<view class="header"> <view class="header" :style="topbarStyle">
<text class="back" @click="goBack"></text> <text class="back" @click="goBack"></text>
<text class="title">个人中心</text> <text class="title">个人中心</text>
<text></text> <text></text>
@@ -12,15 +12,10 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue'
import MineView from '../main/MineView.vue' import MineView from '../main/MineView.vue'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const safeAreaTop = ref(20) const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 10 })
onMounted(() => {
const info = uni.getWindowInfo()
safeAreaTop.value = info.safeAreaInsets?.top || info.statusBarHeight || 20
})
const goBack = () => { const goBack = () => {
uni.navigateBack() uni.navigateBack()
@@ -1,6 +1,6 @@
<template> <template>
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false"> <scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }"> <view class="nav" :style="navStyle">
<text class="back" @click="goBack"></text> <text class="back" @click="goBack"></text>
<text class="title">社交数据导入</text> <text class="title">社交数据导入</text>
<text class="manage" @click="openInsights">画像</text> <text class="manage" @click="openInsights">画像</text>
@@ -47,12 +47,13 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
import * as socialImport from '../../services/socialImport.js' import * as socialImport from '../../services/socialImport.js'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
const pagePath = '/pages/social-import/index' const pagePath = '/pages/social-import/index'
const platform = ref('xiaohongshu') const platform = ref('xiaohongshu')
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20) const { navStyle } = useMenuButtonSafeArea()
const platforms = [ const platforms = [
{ label: '小红书', value: 'xiaohongshu' }, { label: '小红书', value: 'xiaohongshu' },
@@ -1,6 +1,6 @@
<template> <template>
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false"> <scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }"> <view class="nav" :style="navStyle">
<text class="back" @click="goBack"></text> <text class="back" @click="goBack"></text>
<text class="title">人生素材画像</text> <text class="title">人生素材画像</text>
<text class="add" @click="openImport">导入</text> <text class="add" @click="openImport">导入</text>
@@ -55,13 +55,14 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import * as socialImport from '../../services/socialImport.js' import * as socialImport from '../../services/socialImport.js'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const pagePath = '/pages/social-import/insights' const pagePath = '/pages/social-import/insights'
const insights = ref([]) const insights = ref([])
const activeStatus = ref('all') const activeStatus = ref('all')
const loading = ref(true) const loading = ref(true)
const loadError = ref('') const loadError = ref('')
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20) const { navStyle } = useMenuButtonSafeArea()
const tabs = [ const tabs = [
{ label: '全部', value: 'all' }, { label: '全部', value: 'all' },
@@ -302,10 +303,17 @@ onMounted(() => {
.empty button { .empty button {
height: 72rpx; height: 72rpx;
min-width: 190rpx;
padding: 0 34rpx; padding: 0 34rpx;
border-radius: 999rpx; border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #fff; color: #fff;
font-size: 26rpx; font-size: 26rpx;
line-height: 1;
text-align: center;
background: linear-gradient(135deg, #a855f7, #6d28d9); background: linear-gradient(135deg, #a855f7, #6d28d9);
} }
@@ -1,6 +1,6 @@
<template> <template>
<scroll-view class="page kos-page" scroll-y :show-scrollbar="false"> <scroll-view class="page kos-page" scroll-y :show-scrollbar="false">
<view class="nav" :style="{ paddingTop: safeAreaTop + 'px' }"> <view class="nav" :style="navStyle">
<text class="back" @click="goBack"></text> <text class="back" @click="goBack"></text>
<text class="title">导入预览</text> <text class="title">导入预览</text>
<text class="ghost"></text> <text class="ghost"></text>
@@ -45,6 +45,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import * as socialImport from '../../services/socialImport.js' import * as socialImport from '../../services/socialImport.js'
import analytics from '../../services/analytics.js' import analytics from '../../services/analytics.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
const pagePath = '/pages/social-import/preview' const pagePath = '/pages/social-import/preview'
const draft = uni.getStorageSync('social_import_draft') || {} const draft = uni.getStorageSync('social_import_draft') || {}
@@ -55,7 +56,7 @@ const titleText = ref(draft.title || '')
const contentText = ref(draft.content || '') const contentText = ref(draft.content || '')
const approvedForAi = ref(true) const approvedForAi = ref(true)
const submitting = ref(false) const submitting = ref(false)
const safeAreaTop = ref(uni.getStorageSync('safeAreaTop') || 20) const { navStyle } = useMenuButtonSafeArea()
const platforms = [ const platforms = [
{ label: '小红书', value: 'xiaohongshu' }, { label: '小红书', value: 'xiaohongshu' },
+1 -1
View File
@@ -52,7 +52,7 @@ const resolveInitialRoute = async () => {
const session = await store.restoreSession() const session = await store.restoreSession()
if (session.status === store.SESSION_STATUS.AUTHENTICATED) { if (session.status === store.SESSION_STATUS.AUTHENTICATED) {
const target = session.hasProfile ? '/pages/main/index' : '/pages/onboarding/index' const target = session.hasProfile ? '/pages/main/index?tab=script' : '/pages/onboarding/index'
routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile }) routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile })
return return
} }
+171 -7
View File
@@ -7,6 +7,13 @@ const getAuthHeader = () => {
return token ? { Authorization: `Bearer ${token}` } : {} return token ? { Authorization: `Bearer ${token}` } : {}
} }
const createRequestId = () => {
const random = Math.random().toString(36).slice(2, 10)
return `mp-${Date.now()}-${random}`
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
const decodeChunk = (arrayBuffer) => { const decodeChunk = (arrayBuffer) => {
if (typeof TextDecoder !== 'undefined') { if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8').decode(arrayBuffer) return new TextDecoder('utf-8').decode(arrayBuffer)
@@ -37,34 +44,192 @@ const parseSseFrame = (frame) => {
} }
} }
const queryRuntimeResult = (requestId) => {
return new Promise((resolve, reject) => {
uni.request({
url: `${API_BASE_URL}/ai/runtime/result?requestId=${encodeURIComponent(requestId)}`,
method: 'GET',
header: {
'Content-Type': 'application/json',
...getAuthHeader()
},
timeout: 15000,
success: (res) => {
const data = res.data?.data
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.code === 200 && data) {
resolve(data)
return
}
reject(new Error(res.data?.message || 'AI 结果还在生成中'))
},
fail: (error) => {
reject(new Error(error.errMsg || '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 = ({ sceneCode, inputs = {}, onStart, onDelta, onDone, onError }) => { export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone, onError }) => {
let buffer = '' let buffer = ''
let output = '' let output = ''
let closed = false let closed = false
const requestId = inputs.requestId || createRequestId()
const requestInputs = {
...inputs,
requestId
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let requestTask
let recoveryTimer
let recoveryPromise
const clearRecoveryTimer = () => {
if (recoveryTimer) {
clearTimeout(recoveryTimer)
recoveryTimer = null
}
}
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
closed = true
clearRecoveryTimer()
requestTask?.abort?.()
resolve({ output })
} catch (error) {
if (!closed) {
recoveryPromise = null
}
}
}
const startRecoveryWatcher = () => {
clearRecoveryTimer()
recoveryTimer = setTimeout(() => {
completeFromRecoveredOutput()
}, 8000)
}
const finishRecovered = (message, event) => {
if (!output.trim()) return false
if (closed) return true
closed = true
clearRecoveryTimer()
onDone?.({
type: 'done',
metadata: {
...(event?.metadata || {}),
recovered: true,
warningCode: event?.code,
warningMessage: message || event?.message
}
}, output)
resolve({ output })
return true
}
const recoverOrFail = async (message, event) => {
if (closed) return
if (finishRecovered(message, event)) return
try {
const recoveredOutput = await recoverOnce()
if (closed) return
output = recoveredOutput
closed = true
clearRecoveryTimer()
resolve({ output })
} catch (recoverError) {
if (closed) return
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
closed = true
clearRecoveryTimer()
onError?.(finalMessage, event)
reject(new Error(finalMessage))
}
}
const failStream = (message, event) => { const failStream = (message, event) => {
recoverOrFail(message, event)
}
const failWithoutRecovery = (message, event) => {
if (closed) return if (closed) return
closed = true closed = true
clearRecoveryTimer()
onError?.(message, event) onError?.(message, event)
reject(new Error(message)) reject(new Error(message))
} }
const requestTask = uni.request({ const finishWithOutputOrRecover = async () => {
if (closed) return
if (output.trim()) {
closed = true
clearRecoveryTimer()
resolve({ output })
return
}
await recoverOrFail('AI 生成结果暂时没有返回')
}
startRecoveryWatcher()
requestTask = uni.request({
url: `${API_BASE_URL}/ai/runtime/stream`, url: `${API_BASE_URL}/ai/runtime/stream`,
method: 'POST', method: 'POST',
data: { sceneCode, inputs }, data: { sceneCode, requestId, inputs: requestInputs },
header: { header: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...getAuthHeader() ...getAuthHeader()
}, },
enableChunked: true, enableChunked: true,
timeout: 120000, timeout: 240000,
success: (res) => { success: async (res) => {
if (closed) return if (closed) return
if (res.statusCode >= 400) { if (res.statusCode >= 400) {
const message = res.data?.message || 'AI流式请求失败' const message = res.data?.message || 'AI流式请求失败'
failStream(message) failWithoutRecovery(message)
return return
} }
if (typeof res.data === 'string' && res.data) { if (typeof res.data === 'string' && res.data) {
@@ -73,8 +238,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
if (buffer.trim()) { if (buffer.trim()) {
consumeText('\n\n', failStream) consumeText('\n\n', failStream)
} }
closed = true await finishWithOutputOrRecover()
resolve({ output })
}, },
fail: (error) => { fail: (error) => {
failStream(error.errMsg || 'AI流式请求失败') failStream(error.errMsg || 'AI流式请求失败')
+172 -27
View File
@@ -19,6 +19,16 @@ export interface StreamAiSceneOptions {
onError?: (message: string, event?: AiStreamEvent) => void onError?: (message: string, event?: AiStreamEvent) => void
} }
interface RuntimeLog {
status?: string
outputText?: string
errorMessage?: string
}
const createRequestId = () => `web-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const parseSseFrame = (frame: string): AiStreamEvent | null => { const parseSseFrame = (frame: string): AiStreamEvent | null => {
const parsed = { type: 'message', data: '' } const parsed = { type: 'message', data: '' }
frame.split(/\r?\n/).forEach((line) => { frame.split(/\r?\n/).forEach((line) => {
@@ -33,6 +43,49 @@ const parseSseFrame = (frame: string): AiStreamEvent | null => {
} }
} }
const authHeaders = () => {
const token = localStorage.getItem('access_token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
const queryRuntimeResult = async (requestId: string): Promise<RuntimeLog> => {
const response = await fetch(`${envConfig.apiBaseUrl}/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: string,
callbacks: Pick<StreamAiSceneOptions, 'onDelta' | 'onDone'>
): Promise<string> => {
const maxAttempts = 150
for (let index = 0; index < maxAttempts; index++) {
try {
const log = await queryRuntimeResult(requestId)
const output = String(log.outputText || '').trim()
if (output) {
callbacks.onDelta?.(output, output, { type: 'delta', metadata: { recovered: true, requestId } })
callbacks.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 ({ export const streamAiScene = async ({
sceneCode, sceneCode,
inputs = {}, inputs = {},
@@ -41,26 +94,79 @@ export const streamAiScene = async ({
onDone, onDone,
onError onError
}: StreamAiSceneOptions): Promise<{ output: string }> => { }: StreamAiSceneOptions): Promise<{ output: string }> => {
const token = localStorage.getItem('access_token') const requestId = String(inputs.requestId || createRequestId())
const response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/stream`, { const requestInputs = { ...inputs, requestId }
method: 'POST', const controller = new AbortController()
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')
let buffer = '' let buffer = ''
let output = '' let output = ''
let closed = false
let recovered = false
let recoveryTimer: ReturnType<typeof setTimeout> | undefined
let recoveryPromise: Promise<string> | undefined
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?: AiStreamEvent, message?: string) => {
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?: string, event?: AiStreamEvent) => {
if (finishRecovered(event, message)) return
try {
output = await recoverOnce()
recovered = true
closed = true
clearRecoveryTimer()
} catch (error: any) {
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回'
onError?.(finalMessage, event)
throw new Error(finalMessage)
}
}
const consumeText = (text: string) => { const consumeText = (text: string) => {
buffer += text buffer += text
@@ -76,22 +182,61 @@ export const streamAiScene = async ({
output += delta output += delta
onDelta?.(delta, output, event) onDelta?.(delta, output, event)
} else if (event.type === 'done') { } else if (event.type === 'done') {
closed = true
clearRecoveryTimer()
onDone?.(event, output) onDone?.(event, output)
} else if (event.type === 'error') { } else if (event.type === 'error') {
const message = event.message || event.code || 'AI流式请求失败' throw Object.assign(new Error(event.message || event.code || 'AI 流式请求失败'), { event })
onError?.(message, event)
throw new Error(message)
} }
}) })
} }
while (true) { let response: Response
const { value, done } = await reader.read() try {
if (done) break response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/stream`, {
consumeText(decoder.decode(value, { stream: true })) method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify({ sceneCode, requestId, inputs: requestInputs }),
signal: controller.signal
})
} catch (error: any) {
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: any) {
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 } return { output }
} }
+73 -3
View File
@@ -49,6 +49,70 @@ export const useChatStore = defineStore('chat', () => {
const messageQueue = ref<QueuedMessage[]>([]) const messageQueue = ref<QueuedMessage[]>([])
const isProcessingQueue = ref(false) const isProcessingQueue = ref(false)
const queueProcessingInterval = ref<number | null>(null) const queueProcessingInterval = ref<number | null>(null)
const streamBuffers = new Map<string, string>()
const streamDoneIds = new Set<string>()
const streamTimers = new Map<string, number>()
const TYPEWRITER_INTERVAL = 18
const TYPEWRITER_STEP = 1
const stopStreamTimer = (messageId: string) => {
const timer = streamTimers.get(messageId)
if (timer) {
window.clearInterval(timer)
streamTimers.delete(messageId)
}
}
const stopStreamTypewriter = (messageId: string) => {
stopStreamTimer(messageId)
streamBuffers.delete(messageId)
streamDoneIds.delete(messageId)
}
const flushStreamBuffer = (messageId: string) => {
const message = messages.value.find(m => m.id === messageId)
if (!message) {
stopStreamTypewriter(messageId)
return
}
const buffer = streamBuffers.get(messageId) || ''
if (buffer) {
const nextText = buffer.slice(0, TYPEWRITER_STEP)
message.content += nextText
streamBuffers.set(messageId, buffer.slice(TYPEWRITER_STEP))
message.status = 'sending'
return
}
if (streamDoneIds.has(messageId)) {
message.status = 'sent'
streamDoneIds.delete(messageId)
}
stopStreamTimer(messageId)
}
const ensureStreamTimer = (messageId: string) => {
if (streamTimers.has(messageId)) return
const timer = window.setInterval(() => flushStreamBuffer(messageId), TYPEWRITER_INTERVAL)
streamTimers.set(messageId, timer)
}
const appendStreamText = (messageId: string, text: string) => {
if (!text) return
streamBuffers.set(messageId, `${streamBuffers.get(messageId) || ''}${text}`)
ensureStreamTimer(messageId)
}
const finishStreamText = (messageId: string) => {
streamDoneIds.add(messageId)
ensureStreamTimer(messageId)
}
const stopAllStreamTypewriters = () => {
streamTimers.forEach(timer => window.clearInterval(timer))
streamTimers.clear()
streamBuffers.clear()
streamDoneIds.clear()
}
// 计算属性 // 计算属性
const currentMessages = computed(() => { const currentMessages = computed(() => {
@@ -457,7 +521,7 @@ export const useChatStore = defineStore('chat', () => {
// 添加AI消息到队列,使用后端的messageId // 添加AI消息到队列,使用后端的messageId
const aiMessage = addMessage({ const aiMessage = addMessage({
id: messageId, id: messageId,
content: wsMessage.content.trim(), content: '',
type: 'ai', type: 'ai',
conversationId: wsMessage.conversationId || currentSession.value?.id, conversationId: wsMessage.conversationId || currentSession.value?.id,
timestamp: wsMessage.createTime || new Date().toISOString() timestamp: wsMessage.createTime || new Date().toISOString()
@@ -467,6 +531,8 @@ export const useChatStore = defineStore('chat', () => {
// 立即处理队列 // 立即处理队列
await processMessageQueue() await processMessageQueue()
appendStreamText(aiMessage.id, wsMessage.content?.trim() || '')
finishStreamText(aiMessage.id)
} }
const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => { const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => {
@@ -496,25 +562,28 @@ export const useChatStore = defineStore('chat', () => {
if (wsMessage.type === 'AI_STREAM_START') { if (wsMessage.type === 'AI_STREAM_START') {
isTyping.value = true isTyping.value = true
stopStreamTypewriter(message.id)
message.content = ''
message.status = 'sending' message.status = 'sending'
return return
} }
if (wsMessage.type === 'AI_STREAM_DELTA') { if (wsMessage.type === 'AI_STREAM_DELTA') {
isTyping.value = false isTyping.value = false
message.content += wsMessage.content || '' appendStreamText(message.id, wsMessage.content || '')
message.status = 'sending' message.status = 'sending'
return return
} }
if (wsMessage.type === 'AI_STREAM_DONE') { if (wsMessage.type === 'AI_STREAM_DONE') {
isTyping.value = false isTyping.value = false
message.status = 'sent' finishStreamText(message.id)
return return
} }
if (wsMessage.type === 'AI_STREAM_ERROR') { if (wsMessage.type === 'AI_STREAM_ERROR') {
isTyping.value = false isTyping.value = false
stopStreamTypewriter(message.id)
message.status = 'failed' message.status = 'failed'
message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用' message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用'
if (!message.content) { if (!message.content) {
@@ -653,6 +722,7 @@ export const useChatStore = defineStore('chat', () => {
const disconnectWebSocket = () => { const disconnectWebSocket = () => {
stompWebSocketService.disconnect() stompWebSocketService.disconnect()
stopAllStreamTypewriters()
wsConnected.value = false wsConnected.value = false
isConnected.value = false isConnected.value = false