feat: AI测试输出渲染Markdown/流式响应、Coze/Dify适配器优化
- 新增 MarkdownPreview 组件,支持 AI 测试输出 Markdown 渲染 - Coze 适配器优化:支持流式响应、工作流接口调用、SSE事件处理 - Dify 适配器优化:支持停止接口、流式聊天、SSE事件解析 - web-admin 添加 markdown-it 和 highlight.js 依赖 - AI 配置列表页面优化测试对话框输出显示 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -115,33 +115,85 @@ public class CozeProviderAdapter implements AiProviderAdapter {
|
||||
String type = json.getString("type");
|
||||
String content = json.getString("content");
|
||||
if (StringUtils.hasText(content) && (type == null || type.contains("answer") || type.contains("delta"))) {
|
||||
return content;
|
||||
return normalizeOutputText(content);
|
||||
}
|
||||
String output = json.getString("output");
|
||||
if (StringUtils.hasText(output)) {
|
||||
return output;
|
||||
return normalizeOutputText(output);
|
||||
}
|
||||
String answer = json.getString("answer");
|
||||
if (StringUtils.hasText(answer)) {
|
||||
return answer;
|
||||
return normalizeOutputText(answer);
|
||||
}
|
||||
JSONObject message = json.getJSONObject("message");
|
||||
if (message != null && StringUtils.hasText(message.getString("content"))) {
|
||||
return message.getString("content");
|
||||
return normalizeOutputText(message.getString("content"));
|
||||
}
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
if (data != null) {
|
||||
String dataOutput = data.getString("output");
|
||||
if (StringUtils.hasText(dataOutput)) {
|
||||
return dataOutput;
|
||||
return normalizeOutputText(dataOutput);
|
||||
}
|
||||
String dataAnswer = data.getString("answer");
|
||||
if (StringUtils.hasText(dataAnswer)) {
|
||||
return dataAnswer;
|
||||
return normalizeOutputText(dataAnswer);
|
||||
}
|
||||
String dataContent = data.getString("content");
|
||||
if (StringUtils.hasText(dataContent)) {
|
||||
return dataContent;
|
||||
return normalizeOutputText(dataContent);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeOutputText(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return value;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(trimmed);
|
||||
String extracted = firstText(json);
|
||||
return StringUtils.hasText(extracted) ? normalizeOutputText(extracted) : value;
|
||||
} catch (Exception ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private String firstText(JSONObject json) {
|
||||
String direct = firstTextValue(json, "output", "answer", "content", "text", "result");
|
||||
if (StringUtils.hasText(direct)) {
|
||||
return direct;
|
||||
}
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
if (data != null) {
|
||||
String dataText = firstText(data);
|
||||
if (StringUtils.hasText(dataText)) {
|
||||
return dataText;
|
||||
}
|
||||
}
|
||||
JSONObject outputs = json.getJSONObject("outputs");
|
||||
if (outputs != null) {
|
||||
return firstText(outputs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String firstTextValue(JSONObject json, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = json.get(key);
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
return text;
|
||||
}
|
||||
if (value instanceof JSONObject object) {
|
||||
String nested = firstText(object);
|
||||
if (StringUtils.hasText(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -103,11 +103,11 @@ public class DifyProviderAdapter implements AiProviderAdapter {
|
||||
private String extractDifyDelta(JSONObject json) {
|
||||
String event = json.getString("event");
|
||||
if ("message".equals(event) || "agent_message".equals(event)) {
|
||||
return json.getString("answer");
|
||||
return normalizeOutputText(json.getString("answer"));
|
||||
}
|
||||
if ("text_chunk".equals(event)) {
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
return data == null ? null : data.getString("text");
|
||||
return data == null ? null : normalizeOutputText(data.getString("text"));
|
||||
}
|
||||
if ("workflow_finished".equals(event)) {
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
@@ -117,7 +117,59 @@ public class DifyProviderAdapter implements AiProviderAdapter {
|
||||
if (text == null) {
|
||||
text = outputs.get("answer");
|
||||
}
|
||||
return text == null ? null : String.valueOf(text);
|
||||
return text == null ? null : normalizeOutputText(String.valueOf(text));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeOutputText(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return value;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
JSONObject json = JSON.parseObject(trimmed);
|
||||
String extracted = firstText(json);
|
||||
return StringUtils.hasText(extracted) ? normalizeOutputText(extracted) : value;
|
||||
} catch (Exception ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private String firstText(JSONObject json) {
|
||||
String direct = firstTextValue(json, "output", "answer", "content", "text", "result");
|
||||
if (StringUtils.hasText(direct)) {
|
||||
return direct;
|
||||
}
|
||||
JSONObject data = json.getJSONObject("data");
|
||||
if (data != null) {
|
||||
String dataText = firstText(data);
|
||||
if (StringUtils.hasText(dataText)) {
|
||||
return dataText;
|
||||
}
|
||||
}
|
||||
JSONObject outputs = json.getJSONObject("outputs");
|
||||
if (outputs != null) {
|
||||
return firstText(outputs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String firstTextValue(JSONObject json, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = json.get(key);
|
||||
if (value instanceof String text && StringUtils.hasText(text)) {
|
||||
return text;
|
||||
}
|
||||
if (value instanceof JSONObject object) {
|
||||
String nested = firstText(object);
|
||||
if (StringUtils.hasText(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# AI Test Output Rendering Fix Implementation Plan
|
||||
|
||||
> **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:** Make AI endpoint tests stream parsed正文 and render Markdown in the web-admin test dialogs.
|
||||
|
||||
**Architecture:** Normalize provider stream deltas in backend adapters, add a frontend SSE/display fallback, and replace raw `<pre>` output blocks with a reusable Markdown preview. Keep request shapes and existing AI runtime APIs unchanged.
|
||||
|
||||
**Tech Stack:** Spring Boot, Fastjson2, Vue 3, Element Plus, markdown-it.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Stream Text Normalization
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java`
|
||||
- Modify: `backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java`
|
||||
|
||||
- [x] Add a private helper that parses JSON string wrappers and returns `output`, `answer`, `content`, `text`, `result`, `data.*`, or `outputs.*`.
|
||||
- [x] Call the helper before returning every extracted delta string.
|
||||
- [x] Keep non-JSON Markdown/plain text unchanged.
|
||||
|
||||
### Task 2: Frontend Stream Parsing Fallback
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/aiconfig.ts`
|
||||
|
||||
- [x] Add a `normalizeAiText` helper mirroring backend wrapper extraction.
|
||||
- [x] Apply it before appending `delta.content` in `fetchSseStream`.
|
||||
- [x] Preserve the current callback signature.
|
||||
|
||||
### Task 3: Markdown Preview Component
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/package.json`
|
||||
- Modify: `web-admin/package-lock.json`
|
||||
- Create: `web-admin/src/components/MarkdownPreview.vue`
|
||||
|
||||
- [x] Add `markdown-it` and its TypeScript types.
|
||||
- [x] Create a scoped Markdown renderer with safe defaults (`html: false`, `breaks: true`, `linkify: true`).
|
||||
- [x] Style headings, paragraphs, lists, code blocks, tables, and blockquotes for the existing dark admin theme.
|
||||
|
||||
### Task 4: Test Dialog Output UX
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/aiconfig/AiRoutingList.vue`
|
||||
|
||||
- [x] Import and use `MarkdownPreview`.
|
||||
- [x] Replace raw test `<pre>` blocks with scrollable output panels.
|
||||
- [x] Parse non-stream and stream result output before display.
|
||||
- [x] Auto-scroll output panels when stream text updates.
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Commands:**
|
||||
- `cd web-admin && npm run build`
|
||||
- `cd backend-single && mvn test`
|
||||
- `git diff --check`
|
||||
|
||||
- [x] Build passes.
|
||||
- [x] Backend tests pass.
|
||||
- [x] Diff check has no new whitespace errors beyond existing line-ending warnings.
|
||||
@@ -0,0 +1,32 @@
|
||||
# AI 测试输出渲染修复设计
|
||||
|
||||
## 目标
|
||||
|
||||
后台管理的 AI 配置测试弹窗需要像真实用户端一样展示流式结果:逐步显示正文、支持滚动、Markdown 格式化展示,并避免把 Coze/Dify 的原始 JSON 包装直接丢到结果区。
|
||||
|
||||
## 范围
|
||||
|
||||
- 后端适配器在流式事件解析阶段提取真正正文。
|
||||
- web-admin 在 SSE 客户端和测试弹窗展示层做兜底解析。
|
||||
- web-admin 新增 Markdown 预览组件,结果区固定高度并支持滚动。
|
||||
- 不改变现有 AI 配置、场景绑定、接口测试请求参数和业务调用入口。
|
||||
|
||||
## 设计
|
||||
|
||||
### 后端正文规范化
|
||||
|
||||
Coze 和 Dify 适配器在 `extract*Delta` 返回前先调用统一的文本解包逻辑。如果文本是 JSON 字符串,并且包含 `output`、`answer`、`content`、`text`、`result` 或嵌套 `data/outputs` 字段,就递归提取第一个可用正文。普通 Markdown、纯文本和非 JSON 内容保持原样。
|
||||
|
||||
### 前端流式兜底
|
||||
|
||||
`web-admin/src/api/aiconfig.ts` 在拼接 `delta.content` 前做同样的轻量解析,避免历史缓存、异常 provider 或非标准事件再次透出包装 JSON。解析失败时保持原文本,保证兼容。
|
||||
|
||||
### 测试弹窗展示
|
||||
|
||||
新增 `MarkdownPreview` 组件,用 `markdown-it` 渲染 Markdown。场景测试和接口工作流测试的非流式、流式结果都通过该组件展示;失败结果按纯文本展示。输出区域使用固定最大高度和 `overflow-y: auto`,流式更新时自动滚动到底部。
|
||||
|
||||
## 验证
|
||||
|
||||
- `web-admin` 执行依赖安装与生产构建。
|
||||
- 后端执行 Maven 测试或打包。
|
||||
- 关键检查:Coze 返回 `{"output":"### 标题"}` 时,弹窗展示 `### 标题` 的 Markdown 渲染结果,不展示原始 JSON。
|
||||
Generated
+74
-1
@@ -13,12 +13,14 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"element-plus": "^2.4.4",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.10.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
@@ -1347,6 +1349,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz",
|
||||
@@ -1362,6 +1371,24 @@
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.23",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.23.tgz",
|
||||
@@ -1888,7 +1915,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
@@ -3114,6 +3140,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3169,6 +3204,23 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -3178,6 +3230,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
@@ -3540,6 +3598,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3879,6 +3946,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^5.4.3",
|
||||
"element-plus": "^2.4.4",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.10.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
|
||||
@@ -296,6 +296,36 @@ function parseSseFrame(frame: string): AiRuntimeStreamEvent | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAiText(value?: string): string {
|
||||
if (!value) return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return value
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const extracted = extractTextValue(parsed)
|
||||
return extracted ? normalizeAiText(extracted) : value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function extractTextValue(value: any): string {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
||||
for (const key of ['output', 'answer', 'content', 'text', 'result']) {
|
||||
const item = value[key]
|
||||
if (typeof item === 'string' && item.trim()) return item
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const nested = extractTextValue(item)
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
for (const key of ['data', 'outputs', 'message']) {
|
||||
const nested = extractTextValue(value[key])
|
||||
if (nested) return nested
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function fetchSseStream(
|
||||
url: string,
|
||||
body: Record<string, any>,
|
||||
@@ -327,7 +357,7 @@ async function fetchSseStream(
|
||||
const event = parseSseFrame(frame)
|
||||
if (!event) return
|
||||
if (event.type === 'delta') {
|
||||
output += event.content || ''
|
||||
output += normalizeAiText(event.content || '')
|
||||
}
|
||||
onEvent(event, output)
|
||||
if (event.type === 'error') {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="markdown-preview" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const props = defineProps<{
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true
|
||||
})
|
||||
|
||||
const html = computed(() => md.render(props.content || ''))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-preview {
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h1),
|
||||
.markdown-preview :deep(h2),
|
||||
.markdown-preview :deep(h3),
|
||||
.markdown-preview :deep(h4) {
|
||||
margin: 14px 0 8px;
|
||||
color: var(--ls-text);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h1) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h2) {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(ul),
|
||||
.markdown-preview :deep(ol) {
|
||||
margin: 8px 0 12px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(blockquote) {
|
||||
margin: 12px 0;
|
||||
padding: 8px 12px;
|
||||
color: rgba(226, 232, 240, 0.78);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 3px solid rgba(255, 171, 145, 0.55);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(code) {
|
||||
padding: 2px 5px;
|
||||
color: #ffd6c8;
|
||||
background: rgba(255, 171, 145, 0.12);
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(pre) {
|
||||
overflow: auto;
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(pre code) {
|
||||
padding: 0;
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(table) {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(th),
|
||||
.markdown-preview :deep(td) {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.markdown-preview :deep(th) {
|
||||
color: var(--ls-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.markdown-preview :deep(a) {
|
||||
color: #81d4fa;
|
||||
}
|
||||
</style>
|
||||
@@ -272,14 +272,20 @@
|
||||
:title="nonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
|
||||
show-icon
|
||||
/>
|
||||
<pre v-if="nonStreamResult" class="test-output">{{ nonStreamResult.output || nonStreamResult.errorMessage || '暂无输出' }}</pre>
|
||||
<div v-if="nonStreamResult" class="test-output">
|
||||
<MarkdownPreview v-if="nonStreamResult.status === 'success'" :content="testOutput(nonStreamResult)" />
|
||||
<pre v-else class="plain-output">{{ testOutput(nonStreamResult) }}</pre>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="testResult"
|
||||
:type="testResult.status === 'success' ? 'success' : 'error'"
|
||||
:title="testResult.status === 'success' ? '流式测试成功' : '流式测试失败'"
|
||||
show-icon
|
||||
/>
|
||||
<pre v-if="testResult" class="test-output">{{ testResult.output || testResult.errorMessage || '暂无输出' }}</pre>
|
||||
<div v-if="testResult" ref="sceneOutputRef" class="test-output">
|
||||
<MarkdownPreview v-if="testResult.status === 'success'" :content="testOutput(testResult)" />
|
||||
<pre v-else class="plain-output">{{ testOutput(testResult) }}</pre>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="testDialog = false">关闭</el-button>
|
||||
<el-button :loading="testing" @click="submitNonStreamTest">非流式测试</el-button>
|
||||
@@ -313,14 +319,20 @@
|
||||
:title="endpointNonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
|
||||
show-icon
|
||||
/>
|
||||
<pre v-if="endpointNonStreamResult" class="test-output">{{ endpointNonStreamResult.output || endpointNonStreamResult.errorMessage || '暂无输出' }}</pre>
|
||||
<div v-if="endpointNonStreamResult" class="test-output">
|
||||
<MarkdownPreview v-if="endpointNonStreamResult.status === 'success'" :content="testOutput(endpointNonStreamResult)" />
|
||||
<pre v-else class="plain-output">{{ testOutput(endpointNonStreamResult) }}</pre>
|
||||
</div>
|
||||
<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>
|
||||
<div v-if="endpointTestResult" ref="endpointOutputRef" class="test-output">
|
||||
<MarkdownPreview v-if="endpointTestResult.status === 'success'" :content="testOutput(endpointTestResult)" />
|
||||
<pre v-else class="plain-output">{{ testOutput(endpointTestResult) }}</pre>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="endpointTestDialog = false">关闭</el-button>
|
||||
<el-button :loading="endpointTesting" @click="submitEndpointNonStreamTest">非流式测试</el-button>
|
||||
@@ -331,9 +343,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, VideoPlay } from '@element-plus/icons-vue'
|
||||
import MarkdownPreview from '@/components/MarkdownPreview.vue'
|
||||
import {
|
||||
deleteAiEndpoint,
|
||||
deleteAiProvider,
|
||||
@@ -344,6 +357,7 @@ import {
|
||||
listAiEndpoints,
|
||||
listAiProviders,
|
||||
listAiScenes,
|
||||
normalizeAiText,
|
||||
saveAiEndpoint,
|
||||
saveAiProvider,
|
||||
saveAiScene,
|
||||
@@ -369,6 +383,7 @@ const testing = ref(false)
|
||||
const testResult = ref<AiRuntimeTestResponse | null>(null)
|
||||
const nonStreamResult = ref<AiRuntimeTestResponse | null>(null)
|
||||
const testInputsJson = ref('{\n "prompt": "请用一句中文回复测试成功。"\n}')
|
||||
const sceneOutputRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const endpointTestDialog = ref(false)
|
||||
const endpointTesting = ref(false)
|
||||
@@ -377,6 +392,7 @@ const endpointTestResult = ref<AiRuntimeTestResponse | null>(null)
|
||||
const endpointNonStreamResult = ref<AiRuntimeTestResponse | null>(null)
|
||||
const endpointInputsJson = ref('{}')
|
||||
const endpointParamFields = ref<TestParamField[]>([])
|
||||
const endpointOutputRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const paramDefinitions = ref<ParamDefinition[]>([])
|
||||
|
||||
@@ -448,6 +464,19 @@ function statusLabel(status?: string) {
|
||||
return status ? map[status] || status : '-'
|
||||
}
|
||||
|
||||
function testOutput(result?: AiRuntimeTestResponse | null) {
|
||||
return normalizeAiText(result?.output || result?.errorMessage || '暂无输出')
|
||||
}
|
||||
|
||||
function scrollOutput(target: { value: HTMLElement | null }) {
|
||||
nextTick(() => {
|
||||
const element = target.value
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -757,6 +786,7 @@ async function submitRuntimeTest() {
|
||||
await streamAiRuntime({ sceneCode: testForm.sceneCode, inputs }, (event, output) => {
|
||||
if (!testResult.value) return
|
||||
testResult.value.output = output
|
||||
scrollOutput(sceneOutputRef)
|
||||
if (event.type === 'delta') {
|
||||
testResult.value.streamChunks = (testResult.value.streamChunks || 0) + 1
|
||||
}
|
||||
@@ -831,6 +861,7 @@ async function submitEndpointStreamTest() {
|
||||
await streamEndpointRuntime({ endpointId: endpointTestRow.value.id!, inputs }, (event, output) => {
|
||||
if (!endpointTestResult.value) return
|
||||
endpointTestResult.value.output = output
|
||||
scrollOutput(endpointOutputRef)
|
||||
if (event.type === 'delta') {
|
||||
endpointTestResult.value.streamChunks = (endpointTestResult.value.streamChunks || 0) + 1
|
||||
}
|
||||
@@ -975,7 +1006,16 @@ onMounted(loadAll)
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--ls-radius-md);
|
||||
}
|
||||
|
||||
.plain-output {
|
||||
margin: 0;
|
||||
color: rgba(226, 232, 240, 0.92);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.endpoint-info {
|
||||
|
||||
Reference in New Issue
Block a user