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:
2026-05-23 16:24:07 +08:00
parent d3746fa6c7
commit bdb4fd8c8e
9 changed files with 479 additions and 17 deletions
+31 -1
View File
@@ -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>
+45 -5
View File
@@ -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 {