diff --git a/docs/superpowers/plans/2026-05-23-ai-config-row-test.md b/docs/superpowers/plans/2026-05-23-ai-config-row-test.md new file mode 100644 index 0000000..3b67c13 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ai-config-row-test.md @@ -0,0 +1,864 @@ +# 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 样式** + +在 `