d3746fa6c7
- 后端新增 /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>
995 lines
40 KiB
Vue
995 lines
40 KiB
Vue
<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>
|