diff --git a/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java index 2e28f08..124da39 100644 --- a/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java +++ b/backend-single/src/main/java/com/emotion/service/ai/CozeProviderAdapter.java @@ -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; diff --git a/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java b/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java index 910f0c6..4501f07 100644 --- a/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java +++ b/backend-single/src/main/java/com/emotion/service/ai/DifyProviderAdapter.java @@ -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; diff --git a/docs/superpowers/plans/2026-05-23-ai-test-output-rendering-fix.md b/docs/superpowers/plans/2026-05-23-ai-test-output-rendering-fix.md new file mode 100644 index 0000000..bbca74c --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ai-test-output-rendering-fix.md @@ -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 `
` 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 `
` 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.
diff --git a/docs/superpowers/specs/2026-05-23-ai-test-output-rendering-design.md b/docs/superpowers/specs/2026-05-23-ai-test-output-rendering-design.md
new file mode 100644
index 0000000..51671ac
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-23-ai-test-output-rendering-design.md
@@ -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。
diff --git a/web-admin/package-lock.json b/web-admin/package-lock.json
index 6855c56..5477158 100644
--- a/web-admin/package-lock.json
+++ b/web-admin/package-lock.json
@@ -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",
diff --git a/web-admin/package.json b/web-admin/package.json
index a34e84f..b013356 100644
--- a/web-admin/package.json
+++ b/web-admin/package.json
@@ -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",
diff --git a/web-admin/src/api/aiconfig.ts b/web-admin/src/api/aiconfig.ts
index c28b344..37b04f4 100644
--- a/web-admin/src/api/aiconfig.ts
+++ b/web-admin/src/api/aiconfig.ts
@@ -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,
@@ -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') {
diff --git a/web-admin/src/components/MarkdownPreview.vue b/web-admin/src/components/MarkdownPreview.vue
new file mode 100644
index 0000000..d25d50f
--- /dev/null
+++ b/web-admin/src/components/MarkdownPreview.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
diff --git a/web-admin/src/views/aiconfig/AiRoutingList.vue b/web-admin/src/views/aiconfig/AiRoutingList.vue
index c5aa3bf..fb4ce05 100644
--- a/web-admin/src/views/aiconfig/AiRoutingList.vue
+++ b/web-admin/src/views/aiconfig/AiRoutingList.vue
@@ -272,14 +272,20 @@
         :title="nonStreamResult.status === 'success' ? '非流式测试成功' : '非流式测试失败'"
         show-icon
       />
-      
{{ nonStreamResult.output || nonStreamResult.errorMessage || '暂无输出' }}
+
+ +
{{ testOutput(nonStreamResult) }}
+
-
{{ testResult.output || testResult.errorMessage || '暂无输出' }}
+
+ +
{{ testOutput(testResult) }}
+