fe4a39f0d4
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
16 KiB
author, created_at, purpose
| author | created_at | purpose |
|---|---|---|
| 华钟民 | 2026-05-23 | 在 AI 配置管理页面为场景绑定表和接口工作流表各增加行内测试按钮,工作流测试独立走 endpoint 直调链路 |
AI 配置行内测试功能设计
1. 问题背景
当前 web-admin 后台「AI 配置管理」页面中:
- 场景绑定 Tab:只有顶部工具栏的全局「流式测试」按钮,需要手动选择场景
- 接口工作流 Tab:没有任何测试功能
需要在两个表的每行操作列增加「测试」按钮,让用户直接对某一行进行测试。
2. 设计方案
2.1 场景绑定表 — 行内测试
- 每行操作列增加「测试」按钮
- 点击后打开现有测试对话框(
testDialog),自动填入该行的sceneCode - 后续流程不变,走
sceneCode → resolveTarget → adapter链路
2.2 接口工作流表 — 行内测试
工作流测试独立于场景,直接基于 endpoint 调用。
后端新增
AiRuntimeRequest DTO 增强(AiRuntimeRequest.java):
- 新增
endpointId字段 fromPayload()中新增提取逻辑:request.setEndpointId(payload.getString("endpointId"))RESERVED_KEYS集合中新增"endpointId",防止扁平 payload 中endpointId错误泄漏到 inputs
AiRuntimeService 接口:
// 非流式 endpoint 测试
AiRuntimeTestResponse testEndpoint(String endpointId, Map<String, Object> inputs);
// 流式 endpoint 测试(SSE 回调)
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> consumer);
AiRuntimeServiceImpl 实现(testEndpoint 和 invokeEndpointStream 共用以下逻辑):
- 根据
endpointId查 endpoint(getEnabledById,null 时抛AI_ENDPOINT_DISABLED) - 查 provider(
getEnabledById,null 时抛AI_PROVIDER_DISABLED) - 构造
AiRuntimeRequest,显式设置:endpointId= 入参 endpointIdinputs= 入参 inputssceneCode= null(留空)userId=UserContextHolder.getCurrentUserId()(回退"anonymous")userName=UserContextHolder.getCurrentUsername()userType=UserContextHolder.getCurrentUserType()requestId=UserContextHolder.getRequestId()
- 调用
enrichInputs(request)→ userId/userName/userType/requestId 注入到 inputs →enrichSceneInputs()因 sceneCode 为空自动跳过 - 根据
provider.getProviderType()查找对应的AiProviderAdapter,调用adapter.stream(provider, endpoint, request, consumer) - 记录 callLog(
sceneCode留空,endpointCode填 endpoint 编码,providerCode填 provider 编码) - 复用
AiStreamEvent事件体系和AiRuntimeTestResponse响应结构
AiRoutingController 新增接口:
@PostMapping("/endpoint/test")
public Result<AiRuntimeTestResponse> endpointTest(@RequestBody JSONObject payload) {
String endpointId = payload.getString("endpointId");
JSONObject inputs = payload.getJSONObject("inputs");
Map<String, Object> inputMap = inputs == null ? Map.of() : inputs;
return Result.success(runtimeService.testEndpoint(endpointId, inputMap));
}
@PostMapping("/endpoint/stream")
public SseEmitter endpointStream(@RequestBody JSONObject payload) {
String endpointId = payload.getString("endpointId");
JSONObject inputs = payload.getJSONObject("inputs");
Map<String, Object> inputMap = inputs == null ? Map.of() : inputs;
SseEmitter emitter = new SseEmitter(0L);
CompletableFuture.runAsync(() -> {
runtimeService.invokeEndpointStream(endpointId, inputMap, event -> sendEvent(emitter, event));
emitter.complete();
}).exceptionally(error -> {
sendEvent(emitter, AiStreamEvent.error("AI_ENDPOINT_TEST_INTERRUPTED", error.getMessage()));
emitter.completeWithError(error);
return null;
});
return emitter;
}
前端新增
类型定义(web-admin/src/types/aiconfig.ts):
export interface AiEndpointRuntimeRequest {
endpointId: string
inputs: Record<string, any>
}
API 层(web-admin/src/api/aiconfig.ts):
先将现有 streamAiRuntime 的 SSE fetch 逻辑提取为共享辅助函数 fetchSseStream,避免复制粘贴 ~50 行代码:
// 共享 SSE fetch 辅助函数(从 streamAiRuntime 提取)
async function fetchSseStream(
url: string,
body: Record<string, any>,
onEvent: (event: AiRuntimeStreamEvent, output: string) => void
) {
const token = localStorage.getItem('adminToken')
const response = await fetch(`${import.meta.env.VITE_APP_BASE_API}${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(body)
})
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
}
然后 streamAiRuntime 和新增的 streamEndpointRuntime 都委托给这个辅助函数:
export async function streamAiRuntime(
data: AiRuntimeRequest,
onEvent: (event: AiRuntimeStreamEvent, output: string) => void
) {
return fetchSseStream('/ai/runtime/stream', data as any, onEvent)
}
export async function streamEndpointRuntime(
data: AiEndpointRuntimeRequest,
onEvent: (event: AiRuntimeStreamEvent, output: string) => void
) {
return fetchSseStream('/ai/endpoint/stream', data as any, onEvent)
}
非流式 endpoint 测试(简单 axios 调用,不涉及 SSE):
export function testEndpointRuntime(data: AiEndpointRuntimeRequest) {
return request({ url: '/ai/endpoint/test', method: 'post', data, timeout: 60000 })
}
UI 层(AiRoutingList.vue):
场景绑定表改造:
- 操作列宽度从
150调整为220 - 每行操作列增加「测试」按钮:
<el-button link type="success" @click="openSceneRuntimeTest(row)">测试</el-button>
- 新增函数
openSceneRuntimeTest(row):
function openSceneRuntimeTest(row: AiSceneBinding) {
testForm.sceneCode = row.sceneCode
testResult.value = null
nonStreamResult.value = null
testDialog.value = true
}
- 现有顶部工具栏的「流式测试」按钮保留不变
接口工作流表改造:
- 操作列宽度从
150调整为220 - 每行操作列增加「测试」按钮(endpoint 禁用时按钮也禁用):
<el-button link type="success" :disabled="row.isEnabled !== 1" @click="openEndpointTest(row)">测试</el-button>
- 新增对话框
endpointTestDialog(独立于testDialog),标题「接口工作流测试」 - 对话框 template 结构:
<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 label-width="110px" style="margin-top: 16px">
<el-form-item label="入参 JSON">
<el-input v-model="endpointInputsJson" type="textarea" :rows="6" placeholder="请输入 JSON 入参" />
</el-form-item>
</el-form>
<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>
- 新增独立响应式状态变量(与
testDialog的状态互不干扰):
const endpointTestDialog = ref(false)
const endpointTesting = ref(false)
const endpointTestRow = ref<AiEndpointConfig | null>(null) // 当前测试的 endpoint 行
const endpointTestResult = ref<AiRuntimeTestResponse | null>(null)
const endpointNonStreamResult = ref<AiRuntimeTestResponse | null>(null)
const endpointInputsJson = ref('{}')
openEndpointTest(row)函数实现:
function openEndpointTest(row: AiEndpointConfig) {
// 1. 存储当前行引用
endpointTestRow.value = row
// 2. 解析 defaultInputs 填入入参框
if (row.defaultInputs) {
try {
const parsed = JSON.parse(row.defaultInputs)
// 必须是非数组的普通对象,否则回退到 {}
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
endpointInputsJson.value = JSON.stringify(parsed, null, 2)
} else {
endpointInputsJson.value = '{}'
}
} catch {
endpointInputsJson.value = '{}'
}
} else {
endpointInputsJson.value = '{}'
}
// 3. 清空之前的测试结果
endpointTestResult.value = null
endpointNonStreamResult.value = null
// 4. 打开对话框
endpointTestDialog.value = true
}
submitEndpointNonStreamTest函数实现(模式与submitNonStreamTest一致,但调用testEndpointRuntime):
async function submitEndpointNonStreamTest() {
if (!endpointTestRow.value) 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
}
}
submitEndpointStreamTest函数实现(模式与submitRuntimeTest一致,但调用streamEndpointRuntime):
async function submitEndpointStreamTest() {
if (!endpointTestRow.value) 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
}
}
- 需要在 import 中新增引入
testEndpointRuntime和streamEndpointRuntime
2.3 数据流
场景绑定行内测试:
用户点击「测试」→ openSceneRuntimeTest(row) → testDialog 打开,sceneCode= row.sceneCode → 输入 params →
流式: streamAiRuntime({ sceneCode, inputs }) → /ai/runtime/stream → AiRuntimeService.invokeStream()
非流式: testAiRuntime({ sceneCode, inputs }) → /ai/runtime/test → AiRuntimeService.test()
接口工作流行内测试:
用户点击「测试」→ openEndpointTest(row) → endpointTestDialog 打开 → defaultInputs 已填入 →
流式: streamEndpointRuntime({ endpointId, inputs }) → /ai/endpoint/stream → AiRuntimeService.invokeEndpointStream()
非流式: testEndpointRuntime({ endpointId, inputs }) → /ai/endpoint/test → AiRuntimeService.testEndpoint()
endpoint 测试的 enrichInputs 行为:
AiRuntimeRequest中sceneCode为空 →enrichSceneInputs()跳过userId/userName/userType/requestId由AiRuntimeServiceImpl测试方法显式从UserContextHolder设置到 request 上,再通过enrichInputs()注入到 inputs- Controller 层不调用
withCurrentUser()(endpoint 测试走独立的简洁路径) socialInsightContext等场景级注入不执行(因为sceneCode为空)
2.4 响应字段说明
AiRuntimeTestResponse.sceneCode 在 endpoint 测试中为空,前端在 endpointTestDialog 中显示 endpointName + endpointCode 替代。
3. 文件清单
| 操作 | 文件 |
|---|---|
| 修改 | backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java |
| 修改 | backend-single/src/main/java/com/emotion/service/AiRuntimeService.java |
| 修改 | backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java |
| 修改 | backend-single/src/main/java/com/emotion/controller/AiRoutingController.java |
| 修改 | web-admin/src/types/aiconfig.ts |
| 修改 | web-admin/src/api/aiconfig.ts |
| 修改 | web-admin/src/views/aiconfig/AiRoutingList.vue |
4. 风险
- endpoint 测试不经过场景绑定,因此不会有场景相关的输入注入(如 socialInsightContext)。这是预期行为——endpoint 测试关注的是接口本身是否通
- endpoint 的
defaultInputs可能不完整、格式不合法或为非对象值(如字符串"123"、数字、null)。对话框打开时JSON.parse后增加类型检查(必须是普通对象且非数组),任何失败均回退到{} - 接口工作流表的「测试」按钮在
isEnabled !== 1时禁用,避免无效调用 - 新增两个后端接口,需确保权限校验和现有
/ai/runtime/*接口一致(走同一个 Admin 鉴权) - 操作列宽度从 150 调整到 220,确保三按钮(编辑/删除/测试)不重叠