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 }
}
+73 -3
View File
@@ -49,6 +49,70 @@ export const useChatStore = defineStore('chat', () => {
const messageQueue = ref<QueuedMessage[]>([])
const isProcessingQueue = ref(false)
const queueProcessingInterval = ref<number | null>(null)
const streamBuffers = new Map<string, string>()
const streamDoneIds = new Set<string>()
const streamTimers = new Map<string, number>()
const TYPEWRITER_INTERVAL = 18
const TYPEWRITER_STEP = 1
const stopStreamTimer = (messageId: string) => {
const timer = streamTimers.get(messageId)
if (timer) {
window.clearInterval(timer)
streamTimers.delete(messageId)
}
}
const stopStreamTypewriter = (messageId: string) => {
stopStreamTimer(messageId)
streamBuffers.delete(messageId)
streamDoneIds.delete(messageId)
}
const flushStreamBuffer = (messageId: string) => {
const message = messages.value.find(m => m.id === messageId)
if (!message) {
stopStreamTypewriter(messageId)
return
}
const buffer = streamBuffers.get(messageId) || ''
if (buffer) {
const nextText = buffer.slice(0, TYPEWRITER_STEP)
message.content += nextText
streamBuffers.set(messageId, buffer.slice(TYPEWRITER_STEP))
message.status = 'sending'
return
}
if (streamDoneIds.has(messageId)) {
message.status = 'sent'
streamDoneIds.delete(messageId)
}
stopStreamTimer(messageId)
}
const ensureStreamTimer = (messageId: string) => {
if (streamTimers.has(messageId)) return
const timer = window.setInterval(() => flushStreamBuffer(messageId), TYPEWRITER_INTERVAL)
streamTimers.set(messageId, timer)
}
const appendStreamText = (messageId: string, text: string) => {
if (!text) return
streamBuffers.set(messageId, `${streamBuffers.get(messageId) || ''}${text}`)
ensureStreamTimer(messageId)
}
const finishStreamText = (messageId: string) => {
streamDoneIds.add(messageId)
ensureStreamTimer(messageId)
}
const stopAllStreamTypewriters = () => {
streamTimers.forEach(timer => window.clearInterval(timer))
streamTimers.clear()
streamBuffers.clear()
streamDoneIds.clear()
}
// 计算属性
const currentMessages = computed(() => {
@@ -457,7 +521,7 @@ export const useChatStore = defineStore('chat', () => {
// 添加AI消息到队列,使用后端的messageId
const aiMessage = addMessage({
id: messageId,
content: wsMessage.content.trim(),
content: '',
type: 'ai',
conversationId: wsMessage.conversationId || currentSession.value?.id,
timestamp: wsMessage.createTime || new Date().toISOString()
@@ -467,6 +531,8 @@ export const useChatStore = defineStore('chat', () => {
// 立即处理队列
await processMessageQueue()
appendStreamText(aiMessage.id, wsMessage.content?.trim() || '')
finishStreamText(aiMessage.id)
}
const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => {
@@ -496,25 +562,28 @@ export const useChatStore = defineStore('chat', () => {
if (wsMessage.type === 'AI_STREAM_START') {
isTyping.value = true
stopStreamTypewriter(message.id)
message.content = ''
message.status = 'sending'
return
}
if (wsMessage.type === 'AI_STREAM_DELTA') {
isTyping.value = false
message.content += wsMessage.content || ''
appendStreamText(message.id, wsMessage.content || '')
message.status = 'sending'
return
}
if (wsMessage.type === 'AI_STREAM_DONE') {
isTyping.value = false
message.status = 'sent'
finishStreamText(message.id)
return
}
if (wsMessage.type === 'AI_STREAM_ERROR') {
isTyping.value = false
stopStreamTypewriter(message.id)
message.status = 'failed'
message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用'
if (!message.content) {
@@ -653,6 +722,7 @@ export const useChatStore = defineStore('chat', () => {
const disconnectWebSocket = () => {
stompWebSocketService.disconnect()
stopAllStreamTypewriters()
wsConnected.value = false
isConnected.value = false