Block Editor重构审阅模式下diff功能的实现

This commit is contained in:
jingrow 2026-06-14 03:32:54 +08:00
parent e86af3110a
commit c88f94726e
4 changed files with 111 additions and 3 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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<Editor | null>) {
@ -96,8 +99,10 @@ export function useAIReview(editorRef: Ref<Editor | null>) {
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含位置

View File

@ -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
}