diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index cd7fcf3e..ba441f79 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -180,8 +180,11 @@ bun run install:plugins bun run build:pkg ``` -### 3. 生产部署 +### 3. 测试和部署 ```bash +# 运行测试 +bun run test + # 构建生产版本 bun run build:runtime @@ -189,6 +192,10 @@ bun run build:runtime bun run start ``` +**⚠️ 重要**: +- 测试命令使用 `bun run test` 而不是 `bun test` +- 测试环境使用 Vitest 运行器,支持 mock 和覆盖率 + ## 快速适配指南 ### 1. 工具适配步骤 diff --git a/lib/s3/controller.ts b/lib/s3/controller.ts index 86cf5d06..2aafce72 100644 --- a/lib/s3/controller.ts +++ b/lib/s3/controller.ts @@ -249,11 +249,11 @@ export class S3Service { // const fileId = this.generateFileId(); const prefix = input.prefix - ? input.prefix?.endsWith('/') - ? input.prefix - : input.prefix + '/' + ? !input.prefix?.endsWith('/') + ? input.prefix + '/' + : input.prefix : PluginBaseS3Prefix; - const objectName = `${prefix}${input.keepRawFilename ? '' : this.generateFileId() + '-'}${originalFilename}`; + const objectName = `${prefix}/${input.keepRawFilename ? '' : this.generateFileId() + '-'}${originalFilename}`; if (input.expireMins) { await MongoS3TTL.create({ bucketName: this.config.bucket, diff --git a/modules/tool/packages/docDiff/DESIGN.md b/modules/tool/packages/docDiff/DESIGN.md new file mode 100644 index 00000000..f08a22e2 --- /dev/null +++ b/modules/tool/packages/docDiff/DESIGN.md @@ -0,0 +1,12 @@ +# DocDiff 系统工具 + +输入:两个 markdown 格式的纯文本内容 +输出:一个 html 文件 + +提供一个 html 模版文件, 对两个文本内容进行对比,逐段对比,分析其中的差异,高亮显示差异。 + +描述工具集名称,以及子工具,子工具需要包含 ID,名字,哪些输入输出,例如: + +--- + +下面由 AI 生成完整的设计文档 diff --git a/modules/tool/packages/docDiff/README.md b/modules/tool/packages/docDiff/README.md new file mode 100644 index 00000000..8d4e68a2 --- /dev/null +++ b/modules/tool/packages/docDiff/README.md @@ -0,0 +1,127 @@ +# DocDiff 文档对比工具 + +一个用于对比两个 Markdown 文档差异的 FastGPT 工具,生成可视化的 HTML 对比报告。 + +## 功能特性 + +- 📝 **行级对比**: 逐行分析文档差异,精确识别变更 +- 🎨 **可视化报告**: 生成美观的双栏 HTML 对比报告 +- 🔄 **导航功能**: 支持按钮和键盘快捷键在变更间跳转 +- 📊 **统计信息**: 显示新增、删除、修改、未修改的行数 +- 🔒 **安全防护**: 自动转义 HTML 字符,防止 XSS 攻击 +- 📱 **响应式设计**: 支持移动端浏览 +- ⚡ **高性能**: 基于相似度算法的智能对比 + +## 使用方法 + +### 输入参数 + +- **originalText** (必需): 原始的 Markdown 文档内容 +- **modifiedText** (必需): 修改后的 Markdown 文档内容 +- **title** (可选): 对比报告的标题,默认为"文档对比报告" + +### 输出 + +返回 HTML 文件访问 URL,包含: +- 文档标题和生成时间 +- 变更统计信息 +- 双栏对比视图(左为原始文档,右为修改后文档) +- 导航控制按钮 +- 响应式设计支持 + +## 对比算法 + +工具使用智能的相似度算法来识别文档变化: + +1. **行级分割**: 将文档按换行符分割为行 +2. **相似度计算**: 基于字符级别的相似度匹配 +3. **变更分类**: + - 未修改 (>80% 相似度) + - 修改 (10%-80% 相似度) + - 新增 (仅在修改后文档中存在) + - 删除 (仅在原始文档中存在) +4. **最佳匹配**: 寻找最相似的行配对 + +## 使用示例 + +```typescript +const result = await tool({ + originalText: `# 项目文档 + +## 功能介绍 +这是一个测试功能。 + +## 安装步骤 +1. 下载代码 +2. 运行安装命令`, + + modifiedText: `# 项目文档 + +## 功能介绍 +这是一个更新的测试功能。 + +## 安装步骤 +1. 下载代码 +2. 运行安装命令 +3. 配置环境变量`, + + title: "项目文档变更记录" +}); + +console.log(result.htmlUrl); // 输出 HTML 对比报告 URL +``` + +## HTML 报告特性 + +- 🎨 **现代化设计**: 渐变背景、卡片布局、阴影效果 +- 📈 **统计仪表板**: 直观显示各类变更数量 +- 🔄 **双栏对比**: 左右并排显示原始和修改内容 +- 🎯 **导航功能**: 点击按钮或使用键盘快捷键在变更间跳转 +- 🏷️ **状态标签**: 不同颜色标识变更类型 +- ✨ **高亮效果**: 柔和的高亮提示,自动消失 +- 📝 **代码显示**: 使用等宽字体保持格式 +- 📱 **响应式布局**: 自适应不同屏幕尺寸 + +## 技术实现 + +- **TypeScript**: 类型安全的实现 +- **Zod 验证**: 输入参数验证 +- **纯函数设计**: 无副作用的处理逻辑 +- **HTML 安全**: 自动转义特殊字符 + +## 适用场景 + +- 📄 **文档版本对比**: 比较文档的不同版本 +- 📝 **内容审核**: 查看内容修改记录 +- 🔄 **变更追踪**: 跟踪文档变化 +- 📊 **差异分析**: 分析文档变更模式 + +## 开发和测试 + +```bash +# 安装依赖 +bun install + +# 运行测试 +bun test + +# 类型检查 +bun run tsc --noEmit + +# 构建 +bun run build +``` + +## 测试覆盖 + +- ✅ 输入验证测试 +- ✅ 文档对比逻辑测试 +- ✅ HTML 输出质量测试 +- ✅ 边界情况测试 +- ✅ 安全性测试 + +## 版本信息 + +- **当前版本**: 1.0.0 +- **FastGPT 兼容性**: 完全兼容 +- **依赖**: 仅依赖 Zod,无外部 API 调用 diff --git a/modules/tool/packages/docDiff/bun.lock b/modules/tool/packages/docDiff/bun.lock new file mode 100644 index 00000000..ecfad29b --- /dev/null +++ b/modules/tool/packages/docDiff/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@fastgpt-plugins/tool-doc-diff", + "dependencies": { + "zod": "^3.25.76", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + } +} diff --git a/modules/tool/packages/docDiff/config.ts b/modules/tool/packages/docDiff/config.ts new file mode 100644 index 00000000..e6c6374b --- /dev/null +++ b/modules/tool/packages/docDiff/config.ts @@ -0,0 +1,67 @@ +import { defineTool } from '@tool/type'; +import { FlowNodeInputTypeEnum, WorkflowIOValueTypeEnum } from '@tool/type/fastgpt'; +import { ToolTagEnum } from '@tool/type/tags'; + +export default defineTool({ + name: { + 'zh-CN': '文档对比工具', + en: 'DocDiff' + }, + tags: [ToolTagEnum.enum.tools], + description: { + 'zh-CN': '对比两个 Markdown 文档的差异,生成可视化的 HTML 对比报告', + en: 'Compare differences between two Markdown documents and generate visual HTML comparison report' + }, + toolDescription: + 'A tool that compares two markdown documents and generates a visual HTML diff report showing differences section by section', + + versionList: [ + { + value: '1.0.0', + description: 'Initial version', + inputs: [ + { + key: 'originalText', + label: '原始文档', + description: '原始的 Markdown 格式文档内容', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], + toolDescription: 'The original markdown document content to compare' + }, + { + key: 'modifiedText', + label: '修改后文档', + description: '修改后的 Markdown 格式文档内容', + required: true, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.textarea, FlowNodeInputTypeEnum.reference], + toolDescription: 'The modified markdown document content to compare' + }, + { + key: 'title', + label: '对比报告标题', + description: '生成的 HTML 对比报告的标题', + required: false, + valueType: WorkflowIOValueTypeEnum.string, + renderTypeList: [FlowNodeInputTypeEnum.input], + defaultValue: '文档对比报告' + } + ], + outputs: [ + { + valueType: WorkflowIOValueTypeEnum.string, + key: 'htmlUrl', + label: 'HTML 对比报告连接', + description: '生成的 HTML 对比报告的访问连接' + }, + { + valueType: WorkflowIOValueTypeEnum.arrayObject, + key: 'diffs', + label: '差异结果数组', + description: '过滤后的文档差异数组,包含新增、删除、修改的变更' + } + ] + } + ] +}); diff --git a/modules/tool/packages/docDiff/index.ts b/modules/tool/packages/docDiff/index.ts new file mode 100644 index 00000000..d698ed48 --- /dev/null +++ b/modules/tool/packages/docDiff/index.ts @@ -0,0 +1,10 @@ +import config from './config'; +import { InputType, OutputType, tool as toolCb } from './src'; +import { exportTool } from '@tool/utils/tool'; + +export default exportTool({ + toolCb, + InputType, + OutputType, + config +}); diff --git a/modules/tool/packages/docDiff/logo.svg b/modules/tool/packages/docDiff/logo.svg new file mode 100644 index 00000000..8621c8aa --- /dev/null +++ b/modules/tool/packages/docDiff/logo.svg @@ -0,0 +1,20 @@ + diff --git a/modules/tool/packages/docDiff/package.json b/modules/tool/packages/docDiff/package.json new file mode 100644 index 00000000..c8e25eea --- /dev/null +++ b/modules/tool/packages/docDiff/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fastgpt-plugins/tool-doc-diff", + "module": "index.ts", + "type": "module", + "scripts": { + "build": "bun ../../../../scripts/build.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "zod": "^3.25.76" + } +} diff --git a/modules/tool/packages/docDiff/src/index.ts b/modules/tool/packages/docDiff/src/index.ts new file mode 100644 index 00000000..40f1d40d --- /dev/null +++ b/modules/tool/packages/docDiff/src/index.ts @@ -0,0 +1,1119 @@ +import { uploadFile } from '@tool/utils/uploadFile'; +import { z } from 'zod'; + +export const InputType = z.object({ + originalText: z.string().min(1, '原始文档内容不能为空'), + modifiedText: z.string().min(1, '修改后文档内容不能为空'), + title: z.string().optional().default('文档对比报告') +}); + +export const OutputType = z.object({ + htmlUrl: z.string(), + diffs: z.array( + z.object({ + type: z.enum(['added', 'removed', 'modified']), + original: z.string().optional(), + modified: z.string().optional(), + lineNumber: z.number() + }) + ) +}); + +// 输入类型:title 是可选的 +export type InputType = { + originalText: string; + modifiedText: string; + title?: string; +}; + +// 输出类型 +export type OutputType = { + htmlUrl: string; + diffs: { + type: 'added' | 'removed' | 'modified'; + original?: string; + modified?: string; + lineNumber: number; + }[]; +}; + +// 定义段落差异类型 +type DiffType = 'unchanged' | 'added' | 'removed' | 'modified'; + +interface ParagraphDiff { + type: DiffType; + original?: string; + modified?: string; + lineNumber?: number; +} + +// 分割文档为行 +function splitIntoLines(text: string): string[] { + return text.split('\n'); +} + +// 计算两个段的相似度 +function calculateSimilarity(text1: string, text2: string): number { + // 移除首尾空白字符 + const clean1 = text1.trim(); + const clean2 = text2.trim(); + + // 如果两行都为空,则完全相同 + if (!clean1 && !clean2) return 1.0; + if (!clean1 || !clean2) return 0.0; + + // 如果内容完全相同,直接返回1.0 + if (clean1 === clean2) return 1.0; + + // 移除所有空白字符并转换为小写进行比较 + const chars1 = clean1.replace(/\s+/g, '').toLowerCase(); + const chars2 = clean2.replace(/\s+/g, '').toLowerCase(); + + const longer = chars1.length > chars2.length ? chars1 : chars2; + const shorter = chars1.length > chars2.length ? chars2 : chars1; + + if (longer.length === 0) return 1.0; + + const matches = Array.from(longer).filter( + (char, index) => index < shorter.length && char === shorter[index] + ).length; + + return matches / longer.length; +} + +// 对比两个文档 +function compareDocuments(originalText: string, modifiedText: string): ParagraphDiff[] { + const originalLines = splitIntoLines(originalText); + const modifiedLines = splitIntoLines(modifiedText); + + const diffs: ParagraphDiff[] = []; + let origIndex = 0; + let modIndex = 0; + let currentLineNumber = 1; // 使用连续的行号 + + while (origIndex < originalLines.length || modIndex < modifiedLines.length) { + const originalLine = originalLines[origIndex] || ''; + const modifiedLine = modifiedLines[modIndex] || ''; + + // 如果其中一个文档已经处理完毕 + if (origIndex >= originalLines.length) { + // 只有修改后的文档有内容,这是新增行 + if (modifiedLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'added', + modified: modifiedLine, + lineNumber: currentLineNumber++ + }); + } + modIndex++; + continue; + } + + if (modIndex >= modifiedLines.length) { + // 只有原始文档有内容,这是删除行 + if (originalLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'removed', + original: originalLine, + lineNumber: currentLineNumber++ + }); + } + origIndex++; + continue; + } + + // 如果两行都是空的,跳过 + if (!originalLine.trim() && !modifiedLine.trim()) { + origIndex++; + modIndex++; + continue; + } + + // 计算行相似度 + const similarity = calculateSimilarity(originalLine, modifiedLine); + + if (similarity > 0.9) { + // 完全相同的行,标记为unchanged + diffs.push({ + type: 'unchanged', + original: originalLine, + modified: modifiedLine, + lineNumber: currentLineNumber++ + }); + origIndex++; + modIndex++; + } else if (similarity > 0.8) { + // 修改的行 + diffs.push({ + type: 'modified', + original: originalLine, + modified: modifiedLine, + lineNumber: currentLineNumber++ + }); + origIndex++; + modIndex++; + } else { + // 寻找最佳匹配 + let bestMatchIndex = -1; + let bestSimilarity = 0; + + for (let i = 0; i < Math.min(3, modifiedLines.length - modIndex); i++) { + const candidateSimilarity = calculateSimilarity(originalLine, modifiedLines[modIndex + i]); + if (candidateSimilarity > bestSimilarity) { + bestSimilarity = candidateSimilarity; + bestMatchIndex = i; + } + } + + if (bestSimilarity > 0.6) { + // 找到匹配,先添加新增的行 + for (let i = 0; i < bestMatchIndex; i++) { + const addedLine = modifiedLines[modIndex + i]; + if (addedLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'added', + modified: addedLine, + lineNumber: currentLineNumber++ + }); + } + } + + // 添加修改的行 + diffs.push({ + type: 'modified', + original: originalLine, + modified: modifiedLines[modIndex + bestMatchIndex], + lineNumber: currentLineNumber++ + }); + modIndex += bestMatchIndex + 1; + origIndex++; + } else { + // 没有找到匹配,可能是删除 + if (originalLine.trim()) { + // 只添加非空行 + diffs.push({ + type: 'removed', + original: originalLine, + lineNumber: currentLineNumber++ + }); + } + origIndex++; + } + } + } + + return diffs; +} + +// 生成 HTML 报告 +function generateHtmlReport(diffs: ParagraphDiff[], title: string): string { + const timestamp = new Date().toLocaleString('zh-CN'); + + const css = ` + + `; + + const js = ` + + `; + + // 计算统计信息 + const stats = diffs.reduce( + (acc, diff) => { + acc[diff.type]++; + return acc; + }, + { unchanged: 0, added: 0, removed: 0, modified: 0 } + ); + + // 生成左侧原始内容 + const originalContent = diffs + .map((diff, index) => { + let content = ''; + let badge = ''; + const typeClass = diff.type; + + if (diff.type === 'added') { + // 新增的内容在左侧显示为空占位符 + content = '
'; + } else if (diff.type === 'removed') { + content = `