# 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

| 文件 | 职责 | 操作 |
|------|------|------|
| `server/src/main/java/com/emotion/dto/request/ai/AiRuntimeRequest.java` | DTO：新增 endpointId 字段，更新 RESERVED_KEYS | 修改 |
| `server/src/main/java/com/emotion/service/AiRuntimeService.java` | Service 接口：新增 testEndpoint + invokeEndpointStream | 修改 |
| `server/src/main/java/com/emotion/service/impl/AiRuntimeServiceImpl.java` | Service 实现：新增两个方法的完整实现 | 修改 |
| `server/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: `server/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 server && mvn clean compile -pl :server -am -DskipTests`
Expected: BUILD SUCCESS（endpointId 字段新增不破坏任何编译，因为这是新增字段）

- [ ] **Step 4: Commit**

```bash
git add server/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: `server/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 server && mvn clean compile -pl :server -am -DskipTests`
Expected: BUILD SUCCESS（接口新增方法，但实现类尚未修改，此时不会编译失败 — 因为 ServiceImpl 编译是单独的任务）

- [ ] **Step 3: Commit**

```bash
git add server/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: `server/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 server && mvn clean compile -pl :server -am -DskipTests`
Expected: BUILD SUCCESS

- [ ] **Step 4: Commit**

```bash
git add server/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: `server/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 server && mvn clean compile -pl :server -am -DskipTests`
Expected: BUILD SUCCESS

- [ ] **Step 3: Commit**

```bash
git add server/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` 字段名在后端和前端一致
