docs: 修复 AI 配置行内测试设计文档 - 补充 DTO/类型定义/UI 细节
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String, Object> inputs
|
||||
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> 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<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)
|
||||
}
|
||||
|
||||
// 流式 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
|
||||
<el-button link type="success" @click="openSceneRuntimeTest(row)">测试</el-button>
|
||||
```
|
||||
- 新增函数 `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
|
||||
<el-button link type="success" :disabled="row.isEnabled !== 1" @click="openEndpointTest(row)">测试</el-button>
|
||||
```
|
||||
- 点击打开新对话框 `endpointTestDialog`,标题「接口工作流测试」
|
||||
- 对话框内容:
|
||||
- 接口名称(只读文本,显示 `row.endpointName` + `row.endpointCode`)
|
||||
- 入参 JSON 框,**自动填入该 endpoint 的 `defaultInputs`**(如果有且为合法 JSON),否则填默认模板 `{}`
|
||||
- 非流式/流式测试结果展示区(复用现有 `<el-alert>` + `<pre>` 样式)
|
||||
- 底部:「关闭」「非流式测试」「流式测试」按钮
|
||||
- 使用独立的响应式状态变量:`endpointTesting`、`endpointTestResult`、`endpointNonStreamResult`、`endpointInputsJson`
|
||||
- 两个对话框可共存,状态互不干扰
|
||||
- 新增对话框 `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 数据流
|
||||
|
||||
@@ -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,确保三按钮(编辑/删除/测试)不重叠
|
||||
|
||||
Reference in New Issue
Block a user