feat: AI 场景路由、ASR 服务及前后端全链路同步
- 新增 AI 场景路由控制器和管理接口 - 新增 ASR 语音识别服务及前后端集成 - 同步 AI Runtime 客户端到 Web/小程序/Life-Script - 完善 AI 配置测试修复和管理后台路由配置 - 新增数据库迁移脚本 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { envConfig } from '@/config/env'
|
||||
|
||||
export interface AiStreamEvent {
|
||||
type: 'start' | 'delta' | 'done' | 'error' | string
|
||||
content?: string
|
||||
code?: string
|
||||
message?: string
|
||||
seq?: number
|
||||
metadata?: Record<string, any>
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface StreamAiSceneOptions {
|
||||
sceneCode: string
|
||||
inputs?: Record<string, any>
|
||||
onStart?: (event: AiStreamEvent) => void
|
||||
onDelta?: (delta: string, output: string, event: AiStreamEvent) => void
|
||||
onDone?: (event: AiStreamEvent, output: string) => void
|
||||
onError?: (message: string, event?: AiStreamEvent) => void
|
||||
}
|
||||
|
||||
const parseSseFrame = (frame: string): AiStreamEvent | null => {
|
||||
const parsed = { type: 'message', data: '' }
|
||||
frame.split(/\r?\n/).forEach((line) => {
|
||||
if (line.startsWith('event:')) parsed.type = line.slice(6).trim()
|
||||
if (line.startsWith('data:')) parsed.data += line.slice(5).trim()
|
||||
})
|
||||
if (!parsed.data) return null
|
||||
try {
|
||||
return JSON.parse(parsed.data)
|
||||
} catch {
|
||||
return { type: parsed.type, content: parsed.data }
|
||||
}
|
||||
}
|
||||
|
||||
export const streamAiScene = async ({
|
||||
sceneCode,
|
||||
inputs = {},
|
||||
onStart,
|
||||
onDelta,
|
||||
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')
|
||||
let buffer = ''
|
||||
let output = ''
|
||||
|
||||
const consumeText = (text: string) => {
|
||||
buffer += text
|
||||
const frames = buffer.split(/\r?\n\r?\n/)
|
||||
buffer = frames.pop() || ''
|
||||
frames.forEach((frame) => {
|
||||
const event = parseSseFrame(frame)
|
||||
if (!event) return
|
||||
if (event.type === 'start') {
|
||||
onStart?.(event)
|
||||
} else if (event.type === 'delta') {
|
||||
const delta = event.content || ''
|
||||
output += delta
|
||||
onDelta?.(delta, output, event)
|
||||
} else if (event.type === 'done') {
|
||||
onDone?.(event, output)
|
||||
} else if (event.type === 'error') {
|
||||
const message = event.message || event.code || 'AI流式请求失败'
|
||||
onError?.(message, event)
|
||||
throw new Error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
consumeText(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
consumeText(decoder.decode())
|
||||
if (buffer.trim()) consumeText('\n\n')
|
||||
return { output }
|
||||
}
|
||||
|
||||
export default {
|
||||
streamAiScene
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { envConfig } from '@/config/env'
|
||||
export interface WebSocketMessage {
|
||||
messageId?: string
|
||||
conversationId?: string
|
||||
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING'
|
||||
type: 'TEXT' | 'TYPING' | 'SYSTEM' | 'ERROR' | 'HEARTBEAT' | 'CONNECTION' | 'AI_THINKING' | 'AI_STREAM_START' | 'AI_STREAM_DELTA' | 'AI_STREAM_DONE' | 'AI_STREAM_ERROR' | 'AI_STREAM_EVENT'
|
||||
content: string
|
||||
senderId: string
|
||||
senderType: 'USER' | 'GUEST' | 'AI' | 'SYSTEM'
|
||||
|
||||
@@ -469,6 +469,60 @@ export const useChatStore = defineStore('chat', () => {
|
||||
await processMessageQueue()
|
||||
}
|
||||
|
||||
const ensureStreamingAiMessage = async (wsMessage: WebSocketMessage) => {
|
||||
const messageId = wsMessage.messageId || `ai-stream-${Date.now()}`
|
||||
let message = messages.value.find(m => m.id === messageId)
|
||||
if (!message) {
|
||||
await processMessageQueue()
|
||||
message = messages.value.find(m => m.id === messageId)
|
||||
}
|
||||
if (!message) {
|
||||
addMessage({
|
||||
id: messageId,
|
||||
content: '',
|
||||
type: 'ai',
|
||||
conversationId: wsMessage.conversationId || currentSession.value?.id,
|
||||
timestamp: wsMessage.createTime || new Date().toISOString()
|
||||
})
|
||||
await processMessageQueue()
|
||||
message = messages.value.find(m => m.id === messageId)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
const handleAiStreamMessage = async (wsMessage: WebSocketMessage) => {
|
||||
const message = await ensureStreamingAiMessage(wsMessage)
|
||||
if (!message) return
|
||||
|
||||
if (wsMessage.type === 'AI_STREAM_START') {
|
||||
isTyping.value = true
|
||||
message.status = 'sending'
|
||||
return
|
||||
}
|
||||
|
||||
if (wsMessage.type === 'AI_STREAM_DELTA') {
|
||||
isTyping.value = false
|
||||
message.content += wsMessage.content || ''
|
||||
message.status = 'sending'
|
||||
return
|
||||
}
|
||||
|
||||
if (wsMessage.type === 'AI_STREAM_DONE') {
|
||||
isTyping.value = false
|
||||
message.status = 'sent'
|
||||
return
|
||||
}
|
||||
|
||||
if (wsMessage.type === 'AI_STREAM_ERROR') {
|
||||
isTyping.value = false
|
||||
message.status = 'failed'
|
||||
message.error = wsMessage.data?.message || wsMessage.content || 'AI服务暂时不可用'
|
||||
if (!message.content) {
|
||||
message.content = message.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket消息处理 - 使用队列处理所有消息
|
||||
let handleWebSocketMessage = async (wsMessage: WebSocketMessage) => {
|
||||
console.log('收到WebSocket消息:', wsMessage.type, wsMessage.senderType, '消息ID:', wsMessage.messageId)
|
||||
@@ -484,6 +538,14 @@ export const useChatStore = defineStore('chat', () => {
|
||||
case 'AI_THINKING':
|
||||
// AI正在思考 - 不修改响应式数据,避免竞态
|
||||
console.log('⏳ AI正在思考中...')
|
||||
isTyping.value = true
|
||||
break
|
||||
|
||||
case 'AI_STREAM_START':
|
||||
case 'AI_STREAM_DELTA':
|
||||
case 'AI_STREAM_DONE':
|
||||
case 'AI_STREAM_ERROR':
|
||||
await handleAiStreamMessage(wsMessage)
|
||||
break
|
||||
|
||||
case 'CONNECTION':
|
||||
|
||||
Reference in New Issue
Block a user