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:
+172
-27
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user