From a51d2258973021c5484899544a4b0b7fe60977fa Mon Sep 17 00:00:00 2001 From: Peanut Date: Tue, 26 May 2026 20:50:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20life-script=20AI=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=92=8C=E8=A7=86=E5=9B=BE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- life-script/src/services/aiRuntime.js | 48 +++++++++++++++++++++++--- life-script/src/views/PathView.jsx | 2 +- life-script/src/views/ScriptView.jsx | 2 +- life-script/src/views/TimelineView.jsx | 2 +- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/life-script/src/services/aiRuntime.js b/life-script/src/services/aiRuntime.js index 15ede7a..9715b26 100644 --- a/life-script/src/services/aiRuntime.js +++ b/life-script/src/services/aiRuntime.js @@ -18,6 +18,33 @@ const parseSseFrame = (frame) => { } }; +const findOverlapLength = (current, next) => { + const max = Math.min(current.length, next.length); + for (let size = max; size > 0; size -= 1) { + if (current.slice(-size) === next.slice(0, size)) return size; + } + return 0; +}; + +const mergeStreamOutput = (current, chunk) => { + const next = String(chunk || ''); + if (!next) return { output: current, delta: '' }; + if (!current) return { output: next, delta: next }; + if (next === current) return { output: current, delta: '' }; + if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' }; + if (next.startsWith(current)) { + return { output: next, delta: next.slice(current.length) }; + } + const currentIndex = next.length > current.length ? next.indexOf(current) : -1; + if (currentIndex >= 0) { + return { output: next, delta: next.slice(currentIndex + current.length) }; + } + const overlap = findOverlapLength(current, next); + if (overlap < 8) return { output: current + next, delta: next }; + const delta = next.slice(overlap); + return { output: current + delta, delta }; +}; + const authHeaders = () => { const token = localStorage.getItem('access_token'); return token ? { Authorization: `Bearer ${token}` } : {}; @@ -73,6 +100,7 @@ export const streamAiScene = async ({ let output = ''; let closed = false; let recovered = false; + let streamStarted = false; let recoveryTimer; let recoveryPromise; @@ -92,6 +120,7 @@ export const streamAiScene = async ({ const completeFromRecoveredOutput = async () => { if (closed) return; + if (streamStarted || output.trim()) return; try { const recoveredOutput = await recoverOnce(); if (closed) return; @@ -107,7 +136,7 @@ export const streamAiScene = async ({ recoveryTimer = setTimeout(() => { completeFromRecoveredOutput(); - }, 8000); + }, 25000); const finishRecovered = (event, message) => { if (!output.trim()) return false; @@ -127,13 +156,13 @@ export const streamAiScene = async ({ }; const recoverOrThrow = async (message, event) => { - if (finishRecovered(event, message)) return; try { output = await recoverOnce(); recovered = true; closed = true; clearRecoveryTimer(); } catch (error) { + if (finishRecovered(event, message || error?.message)) return; const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回'; onError?.(finalMessage, event); throw new Error(finalMessage); @@ -148,12 +177,19 @@ export const streamAiScene = async ({ const event = parseSseFrame(frame); if (!event) return; if (event.type === 'start') { + streamStarted = true; + clearRecoveryTimer(); onStart?.(event); } else if (event.type === 'delta') { - const delta = event.content || ''; - output += delta; - onDelta?.(delta, output, event); + streamStarted = true; + clearRecoveryTimer(); + const merged = mergeStreamOutput(output, event.content); + output = merged.output; + if (merged.delta) { + onDelta?.(merged.delta, output, event); + } } else if (event.type === 'done') { + streamStarted = true; closed = true; clearRecoveryTimer(); onDone?.(event, output); @@ -191,6 +227,8 @@ export const streamAiScene = async ({ while (true) { const { value, done } = await reader.read(); if (done) break; + streamStarted = true; + clearRecoveryTimer(); consumeText(decoder.decode(value, { stream: true })); if (closed || recovered) break; } diff --git a/life-script/src/views/PathView.jsx b/life-script/src/views/PathView.jsx index 22a35fe..9afd658 100644 --- a/life-script/src/views/PathView.jsx +++ b/life-script/src/views/PathView.jsx @@ -18,7 +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 pathWriter = useTypewriterStream({ interval: 30, step: 1 }); const selectedScript = getSelectedScript(); diff --git a/life-script/src/views/ScriptView.jsx b/life-script/src/views/ScriptView.jsx index d890f0f..d624829 100644 --- a/life-script/src/views/ScriptView.jsx +++ b/life-script/src/views/ScriptView.jsx @@ -40,7 +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 scriptWriter = useTypewriterStream({ interval: 30, step: 1 }); // 编辑模态框状态 const [isEditModalOpen, setIsEditModalOpen] = useState(false); diff --git a/life-script/src/views/TimelineView.jsx b/life-script/src/views/TimelineView.jsx index 9b22da6..5b31944 100644 --- a/life-script/src/views/TimelineView.jsx +++ b/life-script/src/views/TimelineView.jsx @@ -94,7 +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 }); + const feedbackWriter = useTypewriterStream({ interval: 30, step: 1 }); // 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID) const [editingEventId, setEditingEventId] = useState(null);