概述
本文档详细描述了 AI 项目中对话场景的实现方案,包括请求取消机制、流式响应处理以及多模态内容渲染等核心功能。这些功能共同确保了对话交互的流畅性、实时性和丰富性。
1. 请求取消机制
在对话场景中,用户可能需要中断当前正在进行的 AI 响应请求。我们利用AbortController
API 实现这一功能,它允许我们在请求完成前中止 fetch 请求。
1.1 实现原理
AbortController
提供了一个signal
属性,可传递给 fetch 请求。当调用abort()
方法时,关联的请求会被终止,从而取消网络请求并释放资源。
1.2 代码实现
1.2.1 创建 AbortController 实例
javascript
// 创建新的控制器实例,通常在Vue组件中使用ref存储
abortController.value = new AbortController();
1.2.2 在请求中使用控制器
javascript
// 在fetch请求中关联控制器的signal
const response = await fetch("/api-rag/v1/conversation/completion", {
method: "POST",
headers: {
"Content-Type": "text/event-stream"
},
body: JSON.stringify(data),
signal: abortController.value.signal // 关联信号
});
1.2.3 取消请求的方法
javascript
const cancelSend = () => {
try {
// 调用abort()方法取消请求
abortController.value.abort();
} catch (error) {
console.log("取消请求时发生错误:", error);
}
};
1.3 使用场景
- 用户发送新请求时,取消上一个未完成的请求
- 提供 "取消" 按钮允许用户主动中断当前响应
- 组件卸载前确保取消所有未完成的请求,防止内存泄漏
2. 流式响应处理
为了实现 AI 回复的实时展示(类似打字机效果),我们采用 Server-Sent Events (SSE) 技术,通过流式响应逐步返回内容。
2.1 实现原理
- 服务器端以
text/event-stream
格式返回数据,采用分块传输(Transfer-Encoding: chunked
) - 前端通过 fetch API 获取响应流
- 使用
ReadableStream
API 逐步读取并解析流数据 - 实时更新 UI 展示部分响应内容
2.2 代码实现
javascript
const processSSEStream = async () => {
try {
// 发起请求,使用之前创建的AbortController信号
const response = await fetch("/api-rag/v1/conversation/completion", {
method: "POST",
headers: {
"Content-Type": "text/event-stream"
},
body: JSON.stringify(data),
signal: abortController.value.signal
});
// 检查响应是否正常
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 获取读取器和解码器
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 用于存储未完成的行
// 循环读取流数据
while (true) {
const { done, value } = await reader.read();
// 流结束时退出循环
if (done) break;
// 解码并追加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 按行解析SSE数据(SSE格式每行以\n或\r\n分隔)
const lines = buffer.split(/\r?\n/);
// 保留最后一行(可能不完整)
buffer = lines.pop() || '';
// 处理每一行数据
for (const line of lines) {
// SSE数据行以"data: "开头
if (line.startsWith('data: ')) {
// 提取数据内容并更新响应文本
const data = line.substring(6);
responseText.value += data;
}
}
}
} catch (error) {
// 处理请求错误,特别是用户主动取消的情况
if (error.name !== 'AbortError') {
console.error("处理流式响应时出错:", error);
}
}
};
2.3 关键注意事项
- 确保服务器正确配置
text/event-stream
Content-Type - 实现适当的错误处理,区分用户主动取消和其他错误
- 处理不完整的数据块,使用缓冲区确保数据完整性
- 考虑添加超时机制,防止长时间无响应的情况
3. 多模态内容处理
AI 对话不仅包含纯文本,还可能涉及格式化文本、代码块、图片、流程图等多种内容形式。我们使用 markdown 作为基础格式,并通过自定义处理实现丰富的展示效果。
3.1 Markdown 解析配置
使用markdown-it
及其插件实现全面的 markdown 解析能力:
import MarkdownIt from "markdown-it";
import emoji from "markdown-it-emoji";
import deflist from "markdown-it-deflist";
import abbr from "markdown-it-abbr";
import footnote from "markdown-it-footnote";
import ins from "markdown-it-ins";
import mark from "markdown-it-mark";
import taskLists from "markdown-it-task-lists";
import container from "markdown-it-container";
import toc from "markdown-it-toc-done-right";
import mermaid from "@DatatracCorporation/markdown-it-mermaid";
import hljs from "highlight.js";
import "highlight.js/styles/default.css"; // 引入代码高亮样式
import markdownItHighlightjs from "markdown-it-highlightjs";
// 初始化markdown-it实例
const md = new MarkdownIt({
html: true, // 允许解析HTML
linkify: true, // 自动识别链接
typographer: true // 启用排版优化
})
.use(emoji) // 支持emoji
.use(deflist) // 支持定义列表
.use(abbr) // 支持缩写
.use(footnote) // 支持脚注
.use(ins) // 支持下划线
.use(mark) // 支持高亮
.use(taskLists) // 支持任务列表
.use(toc) // 支持目录
.use(mermaid) // 支持mermaid流程图
.use(markdownItHighlightjs); // 支持代码高亮
// 可以在这里注册自定义容器
md.use(container, 'custom', {
// 自定义容器配置
});
export default md;
3.2 HAST 到 VNode 的转换
为了将解析后的 markdown(HAST 格式)转换为可渲染的 Vue 组件 (VNode),我们实现了hastToVNode
函数:
import { h } from 'vue';
import MermaidParser from './components/MermaidParser.vue';
import CodeParser from './components/CodeParser.vue';
import DeepThinkBlock from './components/DeepThinkBlock.vue';
import katex from 'katex';
import 'katex/dist/katex.min.css';
const hastToVNode = node => {
if (!node) return null;
switch (node.type) {
case "root":
// 根节点渲染为div容器
return h(
"div",
{
class: "markdown-body"
},
node.children?.map(child => hastToVNode(child))
);
case "element":
// 处理代码块
if (node.tagName === "pre") {
const codeNode = node.children?.find(child => child.tagName === "code");
if (codeNode) {
// 处理mermaid流程图
if (codeNode.properties?.className?.includes("language-mermaid")) {
return h(MermaidParser, {
code: codeNode.children[0].value
});
}
// 处理数学公式块
if (codeNode.properties?.className?.includes("language-math")) {
return h("div", {
class: "math-block",
innerHTML: katex.renderToString(codeNode.children[0].value, {
displayMode: true,
throwOnError: false
})
});
}
// 处理其他代码块
// const lang = codeNode.properties?.className?.[0]?.replace("language-", "") || "";
// return h(CodeParser, { code: codeNode.children[0].value, lang });
}
}
// 处理行内代码
if (node.tagName === "code" && !node.properties?.className) {
return h(
"code",
{},
node.children?.map(child => hastToVNode(child))
);
}
// 处理行内数学公式
if (
node.tagName === "code" &&
node.properties?.className?.includes("math-inline")
) {
return h("span", {
class: "math-inline",
innerHTML: katex.renderToString(node.children[0].value, {
displayMode: false,
throwOnError: false
})
});
}
// 处理链接,添加target="_blank"
if (node.tagName === "a") {
return h(
"a",
{
...node.properties,
target: "_blank",
rel: "noopener noreferrer"
},
node.children?.map(child => hastToVNode(child))
);
}
// 处理提示框组件
if (
node.tagName === "div" &&
node.properties?.className?.includes("tooltip")
) {
return h(
node.tagName,
{
class: node.properties?.className,
"data-index": node.properties?.dataIndex
},
node.children?.map(child => hastToVNode(child))
);
}
// 处理自定义容器 deep-thinking
if (
node.tagName === "div" &&
node.properties?.className?.includes("deep-thinking")
) {
// 提取标题
const title =
node.children[0]?.type === "element" &&
node.children[0].tagName === "h3"
? node.children[0].children[0].value
: "";
// 处理容器内的内容(排除标题节点)
const contentNodes = title ? node.children.slice(1) : node.children;
const contentVNode = h(
"div",
{},
contentNodes.map(child => hastToVNode(child))
);
// 返回自定义组件
return h(DeepThinkBlock, {
title,
content: contentVNode
});
}
// 通用元素处理
return h(
node.tagName,
node.properties,
node.children?.map(child => hastToVNode(child))
);
case "text":
// 处理文本节点
return node.value.trim();
case "comment":
// 处理注释节点
return h("span", { class: "comment" }, `<!-- ${node.value} -->`);
default:
// 处理未知类型节点
return node.children
? h(
"span",
{},
node.children.map(child => hastToVNode(child))
)
: null;
}
};
export default hastToVNode;
3.3 自定义指令与容器处理
为了支持自定义格式的内容,我们使用remark-directive
插件处理自定义容器,并将其转换为对应的 Vue 组件。
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkDirective from 'remark-directive';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkGemoji from 'remark-gemoji';
import remarkRehype from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
// 自定义指令处理逻辑
const handleDirective = () => {
return tree => {
// 遍历AST节点,处理自定义容器
tree.children.forEach((node) => {
// 处理deep-thinking容器
if (node.type === "containerDirective" && node.name === "deep-thinking") {
// 将容器节点转换为带类名的div
node.data = {
hName: "div",
hProperties: {
className: `deep-thinking`
}
};
}
// 可以在这里添加更多自定义容器的处理逻辑
// if (node.type === "containerDirective" && node.name === "warning") {
// // 处理warning容器
// }
});
};
};
// 创建统一处理器
const processor = unified()
.use(remarkParse) // 解析markdown
.use(remarkDirective) // 注册指令插件
.use(handleDirective) // 应用自定义处理逻辑
.use(remarkBreaks) // 支持换行
.use(remarkGfm, { singleTilde: false }) // 支持GFM
.use(remarkMath) // 支持数学公式
.use(remarkGemoji) // 支持gemoji
.use(remarkRehype, { allowDangerousHtml: true }) // 转换为HAST
.use(rehypeRaw); // 支持原始HTML
export default processor;
3.4 多模态内容展示规则
-
文件展示:
- 非 markdown 格式的文件(如图片、文档)直接根据数组数据渲染在对话底部
- 图片和流程图等可视化内容渲染在文件列表上方
-
按钮展示:
- 根据不同的回答类型显示不同的操作按钮
- 例如:代码块显示 "复制" 按钮,图片显示 "下载" 按钮
-
自定义组件映射:
- 通过标签名(如
code
、mermaid
)映射到对应的自定义组件 - 在
hastToVNode
函数中实现组件转换逻辑
- 通过标签名(如
4. 综合应用示例
markdown组件
<template>
<div
ref="markdownContainer"
:class="
type === 'assistant'
? 'markdown w-full'
: 'markdown w-full flex justify-end'
"
>
<!-- 显示解析后的 Markdown 内容 -->
<!-- <div v-html="parsedMarkdown" /> -->
<component :is="VNodeTree" />
<el-popover
ref="popoverRef"
:visible="popoverVisible"
placement="top"
:virtual-ref="buttonRef"
trigger="hover"
virtual-triggering
width="460"
>
<div @mouseenter="mouseenterTip" @mouseleave="mouseleaveTip">
<div class="flex">
<div v-if="currentTooltip?.staticImg" class="w-20 mr-2">
<el-image
:src="currentTooltip?.staticImg"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="[currentTooltip?.staticImg]"
show-progress
fit="cover"
class="w-full"
@click.stop
/>
</div>
<el-scrollbar max-height="300px" style="flex: 1">
<div class="content" v-html="currentTooltip?.content" />
</el-scrollbar>
</div>
<div
class="w-full flex flex-row justify-start items-center mt-2 cursor-pointer"
style="color: #3f8b58"
@click="handleClickTooltip(currentTooltip)"
>
<img
v-if="
currentTooltip?.k_type !== 'cb_net' &&
currentTooltip?.retrieve_type === 'knowledge_retrieval' &&
currentTooltip?.url
"
:src="currentTooltip?.url"
class="w-6 h-6 mr-2"
/>
<component :is="getIcon()" v-else class="w-6 h-6 mr-2" />
<template
v-if="
currentTooltip?.k_type === 'cb_knowledge' ||
currentTooltip?.k_type === 'cb_api' ||
currentTooltip?.k_type === 'cb_periphery'
"
>
<el-tooltip
effect="dark"
:content="currentTooltip?.origin_doc_name"
placement="top"
>
<div class="truncate">{{ currentTooltip?.origin_doc_name }}</div>
</el-tooltip>
</template>
<template v-else>
<el-tooltip
effect="dark"
:content="currentTooltip?.document_name"
placement="top"
>
<div class="truncate">{{ currentTooltip?.document_name }}</div>
</el-tooltip>
</template>
</div>
</div>
</el-popover>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, watch, onUnmounted, h } from "vue";
// 引入 markdown-it
import MarkdownIt from "markdown-it";
import emoji from "markdown-it-emoji";
import deflist from "markdown-it-deflist";
import abbr from "markdown-it-abbr";
import footnote from "markdown-it-footnote";
import ins from "markdown-it-ins";
import mark from "markdown-it-mark";
import taskLists from "markdown-it-task-lists";
import container from "markdown-it-container";
import toc from "markdown-it-toc-done-right";
import mermaid from "@DatatracCorporation/markdown-it-mermaid";
import hljs from "highlight.js";
import "highlight.js/styles/default.css"; // 引入 highlight.js 的样式
import markdownItHighlightjs from "markdown-it-highlightjs";
// 获取当前路由信息
import { downloadDataApi } from "@/api/chat.ts";
import {
previewFileType,
fileTypeIconMap,
getFileType
} from "../../views/chat/utils/common.ts";
import DefaultFile from "@/assets/svg/defaultFile.svg?component";
import NetWork from "@/assets/svg/networkSource.svg?component";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkGemoji from "remark-gemoji";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import katex from "katex";
import "katex/dist/katex.min.css";
import remarkDirective from "remark-directive";
import MermaidParser from "./MermaidParser.vue";
import DeepThinkBlock from "./DeepThinkBlock.vue";
const props = defineProps({
content: String,
type: String,
reference: Object,
conversationid: String
});
// 自定义指令处理逻辑(将 :::custom 转换为带类名的 div)
const handleDirective = () => {
return tree => {
// 遍历 AST 节点,处理自定义容器
tree.children.forEach((node, index) => {
if (node.type === "containerDirective" && node.name === "deep-thinking") {
// 将容器节点转换为带类名的 div
node.data = {
hName: "div",
hProperties: {
className: `deep-thinking`
}
};
}
});
};
};
const processor = unified()
.use(remarkParse)
.use(remarkDirective) // 注册指令插件
.use(handleDirective) // 应用自定义处理逻辑
.use(remarkBreaks)
.use(remarkGfm, { singleTilde: false })
.use(remarkMath)
.use(remarkGemoji)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw);
const hastToVNode = node => {
if (!node) return null;
switch (node.type) {
case "root":
return h(
"div",
{
class: "markdown-body"
},
node.children?.map(child => hastToVNode(child))
);
case "element":
// 通过父级节点判断代码块类型,优先处理pre标签
if (node.tagName === "pre") {
const codeNode = node.children?.find(child => child.tagName === "code");
if (codeNode) {
// Mermaid
if (codeNode.properties?.className?.includes("language-mermaid")) {
return h(MermaidParser, { code: codeNode.children[0].value });
}
// Math
if (codeNode.properties?.className?.includes("language-math")) {
return h("div", {
class: "math-block",
innerHTML: katex.renderToString(codeNode.children[0].value, {
displayMode: true,
throwOnError: false
})
});
}
// 其它代码块
// const lang =
// codeNode.properties?.className?.[0]?.replace("language-", "") || "";
// return h(CodeParser, { code: codeNode.children[0].value, lang });
}
}
// 行内代码
if (node.tagName === "code" && !node.properties?.className) {
return h(
"code",
{},
node.children?.map(child => hastToVNode(child))
);
}
// 行内公式
if (
node.tagName === "code" &&
node.properties?.className?.includes("math-inline")
) {
return h("span", {
class: "math-inline",
innerHTML: katex.renderToString(node.children[0].value, {
displayMode: false,
throwOnError: false
})
});
}
// 配置a标签的target属性
if (node.tagName === "a") {
return h(
"a",
{
...node.properties,
target: "_blank",
rel: "noopener noreferrer"
},
node.children?.map(child => hastToVNode(child))
);
}
if (
node.tagName === "div" &&
node.properties?.className?.includes("tooltip")
) {
return h(
node.tagName,
{
class: node.properties?.className,
"data-index": node.properties?.dataIndex
},
node.children?.map(child => hastToVNode(child))
);
}
// 处理自定义容器 deep-thinking
if (
node.tagName === "div" &&
node.properties?.className?.includes("deep-thinking")
) {
const title =
node.children[0]?.type === "element" &&
node.children[0].tagName === "h3"
? node.children[0].children[0].value
: "";
// 处理容器内的内容(排除标题节点)
const contentNodes = title ? node.children.slice(1) : node.children;
const contentVNode = h(
"div",
{},
contentNodes.map(child => hastToVNode(child))
);
// 返回 WarningBlock 组件的 VNode
return h(DeepThinkBlock, {
title,
content: contentVNode
});
}
return h(
node.tagName,
node.properties,
node.children?.map(child => hastToVNode(child))
);
case "text":
return node.value.trim();
case "comment":
return h("span", { class: "comment" }, `<!-- ${node.value} -->`);
default:
// 对于未知类型的节点,如果有子节点则渲染子节点,否则返回 null
return node.children
? h(
"span",
{},
node.children.map(child => hastToVNode(child))
)
: null;
}
};
const VNodeTree = ref("");
// 定义要解析的 Markdown 内容
const markdownContent = ref("");
const pendingImages = new Set(); // 存储待处理的图片
// 处理新添加的图片
const processNewImages = () => {
if (!markdownContainer.value) return;
// 获取所有未处理的图片
const newImages = markdownContainer.value.querySelectorAll(
"img:not(.processed)"
);
newImages.forEach(img => {
if (pendingImages.has(img)) return;
pendingImages.add(img);
// 设置占位尺寸(关键:避免布局重排)
setPlaceholderDimensions(img);
// 处理已加载的图片
if (img.complete) {
handleImageLoad(img);
} else {
// 监听加载事件
img.addEventListener("load", () => handleImageLoad(img));
img.addEventListener("error", () => handleImageError(img));
}
});
};
// 设置图片占位尺寸
const setPlaceholderDimensions = img => {
// 防止图片宽度超过容器
const containerWidth = img.parentElement.offsetWidth;
if (img.naturalWidth > containerWidth) {
img.style.maxWidth = "100%";
}
// 可选:根据图片宽高比计算最佳显示高度
if (img.naturalWidth && img.naturalHeight) {
const ratio = img.naturalHeight / img.naturalWidth;
const maxHeight = 500; // 与CSS中的max-height保持一致
img.style.maxHeight = `${Math.min(maxHeight, containerWidth * ratio)}px`;
}
};
// 处理图片加载完成
const handleImageLoad = img => {
pendingImages.delete(img);
img.classList.add("processed", "loaded");
// 移除事件监听
img.removeEventListener("load", handleImageLoad);
img.removeEventListener("error", handleImageError);
};
// 处理图片加载错误
const handleImageError = img => {
pendingImages.delete(img);
img.classList.add("processed", "load-error");
img.style.maxHeight = "200px";
};
// 存储解析后的 HTML 内容
const parsedMarkdown = ref("");
// 创建 markdown-it 实例
const md = new MarkdownIt({
html: true,
xhtmlOut: true,
breaks: true,
linkify: true,
typographer: true
});
const dataSource = ref([]);
// 自定义渲染规则
const containerPlugin = md => {
md.use(container, "slice-tips", {
validate: function (params) {
return params.trim().match(/^##\s*slice-tips\s*(.*)$/);
},
render: function (tokens, idx) {
const m = tokens[idx].info.trim().match(/^##\s*slice-tips\s*(.*)$/);
if (tokens[idx].nesting === 1) {
const tooltip = dataSource.value[index];
return `<div>${m && m[1] ? `<p>${md.utils.escapeHtml(m[1])}</p>` : ""}`;
} else {
return "</div>";
}
}
});
};
md.use(emoji)
.use(deflist)
.use(abbr)
.use(footnote)
.use(ins)
.use(mark)
.use(taskLists)
.use(container)
.use(container, "hljs-left")
.use(container, "hljs-center")
.use(container, "hljs-right")
.use(toc)
.use(mermaid)
.use(markdownItHighlightjs)
.use(containerPlugin);
const tooltipArr = ref([]);
const replaceFunc = str => {
const regex = /##(\d+)\$\$/g;
return str.replace(regex, (match, indexStr) => {
const index = parseInt(indexStr, 10);
tooltipArr.value.push(index);
if (index >= 0) {
return `<div class="tooltip tooltip-${props.conversationid}-${index}" data-index="${index}"></div>`;
}
return match;
});
};
const buttonRef = ref();
const popoverRef = ref();
const indexClick = ref(0);
const popoverVisible = ref(false);
const popoverVisibleTimer = ref(null);
watch(
() => props.content,
async content => {
// markdownContent.value = content;
// parsedMarkdown.value = md.render(replaceFunc(markdownContent.value));
const hast = await processor.run(processor.parse(replaceFunc(content)));
VNodeTree.value = hastToVNode(hast);
nextTick(() => {
processNewImages();
});
nextTick(() => {
tooltipArr.value.forEach(item => {
const tooltipElements = document.querySelectorAll(
`.tooltip-${props.conversationid}-${item}`
);
tooltipElements.forEach(element => {
element.addEventListener("mouseenter", mouseenter);
element.addEventListener("mouseleave", mouseleave);
});
});
});
},
{ immediate: true }
);
watch(
() => props.reference,
newValue => {
if (!newValue || !newValue.chunks) {
return;
}
dataSource.value = newValue.chunks.map(item => {
const arr = newValue.doc_aggs.filter(x => {
return x.doc_id === item.document_id;
});
if (arr.length > 0 && arr[0].url) {
item.url = arr[0].url;
}
return item;
});
},
{ deep: true, immediate: true }
);
onUnmounted(() => {
// 遍历存储的元素,移除事件监听器
tooltipArr.value.forEach(item => {
const tooltipElements = document.querySelectorAll(
`.tooltip-${props.conversationid}-${item}`
);
tooltipElements.forEach(element => {
element.removeEventListener("mouseenter", mouseenter);
element.removeEventListener("mouseleave", mouseleave);
});
});
});
const markdownContainer = ref(null);
const currentTooltip = ref({});
const mouseenter = e => {
clearTimeout(popoverVisibleTimer.value);
currentTooltip.value = dataSource.value[e.target.dataset.index];
buttonRef.value = e.target;
popoverVisible.value = true;
if (currentTooltip.value?.image_id) {
currentTooltip.value.staticImg = `/api-rag/v1/document/image/${currentTooltip.value.image_id}`;
} else {
if (currentTooltip.value) {
currentTooltip.value.staticImg = "";
}
}
};
const mouseenterTip = () => {
clearTimeout(popoverVisibleTimer.value);
popoverVisible.value = true;
};
const mouseleaveTip = () => {
popoverVisible.value = false;
};
const mouseleave = e => {
popoverVisibleTimer.value = setTimeout(() => {
popoverVisible.value = false;
}, 300);
};
const handleClickTooltip = item => {
if (item.k_type === "cb_periphery" || item.k_type === "cb_api") {
router.push(
`/knowledge-base/chunk?kb_id=${item.kb_id}&doc_id=${item.document_id}`
);
}
if (item.k_type === "origin_net" || item.k_type === "cb_net") {
window.open(item.url, "_blank");
}
if (item.k_type === "origin_knowledge" || item.k_type === "cb_knowledge") {
let docid = item.document_id;
if (item.k_type === "cb_knowledge") {
docid = item.origin_doc_id;
}
downloadDataApi(docid)
.then(res => {
const fileType = res.name.split(".").pop().toLowerCase();
if (previewFileType.includes(fileType)) {
// 构建完整的 URL,包含查询参数
const targetUrl = `/office-viewer?url=${encodeURIComponent(res.sign_url)}&type=${fileType}`;
// 使用 window.open 打开新窗口
window.open(targetUrl, "_blank");
} else {
window.open(res.sign_url, "_blank");
}
})
.catch(err => {
message(err, { type: "error" });
});
}
};
const getIcon = () => {
if (currentTooltip.value?.retrieve_type === "net_retrieval") {
return NetWork;
} else {
return currentTooltip.value?.document_name &&
fileTypeIconMap[getFileType(currentTooltip.value?.document_name)]
? fileTypeIconMap[getFileType(currentTooltip.value?.document_name)]
: DefaultFile;
}
};
</script>
<style lang="css" scoped>
.markdown {
position: relative;
word-break: break-all;
:deep(.tooltip) {
position: relative;
top: 2px;
display: inline-block;
width: 12px;
height: 12px;
margin: 0 5px;
cursor: pointer;
background-image: url("@/assets/chat/ts.png");
background-repeat: no-repeat;
background-size: cover;
}
:deep(img) {
max-width: 30%;
}
:deep(a) {
color: #428bca;
}
:deep(a:hover, a:focus) {
color: #2a6496;
text-decoration: underline;
}
:deep(ul) {
display: block;
/* margin-block-start: 1em;
margin-block-end: 1em; */
padding-inline-start: 40px;
list-style-type: disc;
unicode-bidi: isolate;
}
:deep(hr) {
margin-top: 20px;
margin-bottom: 20px;
border: 0;
border-top: 1px solid #eee;
}
:deep(blockquote) {
padding: 10px 20px;
margin: 0 0 20px;
font-size: 17.5px;
border-left: 5px solid #eee;
}
:deep(code) {
padding: 2px 4px;
font-size: 90%;
color: #c7254e;
background-color: #f9f2f4;
border-radius: 4px;
}
:deep(ol) {
display: block;
/* margin-block-start: 1em;
margin-block-end: 1em; */
padding-inline-start: 40px;
list-style-type: decimal;
unicode-bidi: isolate;
}
:deep(pre) {
display: block;
padding: 9.5px;
margin: 0 0 10px;
font-size: 13px;
line-height: 1.4286;
color: #333;
word-break: break-all;
word-wrap: break-word;
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 4px;
}
:deep(pre code) {
padding: 0;
font-size: inherit;
color: inherit;
white-space: pre-wrap;
background-color: transparent;
border-radius: 0;
}
:deep(table) {
width: 100%;
}
:deep(
table > tbody > tr:nth-child(odd) > td,
table > tbody > tr:nth-child(odd) > th
) {
background-color: #f9f9f9;
}
:deep(
table > thead > tr > th,
table > tbody > tr > th,
table > tfoot > tr > th,
table > thead > tr > td,
table > tbody > tr > td,
table > tfoot > tr > td
) {
padding: 8px;
line-height: 1.4286;
vertical-align: top;
border-top: 1px solid #ddd;
}
:deep(table > thead > tr > th) {
vertical-align: bottom;
border-bottom: 2px solid #ddd;
}
}
.content {
padding: 5px;
}
:deep .content tr {
background-color: transparent !important;
}
:deep .content td {
padding: 5px;
border: 1px solid var(--pure-theme-kn-text-color);
}
:deep .content th {
padding: 5px;
border: 1px solid var(--pure-theme-kn-text-color);
}
</style>
MermaidParser组件
<template>
<div>
<div ref="mermaidContainer" />
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import { v4 as uuidv4 } from "uuid";
import mermaid from "mermaid";
const props = defineProps({
code: String
});
const mermaidContainer = ref(null);
const renderMermaid = async () => {
if (!props.code) return;
const uuid = uuidv4();
try {
mermaid.initialize({ startOnLoad: true });
const svgCode = await mermaid.render("svg-" + uuid, props.code);
mermaidContainer.value.innerHTML = svgCode?.svg;
} catch (error) {
const tempDiv = document.getElementById("svg-" + uuid);
if (tempDiv) tempDiv.remove();
}
};
watch(
() => props.code,
newCode => {
renderMermaid();
},
{ immediate: true }
);
</script>
<style scoped>
/* 可以添加一些样式来调整 Mermaid 图表的显示 */
div {
margin: 10px 0;
}
</style>
DeepThinkBlock组件
<template>
<div class="mt-4 mb-4">
<div class="flex items-center cursor-pointer font-bold">
<div>深度思考</div>
<IconifyIconOffline
v-if="isCollapse"
:icon="ArrowUpBold"
class="ml-2"
@click="handleCollapseClick"
/>
<IconifyIconOffline
v-if="!isCollapse"
:icon="ArrowDownBold"
class="ml-2"
@click="handleCollapseClick"
/>
</div>
<div v-if="isCollapse" class="contentbox mt-2">
<component :is="content" />
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import ArrowDownBold from "@iconify-icons/ep/arrow-down-bold";
import ArrowUpBold from "@iconify-icons/ep/arrow-up-bold";
const props = defineProps({
content: String
});
const isCollapse = ref(true);
const handleCollapseClick = () => {
isCollapse.value = !isCollapse.value;
};
</script>
<style scoped>
.contentbox {
padding: 0 10px;
border-left: 1px solid var(--pure-theme-agent-msg-plan-border-color);
}
</style>