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 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 };
};