diff --git a/frontend/src/core/features/block_editor/BlockEditorEntry.vue b/frontend/src/core/features/block_editor/BlockEditorEntry.vue index 4a0ee313a..d237b6716 100644 --- a/frontend/src/core/features/block_editor/BlockEditorEntry.vue +++ b/frontend/src/core/features/block_editor/BlockEditorEntry.vue @@ -388,6 +388,7 @@ const _handleAIAction = async (e: Event) => { insertTo: range.to, modifiedText: range.modifiedText, originalContent: range.originalContent, + modifiedContent: range.modifiedContent, }) }, }, true) // reviewMode is now always true diff --git a/frontend/src/core/features/block_editor/composables/useAICommand.ts b/frontend/src/core/features/block_editor/composables/useAICommand.ts index 27e91d0f4..e63120129 100644 --- a/frontend/src/core/features/block_editor/composables/useAICommand.ts +++ b/frontend/src/core/features/block_editor/composables/useAICommand.ts @@ -24,7 +24,7 @@ export interface AIStreamCallbacks { /** 流式完成 */ onDone: (fullContent: string) => void /** 审阅模式:AI 输出完成并插入后的位置范围和对比文本 */ - onReviewReady?: (range: { from: number; to: number; originalText: string; modifiedText: string; originalContent?: any[] }) => void + onReviewReady?: (range: { from: number; to: number; originalText: string; modifiedText: string; originalContent?: any[]; modifiedContent?: any[] }) => void /** 出错 */ onError: (error: string) => void } @@ -484,6 +484,10 @@ async function callInlineAI(options: InlineAICallOptions) { if (options.reviewMode && originalInsertAt !== null) { const reviewTo = editor.state.selection.to const modifiedText = editor.state.doc.textBetween(originalInsertAt, reviewTo, '\n', '\n') + // 捕获修改后内容的 PM 结构化节点,用于块级 diff + const modifiedContent = editor.state.doc + .slice(originalInsertAt, reviewTo) + .toJSON()?.content options.callbacks?.onReviewReady?.( { from: originalInsertAt, @@ -491,6 +495,7 @@ async function callInlineAI(options: InlineAICallOptions) { originalText: options.context.selectedText, modifiedText, originalContent: (options as any)._originalContent, + modifiedContent, }, ) } else { diff --git a/frontend/src/core/features/block_editor/composables/useAIReview.ts b/frontend/src/core/features/block_editor/composables/useAIReview.ts index 8d520706d..dd6941006 100644 --- a/frontend/src/core/features/block_editor/composables/useAIReview.ts +++ b/frontend/src/core/features/block_editor/composables/useAIReview.ts @@ -17,6 +17,7 @@ import { ref, type Ref } from 'vue' import type { Editor } from '@tiptap/core' import { Decoration, DecorationSet } from '@tiptap/pm/view' import { computeDiff } from '../plugins/ai-review/diff' +import { computeStructuredDiff } from '../plugins/ai-review/structuredDiff' import { buildDecorationSet, createDeleteWidgetDom } from '../plugins/ai-review/decorations' import { reviewState, @@ -36,6 +37,8 @@ export interface ReviewStartOptions { modifiedText: string /** 原始文档结构(ProseMirror JSON,用于 rejectAll 恢复段落结构) */ originalContent?: any[] + /** 修改后文档结构(ProseMirror JSON,用于块级 diff) */ + modifiedContent?: any[] } export function useAIReview(editorRef: Ref) { @@ -96,8 +99,10 @@ export function useAIReview(editorRef: Ref) { const { originalText, insertFrom, modifiedText, insertTo } = opts - // 1. 计算 diff - const diffSegments = computeDiff(originalText, modifiedText) + // 1. 计算 diff(优先使用块级结构化 diff) + const diffSegments = (opts.originalContent && opts.modifiedContent) + ? computeStructuredDiff(opts.originalContent, opts.modifiedContent) + : computeDiff(originalText, modifiedText) const nonEqualSegs = diffSegments.filter(s => s.type !== 'equal') // 2. 构建 segment 信息列表 + DecorationSet(含位置) diff --git a/frontend/src/core/features/block_editor/plugins/ai-review/structuredDiff.ts b/frontend/src/core/features/block_editor/plugins/ai-review/structuredDiff.ts new file mode 100644 index 000000000..f6b7c1e66 --- /dev/null +++ b/frontend/src/core/features/block_editor/plugins/ai-review/structuredDiff.ts @@ -0,0 +1,97 @@ +/** + * plugins/ai-review/structuredDiff.ts — 块级结构化差异算法 + * + * 将 ProseMirror JSON 节点数组按块对齐,跳过未变更的整块段落, + * 仅在变更块内部执行字符级 LCS diff。 + * + * 相比纯字符级 diff 的优势: + * - 未变更的整块段落不产生任何 decoration(用户不会看到"无关文本被灰色覆盖") + * - diff 不会跨块边界,避免块间误匹配 + * - 与 ProseMirror 文档模型天然对齐 + * + * 用法:当 originalContent(原始 PM 节点数组)和 modifiedContent(修改后 PM 节点数组) + * 同时存在时,此函数替代 computeDiff。 + */ + +import { computeDiff, type DiffSegment } from './diff' + +/** ProseMirror JSON 节点(简化接口) */ +interface PMNode { + type: string + content?: PMNode[] + text?: string + [key: string]: unknown +} + +/** + * 提取 PM 节点的纯文本内容(递归遍历 content 数组)。 + * 与 ProseMirror 的 textBetween('', '') 行为一致。 + */ +function extractNodeText(node: PMNode): string { + if (node.type === 'text') return node.text || '' + if (!node.content || node.content.length === 0) return '' + return node.content.map(extractNodeText).join('') +} + +/** + * 块级结构化 diff。 + * + * @param originalContent 原始内容的 PM JSON 节点数组(doc.slice().toJSON().content) + * @param modifiedContent 修改后内容的 PM JSON 节点数组 + * @returns DiffSegment[] 可直接输入 buildDecorationSet + * + * 算法: + * 1. 按索引对齐原始与修改的块 + * 2. 文本完全相同的块 → 单个 equal 段(跳过 decoration) + * 3. 文本不同的块 → 在块范围内执行字符级 LCS diff + * 4. 仅出现在原始中的块 → delete 段 + * 5. 仅出现在修改中的块 → insert 段 + * 6. 块间 \n 作为独立 equal 段插入,维持 charOffset 与 textBetween 一致 + */ +export function computeStructuredDiff( + originalContent: PMNode[], + modifiedContent: PMNode[], +): DiffSegment[] { + if (!originalContent || originalContent.length === 0) { + return [{ type: 'insert', original: '', modified: modifiedContent.map(extractNodeText).join('\n') }] + } + if (!modifiedContent || modifiedContent.length === 0) { + return [{ type: 'delete', original: originalContent.map(extractNodeText).join('\n'), modified: '' }] + } + + const origTexts = originalContent.map(extractNodeText) + const modTexts = modifiedContent.map(extractNodeText) + const maxLen = Math.max(origTexts.length, modTexts.length) + + const segments: DiffSegment[] = [] + + for (let i = 0; i < maxLen; i++) { + // 块间分隔符:\n 作为独立 equal 段 + if (i > 0) { + segments.push({ type: 'equal', original: '\n', modified: '\n' }) + } + + const orig = i < origTexts.length ? origTexts[i] : '' + const mod = i < modTexts.length ? modTexts[i] : '' + + if (orig === mod) { + // ── 未变更块:单段 equal,不产生 decoration ── + if (orig !== '') { + segments.push({ type: 'equal', original: orig, modified: mod }) + } + // 空块跳过(不输出,保留位置计数但装饰系统会忽略) + } else if (orig === '') { + // ── 新增块 ── + segments.push({ type: 'insert', original: '', modified: mod }) + } else if (mod === '') { + // ── 删除块 ── + segments.push({ type: 'delete', original: orig, modified: '' }) + } else { + // ── 变更块:块内字符级 LCS diff ── + const innerSegs = computeDiff(orig, mod) + segments.push(...innerSegs) + } + } + + return segments +}