Files
happy-life-star/web-admin/src/views/aiconfig/AiRoutingList.vue
T
peanut d3746fa6c7 feat: AI端点测试动态参数表单、接口工作流行内测试、本地开发环境改为线上域名
- 后端新增 /ai/endpoint/test 和 /ai/endpoint/stream 接口,支持直接端点测试
- 前端增加行内测试功能(场景绑定+接口工作流)
- 测试对话框增加动态参数表单和参数定义编辑
- 支持 _meta 格式的默认输入参数处理
- web、web-admin 本地开发环境 API 调用改为线上域名 https://lifescript.happylifeos.com

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:10:24 +08:00

995 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="220" 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>
<el-button link type="success" :disabled="row.isEnabled !== 1" @click="openEndpointTest(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="220" 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>
<el-button link type="success" @click="openSceneRuntimeTest(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="参数定义">
<div v-for="(param, index) in paramDefinitions" :key="index" class="param-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center">
<el-input v-model="param.name" placeholder="参数名" style="width: 110px" />
<el-input v-model="param.label" placeholder="显示标签" style="width: 120px" />
<el-select v-model="param.type" style="width: 100px">
<el-option label="文本" value="string" />
<el-option label="多行" value="textarea" />
<el-option label="数字" value="number" />
<el-option label="开关" value="boolean" />
<el-option label="JSON" value="json" />
</el-select>
<el-input v-model="param.defaultValue" placeholder="默认值" style="width: 100px" />
<el-checkbox v-model="param.required">必填</el-checkbox>
<el-button link type="danger" @click="removeParam(index)">删除</el-button>
</div>
<el-button link type="primary" @click="addParam">+ 添加参数</el-button>
</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>
<el-dialog v-model="endpointTestDialog" title="接口工作流测试" width="760px">
<div class="endpoint-info">
<span class="endpoint-label">接口名称</span>
<span>{{ endpointTestRow?.endpointName }}{{ endpointTestRow?.endpointCode }}</span>
</div>
<el-form v-if="endpointParamFields.length > 0" label-width="120px" style="margin-top: 16px">
<el-form-item v-for="field in endpointParamFields" :key="field.name" :label="field.label" :required="field.required"
style="margin-bottom: 12px">
<el-input v-if="field.type === 'string'" v-model="field.value" :placeholder="field.name" @input="syncEndpointJsonFromFields" />
<el-input v-if="field.type === 'textarea'" v-model="field.value" type="textarea" :rows="3" :placeholder="field.name" @input="syncEndpointJsonFromFields" />
<el-input-number v-if="field.type === 'number'" v-model="field.value" :placeholder="field.name" @change="syncEndpointJsonFromFields" style="width: 100%" />
<el-switch v-if="field.type === 'boolean'" v-model="field.value" @change="syncEndpointJsonFromFields" />
<el-input v-if="field.type === 'json'" v-model="field.value" type="textarea" :rows="4" :placeholder="field.placeholder || field.name" @input="syncEndpointJsonFromFields" />
</el-form-item>
</el-form>
<el-collapse style="margin-top: 12px">
<el-collapse-item title="高级编辑(JSON">
<el-input v-model="endpointInputsJson" type="textarea" :rows="6" placeholder="请输入 JSON 入参" @input="syncEndpointFieldsFromJson" />
</el-collapse-item>
</el-collapse>
<el-alert
v-if="endpointNonStreamResult"
:type="endpointNonStreamResult.status === 'success' ? 'success' : 'error'"
:title="endpointNonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
show-icon
/>
<pre v-if="endpointNonStreamResult" class="test-output">{{ endpointNonStreamResult.output || endpointNonStreamResult.errorMessage || '暂无输出' }}</pre>
<el-alert
v-if="endpointTestResult"
:type="endpointTestResult.status === 'success' ? 'success' : 'error'"
:title="endpointTestResult.status === 'success' ? '流式测试成功' : '流式测试失败'"
show-icon
/>
<pre v-if="endpointTestResult" class="test-output">{{ endpointTestResult.output || endpointTestResult.errorMessage || '暂无输出' }}</pre>
<template #footer>
<el-button @click="endpointTestDialog = false">关闭</el-button>
<el-button :loading="endpointTesting" @click="submitEndpointNonStreamTest">非流式测试</el-button>
<el-button type="primary" :loading="endpointTesting" @click="submitEndpointStreamTest">流式测试</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,
getEndpointTestTemplate,
getSceneTestTemplate,
listAiCallLogs,
listAiEndpoints,
listAiProviders,
listAiScenes,
saveAiEndpoint,
saveAiProvider,
saveAiScene,
streamAiRuntime,
streamEndpointRuntime,
testAiRuntime,
testEndpointRuntime
} from '@/api/aiconfig'
import type { AiCallLog, AiEndpointConfig, AiProvider, AiRuntimeTestResponse, AiSceneBinding, TestParamField, ParamDefinition, AiTestTemplateResponse } 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 endpointTestDialog = ref(false)
const endpointTesting = ref(false)
const endpointTestRow = ref<AiEndpointConfig | null>(null)
const endpointTestResult = ref<AiRuntimeTestResponse | null>(null)
const endpointNonStreamResult = ref<AiRuntimeTestResponse | null>(null)
const endpointInputsJson = ref('{}')
const endpointParamFields = ref<TestParamField[]>([])
const paramDefinitions = ref<ParamDefinition[]>([])
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())
paramDefinitions.value = parseDefinitionsFromDefaultInputs(row?.defaultInputs)
endpointDialog.value = true
}
function openScene(row?: AiSceneBinding) {
assignForm(sceneForm, row ? { ...row } : newScene())
sceneDialog.value = true
}
async function openRuntimeTest() {
testForm.sceneCode = scenes.value.find(item => item.isEnabled === 1 && item.endpointId)?.sceneCode || scenes.value[0]?.sceneCode || ''
testResult.value = null
nonStreamResult.value = null
await loadSceneTemplate(testForm.sceneCode)
testDialog.value = true
}
async function openSceneRuntimeTest(row: AiSceneBinding) {
testForm.sceneCode = row.sceneCode
testResult.value = null
nonStreamResult.value = null
await loadSceneTemplate(row.sceneCode)
testDialog.value = true
}
async function openEndpointTest(row: AiEndpointConfig) {
if (!row.id) return
endpointTestRow.value = row
endpointParamFields.value = parseParamFields(row.defaultInputs)
endpointInputsJson.value = buildInputsJson(endpointParamFields.value)
endpointTestResult.value = null
endpointNonStreamResult.value = null
endpointTestDialog.value = true
endpointTesting.value = true
try {
const res = await getEndpointTestTemplate(row.id!)
applyEndpointTemplate(res.data as AiTestTemplateResponse)
} catch (error: any) {
ElMessage.warning(error?.message || '测试样例加载失败,已使用本地默认参数')
} finally {
endpointTesting.value = false
}
}
async function loadSceneTemplate(sceneCode: string) {
if (!sceneCode) {
testInputsJson.value = '{\n "prompt": "请用一句中文回复测试成功。"\n}'
return
}
try {
const res = await getSceneTestTemplate(sceneCode)
const template = res.data as AiTestTemplateResponse
testInputsJson.value = JSON.stringify(template.inputs || {}, null, 2)
} catch (error: any) {
testInputsJson.value = '{\n "prompt": "请用一句中文回复测试成功。"\n}'
ElMessage.warning(error?.message || '场景测试样例加载失败,已使用通用测试参数')
}
}
function applyEndpointTemplate(template: AiTestTemplateResponse) {
const inputs = template.inputs || {}
endpointInputsJson.value = JSON.stringify(inputs, null, 2)
endpointParamFields.value = (template.paramFields || []).map(field => ({
...field,
value: field.type === 'json'
? JSON.stringify(field.value ?? {}, null, 2)
: field.value,
defaultValue: field.value,
required: Boolean(field.required)
}))
}
function parseParamFields(defaultInputs?: string): TestParamField[] {
if (!defaultInputs) return []
try {
const parsed = JSON.parse(defaultInputs)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return []
return Object.entries(parsed).map(([key, val]: [string, any]) => {
// 新格式:{ _meta: {...}, value: ... }
if (val && typeof val === 'object' && '_meta' in val) {
return {
name: key,
label: val._meta.label || key,
type: val._meta.type || 'string',
value: val.value,
defaultValue: val.value,
required: val._meta.required || false
} as TestParamField
}
// 旧格式:直接值
return {
name: key,
label: key,
type: inferParamType(val),
value: val,
defaultValue: val,
required: false
} as TestParamField
})
} catch {
return []
}
}
function inferParamType(val: any): 'string' | 'textarea' | 'number' | 'boolean' | 'json' {
if (typeof val === 'number') return 'number'
if (typeof val === 'boolean') return 'boolean'
if (val && typeof val === 'object') return 'json'
if (typeof val === 'string' && val.length > 80) return 'textarea'
return 'string'
}
function buildInputsJson(fields: TestParamField[]): string {
const obj: Record<string, any> = {}
fields.forEach(f => {
if (!f.name) return
if (f.type === 'json') {
try {
obj[f.name] = typeof f.value === 'string' ? JSON.parse(f.value || '{}') : f.value
} catch {
obj[f.name] = f.value
}
} else {
obj[f.name] = f.value
}
})
return JSON.stringify(obj, null, 2)
}
function syncEndpointJsonFromFields() {
endpointInputsJson.value = buildInputsJson(endpointParamFields.value)
}
function syncEndpointFieldsFromJson() {
try {
const parsed = JSON.parse(endpointInputsJson.value || '{}')
endpointParamFields.value.forEach(field => {
if (field.name in parsed) {
field.value = field.type === 'json'
? JSON.stringify(parsed[field.name] ?? {}, null, 2)
: parsed[field.name]
}
})
} catch { /* ignore parse errors */ }
}
function addParam() {
paramDefinitions.value.push({ name: '', label: '', type: 'string', defaultValue: '', required: false })
}
function removeParam(index: number) {
paramDefinitions.value.splice(index, 1)
}
function buildDefaultInputsFromDefinitions(defs: ParamDefinition[]): string {
const obj: Record<string, any> = {}
defs.forEach(d => {
if (d.name) {
obj[d.name] = {
_meta: { label: d.label || d.name, type: d.type, required: d.required },
value: d.defaultValue
}
}
})
return JSON.stringify(obj)
}
function parseDefinitionsFromDefaultInputs(defaultInputs?: string): ParamDefinition[] {
if (!defaultInputs) return []
try {
const parsed = JSON.parse(defaultInputs)
if (typeof parsed !== 'object' || parsed === null) return []
return Object.entries(parsed).map(([key, val]: [string, any]) => {
if (val && typeof val === 'object' && '_meta' in val) {
return {
name: key,
label: val._meta.label || key,
type: val._meta.type || 'string',
defaultValue: val.value,
required: val._meta.required || false
} as ParamDefinition
}
return { name: key, label: key, type: inferParamType(val), defaultValue: val, required: false } as ParamDefinition
})
} catch {
return []
}
}
async function submitProvider() {
await saveAiProvider({ ...providerForm })
providerDialog.value = false
ElMessage.success('服务商已保存')
await loadAll()
}
async function submitEndpoint() {
const data = { ...endpointForm }
// 如果有参数定义,合并到 defaultInputs
if (paramDefinitions.value.length > 0) {
data.defaultInputs = buildDefaultInputsFromDefinitions(paramDefinitions.value)
}
await saveAiEndpoint(data)
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
}
}
async function submitEndpointNonStreamTest() {
if (!endpointTestRow.value) return
if (!validateEndpointFields()) return
let inputs: Record<string, any>
try {
inputs = JSON.parse(endpointInputsJson.value || '{}')
} catch {
ElMessage.error('入参 JSON 格式不正确')
return
}
endpointTesting.value = true
try {
const res = await testEndpointRuntime({ endpointId: endpointTestRow.value.id!, inputs })
endpointNonStreamResult.value = res.data as AiRuntimeTestResponse
if (endpointNonStreamResult.value.status === 'success') {
ElMessage.success('非流式测试成功')
} else {
ElMessage.error(`测试失败: ${endpointNonStreamResult.value.errorMessage || endpointNonStreamResult.value.errorCode}`)
}
await loadAll()
} catch (error: any) {
endpointNonStreamResult.value = {
sceneCode: '',
status: 'failed',
errorMessage: error?.message || '非流式测试失败'
} as AiRuntimeTestResponse
ElMessage.error(error?.message || '非流式测试失败')
} finally {
endpointTesting.value = false
}
}
async function submitEndpointStreamTest() {
if (!endpointTestRow.value) return
if (!validateEndpointFields()) return
let inputs: Record<string, any>
try {
inputs = JSON.parse(endpointInputsJson.value || '{}')
} catch {
ElMessage.error('入参 JSON 格式不正确')
return
}
endpointTesting.value = true
endpointTestResult.value = { sceneCode: '', status: 'success', output: '', streamChunks: 0, durationMs: 0 }
const startedAt = Date.now()
try {
await streamEndpointRuntime({ endpointId: endpointTestRow.value.id!, inputs }, (event, output) => {
if (!endpointTestResult.value) return
endpointTestResult.value.output = output
if (event.type === 'delta') {
endpointTestResult.value.streamChunks = (endpointTestResult.value.streamChunks || 0) + 1
}
if (event.type === 'done') {
endpointTestResult.value.status = 'success'
}
if (event.type === 'error') {
endpointTestResult.value.status = 'failed'
endpointTestResult.value.errorCode = event.code
endpointTestResult.value.errorMessage = event.message
}
endpointTestResult.value.durationMs = Date.now() - startedAt
})
await loadAll()
} catch (error: any) {
if (endpointTestResult.value) {
endpointTestResult.value.status = 'failed'
endpointTestResult.value.errorMessage = error?.message || '流式测试失败'
endpointTestResult.value.durationMs = Date.now() - startedAt
}
} finally {
endpointTesting.value = false
}
}
function validateEndpointFields() {
for (const field of endpointParamFields.value) {
if (!field.required) continue
const value = field.value
if (value === null || value === undefined || String(value).trim() === '') {
ElMessage.error(`请填写必填参数:${field.label || field.name}`)
return false
}
}
for (const field of endpointParamFields.value.filter(item => item.type === 'json')) {
try {
JSON.parse(field.value || '{}')
} catch {
ElMessage.error(`参数 ${field.label || field.name} 不是有效 JSON`)
return false
}
}
return true
}
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;
}
.endpoint-info {
padding: 10px 14px;
color: rgba(226, 232, 240, 0.85);
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--ls-radius-md);
font-size: 14px;
}
.endpoint-label {
color: var(--ls-text-muted);
margin-right: 6px;
}
</style>