fix: 修复 Dify 非流式测试 user_id 缺失和超时问题

- enrichInputs 增加 user_id 下划线字段注入(Dify API 要求下划线格式)
- testAiRuntime 接口超时从 15 秒延长到 60 秒

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 13:19:18 +08:00
parent 0968f9418f
commit d77090aa5e
4 changed files with 1003 additions and 1857 deletions
+121 -1
View File
@@ -2,7 +2,11 @@ import request from '@/utils/request'
import type {
AiConfigPageRequest,
AiConfigCreateRequest,
AiConfigUpdateRequest
AiConfigUpdateRequest,
AiProvider,
AiEndpointConfig,
AiSceneBinding,
AiRuntimeRequest
} from '@/types/aiconfig'
// 分页查询AI配置
@@ -215,3 +219,119 @@ export function updateAiConfigFromTest(data: any) {
data
})
}
export function listAiProviders() {
return request({ url: '/ai/providers', method: 'get' })
}
export function saveAiProvider(data: AiProvider) {
return request({ url: '/ai/providers', method: data.id ? 'put' : 'post', data })
}
export function deleteAiProvider(id: string) {
return request({ url: '/ai/providers', method: 'delete', params: { id } })
}
export function listAiEndpoints() {
return request({ url: '/ai/endpoints', method: 'get' })
}
export function saveAiEndpoint(data: AiEndpointConfig) {
return request({ url: '/ai/endpoints', method: data.id ? 'put' : 'post', data })
}
export function deleteAiEndpoint(id: string) {
return request({ url: '/ai/endpoints', method: 'delete', params: { id } })
}
export function listAiScenes() {
return request({ url: '/ai/scenes', method: 'get' })
}
export function saveAiScene(data: AiSceneBinding) {
return request({ url: '/ai/scenes', method: data.id ? 'put' : 'post', data })
}
export function deleteAiScene(id: string) {
return request({ url: '/ai/scenes', method: 'delete', params: { id } })
}
export function listAiCallLogs(limit = 50) {
return request({ url: '/ai/call-logs', method: 'get', params: { limit } })
}
export function testAiRuntime(data: AiRuntimeRequest) {
return request({ url: '/ai/runtime/test', method: 'post', data, timeout: 60000 })
}
export interface AiRuntimeStreamEvent {
type: string
content?: string
code?: string
message?: string
seq?: number
metadata?: Record<string, any>
}
function parseSseFrame(frame: string): AiRuntimeStreamEvent | null {
const event = { type: 'message', data: '' }
frame.split(/\r?\n/).forEach((line) => {
if (line.startsWith('event:')) event.type = line.slice(6).trim()
if (line.startsWith('data:')) event.data += line.slice(5).trim()
})
if (!event.data) return null
try {
return JSON.parse(event.data)
} catch {
return { type: event.type, content: event.data }
}
}
export async function streamAiRuntime(
data: AiRuntimeRequest,
onEvent: (event: AiRuntimeStreamEvent, output: string) => void
) {
const token = localStorage.getItem('adminToken')
const response = await fetch(`${import.meta.env.VITE_APP_BASE_API}/ai/runtime/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(data)
})
if (!response.ok || !response.body) {
throw new Error(`流式测试请求失败(${response.status})`)
}
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 === 'delta') {
output += event.content || ''
}
onEvent(event, output)
if (event.type === 'error') {
throw new Error(event.message || event.code || '流式测试失败')
}
})
}
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
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,633 @@
<template>
<div class="ai-routing-page">
<div class="page-header">
<div class="header-left">
<h2 class="page-title">AI 配置管理</h2>
<p class="page-desc">统一管理服务商接口工作流业务场景绑定流式测试和调用日志</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新数据</el-button>
</div>
</div>
<el-row :gutter="16" class="stats-row">
<el-col :span="6">
<div class="stat-card stat-provider">
<div class="stat-value">{{ providers.length }}</div>
<div class="stat-label">服务商</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card stat-endpoint">
<div class="stat-value">{{ endpoints.length }}</div>
<div class="stat-label">接口工作流</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card stat-scene">
<div class="stat-value">{{ enabledSceneCount }}</div>
<div class="stat-label">已启用场景</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card stat-log">
<div class="stat-value">{{ logs.length }}</div>
<div class="stat-label">最近调用日志</div>
</div>
</el-col>
</el-row>
<el-card class="table-card routing-card" shadow="never">
<el-tabs v-model="activeTab" class="routing-tabs">
<el-tab-pane label="服务商配置" name="providers">
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="openProvider()">新增服务商</el-button>
</div>
<el-table :data="providers" v-loading="loading" stripe empty-text="暂无服务商配置">
<el-table-column prop="providerCode" label="服务商编码" width="170" show-overflow-tooltip />
<el-table-column prop="providerName" label="服务商名称" min-width="160" show-overflow-tooltip />
<el-table-column label="服务商类型" width="120">
<template #default="{ row }">
<el-tag type="primary" effect="plain">{{ providerTypeLabel(row.providerType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="baseUrl" label="基础地址" min-width="280" show-overflow-tooltip />
<el-table-column prop="timeoutMs" label="超时毫秒" width="110" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain">
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openProvider(row)">编辑</el-button>
<el-button link type="danger" @click="removeProvider(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="接口工作流" name="endpoints">
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="openEndpoint()">新增接口</el-button>
</div>
<el-table :data="endpoints" v-loading="loading" stripe empty-text="暂无接口工作流配置">
<el-table-column prop="endpointCode" label="接口编码" width="220" show-overflow-tooltip />
<el-table-column prop="endpointName" label="接口名称" min-width="180" show-overflow-tooltip />
<el-table-column label="服务商" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ providerName(row.providerId) }}</template>
</el-table-column>
<el-table-column label="接口类型" width="110" align="center">
<template #default="{ row }">{{ endpointTypeLabel(row.endpointType) }}</template>
</el-table-column>
<el-table-column prop="apiPath" label="接口路径" min-width="210" show-overflow-tooltip />
<el-table-column label="流式" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.supportStream === 1 ? 'success' : 'danger'" effect="plain">
{{ row.supportStream === 1 ? '支持' : '不支持' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain">
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openEndpoint(row)">编辑</el-button>
<el-button link type="danger" @click="removeEndpoint(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="场景绑定" name="scenes">
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="openScene()">新增场景</el-button>
<el-button :icon="VideoPlay" @click="openRuntimeTest()">流式测试</el-button>
</div>
<el-table :data="scenes" v-loading="loading" stripe empty-text="暂无场景绑定">
<el-table-column prop="sceneCode" label="场景编码" width="190" show-overflow-tooltip />
<el-table-column prop="sceneName" label="场景名称" min-width="170" show-overflow-tooltip />
<el-table-column label="绑定接口" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<span :class="{ muted: !row.endpointId }">{{ endpointName(row.endpointId) }}</span>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="90" align="center" />
<el-table-column label="强制流式" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.requiredStream === 1 ? 'success' : 'warning'" effect="plain">
{{ row.requiredStream === 1 ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.isEnabled === 1 ? 'success' : 'danger'" effect="plain">
{{ row.isEnabled === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openScene(row)">编辑</el-button>
<el-button link type="danger" @click="removeScene(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="调用日志" name="logs">
<el-table :data="logs" v-loading="loading" stripe empty-text="暂无调用日志">
<el-table-column prop="createTime" label="调用时间" width="175" />
<el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip />
<el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip />
<el-table-column prop="endpointCode" label="接口" min-width="210" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" effect="plain">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="streamChunks" label="片段数" width="90" align="center" />
<el-table-column prop="firstTokenMs" label="首字耗时" width="110" align="center" />
<el-table-column prop="durationMs" label="总耗时" width="100" align="center" />
<el-table-column prop="errorCode" label="错误码" min-width="180" show-overflow-tooltip />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="providerDialog" :title="providerForm.id ? '编辑服务商' : '新增服务商'" width="680px">
<el-form :model="providerForm" label-width="120px">
<el-form-item label="服务商编码"><el-input v-model="providerForm.providerCode" placeholder="例如 coze_default" /></el-form-item>
<el-form-item label="服务商名称"><el-input v-model="providerForm.providerName" placeholder="请输入服务商名称" /></el-form-item>
<el-form-item label="服务商类型">
<el-select v-model="providerForm.providerType" style="width: 100%">
<el-option label="Dify" value="dify" />
<el-option label="Coze" value="coze" />
<el-option label="OpenAI" value="openai" disabled />
</el-select>
</el-form-item>
<el-form-item label="基础地址"><el-input v-model="providerForm.baseUrl" placeholder="例如 https://api.coze.cn" /></el-form-item>
<el-form-item label="接口密钥"><el-input v-model="providerForm.apiKey" type="password" show-password placeholder="编辑时为空表示不修改密钥" /></el-form-item>
<el-form-item label="请求头配置"><el-input v-model="providerForm.defaultHeaders" type="textarea" :rows="3" placeholder="JSON 格式,可为空" /></el-form-item>
<el-form-item label="启用状态"><el-switch v-model="providerEnabled" active-text="启用" inactive-text="禁用" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="providerDialog = false">取消</el-button>
<el-button type="primary" @click="submitProvider">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="endpointDialog" :title="endpointForm.id ? '编辑接口工作流' : '新增接口工作流'" width="760px">
<el-form :model="endpointForm" label-width="130px">
<el-form-item label="接口编码"><el-input v-model="endpointForm.endpointCode" placeholder="例如 coze.script.generate" /></el-form-item>
<el-form-item label="接口名称"><el-input v-model="endpointForm.endpointName" placeholder="请输入接口名称" /></el-form-item>
<el-form-item label="所属服务商">
<el-select v-model="endpointForm.providerId" filterable style="width: 100%">
<el-option v-for="item in providers" :key="item.id" :label="`${item.providerName}${item.providerCode}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="接口类型">
<el-select v-model="endpointForm.endpointType" style="width: 100%">
<el-option label="工作流" value="workflow" />
<el-option label="对话" value="chat" />
</el-select>
</el-form-item>
<el-form-item label="接口路径"><el-input v-model="endpointForm.apiPath" placeholder="/v1/workflow/stream_run 或 /chat-messages" /></el-form-item>
<el-form-item label="工作流 ID"><el-input v-model="endpointForm.workflowId" /></el-form-item>
<el-form-item label="机器人 ID"><el-input v-model="endpointForm.botId" /></el-form-item>
<el-form-item label="请求模板"><el-input v-model="endpointForm.requestTemplate" type="textarea" :rows="4" placeholder="JSON 模板,可使用 {{prompt}} 等变量" /></el-form-item>
<el-form-item label="默认入参"><el-input v-model="endpointForm.defaultInputs" type="textarea" :rows="3" placeholder="JSON 格式,会与用户端入参合并" /></el-form-item>
<el-form-item label="支持流式"><el-switch v-model="endpointStream" active-text="支持" inactive-text="不支持" /></el-form-item>
<el-form-item label="启用状态"><el-switch v-model="endpointEnabled" active-text="启用" inactive-text="禁用" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="endpointDialog = false">取消</el-button>
<el-button type="primary" @click="submitEndpoint">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="sceneDialog" :title="sceneForm.id ? '编辑场景绑定' : '新增场景绑定'" width="720px">
<el-form :model="sceneForm" label-width="120px">
<el-form-item label="场景编码"><el-input v-model="sceneForm.sceneCode" placeholder="例如 script_generate" /></el-form-item>
<el-form-item label="场景名称"><el-input v-model="sceneForm.sceneName" placeholder="请输入中文场景名称" /></el-form-item>
<el-form-item label="绑定接口">
<el-select v-model="sceneForm.endpointId" filterable style="width: 100%" placeholder="请选择接口工作流">
<el-option v-for="item in endpoints" :key="item.id" :label="`${item.endpointName}${item.endpointCode}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="入参说明"><el-input v-model="sceneForm.inputSchema" type="textarea" :rows="3" placeholder="JSON 格式,描述该场景允许的入参" /></el-form-item>
<el-form-item label="优先级"><el-input-number v-model="sceneForm.priority" :min="0" :max="9999" /></el-form-item>
<el-form-item label="强制流式"><el-switch v-model="sceneStream" active-text="是" inactive-text="否" /></el-form-item>
<el-form-item label="启用状态"><el-switch v-model="sceneEnabled" active-text="启用" inactive-text="禁用" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="sceneDialog = false">取消</el-button>
<el-button type="primary" @click="submitScene">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="testDialog" title="接口测试" width="760px">
<el-form label-width="110px">
<el-form-item label="业务场景">
<el-select v-model="testForm.sceneCode" filterable style="width: 100%">
<el-option v-for="item in scenes" :key="item.id" :label="`${item.sceneName}${item.sceneCode}`" :value="item.sceneCode" />
</el-select>
</el-form-item>
<el-form-item label="入参 JSON">
<el-input v-model="testInputsJson" type="textarea" :rows="6" placeholder="请输入 JSON 入参" />
</el-form-item>
</el-form>
<el-alert
v-if="nonStreamResult"
:type="nonStreamResult.status === 'success' ? 'success' : 'error'"
:title="nonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
show-icon
/>
<pre v-if="nonStreamResult" class="test-output">{{ nonStreamResult.output || nonStreamResult.errorMessage || '暂无输出' }}</pre>
<el-alert
v-if="testResult"
:type="testResult.status === 'success' ? 'success' : 'error'"
:title="testResult.status === 'success' ? '流式测试成功' : '流式测试失败'"
show-icon
/>
<pre v-if="testResult" class="test-output">{{ testResult.output || testResult.errorMessage || '暂无输出' }}</pre>
<template #footer>
<el-button @click="testDialog = false">关闭</el-button>
<el-button :loading="testing" @click="submitNonStreamTest">非流式测试</el-button>
<el-button type="primary" :loading="testing" @click="submitRuntimeTest">流式测试</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
import {
deleteAiEndpoint,
deleteAiProvider,
deleteAiScene,
listAiCallLogs,
listAiEndpoints,
listAiProviders,
listAiScenes,
saveAiEndpoint,
saveAiProvider,
saveAiScene,
streamAiRuntime,
testAiRuntime
} from '@/api/aiconfig'
import type { AiCallLog, AiEndpointConfig, AiProvider, AiRuntimeTestResponse, AiSceneBinding } from '@/types/aiconfig'
const activeTab = ref('providers')
const loading = ref(false)
const providers = ref<AiProvider[]>([])
const endpoints = ref<AiEndpointConfig[]>([])
const scenes = ref<AiSceneBinding[]>([])
const logs = ref<AiCallLog[]>([])
const providerDialog = ref(false)
const endpointDialog = ref(false)
const sceneDialog = ref(false)
const testDialog = ref(false)
const testing = ref(false)
const testResult = ref<AiRuntimeTestResponse | null>(null)
const nonStreamResult = ref<AiRuntimeTestResponse | null>(null)
const testInputsJson = ref('{\n "prompt": "请用一句中文回复测试成功。"\n}')
const providerForm = reactive<AiProvider>(newProvider())
const endpointForm = reactive<AiEndpointConfig>(newEndpoint())
const sceneForm = reactive<AiSceneBinding>(newScene())
const testForm = reactive({ sceneCode: '' })
const enabledSceneCount = computed(() => scenes.value.filter(item => item.isEnabled === 1).length)
const providerEnabled = computed({
get: () => providerForm.isEnabled === 1,
set: value => { providerForm.isEnabled = value ? 1 : 0 }
})
const endpointEnabled = computed({
get: () => endpointForm.isEnabled === 1,
set: value => { endpointForm.isEnabled = value ? 1 : 0 }
})
const endpointStream = computed({
get: () => endpointForm.supportStream === 1,
set: value => { endpointForm.supportStream = value ? 1 : 0 }
})
const sceneEnabled = computed({
get: () => sceneForm.isEnabled === 1,
set: value => { sceneForm.isEnabled = value ? 1 : 0 }
})
const sceneStream = computed({
get: () => sceneForm.requiredStream === 1,
set: value => { sceneForm.requiredStream = value ? 1 : 0 }
})
function newProvider(): AiProvider {
return { providerCode: '', providerName: '', providerType: 'dify', baseUrl: '', authType: 'bearer', timeoutMs: 60000, isEnabled: 1 }
}
function newEndpoint(): AiEndpointConfig {
return { endpointCode: '', endpointName: '', providerId: '', endpointType: 'workflow', responseMode: 'streaming', supportStream: 1, isEnabled: 1, timeoutMs: 60000 }
}
function newScene(): AiSceneBinding {
return { sceneCode: '', sceneName: '', endpointId: '', requiredStream: 1, priority: 0, isEnabled: 1, version: 'v1' }
}
function assignForm<T extends object>(target: T, source: T) {
Object.keys(target).forEach(key => delete (target as Record<string, unknown>)[key])
Object.assign(target, source)
}
function providerName(id?: string) {
return providers.value.find(item => item.id === id)?.providerName || id || '未配置'
}
function endpointName(id?: string) {
return endpoints.value.find(item => item.id === id)?.endpointName || id || '未绑定接口'
}
function providerTypeLabel(type?: string) {
const map: Record<string, string> = { dify: 'Dify', coze: 'Coze', openai: 'OpenAI', custom: '自定义' }
return type ? map[type] || type : '-'
}
function endpointTypeLabel(type?: string) {
const map: Record<string, string> = { workflow: '工作流', chat: '对话', completion: '补全', custom: '自定义' }
return type ? map[type] || type : '-'
}
function statusLabel(status?: string) {
const map: Record<string, string> = { running: '运行中', success: '成功', failed: '失败' }
return status ? map[status] || status : '-'
}
async function loadAll() {
loading.value = true
try {
const [providerRes, endpointRes, sceneRes, logRes] = await Promise.all([
listAiProviders(),
listAiEndpoints(),
listAiScenes(),
listAiCallLogs(80)
])
providers.value = providerRes.data || []
endpoints.value = endpointRes.data || []
scenes.value = sceneRes.data || []
logs.value = logRes.data || []
} finally {
loading.value = false
}
}
function openProvider(row?: AiProvider) {
assignForm(providerForm, row ? { ...row, apiKey: '' } : newProvider())
providerDialog.value = true
}
function openEndpoint(row?: AiEndpointConfig) {
assignForm(endpointForm, row ? { ...row } : newEndpoint())
endpointDialog.value = true
}
function openScene(row?: AiSceneBinding) {
assignForm(sceneForm, row ? { ...row } : newScene())
sceneDialog.value = true
}
function openRuntimeTest() {
testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || ''
testResult.value = null
nonStreamResult.value = null
testDialog.value = true
}
async function submitProvider() {
await saveAiProvider({ ...providerForm })
providerDialog.value = false
ElMessage.success('服务商已保存')
await loadAll()
}
async function submitEndpoint() {
await saveAiEndpoint({ ...endpointForm })
endpointDialog.value = false
ElMessage.success('接口工作流已保存')
await loadAll()
}
async function submitScene() {
await saveAiScene({ ...sceneForm })
sceneDialog.value = false
ElMessage.success('场景绑定已保存')
await loadAll()
}
async function removeProvider(row: AiProvider) {
if (!row.id) return
await ElMessageBox.confirm('确认删除这个服务商配置吗?', '删除确认', { type: 'warning' })
await deleteAiProvider(row.id)
ElMessage.success('服务商已删除')
await loadAll()
}
async function removeEndpoint(row: AiEndpointConfig) {
if (!row.id) return
await ElMessageBox.confirm('确认删除这个接口工作流吗?', '删除确认', { type: 'warning' })
await deleteAiEndpoint(row.id)
ElMessage.success('接口工作流已删除')
await loadAll()
}
async function removeScene(row: AiSceneBinding) {
if (!row.id) return
await ElMessageBox.confirm('确认删除这个场景绑定吗?', '删除确认', { type: 'warning' })
await deleteAiScene(row.id)
ElMessage.success('场景绑定已删除')
await loadAll()
}
async function submitNonStreamTest() {
let inputs: Record<string, any>
try {
inputs = JSON.parse(testInputsJson.value || '{}')
} catch (error) {
ElMessage.error('入参 JSON 格式不正确')
return
}
testing.value = true
try {
const res = await testAiRuntime({ sceneCode: testForm.sceneCode, inputs })
nonStreamResult.value = res.data as AiRuntimeTestResponse
if (nonStreamResult.value.status === 'success') {
ElMessage.success('非流式测试成功')
} else {
ElMessage.error(`测试失败: ${nonStreamResult.value.errorMessage || nonStreamResult.value.errorCode}`)
}
await loadAll()
} catch (error: any) {
nonStreamResult.value = {
sceneCode: testForm.sceneCode,
status: 'failed',
errorMessage: error?.message || '非流式测试失败'
} as AiRuntimeTestResponse
ElMessage.error(error?.message || '非流式测试失败')
} finally {
testing.value = false
}
}
async function submitRuntimeTest() {
let inputs: Record<string, any>
try {
inputs = JSON.parse(testInputsJson.value || '{}')
} catch (error) {
ElMessage.error('入参 JSON 格式不正确')
return
}
testing.value = true
testResult.value = { sceneCode: testForm.sceneCode, status: 'success', output: '', streamChunks: 0, durationMs: 0 }
const startedAt = Date.now()
try {
await streamAiRuntime({ sceneCode: testForm.sceneCode, inputs }, (event, output) => {
if (!testResult.value) return
testResult.value.output = output
if (event.type === 'delta') {
testResult.value.streamChunks = (testResult.value.streamChunks || 0) + 1
}
if (event.type === 'done') {
testResult.value.status = 'success'
}
if (event.type === 'error') {
testResult.value.status = 'failed'
testResult.value.errorCode = event.code
testResult.value.errorMessage = event.message
}
testResult.value.durationMs = Date.now() - startedAt
})
await loadAll()
} catch (error: any) {
if (testResult.value) {
testResult.value.status = 'failed'
testResult.value.errorMessage = error?.message || '流式测试失败'
testResult.value.durationMs = Date.now() - startedAt
}
} finally {
testing.value = false
}
}
onMounted(loadAll)
</script>
<style scoped>
.ai-routing-page {
padding: 24px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
}
.page-title {
margin: 0 0 8px;
color: var(--ls-text);
font-size: 24px;
font-weight: 700;
}
.page-desc {
margin: 0;
color: var(--ls-text-muted);
font-size: 14px;
}
.stats-row {
margin-bottom: 16px;
}
.stat-card {
min-height: 92px;
padding: 18px 20px;
border: 1px solid var(--ls-glass-border);
border-radius: var(--ls-radius-lg);
background: rgba(15, 17, 26, 0.38);
box-shadow: var(--ls-shadow);
backdrop-filter: blur(20px) saturate(160%);
}
.stat-value {
color: var(--ls-text);
font-size: 30px;
font-weight: 700;
line-height: 1;
}
.stat-label {
margin-top: 10px;
color: var(--ls-text-muted);
font-size: 13px;
}
.stat-provider {
border-color: rgba(255, 171, 145, 0.24);
}
.stat-endpoint {
border-color: rgba(129, 212, 250, 0.22);
}
.stat-scene {
border-color: rgba(103, 194, 58, 0.20);
}
.stat-log {
border-color: rgba(230, 162, 60, 0.20);
}
.routing-card {
padding: 4px 6px 14px;
}
.routing-tabs :deep(.el-tabs__header) {
margin-bottom: 16px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.muted {
color: rgba(226, 232, 240, 0.45);
}
.test-output {
min-height: 120px;
max-height: 360px;
overflow: auto;
padding: 14px;
margin-top: 12px;
color: rgba(226, 232, 240, 0.92);
background: rgba(0, 0, 0, 0.22);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--ls-radius-md);
white-space: pre-wrap;
}
</style>