LLM智能聊天助手项目拆解(二)
一.自定义 Markdown 渲染引擎利用 markdown-it 及其生态插件定制了高性能的渲染管线集成 highlight.js 实现代码语法高亮并封装了具备一键复制代码与实时主题切换功能的自定义代码块容器增强了技术内容的阅读体验。markdown.ts主要职责:将 AI 返回的 Markdown 原始文本转换为网页可显示的 HTML并处理代码高亮、安全防护等功能。// src/utils/markdown.ts - 该文件主要负责将 AI 返回的 Markdown 原始文本转换为网页可显示的 HTML并处理代码高亮 // 导入核心库 markdown-it用于解析 markdown 语法的核心引擎 import MarkdownIt from markdown-it // 导入 highlight.js 库用于让代码块显示五颜六色的高亮效果 import hljs from highlight.js // 导入属性处理插件让 markdown 生成的链接A标签带上自定义属性如在新窗口打开 import mdLinkAttributes from markdown-it-link-attributes // 导入 emoji 插件支持渲染文字表情包如 :smile: import { full as emoji } from markdown-it-emoji // 导入 DOMPurify这是一个极其重要的安全库专门用来清洗 HTML防止脚本注入XSS攻击 import DOMPurify from dompurify // 引入一款经典的代码高亮配色方案样式表Atom One Dark 风格 import highlight.js/styles/atom-one-dark.css // 以下是通过 Vite 的静态资源导入机制将本地图片转换为运行时可访问的 URL 地址 import copyIcon from /assets/photo/复制.png // 用于代码块右上角的复制按钮 import darkIcon from /assets/photo/暗黑模式.png // 用于代码块主题切换按钮 import lightIcon from /assets/photo/明亮模式.png // 用于主题切换时的亮色图标 // --- Markdown 解析引擎配置配置 --- // 实例化解析器对象并进行个性化参数配置 const md: MarkdownIt new MarkdownIt({ // 允许原始 HTML 标签通过解析后续会由 DOMPurify 进行安全清洗 html: true, // 识别文本中的换行并自动转换为网页的 br 标签 breaks: true, // 智能识别文本中的超链接地址如 http://...并自动包裹 a 标签 linkify: true, /** * highlight 回调函数每当解析到代码块 ... 时markdown-it 会调用此函数 * param str 这里的 str 是指代码块内的原始纯代码内容 * param lang 用户在 后指定的编程语言标识符如 js, python */ highlight: function (str: string, lang: string): string { // 逻辑判断如果用户指定了语言且我们的 highlight.js 库支持这种语言的高亮 if (lang hljs.getLanguage(lang)) { try { // 调用 highlight.js 执行高亮转换设置 ignoreIllegals 为 true 提高容错性 // 返回的是被 span 标签层层包裹的有颜色标记的 HTML 字符串 const highlighted hljs.highlight(str, { language: lang, ignoreIllegals: true }).value // 核心手动构造一个极其复杂的 DOM 结构包含头部工具栏语言名称、复制按钮、切换主题按钮和代码内容区 // 使用模板字符串进行拼接注意此处的 CSS 类名如 .code-block, .code-lang 需要在全局 CSS 中统一定义 return div classcode-blockdiv classcode-headerspan classcode-lang${lang}/spandiv classcode-actionsbutton classcode-action-btn>// src/utils/db.ts import { openDB, type IDBPDatabase } from idb const DB_NAME llm_chat_db const STORE_NAME key_value_pairs const DB_VERSION 1 /** * IndexedDB 数据库管理类 * * 【面试谈资】 * 1. 为什么选择 IndexedDB 它是浏览器提供的非关系型数据库具有更大的存储空间通常几百MB甚至更多 * 且操作是异步的不会阻塞主线程。相比之下localStorage 只有 5MB 且是同步 IO。 * 2. 这里的封装思路由于我们只需要像 localStorage 一样存储大字符串JSON * 所以封装了一个简单的键值对存储仓库。 */ class ChatDB { private dbPromise: PromiseIDBPDatabase constructor() { this.dbPromise openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME) } }, }) } /** * 获取键值 */ async get(key: string): Promiseany { const db await this.dbPromise return db.get(STORE_NAME, key) } /** * 设置键值 */ async set(key: string, value: any): Promisevoid { const db await this.dbPromise await db.put(STORE_NAME, value, key) } /** * 删除键值 */ async delete(key: string): Promisevoid { const db await this.dbPromise await db.delete(STORE_NAME, key) } } export const chatDB new ChatDB()这段代码封装了浏览器的 IndexedDB 数据库对外提供像 localStorage 一样的 get/set/delete 键值对操作解决了 localStorage 存储空间小5MB、同步阻塞主线程的问题专门用于浏览器端存储大量数据比如聊天记录openDBidb 库提供的简化 IndexedDB 操作的核心方法用于打开 / 创建数据库返回 Promiseupgrade回调仅在数据库版本号提升或首次创建时执行用于初始化 / 更新数据仓库比如创建key_value_pairs这个 “表”private dbPromiseTypeScript 私有成员存储数据库连接的 Promise保证全局只有一个数据库连接indexedDBStorage.ts - 持久化适配器// src/utils/indexedDBStorage.ts - 该文件定义了一个适配器让 Pinia 的持久化插件能够使用 IndexedDB 存储数据 // 从本地数据库封装模块中导入已经实例化好的 chatDB 对象 import { chatDB } from ./db // 内存缓存在应用启动阶段把 IndexedDB 中的数据先读到这里 // 对外暴露给 pinia-plugin-persistedstate 的 getItem 就可以是同步的。 const memoryCache: Recordstring, any {} // 当前聊天 Store 使用的持久化 key需与 src/stores/chat.ts 中 persist.key 保持一致 const CHAT_STORE_KEY llm-chat-state /** * 在应用挂载前调用一次用于从 IndexedDB 读取历史状态到内存缓存。 * 如果发现旧版本使用的 key例如 llm-chat会自动迁移到新的 key避免丢失历史记录。 */ export async function initIndexedDBStorage(): Promisevoid { // 优先尝试读取当前约定的 key let stored await chatDB.get(CHAT_STORE_KEY) // 如果当前 key 下没有数据再尝试兼容旧版本 key if (!stored) { const legacyKey llm-chat const legacy await chatDB.get(legacyKey) if (legacy) { stored legacy // 将旧数据迁移到新的 key之后统一从 CHAT_STORE_KEY 读取 await chatDB.set(CHAT_STORE_KEY, legacy) } } if (stored) { memoryCache[CHAT_STORE_KEY] stored } } /** * indexedDBStorage 对象 * 提供同步的 getItem 接口给 pinia-plugin-persistedstate 使用 * 实际数据读取/写入由应用启动时的 init 方法以及异步 setItem 完成。 */ export const indexedDBStorage { /** * getItem 方法在初始化 store 时从内存缓存读取历史状态 * param key 存储的键名通常是 store 的 ID如 chat * returns 返回 JSON 字符串或者 null */ getItem(key: string): string | null { const val memoryCache[key] return val ? JSON.stringify(val) : null }, /** * setItem 方法每当 store 里的数据发生变化时将其写回 IndexedDB并更新内存缓存 * param key 存储的键名 * param value Pinia 插件传过来的状态字符串JSON 格式 */ async setItem(key: string, value: string): Promisevoid { const parsedValue JSON.parse(value) memoryCache[key] parsedValue await chatDB.set(key, parsedValue) }, /** * removeItem 方法用于清空某个 store 的持久化数据 * param key 要移除的键名 */ async removeItem(key: string): Promisevoid { delete memoryCache[key] await chatDB.delete(key) }, }这段代码的核心是解决 Pinia 持久化插件与 IndexedDB 异步特性的兼容问题Pinia 持久化插件pinia-plugin-persistedstate默认期望getItem是同步方法对齐 localStorage 行为IndexedDB 的所有操作都是异步的直接给插件用会报错解决方案应用启动时通过initIndexedDBStorage异步读取 IndexedDB 数据到内存缓存插件调用getItem时直接从内存缓存同步读取避开异步数据更新时setItem异步写入 IndexedDB 更新内存缓存保证数据一致性。chat.ts里的IndexedDB持久化配置persist: { key: llm-chat-state, // 存储在数据库中的键名保持与旧版本一致以平滑迁移 storage: indexedDBStorage as any, // 使用自定义的异步 IndexedDB 存储适配器 // 仅持久化必要的响应式变量 paths: [conversations, currentConversationId] }main.ts应用初始化流程import { createApp } from vue import { createPinia } from pinia import VueVirtualScroller from vue-virtual-scroller import vue-virtual-scroller/dist/vue-virtual-scroller.css import persist from pinia-plugin-persistedstate import App from ./App.vue import router from ./router import ./assets/styles/main.scss import animate.css import { initIndexedDBStorage } from ./utils/indexedDBStorage // 使用一个启动函数先初始化 IndexedDB 缓存再挂载应用 async function bootstrap() { // 确保在 Pinia 创建和插件初始化之前把历史对话从 IndexedDB 读入内存缓存 // 这样刷新后 pinia-plugin-persistedstate 才能正确还原 conversations。 await initIndexedDBStorage() const app createApp(App) const pinia createPinia() pinia.use(persist) app.use(pinia) app.use(router) app.use(VueVirtualScroller) app.mount(#app) } bootstrap()确保应用在正确的时机初始化所有依赖特别是 IndexedDB 数据的预加载保证 Pinia Store 能够正确恢复持久化状态。Bootstrap 函数详解 为什么使用 async 函数1. 异步初始化 initIndexedDBStorage 需要从 IndexedDB 读取数据2. 顺序保证 必须等待数据加载完成才能初始化 Pinia3. 避免竞态条件 确保 Pinia Store 初始化时数据已准备好三.引入 DOMPurify 对 Markdown 渲染后的 HTML 进行二次清洗有什么风险const md: MarkdownIt new MarkdownIt({ html: true, breaks: true, linkify: true, //html: true - 允许在 Markdown 中使用原始 HTML 标签⚠️ 潜在安全风险需要 DOMPurify 清洗markdown-it这个库的核心是把 Markdown 文本转换成 HTML 代码比如把加粗转成strong加粗/strong。当配置html: true时意味着允许你在 Markdown 文本中直接写原生 HTML 标签这些标签不会被转义会被保留并直接渲染成对应的 HTML 元素。这个风险的核心是XSS 攻击跨站脚本攻击—— 如果 Markdown 内容来自用户输入比如聊天应用中用户发送的消息、评论区内容恶意用户可能会在 Markdown 中插入恶意 HTML/JS 代码例如用户输入:你好呀 scriptalert(盗取你的 Cookie)/script如果html: true且没有任何防护转换后的 HTML 会包含script标签浏览器执行这段 JS 代码可能导致弹出恶意弹窗、跳转到钓鱼网站窃取用户的 Cookie、LocalStorage 数据篡改页面内容、发起恶意请求等。DOMPurify 是一个专门用于过滤不安全 HTML 标签 / 属性的库它会扫描所有 HTML 内容只保留 “安全的标签 / 属性”移除所有恶意代码。项目中的markdwon.ts对于这部分做了防护export const renderMarkdown (content: string): string { // 健壮性检查如果输入为空直接返回空串 if (!content) return // 第一步调用已配置好的引擎将 Markdown 转换为原始 HTML const rawHtml md.render(content) // 第二步防御级别升级 —— 使用 DOMPurify 进行全局 XSS 漏洞洗涤 // 为什么要这一步 因为上面配置了 html: true万一 AI 或后端返回了 script src病毒/script 就会被执行。 // DOMPurify 会过滤掉所有具有安全风险的标签和属性只放行文本展示类的 HTML。 return DOMPurify.sanitize(rawHtml) }调用 DOMPurify.sanitize() 方法清洗 HTML有效防止了XSS攻击