# AI 配置行内测试功能实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 AI 配置管理页面的场景绑定表和接口工作流表分别增加行内测试按钮,工作流测试独立走 endpoint 直调链路 **Architecture:** 后端复用现有 adapter/stream 基础设施但绕过场景解析层,前端复用测试对话框 UI 但增加独立的 endpoint 测试对话框。所有修改通过现有 Axios/fetch + SSE 模式完成。 **Tech Stack:** Vue 3 + Element Plus + TypeScript, Spring Boot 2.7 + MyBatis-Plus --- ### File Structure | 文件 | 职责 | 操作 | |------|------|------| | `backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java` | DTO:新增 endpointId 字段,更新 RESERVED_KEYS | 修改 | | `backend-single/src/main/java/com/emotion/service/AiRuntimeService.java` | Service 接口:新增 testEndpoint + invokeEndpointStream | 修改 | | `backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java` | Service 实现:新增两个方法的完整实现 | 修改 | | `backend-single/src/main/java/com/emotion/controller/AiRoutingController.java` | Controller:新增 /endpoint/test 和 /endpoint/stream | 修改 | | `web-admin/src/types/aiconfig.ts` | TypeScript 类型:新增 AiEndpointRuntimeRequest | 修改 | | `web-admin/src/api/aiconfig.ts` | API 层:新增 testEndpointRuntime、streamEndpointRuntime、提取 fetchSseStream | 修改 | | `web-admin/src/views/aiconfig/AiRoutingList.vue` | Vue 组件:增加行内测试按钮 + endpoint 测试对话框 | 修改 | --- ### Task 1: DTO 增强 — AiRuntimeRequest 新增 endpointId **Files:** - Modify: `backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java` - [ ] **Step 1: 新增 endpointId 字段和 RESERVED_KEYS** ```java // 第 12 行:RESERVED_KEYS 增加 "endpointId" private static final Set RESERVED_KEYS = Set.of("sceneCode", "scene", "inputs", "userId", "userName", "username", "userType", "requestId", "endpointId"); // 第 16 行:sceneCode 下方新增 endpointId private String sceneCode; private String endpointId; // ← 新增 private String userId; ``` - [ ] **Step 2: fromPayload() 中增加 endpointId 提取** 在 `fromPayload()` 方法中,在 `request.setSceneCode(sceneCode)` 下方添加: ```java request.setEndpointId(payload.getString("endpointId")); ``` 完整 fromPayload() 片段: ```java public static AiRuntimeRequest fromPayload(JSONObject payload) { AiRuntimeRequest request = new AiRuntimeRequest(); if (payload == null) { return request; } String sceneCode = payload.getString("sceneCode"); if (!StringUtils.hasText(sceneCode)) { sceneCode = payload.getString("scene"); } request.setSceneCode(sceneCode); request.setEndpointId(payload.getString("endpointId")); // ← 新增 JSONObject inputs = payload.getJSONObject("inputs"); if (inputs == null) { inputs = new JSONObject(); JSONObject runtimeInputs = inputs; payload.forEach((key, value) -> { if (!RESERVED_KEYS.contains(key)) { runtimeInputs.put(key, value); } }); } request.setInputs(inputs); return request; } ``` - [ ] **Step 3: 编译验证** Run: `cd backend-single && mvn clean compile -pl :backend-single -am -DskipTests` Expected: BUILD SUCCESS(endpointId 字段新增不破坏任何编译,因为这是新增字段) - [ ] **Step 4: Commit** ```bash git add backend-single/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java git commit -m "feat: AiRuntimeRequest DTO 新增 endpointId 字段 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 2: Service 接口层 — 新增 testEndpoint + invokeEndpointStream **Files:** - Modify: `backend-single/src/main/java/com/emotion/service/AiRuntimeService.java` - [ ] **Step 1: 新增两个接口方法** ```java package com.emotion.service; import com.emotion.dto.request.ai.AiRuntimeRequest; import com.emotion.dto.response.ai.AiRuntimeTestResponse; import com.emotion.dto.response.ai.AiStreamEvent; import java.util.Map; import java.util.function.Consumer; public interface AiRuntimeService { void invokeStream(AiRuntimeRequest request, Consumer consumer); AiRuntimeTestResponse test(AiRuntimeRequest request); // 新增 ↓ AiRuntimeTestResponse testEndpoint(String endpointId, Map inputs); void invokeEndpointStream(String endpointId, Map inputs, Consumer consumer); } ``` - [ ] **Step 2: 编译验证** Run: `cd backend-single && mvn clean compile -pl :backend-single -am -DskipTests` Expected: BUILD SUCCESS(接口新增方法,但实现类尚未修改,此时不会编译失败 — 因为 ServiceImpl 编译是单独的任务) - [ ] **Step 3: Commit** ```bash git add backend-single/src/main/java/com/emotion/service/AiRuntimeService.java git commit -m "feat: AiRuntimeService 接口新增 endpoint 测试方法 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 3: Service 实现层 — testEndpoint + invokeEndpointStream 实现 **Files:** - Modify: `backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java` - [ ] **Step 1: 新增 testEndpoint 方法** 在现有 `test()` 方法(第 115-141 行)之后,添加: ```java @Override public AiRuntimeTestResponse testEndpoint(String endpointId, Map inputs) { long startedAt = System.currentTimeMillis(); StringBuilder output = new StringBuilder(); AtomicInteger chunks = new AtomicInteger(0); final String[] errorCode = new String[1]; final String[] errorMessage = new String[1]; invokeEndpointStream(endpointId, inputs, event -> { if ("delta".equals(event.getType()) && event.getContent() != null) { chunks.incrementAndGet(); output.append(event.getContent()); } else if ("error".equals(event.getType())) { errorCode[0] = event.getCode(); errorMessage[0] = event.getMessage(); } }); return AiRuntimeTestResponse.builder() .sceneCode("") .status(errorCode[0] == null ? "success" : "failed") .output(output.toString()) .streamChunks(chunks.get()) .durationMs(System.currentTimeMillis() - startedAt) .errorCode(errorCode[0]) .errorMessage(errorMessage[0]) .build(); } ``` - [ ] **Step 2: 新增 invokeEndpointStream 方法** 在 `testEndpoint` 方法之后,添加: ```java @Override public void invokeEndpointStream(String endpointId, Map inputs, Consumer consumer) { long startedAt = System.currentTimeMillis(); AtomicLong firstTokenAt = new AtomicLong(0); AtomicInteger chunks = new AtomicInteger(0); String requestId = UUID.randomUUID().toString(); StringBuilder output = new StringBuilder(); AiEndpointConfig endpoint = endpointConfigService.getEnabledById(endpointId); if (endpoint == null) { throw new IllegalStateException("AI_ENDPOINT_DISABLED"); } AiProvider provider = providerService.getEnabledById(endpoint.getProviderId()); if (provider == null) { throw new IllegalStateException("AI_PROVIDER_DISABLED"); } AiProviderAdapter adapter = adapters.stream() .filter(item -> item.supports(provider.getProviderType())) .findFirst() .orElseThrow(() -> new IllegalStateException("AI_PROVIDER_ADAPTER_NOT_FOUND")); // 构造请求,显式从 UserContextHolder 设置用户信息 AiRuntimeRequest request = new AiRuntimeRequest(); request.setInputs(inputs == null ? new com.alibaba.fastjson2.JSONObject() : new com.alibaba.fastjson2.JSONObject(inputs)); request.setEndpointId(endpointId); request.setUserId(resolveUserId(request)); request.setUserName(UserContextHolder.getCurrentUsername()); request.setUserType(UserContextHolder.getCurrentUserType()); request.setRequestId(UserContextHolder.getRequestId()); // 注入 userId / userName / userType / requestId 到 inputs(场景注入因 sceneCode 为空自动跳过) enrichInputs(request); AiCallLog callLog = new AiCallLog(); callLog.setRequestId(requestId); callLog.setEndpointCode(endpoint.getEndpointCode()); callLog.setProviderCode(provider.getProviderCode()); callLog.setUserId(request.getUserId()); callLog.setInputText(JSON.toJSONString(request.getInputs())); callLog.setStatus("running"); callLogService.save(callLog); consumer.accept(AiStreamEvent.start(endpoint.getEndpointCode())); try { adapter.stream(provider, endpoint, request, event -> { if ("delta".equals(event.getType())) { chunks.incrementAndGet(); if (firstTokenAt.compareAndSet(0, System.currentTimeMillis())) { log.debug("AI first token emitted, endpoint={}, requestId={}", endpoint.getEndpointCode(), requestId); } if (event.getContent() != null) { output.append(event.getContent()); } } consumer.accept(event); }); callLog.setStatus("success"); callLog.setOutputText(output.toString()); callLog.setStreamChunks(chunks.get()); callLog.setFirstTokenMs(firstTokenAt.get() == 0 ? null : firstTokenAt.get() - startedAt); callLog.setDurationMs(System.currentTimeMillis() - startedAt); callLogService.updateById(callLog); consumer.accept(AiStreamEvent.done(Map.of( "requestId", requestId, "streamChunks", chunks.get(), "durationMs", callLog.getDurationMs() ))); } catch (Exception e) { String code = normalizeErrorCode(e); callLog.setStatus("failed"); callLog.setErrorCode(code); callLog.setErrorMessage(e.getMessage()); callLog.setOutputText(output.toString()); callLog.setStreamChunks(chunks.get()); callLog.setDurationMs(System.currentTimeMillis() - startedAt); saveOrUpdateLog(callLog); consumer.accept(AiStreamEvent.error(code, e.getMessage())); } } ``` - [ ] **Step 3: 编译验证** Run: `cd backend-single && mvn clean compile -pl :backend-single -am -DskipTests` Expected: BUILD SUCCESS - [ ] **Step 4: Commit** ```bash git add backend-single/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java git commit -m "feat: AiRuntimeServiceImpl 实现 endpoint 直接测试方法 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 4: Controller 层 — 新增 /endpoint/test 和 /endpoint/stream **Files:** - Modify: `backend-single/src/main/java/com/emotion/controller/AiRoutingController.java` - [ ] **Step 1: 在 runtimeStream 方法之后新增两个 endpoint 方法** 在 `runtimeStream` 方法(第 138-151 行)的 `}` 之后、`withCurrentUser` 方法之前,添加: ```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; } ``` - [ ] **Step 2: 编译并验证完整后端** Run: `cd backend-single && mvn clean compile -pl :backend-single -am -DskipTests` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add backend-single/src/main/java/com/emotion/controller/AiRoutingController.java git commit -m "feat: AiRoutingController 新增 /endpoint/test 和 /endpoint/stream 接口 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 5: 前端类型定义 — 新增 AiEndpointRuntimeRequest **Files:** - Modify: `web-admin/src/types/aiconfig.ts` - [ ] **Step 1: 在 AiRuntimeRequest 之后新增类型** 在 `AiRuntimeRequest` 接口定义(第 262-266 行)之后、`AiRuntimeTestResponse` 之前,添加: ```typescript export interface AiEndpointRuntimeRequest { endpointId: string inputs: Record } ``` - [ ] **Step 2: Commit** ```bash git add web-admin/src/types/aiconfig.ts git commit -m "feat: 前端类型定义新增 AiEndpointRuntimeRequest Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 6: 前端 API 层 — 提取 fetchSseStream 并新增 endpoint 测试函数 **Files:** - Modify: `web-admin/src/api/aiconfig.ts` - [ ] **Step 1: 在 parseSseFrame 之后新增共享辅助函数 fetchSseStream** 在 `parseSseFrame` 函数(第 276-288 行)之后、`streamAiRuntime` 之前,添加: ```typescript 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 } ``` - [ ] **Step 2: 修改 streamAiRuntime 委托给 fetchSseStream** 将现有 `streamAiRuntime`(第 290-337 行)替换为: ```typescript export async function streamAiRuntime( data: AiRuntimeRequest, onEvent: (event: AiRuntimeStreamEvent, output: string) => void ) { return fetchSseStream('/ai/runtime/stream', data, onEvent) } ``` - [ ] **Step 3: 新增 streamEndpointRuntime 和 testEndpointRuntime** 在修改后的 `streamAiRuntime` 之后,添加: ```typescript export async function streamEndpointRuntime( data: AiEndpointRuntimeRequest, onEvent: (event: AiRuntimeStreamEvent, output: string) => void ) { return fetchSseStream('/ai/endpoint/stream', data, onEvent) } export function testEndpointRuntime(data: AiEndpointRuntimeRequest) { return request({ url: '/ai/endpoint/test', method: 'post', data, timeout: 60000 }) } ``` - [ ] **Step 4: 验证 TypeScript 编译** Run: `cd web-admin && npm run type-check` Expected: 无类型错误 - [ ] **Step 5: Commit** ```bash git add web-admin/src/api/aiconfig.ts git commit -m "feat: API 层提取 fetchSseStream 并新增 endpoint 测试函数 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 7: Vue 组件 — 场景绑定表增加行内测试按钮 **Files:** - Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue` - [ ] **Step 1: 修改场景绑定表操作列宽度和新增测试按钮** 将第 137-142 行(场景绑定表操作列)从: ```vue ``` 替换为: ```vue ``` - [ ] **Step 2: 新增 openSceneRuntimeTest 函数** 在 `openRuntimeTest()` 函数之后、`submitProvider()` 函数之前,添加: ```typescript function openSceneRuntimeTest(row: AiSceneBinding) { testForm.sceneCode = row.sceneCode testResult.value = null nonStreamResult.value = null testDialog.value = true } ``` - [ ] **Step 3: Commit** ```bash git add web-admin/src/views/aiconfig/AiRoutingList.vue git commit -m "feat: 场景绑定表增加行内测试按钮 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 8: Vue 组件 — 接口工作流表增加行内测试按钮 **Files:** - Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue` - [ ] **Step 1: 修改接口工作流表操作列宽度和新增测试按钮** 将第 100-105 行(接口工作流表操作列)从: ```vue ``` 替换为: ```vue ``` - [ ] **Step 2: Commit** ```bash git add web-admin/src/views/aiconfig/AiRoutingList.vue git commit -m "feat: 接口工作流表增加行内测试按钮 Co-Authored-By: Claude Opus 4.7 " ``` --- ### Task 9: Vue 组件 — 新增接口工作流测试对话框 **Files:** - Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue` - [ ] **Step 1: 新增 API import** 在第 288 行(`testAiRuntime` import)之后添加: ```typescript testAiRuntime, testEndpointRuntime, streamEndpointRuntime } from '@/api/aiconfig' ``` - [ ] **Step 2: 新增响应式状态变量** 在 `const testInputsJson` 声明(第 307 行)之后,添加: ```typescript const endpointTestDialog = ref(false) const endpointTesting = ref(false) const endpointTestRow = ref(null) const endpointTestResult = ref(null) const endpointNonStreamResult = ref(null) const endpointInputsJson = ref('{}') ``` - [ ] **Step 3: 新增 endpoint 测试对话框 template** 在现有测试对话框 `` 标签(第 269 行)之后,添加新的 endpoint 测试对话框: ```vue
接口名称: {{ endpointTestRow?.endpointName }}({{ endpointTestRow?.endpointCode }})
{{ endpointNonStreamResult.output || endpointNonStreamResult.errorMessage || '暂无输出' }}
{{ endpointTestResult.output || endpointTestResult.errorMessage || '暂无输出' }}
``` - [ ] **Step 4: 新增 openEndpointTest 函数** 在 `openSceneRuntimeTest()` 函数之后,添加: ```typescript function openEndpointTest(row: AiEndpointConfig) { endpointTestRow.value = row 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 = '{}' } endpointTestResult.value = null endpointNonStreamResult.value = null endpointTestDialog.value = true } ``` - [ ] **Step 5: 新增 submitEndpointNonStreamTest 函数** 在 `submitNonStreamTest()` 函数(第 462-490 行)之后,添加: ```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 } } ``` - [ ] **Step 6: 新增 submitEndpointStreamTest 函数** 在 `submitEndpointNonStreamTest()` 之后,添加: ```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 } } ``` - [ ] **Step 7: 新增 endpoint-info 样式** 在 `