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 }) => { )) ) : (
正在生成
++ {scriptWriter.isWaiting ? '正在理解你的创作目标' : scriptWriter.isDraining ? '正在收束最后一句' : '正在逐字生成剧本'} +
{streamFeedback}
+ {feedbackWriter.visibleText + ?AI 正在梳理你的生命轨迹,请稍候...
}