docs: 添加 AI 配置行内测试实施计划
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> 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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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<AiStreamEvent> consumer);
|
||||||
|
|
||||||
|
AiRuntimeTestResponse test(AiRuntimeRequest request);
|
||||||
|
|
||||||
|
// 新增 ↓
|
||||||
|
AiRuntimeTestResponse testEndpoint(String endpointId, Map<String, Object> inputs);
|
||||||
|
|
||||||
|
void invokeEndpointStream(String endpointId, Map<String, Object> inputs, Consumer<AiStreamEvent> 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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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<String, Object> 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<String, Object> inputs, Consumer<AiStreamEvent> 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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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<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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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<string, any>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-admin/src/types/aiconfig.ts
|
||||||
|
git commit -m "feat: 前端类型定义新增 AiEndpointRuntimeRequest
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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<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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Vue 组件 — 场景绑定表增加行内测试按钮
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改场景绑定表操作列宽度和新增测试按钮**
|
||||||
|
|
||||||
|
将第 137-142 行(场景绑定表操作列)从:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openScene(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeScene(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
```
|
||||||
|
|
||||||
|
替换为:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openScene(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeScene(row)">删除</el-button>
|
||||||
|
<el-button link type="success" @click="openSceneRuntimeTest(row)">测试</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Vue 组件 — 接口工作流表增加行内测试按钮
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改接口工作流表操作列宽度和新增测试按钮**
|
||||||
|
|
||||||
|
将第 100-105 行(接口工作流表操作列)从:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-table-column label="操作" width="150" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openEndpoint(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeEndpoint(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
```
|
||||||
|
|
||||||
|
替换为:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openEndpoint(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="removeEndpoint(row)">删除</el-button>
|
||||||
|
<el-button link type="success" :disabled="row.isEnabled !== 1" @click="openEndpointTest(row)">测试</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-admin/src/views/aiconfig/AiRoutingList.vue
|
||||||
|
git commit -m "feat: 接口工作流表增加行内测试按钮
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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<AiEndpointConfig | null>(null)
|
||||||
|
const endpointTestResult = ref<AiRuntimeTestResponse | null>(null)
|
||||||
|
const endpointNonStreamResult = ref<AiRuntimeTestResponse | null>(null)
|
||||||
|
const endpointInputsJson = ref('{}')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 新增 endpoint 测试对话框 template**
|
||||||
|
|
||||||
|
在现有测试对话框 `</el-dialog>` 标签(第 269 行)之后,添加新的 endpoint 测试对话框:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 新增 submitEndpointStreamTest 函数**
|
||||||
|
|
||||||
|
在 `submitEndpointNonStreamTest()` 之后,添加:
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: 新增 endpoint-info 样式**
|
||||||
|
|
||||||
|
在 `<style scoped>` 部分末尾(`.test-output` 样式之后),添加:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.endpoint-info {
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: rgba(226, 232, 240, 0.85);
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: var(--ls-radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-label {
|
||||||
|
color: var(--ls-text-muted);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: 编译验证前端**
|
||||||
|
|
||||||
|
Run: `cd web-admin && npm run type-check`
|
||||||
|
Expected: 无类型错误
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web-admin/src/views/aiconfig/AiRoutingList.vue
|
||||||
|
git commit -m "feat: 新增接口工作流测试对话框(流式+非流式)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: 浏览器验证
|
||||||
|
|
||||||
|
- [ ] **Step 1: 启动 web-admin 开发服务器**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-admin && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 登录管理后台**
|
||||||
|
|
||||||
|
打开浏览器访问 `http://localhost:5174/emotion-museum-admin/`
|
||||||
|
|
||||||
|
使用账号登录:
|
||||||
|
- 手机号:19928748688
|
||||||
|
- 短信验证码:123456
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证场景绑定表行内测试**
|
||||||
|
|
||||||
|
1. 导航到「AI 配置管理」页面
|
||||||
|
2. 点击「场景绑定」Tab
|
||||||
|
3. 确认每行操作列有「编辑」「删除」「测试」三个按钮
|
||||||
|
4. 点击某行的「测试」按钮,确认测试对话框打开且自动填入该行的 sceneCode
|
||||||
|
5. 输入入参 `{"prompt": "请用一句中文回复测试成功。"}`
|
||||||
|
6. 点击「非流式测试」,确认返回成功结果
|
||||||
|
7. 点击「流式测试」,确认流式输出正常
|
||||||
|
8. 关闭对话框,点击另一行的「测试」按钮,确认结果已清空且 sceneCode 已更新
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证接口工作流表行内测试**
|
||||||
|
|
||||||
|
1. 点击「接口工作流」Tab
|
||||||
|
2. 确认每行操作列有「编辑」「删除」「测试」三个按钮
|
||||||
|
3. 确认禁用的 endpoint 的「测试」按钮是禁用状态
|
||||||
|
4. 点击某行(如 `dify.script.generate`)的「测试」按钮
|
||||||
|
5. 确认新对话框打开,标题「接口工作流测试」
|
||||||
|
6. 确认对话框顶部显示接口名称和编码
|
||||||
|
7. 确认入参 JSON 框已自动填入该 endpoint 的 defaultInputs(或 `{}`)
|
||||||
|
8. 输入入参,点击「非流式测试」,确认返回成功结果
|
||||||
|
9. 点击「流式测试」,确认流式输出正常
|
||||||
|
10. 关闭对话框,打开另一行的测试,确认结果已清空
|
||||||
|
|
||||||
|
- [ ] **Step 5: 验证控制台无报错**
|
||||||
|
|
||||||
|
确认浏览器 Console 没有任何错误
|
||||||
|
|
||||||
|
- [ ] **Step 6: 最终 Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: AI 配置管理页面增加行内测试功能(场景绑定+接口工作流)
|
||||||
|
|
||||||
|
- 场景绑定表每行增加「测试」按钮,自动填入 sceneCode
|
||||||
|
- 接口工作流表每行增加「测试」按钮,支持流式和非流式测试
|
||||||
|
- 后端新增 /ai/endpoint/test 和 /ai/endpoint/stream 接口
|
||||||
|
- 前端提取 fetchSseStream 共享辅助函数,避免代码重复
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
- ✅ 场景绑定表每行增加「测试」按钮 → Task 7
|
||||||
|
- ✅ 接口工作流表每行增加「测试」按钮 → Task 8
|
||||||
|
- ✅ 接口工作流测试对话框(流式+非流式)→ Task 9
|
||||||
|
- ✅ 后端 DTO 增强(endpointId)→ Task 1
|
||||||
|
- ✅ 后端 Service 接口新增方法 → Task 2
|
||||||
|
- ✅ 后端 Service 实现 → Task 3
|
||||||
|
- ✅ 后端 Controller 新增接口 → Task 4
|
||||||
|
- ✅ 前端类型定义 → Task 5
|
||||||
|
- ✅ 前端 API 层(fetchSseStream 提取 + endpoint 函数)→ Task 6
|
||||||
|
- ✅ 浏览器验证 → Task 10
|
||||||
|
|
||||||
|
**2. Placeholder scan:**
|
||||||
|
- ✅ 无 "TBD", "TODO", "implement later"
|
||||||
|
- ✅ 所有函数都有完整代码
|
||||||
|
- ✅ 无 "similar to Task N" 引用
|
||||||
|
|
||||||
|
**3. Type consistency:**
|
||||||
|
- ✅ `AiEndpointRuntimeRequest` 类型在 Task 5 定义,Task 6 和 Task 9 中使用一致
|
||||||
|
- ✅ `testEndpointRuntime` 和 `streamEndpointRuntime` 签名一致
|
||||||
|
- ✅ `endpointId` 字段名在后端和前端一致
|
||||||
Reference in New Issue
Block a user