--- author: 华钟民 created_at: 2026-05-23 purpose: 在 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` 接口**: ```java // 非流式 endpoint 测试 AiRuntimeTestResponse testEndpoint(String endpointId, Map inputs); // 流式 endpoint 测试(SSE 回调) void invokeEndpointStream(String endpointId, Map inputs, Consumer consumer); ``` **`AiRuntimeServiceImpl` 实现**(`testEndpoint` 和 `invokeEndpointStream` 共用以下逻辑): 1. 根据 `endpointId` 查 endpoint(`getEnabledById`,null 时抛 `AI_ENDPOINT_DISABLED`) 2. 查 provider(`getEnabledById`,null 时抛 `AI_PROVIDER_DISABLED`) 3. 构造 `AiRuntimeRequest`,显式设置: - `endpointId` = 入参 endpointId - `inputs` = 入参 inputs - `sceneCode` = null(留空) - `userId` = `UserContextHolder.getCurrentUserId()`(回退 `"anonymous"`) - `userName` = `UserContextHolder.getCurrentUsername()` - `userType` = `UserContextHolder.getCurrentUserType()` - `requestId` = `UserContextHolder.getRequestId()` 4. 调用 `enrichInputs(request)` → userId/userName/userType/requestId 注入到 inputs → `enrichSceneInputs()` 因 sceneCode 为空自动跳过 5. 根据 `provider.getProviderType()` 查找对应的 `AiProviderAdapter`,调用 `adapter.stream(provider, endpoint, request, consumer)` 6. 记录 callLog(`sceneCode` 留空,`endpointCode` 填 endpoint 编码,`providerCode` 填 provider 编码) 7. 复用 `AiStreamEvent` 事件体系和 `AiRuntimeTestResponse` 响应结构 **`AiRoutingController` 新增接口**: ```java @PostMapping("/endpoint/test") public Result endpointTest(@RequestBody JSONObject payload) { String endpointId = payload.getString("endpointId"); JSONObject inputs = payload.getJSONObject("inputs"); Map 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 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`): ```typescript export interface AiEndpointRuntimeRequest { endpointId: string inputs: Record } ``` **API 层**(`web-admin/src/api/aiconfig.ts`): 先将现有 `streamAiRuntime` 的 SSE fetch 逻辑提取为共享辅助函数 `fetchSseStream`,避免复制粘贴 ~50 行代码: ```typescript // 共享 SSE fetch 辅助函数(从 streamAiRuntime 提取) async function fetchSseStream( url: string, body: Record, 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` 都委托给这个辅助函数: ```typescript 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): ```typescript export function testEndpointRuntime(data: AiEndpointRuntimeRequest) { return request({ url: '/ai/endpoint/test', method: 'post', data, timeout: 60000 }) } ``` **UI 层**(`AiRoutingList.vue`): **场景绑定表改造**: - 操作列宽度从 `150` 调整为 `220` - 每行操作列增加「测试」按钮: ```vue 测试 ``` - 新增函数 `openSceneRuntimeTest(row)`: ```typescript function openSceneRuntimeTest(row: AiSceneBinding) { testForm.sceneCode = row.sceneCode testResult.value = null nonStreamResult.value = null testDialog.value = true } ``` - 现有顶部工具栏的「流式测试」按钮保留不变 **接口工作流表改造**: - 操作列宽度从 `150` 调整为 `220` - 每行操作列增加「测试」按钮(endpoint 禁用时按钮也禁用): ```vue 测试 ``` - 新增对话框 `endpointTestDialog`(独立于 `testDialog`),标题「接口工作流测试」 - 对话框 template 结构: ```vue
接口名称: {{ endpointTestRow?.endpointName }}({{ endpointTestRow?.endpointCode }})
{{ endpointNonStreamResult.output || endpointNonStreamResult.errorMessage || '暂无输出' }}
{{ endpointTestResult.output || endpointTestResult.errorMessage || '暂无输出' }}
``` - 新增独立响应式状态变量(与 `testDialog` 的状态互不干扰): ```typescript const endpointTestDialog = ref(false) const endpointTesting = ref(false) const endpointTestRow = ref(null) // 当前测试的 endpoint 行 const endpointTestResult = ref(null) const endpointNonStreamResult = ref(null) const endpointInputsJson = ref('{}') ``` - `openEndpointTest(row)` 函数实现: ```typescript 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`): ```typescript async function submitEndpointNonStreamTest() { if (!endpointTestRow.value) return let inputs: Record 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`): ```typescript async function submitEndpointStreamTest() { if (!endpointTestRow.value) return let inputs: Record 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,确保三按钮(编辑/删除/测试)不重叠