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
+172 -27
View File
@@ -19,6 +19,16 @@ export interface StreamAiSceneOptions {
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 parsed = { type: 'message', data: '' }
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 ({
sceneCode,
inputs = {},
@@ -41,26 +94,79 @@ export const streamAiScene = async ({
onDone,
onError
}: StreamAiSceneOptions): Promise<{ output: string }> => {
const token = localStorage.getItem('access_token')
const response = await fetch(`${envConfig.apiBaseUrl}/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: 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) => {
buffer += text
@@ -76,22 +182,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: Response
try {
response = await fetch(`${envConfig.apiBaseUrl}/ai/runtime/stream`, {
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 }
}