阅读视图

发现新文章,点击刷新页面。

Next.js的水合:静默的页面“唤醒”术

大家好,我是大鱼。今天我们要聊一个Next.js中极为重要却又容易被忽视的概念——水合。这不是化学课,但理解它能让你的Next.js应用“活”起来!

什么是水合?

想象一下,你在网上买了一个宜家书架。快递送到时,所有木板都已经切割好、打好孔,甚至部分组装好了——这就是服务端渲染

但此时的书架还不能用:隔板无法调节,抽屉不能滑动。直到你按照说明书,拧上最后的螺丝,安装好滑轨——这个过程就是水合

在Next.js中,水合指的是:在浏览器端,将React组件的交互逻辑“附加”到服务端预渲染的静态HTML上的过程

深入水合的全过程

第一阶段:服务端渲染

当用户请求页面时,Next.js服务器会:

  • 执行React组件代码
  • 生成完整的HTML字符串
  • 直接返回给浏览器

此时用户看到的是完整的页面,但无法交互——就像看到了组装一半的书架。

第二阶段:资源加载

浏览器在展示静态HTML的同时,在后台默默加载页面所需的JavaScript包。

第三阶段:水合激活

这是最关键的一步:

// React在背后做的事情大致如下
function hydrate(serverHTML, clientComponents) {
    // 1. 对比服务端HTML与客户端组件
    // 2. 复用现有的DOM节点
    // 3. 附加事件处理器和状态管理
    // 4. 让页面变得可交互
}

水合完成后,你的页面就从一个静态文档变成了功能完整的React应用。

为什么水合如此重要?

1. 极致的首屏性能 用户不需要等待所有JS加载完成就能看到内容,大幅提升首次渲染速度。

2. 无缝的体验过渡 从静态内容到交互应用的转换是平滑的,用户几乎感知不到。

3. SEO优化 搜索引擎可以直接抓取到完整内容,而不是一个空壳。

水合的“坑”与解决之道

在实际开发中,我们最常遇到的就是“水合不匹配”错误。

常见问题场景

场景一:使用了浏览器特有API

// 错误示例
function UserProfile() {
    // 服务端没有localStorage,会导致水合失败
    const user = localStorage.getItem('user');
    return <div>Hello, {user.name}</div>;
}

// 正确做法
function UserProfile() {
    const [user, setUser] = useState(null);
    
    useEffect(() => {
        // 只在客户端执行
        const userData = localStorage.getItem('user');
        setUser(userData);
    }, []);
    
    return <div>Hello, {user?.name || 'Guest'}</div>;
}

场景二:时间或随机数差异

// 问题代码
function UniqueComponent() {
    // 服务端和客户端生成的ID不同
    const id = Math.random().toString(36);
    return <div id={id}>内容</div>;
}

// 解决方案
function UniqueComponent() {
    const [id, setId] = useState(null);
    
    useEffect(() => {
        setId(Math.random().toString(36));
    }, []);
    
    return <div id={id || 'server-id'}>内容</div>;
}

实用调试技巧

当遇到水合错误时,可以:

  1. 检查控制台警告:React会详细指出不匹配的位置
  2. 使用React DevTools:查看组件树和状态差异
  3. 对比HTML源码:分别查看服务端返回的HTML和水合后的DOM

最佳实践指南

经过多个项目的实践,我总结出以下经验:

1. 组件设计原则

  • 保持服务端和客户端渲染的一致性
  • 避免在渲染逻辑中使用浏览器特有API
  • 对动态内容使用条件渲染

2. 性能优化

// 使用动态导入延迟加载非关键组件
const HeavyComponent = dynamic(
    () => import('./HeavyComponent'),
    { 
        ssr: false, // 不需要服务端渲染
        loading: () => <div>加载中...</div>
    }
);

3. 状态管理

  • 使用Next.js的getServerSideProps进行服务端状态初始化
  • 客户端状态在useEffect中处理

总结

水合是Next.js架构中的精髓所在。它巧妙地将服务端渲染的性能优势与客户端渲染的交互体验结合在一起。

互动时间

你在项目中遇到过水合相关的问题吗?或者有什么独特的优化经验?欢迎在评论区分享交流!


PS: 如果你觉得这篇文章有帮助,欢迎点赞、转发。

一些我推荐的前端代码写法

使用解构赋值简化变量声明 要注意解构的对象不能为undefined、null。否则会报错。所以可以给个空对象作为默认值 解构的 key 如果不存在,可以给个默认值,避免后续逻辑出错 合并数据 Obje

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>

前端国际化方案结构设计

前端国际化方案的结构设计核心在于「分层拆分」「语义化命名」和「规则统一」,避免所有翻译内容堆砌在单一文件中,同时让开发者能快速定位到对应文本。

解除有些网站不能复制的终极办法

这是一个“釜底抽薪”的方法,完全绕过了控制台的输入限制。我们直接在开发者工具内部创建一个可以粘贴和执行的代码片段。 操作步骤: 打开开发者工具(F12)。 找到并点击 Sources (源代码) 选项

flutter中 getx 的使用

我来详细介绍GetX库的使用方法,并列举具体实现代码。 基于对项目代码的分析,我来详细介绍GetX库(版本4.6.6)的使用方法,并列举具体实现代码。 GetX库详细介绍 GetX是一个功能强大的Fl

Flutter应用架构设计的思考

Flutter应用架构设计的深度思考:从混乱到清晰的进化之路 前言 作为一名Flutter开发者,我经历过从"能跑就行"到"架构优雅"的转变过程。现在接手的早期的项目,所有代码都堆在Widget里,状
❌