diff --git a/docs/superpowers/specs/2026-05-23-ai-config-row-test-design.md b/docs/superpowers/specs/2026-05-23-ai-config-row-test-design.md index 8a9bac8..5da2f60 100644 --- a/docs/superpowers/specs/2026-05-23-ai-config-row-test-design.md +++ b/docs/superpowers/specs/2026-05-23-ai-config-row-test-design.md @@ -31,6 +31,7 @@ purpose: 在 AI 配置管理页面为场景绑定表和接口工作流表各增 **`AiRuntimeRequest` DTO 增强**(`AiRuntimeRequest.java`): - 新增 `endpointId` 字段 - `fromPayload()` 中新增提取逻辑:`request.setEndpointId(payload.getString("endpointId"))` +- `RESERVED_KEYS` 集合中新增 `"endpointId"`,防止扁平 payload 中 `endpointId` 错误泄漏到 inputs **`AiRuntimeService` 接口**: ```java @@ -41,14 +42,21 @@ AiRuntimeTestResponse testEndpoint(String endpointId, Map inputs void invokeEndpointStream(String endpointId, Map inputs, Consumer consumer); ``` -**`AiRuntimeServiceImpl` 实现**: -- 根据 `endpointId` 查 endpoint(`getEnabledById`,null 时抛 `AI_ENDPOINT_DISABLED`) -- 查 provider(`getEnabledById`,null 时抛 `AI_PROVIDER_DISABLED`) -- 构造 `AiRuntimeRequest`,设置 `endpointId` 和 `inputs`,`sceneCode` 留空 -- 通过 `enrichInputs()` 注入 userId(场景注入因 sceneCode 为空自动跳过) -- 根据 `provider.getProviderType()` 查找对应的 `AiProviderAdapter`,调用 `adapter.stream()` -- 记录 callLog,`sceneCode` 字段留空,`endpointCode` 填当前 endpoint 编码 -- 复用 `AiStreamEvent` 事件体系和 `AiRuntimeTestResponse` 响应结构 +**`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 @@ -89,18 +97,81 @@ export interface AiEndpointRuntimeRequest { ``` **API 层**(`web-admin/src/api/aiconfig.ts`): + +先将现有 `streamAiRuntime` 的 SSE fetch 逻辑提取为共享辅助函数 `fetchSseStream`,避免复制粘贴 ~50 行代码: + ```typescript -// 非流式 endpoint 测试 -export function testEndpointRuntime(data: AiEndpointRuntimeRequest) { - return request({ url: '/ai/endpoint/test', method: 'post', data, timeout: 60000 }) +// 共享 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) } -// 流式 endpoint 测试(复用 streamAiRuntime 的 SSE 解析逻辑,只是 endpoint 不同) export async function streamEndpointRuntime( data: AiEndpointRuntimeRequest, onEvent: (event: AiRuntimeStreamEvent, output: string) => void ) { - // 内部实现与 streamAiRuntime 相同,只是 URL 为 /ai/endpoint/stream + 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 }) } ``` @@ -112,23 +183,176 @@ export async function streamEndpointRuntime( ```vue 测试 ``` -- 新增函数 `openSceneRuntimeTest(row)`:打开现有 `testDialog`,自动填入 `row.sceneCode` +- 新增函数 `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`,标题「接口工作流测试」 -- 对话框内容: - - 接口名称(只读文本,显示 `row.endpointName` + `row.endpointCode`) - - 入参 JSON 框,**自动填入该 endpoint 的 `defaultInputs`**(如果有且为合法 JSON),否则填默认模板 `{}` - - 非流式/流式测试结果展示区(复用现有 `` + `
` 样式)
-  - 底部:「关闭」「非流式测试」「流式测试」按钮
-- 使用独立的响应式状态变量:`endpointTesting`、`endpointTestResult`、`endpointNonStreamResult`、`endpointInputsJson`
-- 两个对话框可共存,状态互不干扰
+- 新增对话框 `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 数据流 @@ -146,7 +370,8 @@ export async function streamEndpointRuntime( **endpoint 测试的 enrichInputs 行为**: - `AiRuntimeRequest` 中 `sceneCode` 为空 → `enrichSceneInputs()` 跳过 -- `userId` / `userName` / `userType` / `requestId` 仍通过 `withCurrentUser` + `enrichInputs` 注入 +- `userId` / `userName` / `userType` / `requestId` 由 `AiRuntimeServiceImpl` 测试方法显式从 `UserContextHolder` 设置到 request 上,再通过 `enrichInputs()` 注入到 inputs +- Controller 层不调用 `withCurrentUser()`(endpoint 测试走独立的简洁路径) - `socialInsightContext` 等场景级注入不执行(因为 `sceneCode` 为空) ### 2.4 响应字段说明 @@ -168,7 +393,7 @@ export async function streamEndpointRuntime( ## 4. 风险 - endpoint 测试不经过场景绑定,因此不会有场景相关的输入注入(如 socialInsightContext)。这是预期行为——endpoint 测试关注的是接口本身是否通 -- endpoint 的 `defaultInputs` 可能不完整或格式不合法,对话框打开时会尝试 JSON.parse,失败时回退到 `{}` +- endpoint 的 `defaultInputs` 可能不完整、格式不合法或为非对象值(如字符串 `"123"`、数字、null)。对话框打开时 `JSON.parse` 后增加类型检查(必须是普通对象且非数组),任何失败均回退到 `{}` - 接口工作流表的「测试」按钮在 `isEnabled !== 1` 时禁用,避免无效调用 - 新增两个后端接口,需确保权限校验和现有 `/ai/runtime/*` 接口一致(走同一个 Admin 鉴权) - 操作列宽度从 150 调整到 220,确保三按钮(编辑/删除/测试)不重叠