diff --git a/.changeset/fix-addons-build-env.md b/.changeset/fix-addons-build-env.md new file mode 100644 index 000000000..b58b16d23 --- /dev/null +++ b/.changeset/fix-addons-build-env.md @@ -0,0 +1,5 @@ +--- +'cherry-markdown': patch +--- + +- fix: 修复 addons 构建缺少环境变量替换导致运行时报错的问题 diff --git a/examples/ai_chat_stream.html b/examples/ai_chat_stream.html index 1ecb54b4f..9a34e74e3 100644 --- a/examples/ai_chat_stream.html +++ b/examples/ai_chat_stream.html @@ -1,5 +1,5 @@ - + @@ -137,58 +137,147 @@ cursor: pointer; } + /* 插件选项 - 胶囊样式 */ .plugin-options { width: 100%; max-width: var(--ai-max-width); - margin: 0 auto 16px; - padding: 16px; - background: var(--ai-surface); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + margin: 0 auto 12px; + padding: 0; + background: transparent; + box-shadow: none; box-sizing: border-box; } - .plugin-options h3 { - margin: 0 0 12px; - font-size: 14px; - color: #666; + .plugin-title { + font-size: 13px; + font-weight: 600; + color: #333; + margin: 0 0 8px 0; + display: flex; + align-items: center; + gap: 6px; + } + + .plugin-title-hint { + font-size: 11px; + font-weight: 400; + color: #999; } .plugin-list { display: flex; - gap: 20px; + gap: 8px; flex-wrap: wrap; + align-items: center; + } + + .plugin-sep { + width: 1px; + height: 20px; + background: var(--ai-muted); + margin: 0 4px; + } + + .plugin-group-label { + font-size: 12px; + color: #999; + margin-right: 2px; + } + + .plugin-or { + font-size: 11px; + color: #bbb; + font-style: italic; } .plugin-item { - display: flex; - align-items: center; - gap: 8px; + position: relative; + cursor: pointer; + user-select: none; } .plugin-item input { - width: 18px; - height: 18px; - cursor: pointer; + position: absolute; + opacity: 0; + width: 0; + height: 0; } .plugin-item label { - cursor: pointer; - font-size: 14px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + border: 1px solid var(--ai-muted); + background: var(--ai-surface); + color: #666; + transition: all 0.2s ease; + white-space: nowrap; } - .plugin-item .plugin-status { - font-size: 12px; - color: #999; - margin-left: 4px; + .plugin-item input:checked+label { + background: #e6f0ff; + border-color: var(--ai-accent); + color: var(--ai-accent); } - .plugin-item .plugin-status.loaded { - color: #52c41a; + .plugin-item input:disabled+label, + .plugin-item input[disabled]+label { + opacity: 0.5; + pointer-events: none; } - .plugin-item .plugin-status.loading { - color: #faad14; + .plugin-item .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: transparent; + transition: background 0.3s ease; + flex-shrink: 0; + } + + .plugin-item .status-dot.loading { + background: #faad14; + animation: pulse-dot 1s infinite; + } + + .plugin-item .status-dot.loaded { + background: #52c41a; + } + + @keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } + + /* 消息选择器 */ + .msg-picker { + width: 100%; + max-width: var(--ai-max-width); + margin: 0 auto 12px; + box-sizing: border-box; + } + + .msg-picker-label { + font-size: 13px; + color: #666; + margin-bottom: 8px; + } + + .msg-picker-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .j-msg-pick-btn:disabled, + .j-msg-pick-btn[disabled] { + opacity: 0.5; + cursor: not-allowed; } .custom-input { @@ -215,6 +304,51 @@ min-height: 0; } + /* Toast 提示 */ + .toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; + } + + .toast { + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + line-height: 1.5; + color: #fff; + background: #333; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + opacity: 0; + transform: translateX(20px); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: auto; + max-width: 360px; + word-break: break-word; + } + + .toast-visible { + opacity: 1; + transform: translateX(0); + } + + .toast-error { + background: #ff4d4f; + } + + .toast-success { + background: #52c41a; + } + + .toast-info { + background: #1890ff; + } + @media (max-width: 720px) { .ai-chat-wrapper { padding: 12px; @@ -230,8 +364,12 @@ } .plugin-list { - flex-direction: column; - gap: 12px; + gap: 6px; + } + + .plugin-item label { + font-size: 12px; + padding: 5px 10px; } } @@ -240,43 +378,48 @@ + +
+
-

🔌 插件懒加载选项(勾选后懒加载对应插件)

+
+ 扩展插件 勾选后点击下方消息按钮即可加载 +
- - +
+ +
数学公式(二选一)
- - - + +
+
or
- - + +
+ +
+ +
-
- - - @@ -297,4 +450,4 @@

🔌 插件懒加载选项(勾选后懒加载对应插件)

- \ No newline at end of file + diff --git a/examples/assets/scripts/ai-chat-stream-demo.js b/examples/assets/scripts/ai-chat-stream-demo.js index 23f3670dc..10c0edd47 100644 --- a/examples/assets/scripts/ai-chat-stream-demo.js +++ b/examples/assets/scripts/ai-chat-stream-demo.js @@ -1,34 +1,208 @@ -// 插件配置 -const pluginConfig = { +/** + * AI Chat Stream Demo - 插件懒加载与启用/禁用管理 + * + * ## 🚀 快速开始 + * + * ### 1. 引入 Cherry Markdown + * ```html + * + * ``` + * + * ### 2. 创建 HTML 结构 + * ```html + * + * + * + * + * + *
+ * + * + * + * ``` + * + * ### 3. 初始化场景 + * ```javascript + * import { aiChatStreamScenario } from './ai-chat-stream-demo.js'; + * aiChatStreamScenario(); + * ``` + * + * ## 📋 核心功能 + * + * - ✅ **插件懒加载** - 勾选时才加载,节省初始加载时间 + * - ✅ **插件动态切换** - 启用/禁用无需刷新页面 + * - ✅ **流式渲染** - 逐字打印效果,适合 AI 对话场景 + * - ✅ **多实例支持** - 每条消息独立的 Cherry 实例 + * + * ## 🎨 支持的插件 + * + * | 插件 | 功能 | 互斥 | 配置路径 | + * |------|------|------|----------| + * | Mermaid | 流程图/时序图 | 无 | codeBlock.customRenderer.mermaid | + * | KaTeX | 数学公式(快) | MathJax | mathBlock.engine='katex' | + * | MathJax | 数学公式(全) | KaTeX | mathBlock.engine='MathJax' | + * | ECharts | 表格图表 | 无 | table.enableChart=true | + * + * ## 🔧 关键机制 + * + * ### 数学公式(KaTeX / MathJax) + * - Cherry 默认 `mathBlock.engine='MathJax'`,必须显式覆盖为空来禁用 + * - `initMath()` 在 engine/src 都为空时跳过加载 + * + * ### Mermaid + * - 不使用 `Cherry.usePlugin()`(会永久污染全局配置) + * - 通过 `codeBlock.customRenderer.mermaid` 注入实例 + * - 使用 `wrapperRender` 阻止 MutationObserver 自动渲染 + * + * ### ECharts 表格图表 + * - 通过 `table.enableChart` + `chartRenderEngine` + `externals` 配置 + * - ⚠️ `enableChart` 必须是布尔值 `true`,不能是对象 + * - 需要在 `externals` 中注入 `echarts` 实例 + * + * ### 流式打印 + * - 每条消息创建独立的 Cherry 实例 + * - 通过 `setMarkdown(msg.substring(0, index))` 逐字更新 + * - 支持暂停/继续功能 + * + * ## 💡 最佳实践 + * + * 1. **插件按需加载**:只勾选需要的插件,减少初始加载时间 + * 2. **数学引擎二选一**:KaTeX 更快,MathJax 功能更全 + * 3. **流式适配**:开启后打印速度更快(30ms),适合快速演示 + * 4. **自定义内容**:可在文本框输入自己的 Markdown 内容测试 + */ + +// ============================================================================ +// 插件 CDN 配置 +// ============================================================================ +/** + * 插件 CDN 配置 + * + * 每个插件包含: + * - src: 主库的 CDN 地址 + * - css: 样式文件(可选) + * - pluginSrc: Cherry 适配插件的路径(可选) + */ +const PLUGIN_CDN = { mermaid: { - loaded: false, - loading: false, src: 'https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js', pluginSrc: '../packages/cherry-markdown/dist/addons/cherry-code-block-mermaid-plugin.js', }, katex: { - loaded: false, - loading: false, src: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', css: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css', }, mathjax: { - loaded: false, - loading: false, src: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js', }, + echarts: { + src: 'https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js', + pluginSrc: '../packages/cherry-markdown/dist/addons/advance/cherry-table-echarts-plugin.js', + }, +}; + +/** + * 插件运行时状态 + * 用于跟踪每个插件的加载状态,避免重复加载 + */ +const pluginState = { + mermaid: { loaded: false, loading: false }, + katex: { loaded: false, loading: false }, + mathjax: { loaded: false, loading: false }, + echarts: { loaded: false, loading: false }, }; -// 示例消息列表 +/** + * KaTeX ↔ MathJax 互斥映射 + * 两个数学引擎不能同时使用,勾选其中一个会自动取消另一个 + */ +const MUTUAL_EXCLUSION = { katex: 'mathjax', mathjax: 'katex' }; + +// ============================================================================ +// 示例消息 +// ============================================================================ const msgList = [ - '### 概述\n通过以下方式打开Cherry Markdown的流式渲染能力:\n```javascript\nconst cherry = new Cherry({\n editor: {\n height: "auto",\n defaultModel: "previewOnly",\n },\n engine: {\n global: {\n flowSessionContext: true,\n flowSessionCursor: "default",\n },\n },\n});\n```\n', - '### 数学公式示例\n\n#### 行内公式\n质能方程:$E = mc^2$\n\n#### 块级公式\n高斯公式:\n$$\\oint_S \\vec{F} \\cdot d\\vec{A} = \\int_V (\\nabla \\cdot \\vec{F}) dV$$\n\n二次方程根:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n', - '### Mermaid 流程图示例\n\n```mermaid\ngraph TD\n A[开始] --> B{是否加载插件?}\n B -->|是| C[懒加载插件]\n B -->|否| D[使用默认渲染]\n C --> E[渲染内容]\n D --> E\n E --> F[结束]\n```\n\n#### 时序图\n\n```mermaid\nsequenceDiagram\n participant 用户\n participant Cherry\n participant 插件\n 用户->>Cherry: setMarkdown()\n Cherry->>插件: 检查是否需要渲染\n 插件-->>Cherry: 返回渲染结果\n Cherry-->>用户: 显示内容\n```\n', - '### 综合示例\n\n#### 代码块\n```python\ndef fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)\n\nprint(fibonacci(10)) # 输出: 55\n```\n\n#### 表格\n| 插件 | 用途 | 大小 |\n|:----:|:-----|-----:|\n| Mermaid | 流程图、时序图 | ~2MB |\n| KaTeX | 数学公式(快) | ~300KB |\n| MathJax | 数学公式(全) | ~3MB |\n\n#### 数学公式\n欧拉公式:$e^{i\\pi} + 1 = 0$\n', + { + title: '概述:流式渲染配置', + content: + '### 概述\n\n#### 1. 引入 Stream 版本\n```html\n\n```\n\n#### 2. 启用流式渲染能力\n```javascript\nconst cherry = new Cherry({\n editor: {\n height: "auto",\n defaultModel: "previewOnly", // 纯预览模式\n },\n engine: {\n global: {\n flowSessionContext: true, // 开启流式渲染\n flowSessionCursor: "default",\n },\n },\n});\n```\n\n#### 3. 流式更新内容\n```javascript\n// 逐字更新内容\ncherry.setMarkdown(text.substring(0, index));\n```\n', + }, + { + title: '数学公式', + content: + '### 数学公式示例\n\n#### 行内公式\n质能方程:$E = mc^2$\n\n#### 块级公式\n高斯公式:\n$$\\oint_S \\vec{F} \\cdot d\\vec{A} = \\int_V (\\nabla \\cdot \\vec{F}) dV$$\n\n二次方程根:\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n', + }, + { + title: 'Mermaid 流程图', + content: + '### Mermaid 流程图示例\n\n```mermaid\ngraph TD\n A[开始] --> B{是否加载插件?}\n B -->|是| C[懒加载插件]\n B -->|否| D[使用默认渲染]\n C --> E[渲染内容]\n D --> E\n E --> F[结束]\n```\n\n#### 时序图\n\n```mermaid\nsequenceDiagram\n participant 用户\n participant Cherry\n participant 插件\n 用户->>Cherry: setMarkdown()\n Cherry->>插件: 检查是否需要渲染\n 插件-->>Cherry: 返回渲染结果\n Cherry-->>用户: 显示内容\n```\n', + }, + { + title: '表格图表(ECharts)', + content: + '## 表格图表示例\n\n### 折线图\n| :line:{"title": "折线图"} | Header1 | Header2 | Header3 | Header4 |\n| ------ | ------ | ------ | ------ | ------ |\n| Sample1 | 11 | 11 | 4 | 33 |\n| Sample2 | 112 | 111 | 22 | 222 |\n| Sample3 | 333 | 142 | 311 | 11 |\n\n### 柱状图\n| :bar:{"title": "柱状图"} | Header1 | Header2 | Header3 | Header4 |\n| ------ | ------ | ------ | ------ | ------ |\n| Sample1 | 11 | 11 | 4 | 33 |\n| Sample2 | 112 | 111 | 22 | 222 |\n| Sample3 | 333 | 142 | 311 | 11 |\n\n### 热力图\n| :heatmap:{"title": "热力图"} | 周一 | 周二 | 周三 | 周四 | 周五 |\n| ------ | ------ | ------ | ------ | ------ | ------ |\n| 上午 | 10 | 20 | 30 | 40 | 50 |\n| 下午 | 15 | 25 | 35 | 45 | 55 |\n| 晚上 | 5 | 15 | 25 | 35 | 45 |\n\n### 饼图\n| :pie:{"title": "饼图"} | 数值 |\n| ------ | ------ |\n| 苹果 | 40 |\n| 香蕉 | 30 |\n| 橙子 | 20 |\n| 葡萄 | 10 |\n\n### 雷达图\n| :radar:{"title": "雷达图"} | 技能1 | 技能2 | 技能3 | 技能4 | 技能5 |\n| ------ | ------ | ------ | ------ | ------ | ------ |\n| 用户A | 90 | 85 | 75 | 80 | 88 |\n| 用户B | 75 | 90 | 88 | 85 | 78 |\n| 用户C | 85 | 78 | 90 | 88 | 85 |\n\n### 散点图\n| :scatter:{"title": "数据散点图"} | 横坐标 | 纵坐标 | 大小 | 系列 |\n| ------ | ------ | ------ | ------ | ------ |\n| A1 | 10 | 20 | 5 | 系列一 |\n| A2 | 15 | 25 | 10 | 系列一 |\n| A3 | 18 | 22 | 8 | 系列一 |\n| A4 | 22 | 28 | 12 | 系列一 |\n| A5 | 25 | 35 | 15 | 系列一 |\n| B1 | 12 | 18 | 8 | 系列二 |\n| B2 | 20 | 30 | 12 | 系列二 |\n| B3 | 28 | 25 | 10 | 系列二 |\n| B4 | 35 | 38 | 14 | 系列二 |\n| B5 | 40 | 45 | 16 | 系列二 |\n\n### 桑基图\n| :sankey:{"title": "能源流向图"} | 目标 | 数值 |\n| ------ | ------ | ------ |\n| 煤炭 | 发电 | 300 |\n| 天然气 | 发电 | 200 |\n| 石油 | 交通 | 250 |\n| 水力 | 发电 | 150 |\n| 发电 | 工业 | 400 |\n| 发电 | 居民 | 250 |\n| 交通 | 货运 | 150 |\n| 交通 | 客运 | 100 |\n\n### 地图\n| :map:{"title": "中国地图"} | 数值 |\n| :-: | :-: |\n| 北京 | 100 |\n| 上海 | 200 |\n| 广东 | 300 |\n| 四川 | 150 |\n| 江苏 | 250 |\n| 浙江 | 180 |\n', + }, ]; -// 加载脚本 -function loadScript(src, id) { +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * Promise 超时包装器 + * 给任意 Promise 添加超时保护,超时后自动 reject + * @param {Promise} promise - 原始 Promise + * @param {number} ms - 超时毫秒数 + * @param {string} message - 超时错误信息 + * @returns {Promise} + */ +function withTimeout(promise, ms, message = '操作超时') { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * 轻量 Toast 提示 + * @param {string} message - 提示文本 + * @param {'error'|'success'|'info'} type - 提示类型 + * @param {number} duration - 显示时长(毫秒),默认 3000 + */ +function showToast(message, type = 'info', duration = 3000) { + const container = document.querySelector('.j-toast-container'); + if (!container) return; + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + // 触发淡入(需等下一帧让浏览器应用初始样式) + requestAnimationFrame(() => { toast.classList.add('toast-visible'); }); + setTimeout(() => { + toast.classList.remove('toast-visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + // 兜底:如果 transitionend 没触发,400ms 后强制移除 + setTimeout(() => toast.remove(), 400); + }, duration); +} + +/** + * 动态加载 JavaScript 脚本 + * @param {string} src - 脚本 URL + * @param {string} id - 脚本元素 ID(用于避免重复加载) + * @param {Object} options - 配置选项 + * @param {boolean} options.module - 是否为 ES module + * @returns {Promise} + */ +function loadScript(src, id, { module = false } = {}) { return new Promise((resolve, reject) => { if (document.getElementById(id)) { resolve(); @@ -37,18 +211,21 @@ function loadScript(src, id) { const script = document.createElement('script'); script.id = id; script.src = src; - script.onload = () => { - // 等待一小段时间确保脚本执行完毕并挂载到 window - setTimeout(resolve, 100); - }; - script.onerror = reject; + if (module) script.type = 'module'; + script.onload = resolve; // 浏览器保证 onload 时脚本已执行,无需额外延迟 + script.onerror = () => reject(new Error(`脚本加载失败: ${src}`)); document.head.appendChild(script); }); } -// 加载样式 +/** + * 动态加载 CSS 样式表 + * @param {string} href - 样式表 URL + * @param {string} id - link 元素 ID + * @returns {Promise} + */ function loadCSS(href, id) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { if (document.getElementById(id)) { resolve(); return; @@ -58,264 +235,445 @@ function loadCSS(href, id) { link.rel = 'stylesheet'; link.href = href; link.onload = resolve; + link.onerror = () => reject(new Error(`样式加载失败: ${href}`)); document.head.appendChild(link); }); } -// 更新插件状态显示 +/** + * 更新插件状态指示器(小圆点) + * @param {string} plugin - 插件名称 + * @param {string} status - 状态: '' | 'loading' | 'loaded' + */ function updatePluginStatus(plugin, status) { - const statusEl = document.querySelector(`.j-plugin-status[data-plugin="${plugin}"]`); - if (statusEl) { - statusEl.className = `plugin-status j-plugin-status ${status}`; - switch (status) { - case 'loading': - statusEl.textContent = '(加载中...)'; - break; - case 'loaded': - statusEl.textContent = '(已加载)'; - break; - default: - statusEl.textContent = ''; - } + const dot = document.querySelector(`.j-status-dot-${plugin}`); + if (dot) { + dot.className = `status-dot j-plugin-status j-status-dot-${plugin}${status ? ` ${status}` : ''}`; } } -// 懒加载插件 +/** + * 检查插件复选框是否被勾选 + * @param {string} plugin - 插件名称 + * @returns {boolean} + */ +function isChecked(plugin) { + return document.getElementById(`plugin-${plugin}`)?.checked ?? false; +} + +// ============================================================================ +// 插件加载 / 卸载 +// ============================================================================ + +/** + * 懒加载插件脚本到 DOM。 + * 不调用 Cherry.usePlugin(),避免污染全局默认配置(静态属性,注册后无法撤销)。 + */ async function loadPlugin(plugin) { - const config = pluginConfig[plugin]; - if (config.loaded || config.loading) return; + const state = pluginState[plugin]; + const cdn = PLUGIN_CDN[plugin]; + if (state.loaded || state.loading) return; - config.loading = true; + state.loading = true; updatePluginStatus(plugin, 'loading'); try { - if (config.css) { - await loadCSS(config.css, `${plugin}-css`); - } - await loadScript(config.src, `${plugin}-js`); + // CDN 资源加载超时 15s(大文件如 ECharts ~3MB 需要足够时间) + if (cdn.css) await withTimeout(loadCSS(cdn.css, `${plugin}-css`), 15000, `${plugin} 样式加载超时`); + await withTimeout(loadScript(cdn.src, `${plugin}-js`), 15000, `${plugin} 脚本加载超时`); - // mermaid 需要额外加载插件脚本 - if (plugin === 'mermaid' && config.pluginSrc) { - await loadScript(config.pluginSrc, `${plugin}-plugin-js`); + if (plugin === 'mermaid' && window.mermaid) { + window.mermaid.initialize({ startOnLoad: false }); } - // 特殊初始化 - if (plugin === 'mermaid' && window.mermaid && window.CherryCodeBlockMermaidPlugin) { - // 使用 usePlugin 注册 mermaid 插件 - Cherry.usePlugin(window.CherryCodeBlockMermaidPlugin, { - mermaid: window.mermaid, - mermaidAPI: window.mermaid, - }); + if (cdn.pluginSrc) { + await withTimeout( + loadScript(cdn.pluginSrc, `${plugin}-plugin-js`, { module: true }), + 15000, + `${plugin} 适配插件加载超时`, + ); + // 等待 module script 执行完成(5s 超时保护) + await withTimeout( + new Promise((resolve) => { + const check = () => + (plugin === 'mermaid' && window.CherryCodeBlockMermaidPlugin) || + (plugin === 'echarts' && window.CherryTableEchartsPlugin) + ? resolve() + : setTimeout(check, 50); + check(); + }), + 5000, + `${plugin} 插件初始化超时`, + ); } - config.loaded = true; - config.loading = false; + state.loaded = true; + state.loading = false; updatePluginStatus(plugin, 'loaded'); - console.log(`[Plugin] ${plugin} 加载完成`); + showToast(`${plugin} 加载成功`, 'success', 2000); } catch (e) { - config.loading = false; + state.loading = false; updatePluginStatus(plugin, ''); + // 加载失败时取消勾选 + const cb = document.getElementById(`plugin-${plugin}`); + if (cb) cb.checked = false; + showToast(`${plugin} 加载失败: ${e.message}`, 'error'); console.error(`[Plugin] ${plugin} 加载失败:`, e); } } -// 获取当前 Cherry 配置 -function getCherryConfig() { - const useMermaid = document.getElementById('plugin-mermaid').checked; - const useKatex = document.getElementById('plugin-katex').checked; - const useMathJax = document.getElementById('plugin-mathjax').checked; - - // 数学引擎配置 - let mathEngine = 'katex'; - let mathSrc = pluginConfig.katex.src; - let mathCss = pluginConfig.katex.css; - - if (useMathJax && !useKatex) { - mathEngine = 'MathJax'; - mathSrc = pluginConfig.mathjax.src; - mathCss = ''; +/** + * 卸载插件。 + * - mermaid:不删除脚本(MutationObserver 不可撤销),仅标记 loaded=false + * - katex/mathjax/echarts:删除 JS 标签 + 清理 window 引用 + * - CSS 保留不删除,避免已渲染消息的公式/图表样式错乱 + */ +function unloadPlugin(plugin) { + const state = pluginState[plugin]; + state.loaded = false; + state.loading = false; + + if (plugin === 'mermaid') { + window.mermaid?.initialize({ startOnLoad: false }); + } else { + document.getElementById(`${plugin}-js`)?.remove(); + // 注意:不删除 CSS,保留已渲染内容的样式 + if (plugin === 'katex') delete window.katex; + if (plugin === 'mathjax') delete window.MathJax; + if (plugin === 'echarts') { + delete window.echarts; + delete window.CherryTableEchartsPlugin; + } } + updatePluginStatus(plugin, ''); +} + +// ============================================================================ +// Cherry 配置生成 +// ============================================================================ + +/** + * 根据 UI 状态动态生成 Cherry 配置 + * + * 这是整个 demo 的核心函数,负责: + * 1. 检查插件勾选状态和加载状态 + * 2. 生成对应的 Cherry 配置对象 + * 3. 配置数学引擎、Mermaid、ECharts 等插件 + * + * @returns {Object} Cherry Markdown 配置对象 + * + * @example + * // 在创建 Cherry 实例时调用 + * const config = getCherryConfig(); + * config.el = document.getElementById('container'); + * const cherry = new Cherry(config); + */ +function getCherryConfig() { + // ---- 数学引擎配置 ---- + // KaTeX 和 MathJax 互斥,只能二选一 + const mathConfig = + isChecked('katex') && pluginState.katex.loaded + ? { engine: 'katex', src: PLUGIN_CDN.katex.src, css: PLUGIN_CDN.katex.css || '' } + : isChecked('mathjax') && pluginState.mathjax.loaded + ? { engine: 'MathJax', src: PLUGIN_CDN.mathjax.src, css: '' } + : { engine: '', src: '', css: '' }; // 都未勾选时禁用数学公式 + + // ---- Mermaid 流程图配置 ---- + const mermaidReady = + isChecked('mermaid') && pluginState.mermaid.loaded && window.CherryCodeBlockMermaidPlugin && window.mermaid; + + const codeBlockCfg = { + selfClosing: false, + mermaid: { showSourceToolbar: true }, + // 通过 customRenderer 注入 Mermaid 插件(不使用 usePlugin) + customRenderer: mermaidReady + ? { + mermaid: new window.CherryCodeBlockMermaidPlugin({ + mermaid: window.mermaid, + mermaidAPI: window.mermaid, + }), + } + : undefined, + // 阻止 mermaid MutationObserver 自动渲染(即使未启用也设置) + wrapperRender: (language, _code, innerHTML) => + language === 'mermaid' ? innerHTML.replace(/language-mermaid/g, 'language-mermaid-disabled') : innerHTML, + }; + + // ---- ECharts 表格图表配置 ---- + const echartsReady = isChecked('echarts') && pluginState.echarts.loaded && !!window.echarts; + const tableConfig = echartsReady && window.CherryTableEchartsPlugin + ? { + enableChart: true, // 必须是布尔值 true,不能是对象 + chartRenderEngine: window.CherryTableEchartsPlugin, + externals: ['echarts'], // 声明需要注入的外部依赖 + selfClosing: false, + } + : { + enableChart: false, + selfClosing: false, + }; + + // ---- 组装完整的 Cherry 配置 ---- return { editor: { height: 'auto', - defaultModel: 'previewOnly', + defaultModel: 'previewOnly', // 纯预览模式(流式场景必需) }, engine: { global: { - flowSessionContext: document.querySelector('.j-status-input').checked, + flowSessionContext: document.querySelector('.j-status-input').checked, // 流式渲染开关 flowSessionCursor: 'default', }, syntax: { - codeBlock: { - selfClosing: false, - mermaid: { - showSourceToolbar: true, - }, - }, + codeBlock: codeBlockCfg, + table: tableConfig, inlineCode: { selfClosing: false }, header: { anchorStyle: 'none', selfClosing: false }, - table: { selfClosing: false }, fontEmphasis: { selfClosing: false }, link: { selfClosing: false }, image: { selfClosing: false }, - mathBlock: { - selfClosing: false, - engine: mathEngine, - src: mathSrc, - css: mathCss, - }, - inlineMath: { - selfClosing: false, - engine: mathEngine, - }, + mathBlock: { selfClosing: false, ...mathConfig }, + inlineMath: { selfClosing: false, engine: mathConfig.engine, src: '' }, }, }, externals: { - // mermaid 通过 usePlugin 方式注册,不需要在这里配置 - }, - previewer: { - enablePreviewerBubble: true, + echarts: window.echarts, // 全局注入 echarts 实例 }, + previewer: { enablePreviewerBubble: true }, }; } +// ============================================================================ +// 场景初始化 - 流式打印主逻辑 +// ============================================================================ + /** - * AI Chat Stream 场景初始化 + * 初始化 AI Chat Stream 场景 + * + * 这是整个 demo 的入口函数,负责: + * 1. 渲染示例消息按钮 + * 2. 绑定插件开关事件 + * 3. 实现流式打印功能 + * 4. 处理用户交互 + * + * @example + * // 在 HTML 文件中调用 + * */ export function aiChatStreamScenario() { - // 初始化 DOM 元素 + // 获取 DOM 元素 const dialog = document.querySelector('.j-dialog'); const msgTemplate = document.querySelector('.j-one-msg'); - const button = document.querySelector('.j-button'); - const buttonTips = document.querySelector('.j-button-tips'); + const msgPickerList = document.querySelector('.j-msg-picker-list'); const pauseBtn = document.querySelector('.j-pause-button'); const customTextarea = document.querySelector('.j-custom-textarea'); const customButton = document.querySelector('.j-custom-button'); - let currentCherry = null; - let printing = false; - let paused = false; - let currentMsgIndex = msgList.length; - let currentWordIndex = 0; - let interval = 30; + // 流式打印状态 + let currentCherry = null; // 当前 Cherry 实例 + let printing = false; // 是否正在打印 + let paused = false; // 是否暂停 + let currentWordIndex = 0; // 当前打印到的字符索引 + let interval = 30; // 打印间隔(毫秒) + let rafId = null; // requestAnimationFrame ID + + // Cherry 实例管理(防止内存泄漏) + const MAX_INSTANCES = 5; + const cherryInstances = []; + + /** + * 销毁所有 Cherry 实例 + */ + function destroyAllInstances() { + while (cherryInstances.length > 0) { + const instance = cherryInstances.shift(); + try { instance.destroy(); } catch (e) { /* noop */ } + } + } - buttonTips.innerHTML = currentMsgIndex; + /** + * 注册 Cherry 实例,超过上限时销毁最早的实例 + * @param {Object} cherry - Cherry 实例 + */ + function registerInstance(cherry) { + cherryInstances.push(cherry); + while (cherryInstances.length > MAX_INSTANCES) { + const oldest = cherryInstances.shift(); + try { oldest.destroy(); } catch (e) { /* noop */ } + } + } + + /** + * 打印期间禁用/恢复交互控件 + * @param {boolean} disabled - 是否禁用 + */ + function setControlsDisabled(disabled) { + document.querySelectorAll('.j-msg-pick-btn, .j-custom-button').forEach((el) => { + el.disabled = disabled; + }); + customTextarea.disabled = disabled; + } + + /** + * 更新暂停按钮状态 + * @param {boolean} isPrinting - 当前是否正在打印 + */ + function updatePauseButton(isPrinting) { + pauseBtn.disabled = !isPrinting; + if (!isPrinting) { + paused = false; + pauseBtn.innerText = '暂停流式'; + } + } + + // 渲染消息选择按钮 + msgList.forEach((item, index) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button secondary j-msg-pick-btn'; + btn.dataset.index = index; + btn.textContent = item.title; + msgPickerList.appendChild(btn); + }); + + /** + * 确保所有已勾选的插件加载完成 + * 在开始打印前调用,避免渲染时插件未就绪 + */ + async function ensureCheckedPluginsLoaded() { + const checkboxes = document.querySelectorAll('.j-plugin-checkbox:checked'); + for (const cb of checkboxes) { + await loadPlugin(cb.dataset.plugin); + } + } - // 流式打印函数 + /** + * 开始流式打印 + * 创建新的 Cherry 实例并使用 rAF + 批量字符推进 + * @param {string} msg - 要打印的 Markdown 内容 + */ function beginPrint(msg) { + // 取消之前的 rAF(若有残留) + if (rafId) { cancelAnimationFrame(rafId); rafId = null; } + printing = true; - function step() { + paused = false; + currentWordIndex = 0; + setControlsDisabled(true); + updatePauseButton(true); + + // 克隆消息模板 + const msgEl = msgTemplate.cloneNode(true); + msgEl.classList.remove('j-one-msg'); + + // 创建 Cherry 实例 + const config = getCherryConfig(); + config.el = msgEl.querySelector('.chat-one-msg'); + currentCherry = new Cherry(config); + registerInstance(currentCherry); + dialog.appendChild(msgEl); + + // rAF + 基于时间的批量字符推进 + let lastTime = performance.now(); + + function step(now) { + if (!printing) return; + if (paused) { - setTimeout(step, 100); + lastTime = now; // 暂停时重置时间基准,避免恢复后一次性跳过大量字符 + rafId = requestAnimationFrame(step); return; } - const currentText = msg.substring(0, currentWordIndex); - currentCherry.setMarkdown(currentText); - try { - dialog.scrollTop = dialog.scrollHeight; - } catch (e) {} + + // 根据经过时间计算本帧应推进的字符数 + const elapsed = now - lastTime; + const charsToAdvance = Math.max(1, Math.floor(elapsed / interval)); + lastTime = now; + + currentWordIndex = Math.min(currentWordIndex + charsToAdvance, msg.length); + currentCherry.setMarkdown(msg.substring(0, currentWordIndex)); + if (currentWordIndex < msg.length) { - currentWordIndex++; - setTimeout(step, interval); + rafId = requestAnimationFrame(step); } else { + // 打印完成 + rafId = null; printing = false; - currentWordIndex = 0; + setControlsDisabled(false); + updatePauseButton(false); } } - setTimeout(step, interval); + + rafId = requestAnimationFrame(step); } - // 插件复选框事件 + // ======================================================================== + // 事件绑定 + // ======================================================================== + + // 消息选择按钮点击事件 + msgPickerList.addEventListener('click', async (e) => { + const btn = e.target.closest('.j-msg-pick-btn'); + if (!btn || printing) return; + await ensureCheckedPluginsLoaded(); // 先加载插件 + beginPrint(msgList[Number(btn.dataset.index)].content); // 开始打印 + }); + + // 插件复选框切换事件(加载/卸载 + 互斥处理) document.querySelectorAll('.j-plugin-checkbox').forEach((checkbox) => { checkbox.addEventListener('change', async function () { const plugin = this.dataset.plugin; - // KaTeX 和 MathJax 互斥 - if (plugin === 'katex' && this.checked) { - document.getElementById('plugin-mathjax').checked = false; - } else if (plugin === 'mathjax' && this.checked) { - document.getElementById('plugin-katex').checked = false; - } - - // 懒加载插件 if (this.checked) { - await loadPlugin(plugin); + // 加载期间禁用所有插件复选框,防止重复点击 + document.querySelectorAll('.j-plugin-checkbox').forEach((cb) => { cb.disabled = true; }); + + // KaTeX ↔ MathJax 互斥:勾选其中一个会自动取消另一个 + const other = MUTUAL_EXCLUSION[plugin]; + if (other) { + const otherCb = document.getElementById(`plugin-${other}`); + if (otherCb?.checked) { + otherCb.checked = false; + unloadPlugin(other); + } + } + await loadPlugin(plugin); // 懒加载插件 + + // 加载完成,恢复所有复选框可用 + document.querySelectorAll('.j-plugin-checkbox').forEach((cb) => { cb.disabled = false; }); + } else { + unloadPlugin(plugin); // 卸载插件 } }); }); - // 流式适配开关 + // 流式适配开关(影响打印速度) document.querySelector('.j-status-input').addEventListener('change', function () { - interval = this.checked ? 30 : 50; - currentWordIndex = 0; - currentMsgIndex = msgList.length; - buttonTips.innerHTML = currentMsgIndex; - dialog.innerHTML = ''; + interval = this.checked ? 30 : 50; // 开启时快速打印(30ms),关闭时慢速(50ms) + destroyAllInstances(); // 销毁所有 Cherry 实例 + dialog.innerHTML = ''; // 清空消息列表 }); // 暂停/继续按钮 - pauseBtn.addEventListener('click', function () { + pauseBtn.addEventListener('click', () => { + if (!printing) return; // 非打印状态不响应 paused = !paused; pauseBtn.innerText = paused ? '继续流式' : '暂停流式'; }); - // 获取消息按钮 - button.addEventListener('click', async function () { - if (printing || currentMsgIndex === 0) return; - - // 检查并加载需要的插件 - const checkboxes = document.querySelectorAll('.j-plugin-checkbox:checked'); - for (const cb of checkboxes) { - await loadPlugin(cb.dataset.plugin); - } - - const msg = msgTemplate.cloneNode(true); - msg.classList.remove('j-one-msg'); - const config = getCherryConfig(); - config.el = msg.querySelector('.chat-one-msg'); - currentCherry = new Cherry(config); - dialog.appendChild(msg); - - try { - dialog.scrollTop = dialog.scrollHeight; - } catch (e) {} - - beginPrint(msgList[msgList.length - currentMsgIndex]); - currentMsgIndex--; - buttonTips.innerHTML = currentMsgIndex; - }); - - // 自定义内容按钮 - customButton.addEventListener('click', async function () { + // 自定义内容打印 + customButton.addEventListener('click', async () => { if (printing) return; - - const customContent = customTextarea.value.trim(); - if (!customContent) { - alert('请输入要流式打印的内容'); + const content = customTextarea.value.trim(); + if (!content) { + showToast('请输入要流式打印的内容', 'info'); return; } - - // 检查并加载需要的插件 - const checkboxes = document.querySelectorAll('.j-plugin-checkbox:checked'); - for (const cb of checkboxes) { - await loadPlugin(cb.dataset.plugin); - } - - const msg = msgTemplate.cloneNode(true); - msg.classList.remove('j-one-msg'); - const config = getCherryConfig(); - config.el = msg.querySelector('.chat-one-msg'); - currentCherry = new Cherry(config); - dialog.appendChild(msg); - - try { - dialog.scrollTop = dialog.scrollHeight; - } catch (e) {} - - beginPrint(customContent); + await ensureCheckedPluginsLoaded(); + beginPrint(content); }); - - // 默认加载 KaTeX - loadPlugin('katex'); } diff --git a/packages/cherry-markdown/build/addons.build.js b/packages/cherry-markdown/build/addons.build.js index 01165bbd7..340ed0537 100644 --- a/packages/cherry-markdown/build/addons.build.js +++ b/packages/cherry-markdown/build/addons.build.js @@ -5,6 +5,7 @@ import eslint from '@rollup/plugin-eslint'; import alias from '@rollup/plugin-alias'; import json from '@rollup/plugin-json'; import typescript from 'rollup-plugin-typescript2'; +import envReplacePlugin from './env.js'; import { resolve as _resolve, join, dirname, basename, extname } from 'path'; import { mkdirSync, writeFileSync } from 'fs'; @@ -14,9 +15,9 @@ import glob from 'glob'; import { rollup as _rollup } from 'rollup'; import terser from '@rollup/plugin-terser'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const PROJECT_ROOT_PATH = _resolve(__dirname, '../'); +const currentFilename = fileURLToPath(import.meta.url); +const currentDirname = dirname(currentFilename); +const PROJECT_ROOT_PATH = _resolve(currentDirname, '../'); const IS_PRODUCTION = process.env.NODE_ENV === 'production'; glob( @@ -64,7 +65,7 @@ function buildAddons(entries) { ] : []), json(), - // envReplacePlugin(), + envReplacePlugin(), alias({ entries: [ { diff --git a/packages/cherry-markdown/vite.plugins.ts b/packages/cherry-markdown/vite.plugins.ts index 0e38f8502..e779e2d07 100644 --- a/packages/cherry-markdown/vite.plugins.ts +++ b/packages/cherry-markdown/vite.plugins.ts @@ -69,13 +69,17 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug /** * 将 addon 文件名转为 UMD 全局变量名(camelCase) - * 例如:cherry-code-block-mermaid-plugin → CherryCodeBlockMermaidPlugin + * 例如: + * - cherry-code-block-mermaid-plugin → CherryCodeBlockMermaidPlugin + * - advance/cherry-table-echarts-plugin → CherryTableEchartsPlugin * * 这与 addons.build.js 中的命名逻辑保持一致, - * 确保 HTML 中使用 window.CherryCodeBlockMermaidPlugin 能正确访问 + * 确保 HTML 中使用 window.CherryTableEchartsPlugin 能正确访问 */ function addonFileNameToGlobalName(fileName: string): string { - const nameWithoutExt = fileName.replace(/\.js$/, ''); + // 去掉路径前缀(如 advance/),只保留文件名 + const baseName = fileName.includes('/') ? fileName.split('/').pop()! : fileName; + const nameWithoutExt = baseName.replace(/\.js$/, ''); return nameWithoutExt .split('-') .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) @@ -83,11 +87,14 @@ export function cherryDevPlugin(srcDir: string, cherryMarkdownDir: string): Plug } /** - * 从请求 URL 中提取 addon 文件名 - * 匹配模式:.../dist/addons/.js + * 从请求 URL 中提取 addon 文件名(支持子目录) + * 匹配模式:.../dist/addons/[subdir/].js + * 例如: + * - dist/addons/cherry-code-block-mermaid-plugin.js → cherry-code-block-mermaid-plugin.js + * - dist/addons/advance/cherry-table-echarts-plugin.js → advance/cherry-table-echarts-plugin.js */ function extractAddonFileName(url: string): string | null { - const match = url.match(/\/packages\/cherry-markdown\/dist\/addons\/([^/?]+\.js)/); + const match = url.match(/\/packages\/cherry-markdown\/dist\/addons\/([^?]+\.js)/); return match ? match[1] : null; }