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'
|
||||
|
||||
Reference in New Issue
Block a user