diff --git a/life-script/src/services/aiRuntime.js b/life-script/src/services/aiRuntime.js index 553aa02..15ede7a 100644 --- a/life-script/src/services/aiRuntime.js +++ b/life-script/src/services/aiRuntime.js @@ -1,5 +1,9 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'; +const createRequestId = () => `life-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + const parseSseFrame = (frame) => { const event = { type: 'message', data: '' }; frame.split(/\r?\n/).forEach((line) => { @@ -14,6 +18,46 @@ const parseSseFrame = (frame) => { } }; +const authHeaders = () => { + const token = localStorage.getItem('access_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +}; + +const queryRuntimeResult = async (requestId) => { + const response = await fetch(`${API_BASE_URL}/ai/runtime/result?requestId=${encodeURIComponent(requestId)}`, { + headers: authHeaders() + }); + const payload = await response.json().catch(() => null); + if (response.ok && payload?.code === 200 && payload.data) { + return payload.data; + } + throw new Error(payload?.message || 'AI 结果还在生成中'); +}; + +const recoverRuntimeOutput = async (requestId, { onDelta, onDone }) => { + const maxAttempts = 150; + for (let index = 0; index < maxAttempts; index++) { + try { + const log = await queryRuntimeResult(requestId); + const output = String(log.outputText || '').trim(); + if (output) { + onDelta?.(output, output, { type: 'delta', metadata: { recovered: true, requestId } }); + onDone?.({ type: 'done', metadata: { recovered: true, requestId, source: 'call-log' } }, output); + return output; + } + if (log.status === 'failed') { + throw new Error(log.errorMessage || 'AI 生成失败'); + } + } catch (error) { + if (index === maxAttempts - 1) { + throw error; + } + } + await sleep(2000); + } + throw new Error('AI 生成结果暂时没有返回'); +}; + export const streamAiScene = async ({ sceneCode, inputs = {}, @@ -22,26 +66,79 @@ export const streamAiScene = async ({ onDone, onError }) => { - const token = localStorage.getItem('access_token'); - const response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}) - }, - body: JSON.stringify({ sceneCode, inputs }) - }); - - if (!response.ok || !response.body) { - const message = `AI流式请求失败(${response.status})`; - onError?.(message); - throw new Error(message); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); + const requestId = String(inputs.requestId || createRequestId()); + const requestInputs = { ...inputs, requestId }; + const controller = new AbortController(); let buffer = ''; let output = ''; + let closed = false; + let recovered = false; + let recoveryTimer; + let recoveryPromise; + + const clearRecoveryTimer = () => { + if (recoveryTimer) { + clearTimeout(recoveryTimer); + recoveryTimer = undefined; + } + }; + + const recoverOnce = () => { + if (!recoveryPromise) { + recoveryPromise = recoverRuntimeOutput(requestId, { onDelta, onDone }); + } + return recoveryPromise; + }; + + const completeFromRecoveredOutput = async () => { + if (closed) return; + try { + const recoveredOutput = await recoverOnce(); + if (closed) return; + output = recoveredOutput; + recovered = true; + closed = true; + clearRecoveryTimer(); + controller.abort(); + } catch { + if (!closed) recoveryPromise = undefined; + } + }; + + recoveryTimer = setTimeout(() => { + completeFromRecoveredOutput(); + }, 8000); + + const finishRecovered = (event, message) => { + if (!output.trim()) return false; + recovered = true; + closed = true; + clearRecoveryTimer(); + onDone?.({ + type: 'done', + metadata: { + ...(event?.metadata || {}), + recovered: true, + warningCode: event?.code, + warningMessage: message || event?.message + } + }, output); + return true; + }; + + const recoverOrThrow = async (message, event) => { + if (finishRecovered(event, message)) return; + try { + output = await recoverOnce(); + recovered = true; + closed = true; + clearRecoveryTimer(); + } catch (error) { + const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回'; + onError?.(finalMessage, event); + throw new Error(finalMessage); + } + }; const consumeText = (text) => { buffer += text; @@ -57,22 +154,61 @@ export const streamAiScene = async ({ output += delta; onDelta?.(delta, output, event); } else if (event.type === 'done') { + closed = true; + clearRecoveryTimer(); onDone?.(event, output); } else if (event.type === 'error') { - const message = event.message || event.code || 'AI流式请求失败'; - onError?.(message, event); - throw new Error(message); + throw Object.assign(new Error(event.message || event.code || 'AI 流式请求失败'), { event }); } }); }; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - consumeText(decoder.decode(value, { stream: true })); + let response; + try { + response = await fetch(`${API_BASE_URL}/ai/runtime/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ sceneCode, requestId, inputs: requestInputs }), + signal: controller.signal + }); + } catch (error) { + if (!closed) await recoverOrThrow(error?.message); + return { output }; + } + + if (!response.ok || !response.body) { + await recoverOrThrow(`AI 流式请求失败(${response.status})`); + return { output }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + consumeText(decoder.decode(value, { stream: true })); + if (closed || recovered) break; + } + if (!closed && !recovered) { + consumeText(decoder.decode()); + if (buffer.trim()) consumeText('\n\n'); + } + } catch (error) { + if (!closed) { + await recoverOrThrow(error?.message, error?.event); + } + } finally { + clearRecoveryTimer(); + } + + if (!output.trim() && !closed) { + await recoverOrThrow('AI 生成结果暂时没有返回'); } - consumeText(decoder.decode()); - if (buffer.trim()) consumeText('\n\n'); return { output }; }; diff --git a/life-script/src/views/PathView.jsx b/life-script/src/views/PathView.jsx index 1573f8b..22a35fe 100644 --- a/life-script/src/views/PathView.jsx +++ b/life-script/src/views/PathView.jsx @@ -5,6 +5,7 @@ import { GlassCard, GlassButton } from '../components/ui'; import Modal from '../components/Modal'; import useStore from '../store/useStore'; import { generatePath } from '../services/ai'; +import useTypewriterStream from '../hooks/useTypewriterStream'; /** * PathView 组件 @@ -17,6 +18,7 @@ const PathView = ({ onGoToScript }) => { const [isLoading, setIsLoading] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [streamPath, setStreamPath] = useState(''); + const pathWriter = useTypewriterStream({ interval: 18, step: 1 }); const selectedScript = getSelectedScript(); @@ -36,15 +38,22 @@ const PathView = ({ onGoToScript }) => { if (!selectedScript) return; setIsLoading(true); + pathWriter.reset(); try { setStreamPath(''); const path = await generatePath(selectedScript.content, { - onDelta: (_delta, output) => setStreamPath(output) + onDelta: (_delta, output) => { + setStreamPath(output); + pathWriter.push(output); + } }); + pathWriter.finish(path); + await pathWriter.waitForDone(); await setPath(path, selectedScriptId); setStreamPath(''); } catch (error) { + pathWriter.fail('路径生成失败,请稍后重试'); console.error('Failed to generate path:', error); } finally { setIsLoading(false); @@ -82,7 +91,7 @@ const PathView = ({ onGoToScript }) => { }); }; - const visiblePath = streamPath || selectedPath; + const visiblePath = isLoading ? pathWriter.visibleText : selectedPath; const pathSteps = parsePathSteps(visiblePath); // 无剧本时显示提示 @@ -164,7 +173,9 @@ const PathView = ({ onGoToScript }) => { )) ) : (
- 等待开启人生导航... + {isLoading + ? (pathWriter.isWaiting ? '正在分析剧本,拆解路径...' : '正在逐字生成路径...') + : '等待开启人生导航...'}
)} diff --git a/life-script/src/views/ScriptView.jsx b/life-script/src/views/ScriptView.jsx index 4899ad3..d890f0f 100644 --- a/life-script/src/views/ScriptView.jsx +++ b/life-script/src/views/ScriptView.jsx @@ -5,6 +5,7 @@ import Modal from '../components/Modal'; import useStore from '../store/useStore'; import { scriptStyles, scriptLengths } from '../utils/constants'; import { generateEpicScript } from '../services/ai'; +import useTypewriterStream from '../hooks/useTypewriterStream'; /** * ScriptView 组件 @@ -39,6 +40,7 @@ const ScriptView = ({ onOpenProfile }) => { const [length, setLength] = useState(scriptLengths[0].value); const [isLoading, setIsLoading] = useState(false); const [streamContent, setStreamContent] = useState(''); + const scriptWriter = useTypewriterStream({ interval: 18, step: 1 }); // 编辑模态框状态 const [isEditModalOpen, setIsEditModalOpen] = useState(false); @@ -62,14 +64,22 @@ const ScriptView = ({ onOpenProfile }) => { } setIsLoading(true); + scriptWriter.reset(); try { setStreamContent(''); const content = await generateEpicScript( { theme, style, length, character: registrationData }, lifeEvents, - { onDelta: (_delta, output) => setStreamContent(output) } + { + onDelta: (_delta, output) => { + setStreamContent(output); + scriptWriter.push(output); + } + } ); + scriptWriter.finish(content); + await scriptWriter.waitForDone(); await addScript({ theme, style, @@ -82,6 +92,7 @@ const ScriptView = ({ onOpenProfile }) => { setTheme(''); setStreamContent(''); } catch (error) { + scriptWriter.fail('生成失败,请稍后重试'); console.error('Failed to generate script:', error); } finally { setIsLoading(false); @@ -121,14 +132,22 @@ const ScriptView = ({ onOpenProfile }) => { } setIsLoading(true); + scriptWriter.reset(); try { setStreamContent(''); const content = await generateEpicScript( { theme: editForm.theme, style: editForm.style, length: editForm.length, character: registrationData }, lifeEvents, - { onDelta: (_delta, output) => setStreamContent(output) } + { + onDelta: (_delta, output) => { + setStreamContent(output); + scriptWriter.push(output); + } + } ); + scriptWriter.finish(content); + await scriptWriter.waitForDone(); await updateScript({ id: editingScript.id, theme: editForm.theme, @@ -142,6 +161,7 @@ const ScriptView = ({ onOpenProfile }) => { closeEditModal(); setStreamContent(''); } catch (error) { + scriptWriter.fail('生成失败,请稍后重试'); console.error('Failed to update script:', error); } finally { setIsLoading(false); @@ -306,18 +326,21 @@ const ScriptView = ({ onOpenProfile }) => { {/* 右侧剧本展示区 */}
- {isLoading && streamContent ? ( + {isLoading ? (
-

{theme}

-

正在生成

+

{theme || editForm.theme}

+

+ {scriptWriter.isWaiting ? '正在理解你的创作目标' : scriptWriter.isDraining ? '正在收束最后一句' : '正在逐字生成剧本'} +

- +
- {streamContent} + {scriptWriter.visibleText || '故事正在生成,请稍候...'} + {(scriptWriter.isStreaming || scriptWriter.isDraining) && |}
diff --git a/life-script/src/views/TimelineView.jsx b/life-script/src/views/TimelineView.jsx index 2f6073d..9b22da6 100644 --- a/life-script/src/views/TimelineView.jsx +++ b/life-script/src/views/TimelineView.jsx @@ -4,6 +4,7 @@ import { GlassCard, GlassButton, GlassInput, GlassTextarea } from '../components import Modal from '../components/Modal'; import useStore from '../store/useStore'; import { analyzeLifeEvent } from '../services/ai'; +import useTypewriterStream from '../hooks/useTypewriterStream'; /** * 格式化 AI 反馈内容的组件 @@ -93,6 +94,7 @@ const TimelineView = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [streamFeedback, setStreamFeedback] = useState(''); + const feedbackWriter = useTypewriterStream({ interval: 18, step: 1 }); // 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID) const [editingEventId, setEditingEventId] = useState(null); @@ -114,6 +116,7 @@ const TimelineView = () => { setEditingEventId(null); setEventForm({ title: '', time: '', content: '' }); setStreamFeedback(''); + feedbackWriter.reset(); setIsModalOpen(true); }; @@ -129,6 +132,7 @@ const TimelineView = () => { content: event.content || '' }); setStreamFeedback(event.aiFeedback || ''); + feedbackWriter.reset(); setIsModalOpen(true); }; @@ -140,6 +144,7 @@ const TimelineView = () => { setEditingEventId(null); setEventForm({ title: '', time: '', content: '' }); setStreamFeedback(''); + feedbackWriter.reset(); }; /** @@ -152,12 +157,18 @@ const TimelineView = () => { } setIsLoading(true); + feedbackWriter.reset(); try { setStreamFeedback(''); const aiFeedback = await analyzeLifeEvent(eventForm, { - onDelta: (_delta, output) => setStreamFeedback(output) + onDelta: (_delta, output) => { + setStreamFeedback(output); + feedbackWriter.push(output); + } }); + feedbackWriter.finish(aiFeedback); + await feedbackWriter.waitForDone(); if (editingEventId) { // 编辑模式:调用更新接口 @@ -177,6 +188,7 @@ const TimelineView = () => { // 重置表单并关闭模态框 closeModal(); } catch (error) { + feedbackWriter.fail('AI 疗愈反馈生成失败,请稍后重试'); console.error('Failed to save event:', error); } finally { setIsLoading(false); @@ -323,13 +335,17 @@ const TimelineView = () => { onChange={(v) => setEventForm(prev => ({ ...prev, content: v }))} rows={5} /> - {streamFeedback && ( + {(streamFeedback || isLoading) && (
- 实时疗愈反馈 + + {feedbackWriter.isWaiting ? '正在理解这段经历' : feedbackWriter.isDraining ? '正在收束反馈' : '实时疗愈反馈'} +
-

{streamFeedback}

+ {feedbackWriter.visibleText + ? + :

AI 正在梳理你的生命轨迹,请稍候...

}
)} { statusBarHeight.value = windowInfo.statusBarHeight || 20 safeAreaTop.value = windowInfo.safeAreaInsets?.top || windowInfo.statusBarHeight || 20 safeAreaBottom.value = windowInfo.safeAreaInsets?.bottom || 0 + uni.setStorageSync('windowWidth', windowInfo.windowWidth || 375) uni.setStorageSync('statusBarHeight', statusBarHeight.value) uni.setStorageSync('safeAreaTop', safeAreaTop.value) uni.setStorageSync('safeAreaBottom', safeAreaBottom.value) + if (uni.getMenuButtonBoundingClientRect) { + uni.setStorageSync('menuButtonRect', uni.getMenuButtonBoundingClientRect()) + } } catch (error) { statusBarHeight.value = 20 safeAreaTop.value = 20 diff --git a/mini-program/src/pages/life-event/detail.vue b/mini-program/src/pages/life-event/detail.vue index 136fe61..483eb32 100644 --- a/mini-program/src/pages/life-event/detail.vue +++ b/mini-program/src/pages/life-event/detail.vue @@ -2,7 +2,7 @@ - + @@ -118,10 +118,12 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { useAppStore } from '../../stores/app.js' import Markdown from '../../components/Markdown.vue' +import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js' import analytics from '../../services/analytics.js' const store = useAppStore() const pagePath = '/pages/life-event/detail' +const { floatingTopStyle } = useMenuButtonSafeArea() const safeAreaBottom = ref(0) const eventId = ref('') const cachedEvent = ref(null) diff --git a/mini-program/src/pages/life-event/form.vue b/mini-program/src/pages/life-event/form.vue index c493940..77d7ff1 100644 --- a/mini-program/src/pages/life-event/form.vue +++ b/mini-program/src/pages/life-event/form.vue @@ -1,7 +1,7 @@