Files
happy-life-star/docs/superpowers/specs/2026-05-23-ai-config-row-test-design.md
T

400 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<String, Object> inputs);
// 流式 endpoint 测试(SSE 回调)
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> 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<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`):
```typescript
export interface AiEndpointRuntimeRequest {
endpointId: string
inputs: Record<string, any>
}
```
**API 层**`web-admin/src/api/aiconfig.ts`):
先将现有 `streamAiRuntime` 的 SSE fetch 逻辑提取为共享辅助函数 `fetchSseStream`,避免复制粘贴 ~50 行代码:
```typescript
// 共享 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` 都委托给这个辅助函数:
```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
<el-button link type="success" @click="openSceneRuntimeTest(row)">测试</el-button>
```
- 新增函数 `openSceneRuntimeTest(row)`
```typescript
function openSceneRuntimeTest(row: AiSceneBinding) {
testForm.sceneCode = row.sceneCode
testResult.value = null
nonStreamResult.value = null
testDialog.value = true
}
```
- 现有顶部工具栏的「流式测试」按钮保留不变
**接口工作流表改造**
- 操作列宽度从 `150` 调整为 `220`
- 每行操作列增加「测试」按钮(endpoint 禁用时按钮也禁用):
```vue
<el-button link type="success" :disabled="row.isEnabled !== 1" @click="openEndpointTest(row)">测试</el-button>
```
- 新增对话框 `endpointTestDialog`(独立于 `testDialog`),标题「接口工作流测试」
- 对话框 template 结构:
```vue
<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` 的状态互不干扰):
```typescript
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)` 函数实现:
```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<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`):
```typescript
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,确保三按钮(编辑/删除/测试)不重叠