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

16 KiB
Raw Blame History

author, created_at, purpose
author created_at purpose
华钟民 2026-05-23 在 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 接口

// 非流式 endpoint 测试
AiRuntimeTestResponse testEndpoint(String endpointId, Map<String, Object> inputs);

// 流式 endpoint 测试(SSE 回调)
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> consumer);

AiRuntimeServiceImpl 实现testEndpointinvokeEndpointStream 共用以下逻辑):

  1. 根据 endpointId 查 endpointgetEnabledByIdnull 时抛 AI_ENDPOINT_DISABLED
  2. 查 providergetEnabledByIdnull 时抛 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. 记录 callLogsceneCode 留空,endpointCode 填 endpoint 编码,providerCode 填 provider 编码)
  7. 复用 AiStreamEvent 事件体系和 AiRuntimeTestResponse 响应结构

AiRoutingController 新增接口

@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):

export interface AiEndpointRuntimeRequest {
  endpointId: string
  inputs: Record<string, any>
}

API 层web-admin/src/api/aiconfig.ts):

先将现有 streamAiRuntime 的 SSE fetch 逻辑提取为共享辅助函数 fetchSseStream,避免复制粘贴 ~50 行代码:

// 共享 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 都委托给这个辅助函数:

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):

export function testEndpointRuntime(data: AiEndpointRuntimeRequest) {
  return request({ url: '/ai/endpoint/test', method: 'post', data, timeout: 60000 })
}

UI 层AiRoutingList.vue):

场景绑定表改造

  • 操作列宽度从 150 调整为 220
  • 每行操作列增加「测试」按钮:
<el-button link type="success" @click="openSceneRuntimeTest(row)">测试</el-button>
  • 新增函数 openSceneRuntimeTest(row)
function openSceneRuntimeTest(row: AiSceneBinding) {
  testForm.sceneCode = row.sceneCode
  testResult.value = null
  nonStreamResult.value = null
  testDialog.value = true
}
  • 现有顶部工具栏的「流式测试」按钮保留不变

接口工作流表改造

  • 操作列宽度从 150 调整为 220
  • 每行操作列增加「测试」按钮(endpoint 禁用时按钮也禁用):
<el-button link type="success" :disabled="row.isEnabled !== 1" @click="openEndpointTest(row)">测试</el-button>
  • 新增对话框 endpointTestDialog(独立于 testDialog),标题「接口工作流测试」
  • 对话框 template 结构:
<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 的状态互不干扰):
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) 函数实现:
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):
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):
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 中新增引入 testEndpointRuntimestreamEndpointRuntime

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 行为

  • AiRuntimeRequestsceneCode 为空 → enrichSceneInputs() 跳过
  • userId / userName / userType / requestIdAiRuntimeServiceImpl 测试方法显式从 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,确保三按钮(编辑/删除/测试)不重叠