AI项目中对话模块实现及markdown适配

概述

本文档详细描述了 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 实现原理

  1. 服务器端以text/event-stream格式返回数据,采用分块传输(Transfer-Encoding: chunked
  2. 前端通过 fetch API 获取响应流
  3. 使用ReadableStream API 逐步读取并解析流数据
  4. 实时更新 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 多模态内容展示规则

  1. 文件展示

    • 非 markdown 格式的文件(如图片、文档)直接根据数组数据渲染在对话底部
    • 图片和流程图等可视化内容渲染在文件列表上方
  2. 按钮展示

    • 根据不同的回答类型显示不同的操作按钮
    • 例如:代码块显示 "复制" 按钮,图片显示 "下载" 按钮
  3. 自定义组件映射

    • 通过标签名(如codemermaid)映射到对应的自定义组件
    • 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>

<think>好的,用户想了解或设计AI产品中问答模块的原型,并询问最佳实践。我需要结合之前提供的引用内容来给出建议。首先,引用1提到了Dify平台,它可以帮助开发者快速搭建生成式AI应用,非技术人员也能参与,这可能涉及到如何利用现有平台加速开发。引用2讨论了设计模式,特别是工程模式中的工厂、抽象类等,这可能对模块化设计有帮助。引用3提到需求分析和架构设计的重要性,尤其是明确业务目标和应用场景。 用户需要的是问答模块的原型设计最佳实践,所以应该分步骤来回答。首先需求分析是关键,确定场景和用户需求。然后是技术选型,选择合适的LLM和框架,比如Dify这样的平台可以简化开发。接着是架构设计,模块化各个部分,比如输入处理、意图识别、生成回复等,可能用到工厂模式来管理不同的处理模块。用户体验方面,要注重交互设计和反馈机制。最后是测试迭代和运维管理,确保系统稳定。 还要考虑引用中提到的设计模式,比如工程模式中的工厂方法,可能用于创建不同的处理对象,提高扩展性。同时,RAG技术的整合也很重要,结合知识库提高回答准确性。需要确保回答结构清晰,分点说明,并自然地融入引用内容作为支持。最后生成相关问题,帮助用户深入探索。</think># AI产品问答模块原型设计最佳实践 ## 一、需求分析与场景定义 1. **明确核心功能**:确定问答模块需支持单轮对话、多轮上下文理解、知识库检索(RAG)、多模态交互等基础能力,例如金融领域需强化数据查询能力[^3] 2. **用户场景划分**:区分通用问答(如ChatGPT)、领域专精(如医疗咨询)、业务辅助(如客服系统)等场景,参考FreeSWITCH客服系统的需求分析方法[^3] 3. **性能指标定义**:设定响应延迟(建议<3秒)、准确率(>85%)、容错率等关键指标 ## 二、技术架构设计 ```mermaid graph TD A[用户输入] --> B(意图识别) B --> C{查询类型判断} C -->|知识类| D[向量数据库检索] C -->|生成类| E[LLM推理引擎] D --> F[答案生成] E --> F F --> G[结果格式化] G --> H[用户输出] ``` ## 三、关键模块实现 1. **输入处理层**: - 文本清洗:使用正则表达式过滤特殊字符 - 意图识别:基于BERT等模型构建分类器,实现$P(y|x)=\frac{e^{w_y·x}}{\sum_{i=1}^k e^{w_i·x}}$的概率计算 - 实体抽取:采用BiLSTM+CRF模型 2. **核心引擎层**: ```python class AnswerEngine: def __init__(self, mode='general'): self.mode = mode self.retriever = VectorDB() if mode == 'rag' else None self.llm = LLMClient() def generate(self, query): if self.mode == 'rag': context = self.retriever.search(query) return self.llm.generate(f"基于以下内容回答:{context}\n问题:{query}") return self.llm.generate(query) ``` 采用工厂模式构建不同问答类型的处理引擎[^2] 3. **输出处理层**: - 结构化数据转换 - 敏感信息过滤 - 多格式适配Markdown/JSON/HTML) ## 四、用户体验优化 1. **交互设计**: - 渐进式响应:先返回快速确认,再显示完整答案 - 交互控件:提供「澄清需求」「修改回答」等操作入口 - 可视化增强:复杂答案自动生成图表,如$y=sin(x)$函数图像 2. **反馈机制**: ```javascript function collectFeedback(answerId, isUseful) { analytics.track('feedback', { answer_id: answerId, usefulness: isUseful, timestamp: Date.now() }); } ``` ## 五、实施步骤建议 1. 快速原型搭建:使用Dify等LLM应用开发平台实现MVP[^1] 2. A/B测试:对比不同模型(GPT-4/Claude/Mistral)的表现 3. 渐进式迭代:从规则引擎过渡到深度学习模型
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值