feat(ppt): update PPT diff manifest to version 4 with enhanced element tracking

- Changed `PptRuntimeDocument` to include `movedSlideIds`, `movedElementIds`, and `elementLineage`.
- Introduced new types for element and slide snapshots in the PPT diff.
- Updated `PptDiffSlide` to track element changes and counts for positive and negative changes.
- Added a new utility function `assertSupportedPptDiffManifest` to validate the manifest version.
- Implemented comprehensive tests for PPT diff functionality, including net semantics for slides and elements.
- Enhanced existing tests to cover new diff semantics and ensure backward compatibility with version 4.
- Updated end-to-end tests to validate UI interactions with the new diff features.
This commit is contained in:
hy committed 2026-07-29 14:50:47 +08:00
1 parent 98295b808e
commit f92cfd5c36
14 files changed
+1261 -102

No files matched your search

+9 -4
View File
@@ -476,7 +476,7 @@
- `WB-APPLY-002-AC2` 新建 `docx` 后,目标文件立即存在,assistant turn 展示本轮“修改的文件”入口。
- `WB-APPLY-002-AC3` 创建或修改普通文本文件后,目标文件立即更新并生成修改记录;运行期间不展示修改入口,收到 `run_completed / run_failed / run_cancelled` 后 assistant turn 才展示本轮“修改的文件”,右侧可查看由真实写入前后内容计算的文本 diff。`answer_completed` 不视为 run 已结束。
- `WB-APPLY-002-AC4` 普通文本 `edit` 的每个 `oldText` 必须唯一且互不重叠;`replaceAll` 替换一个 `oldText` 的全部非重叠精确匹配;失败时文件保持原样。
- `WB-APPLY-002-AC5` 同一次 AI Run 对同一个 pptx 的多次逐页编辑只生成一条修改记录,加减数量按变化页计算。
- `WB-APPLY-002-AC5` 同一次 AI Run 对同一个 pptx 的多次编辑只生成一条修改记录;PPT Diff 固定比较本 Run 第一次接触文件时的状态和 Run 完成后的最终状态,工具调用只用于识别页面、元素和真实移动,不直接累加操作次数。
- `WB-APPLY-002-AC6` 创建或修改 `xlsx` 后,正式路径立即包含最终净工作簿并生成一条修改记录;同一次 AI Run 对同一路径连续编辑时保持首次写入前基线,不重复生成记录。
- `WB-APPLY-002-AC7` `docx_edit`、`ppt_edit` 和 `excel_edit` 统一使用 `sourcePath + actions[]`;目标不存在时由编辑 Action 直接创建,不再提供独立的文档或演示文稿创建 Action。
- `WB-APPLY-002-AC8` `docx_edit.insert_blocks` 的 `position` 必填,`anchorBlockId` 可选;无锚点时 `before` 表示文档开头,`after` 表示文档末尾。
@@ -501,7 +501,7 @@
- `WB-APPLY-003-AC3` 回退成功后,右侧修改的文件工作区应刷新记录状态;普通文本历史 diff 继续保留并标记“已回退”或“已删除”,避免丢失本次修改记录。
- `WB-APPLY-003-AC4` 终端通过 Shell、Python、Node 或其他程序造成的文件变化均由 Run 结束扫描捕获;终端修改的文本可生成普通行级 Diff,终端直接修改的 Docx 等二进制文件只提供修改前/修改后版本,不伪造语义审阅标记。
- `WB-APPLY-003-AC5` Docx 工具修改保存 before、committed 和 Review Docx;Review Docx 继续由现有 Docx Renderer 渲染并保留段落、表格、图片、字体和页面结构。
- `WB-APPLY-003-AC6` PPT 修改记录在审核模式提供页面级差异内容;Excel 专用工具修改 `xlsx` 时在审核模式提供单元格边框标记,并可切换到预览模式。终端产生的二进制文件变化只提供修改前/修改后版本,不显示模式切换;终端产生的 UTF-8 文本变化按普通文本规则处理。
- `WB-APPLY-003-AC6` PPT 修改记录使用 Diff Manifest v4,在审核模式提供固定的修改前后页面快照;旧 v3 记录明确提示格式过期,正式 PPT 不受影响。Excel 专用工具修改 `xlsx` 时在审核模式提供单元格边框标记,并可切换到预览模式。终端产生的二进制文件变化只提供修改前/修改后版本,不显示模式切换;终端产生的 UTF-8 文本变化按普通文本规则处理。
- `WB-APPLY-003-AC7` 图片生成记录直接预览最终图片并提供回退,不显示不存在的图片 Diff;更新图片回退后恢复首次生成前的文件,新建图片回退后删除目标文件。
- `WB-APPLY-003-AC8` Docx Review 只展示本次 Run 初始文档到最终净稿的净变化;同一原始 block 连续修改只显示一次原始红色版本和一次最终绿色版本,中间稿不得出现,改回原内容不产生修改记录;本轮新增 block 只显示最终仍存在的绿色版本,新增后删除不产生净变化。
@@ -542,13 +542,18 @@
#### WB-PPT-002 PPT 单页左右审核模式
- 系统行为:用户打开 pptx 修改记录后,主区域左侧显示当前页修改前画面,右侧显示同一页修改后画面;最右侧显示缩略图。点击缩略图或滚动时左右同步切页。
- 系统行为:用户打开 pptx 修改记录后,主区域左侧显示当前页修改前画面,右侧显示同一页修改后画面;最右侧显示缩略图。点击缩略图或滚动时左右同步切页。审核内容严格来自同一 Run 首个 baseline 与最终 PPT 的净变化;SVG 只用于展示,不决定变化语义。
- 验收:
- `WB-PPT-002-AC1` 主区域按左侧修改前、右侧修改后一次各显示一页。
- `WB-PPT-002-AC2` 缩略图支持只看变化页和查看全部页两种图标模式,默认只看变化页。
- `WB-PPT-002-AC3` 变化页通过边框和新增、删除、移动图标标记,不显示文字差异说明。
- `WB-PPT-002-AC3` 新增、修改、移动页使用绿色标记,整页删除使用红色标记,不显示“修改几页”等文字差异说明;已有页面只删除内部元素时,该页仍按修改显示绿色。
- `WB-PPT-002-AC4` 新增页左侧为空;删除页右侧为空。
- `WB-PPT-002-AC5` 点击缩略图或滚动后,左右主区域与当前缩略图保持同步。
- `WB-PPT-002-AC6` `+N` 统计最终保留的新增、修改和移动单位,`-N` 统计最终保留的删除单位。新增整页为 `+1`、删除整页为 `-1`、移动整页为 `+1`,整页增删不重复统计内部元素。
- `WB-PPT-002-AC7` 已有页面内新增元素为 `+1`、删除元素为 `-1`、修改或移动元素为 `+1`;同一元素同时修改内容、样式、位置、大小或层级只计一次。页面移动并修改两个元素时为 `+3`。
- `WB-PPT-002-AC8` 插入或删除页面造成的自然页码变化不算移动;元素增删或置顶造成其他元素层级编号自然变化时,也不得把其他元素误算为修改。
- `WB-PPT-002-AC9` A→B→A、移动后移回、新增后删除等零净变化不生成记录;原元素修改后删除只计 `-1`,新页面继续修改仍只计 `+1`。
- `WB-PPT-002-AC10` 审核模式使用固定 Diff 快照,预览模式使用干净的正式 PPT;正式文件不得包含 Diff 标记,回退按修改前 bytes 恢复。
### 8.10 Excel 读取、编辑与审核模式
+9
View File
@@ -587,6 +587,15 @@ AI unit / 辅助测试目录:
- Oracle:每次 `ppt_edit` 成功后正式文件立即存在、可解析且页面内容正确;Run 收尾后同一文件只生成一条修改记录和页面级 diff。
- Not:不验证 human 的工具选择或页面审美。
##### `ppt-diff-slide-and-element-net-semantics`
- 测试类型:`backend / tool`
- What:真实 PPT runtime 按本 Run 首个 baseline 和最终文件生成 Diff Manifest v4,覆盖页面和元素的新增、删除、修改、移动、恢复与组合计数。
- Why:工具调用次数、插入删除造成的页码偏移和置顶造成的层级编号顺移都可能制造假 Diff,必须直接解析真实 artifact 验证净变化。
- How:对三个真实 PPT 连续执行页面 A→B→C、A→B→A、修改后删除、新增后继续修改、新增后删除、中间页删除、主动移动及移动后恢复;再分别改变元素内容、样式、位置、大小、层级,覆盖同元素多种修改、公式 replacement lineage、元素新增后修改、修改后删除、新增后删除、修改后恢复、备注修改后恢复,最后提交 Run 并读取 Manifest 和正式 PPT。
- Oracle:页面组合得到准确 `+3/-2`,元素组合得到准确 `+6/-1`,零净变化文件不生成记录;中间页增删不把后续页标记为移动,置顶只计算真实移动元素;中间稿和临时对象不进入 before/after;正式 PPT 可解析且无 Diff 污染,修改记录的 `insertCount/deleteCount/changeCount` 分别等于 v4 的 positive、negative 和两者之和。
- Not:不验证 Workbench 布局、按钮、颜色 CSS 或模型是否会自主选择操作;这些由 Workbench E2E 和 human/live case 承担。
##### `ppt-professional-element-edit`
- 测试类型:`backend / tool`
+7 -7
View File
@@ -23,7 +23,7 @@
- `web_search` 与 `web_crawl` 需要分开覆盖:搜索 case 只验证候选网页返回;读取 case 必须验证 AI 继续读取网页正文后再回答。
- Workbench 附件链路当前支持图片、`doc`、`docx`、`pptx`、`pdf`、`md`、`json`、`xlsx`、`xlsm`、`xltx`、`xltm`,统一保存到 `<workspace>/.ncurator/runtime/assets/<assetBucketId>/`;`assetBucketId` 为输入草稿 UUID,发送给 AI 时只传附件名称与路径,不再做发送前识图或文件解析。
- `WB-EXCEL-001-AC1`、`WB-EXCEL-001-AC2`、`WB-EXCEL-001-AC3`、`WB-EXCEL-001-AC4`、`WB-EXCEL-001-AC6`、`WB-EXCEL-001-AC7` 的工具输入输出、完整编辑操作、原子失败、冲突、只读格式、默认 Sheet 和 Sheet 严格校验由 AI tool 回归承担;`WB-EXCEL-002` 的完整 XLSX diff 语义也由 AI tool 解析 diff 文件校验。Workbench 的 Excel apply case 负责真实模型选用工具、正式文件、修改入口、只读单网格预览和回退闭环,不重复枚举底层操作。
- docx、ppt、pdf 也按同一分层:格式专用工具的读写、边界、格式保真和 diff artifact 语义放在 AI tool;Workbench e2e 只验证真实用户流程、预览入口、差异面板交互和回退。PDF 当前是只读工具,没有 Workbench apply diff 语义。
- docx、ppt、pdf 也按同一分层:格式专用工具的读写、边界、格式保真和 diff artifact 语义放在 AI tool;其中 PPT tool 必须完整覆盖 baseline 到最终状态的页面/元素净变化、自然位移、零净变化、lineage、v4 schema 和准确计数。Workbench e2e 只验证真实用户流程、现有预览入口、红绿页面标记、`+N/-N`、差异面板交互和回退,不重复枚举底层元素组合。PDF 当前是只读工具,没有 Workbench apply diff 语义。
## 3. Suite 设计
@@ -402,19 +402,19 @@ tests/case/workbench/
#### `ppt-create-diff-and-revert`
- 测试类型:`e2e / live`
- What:真实 AI 创建两页 PPT 后,用户可打开页面级左右 diff、切换缩略图模式并回撤文件。
- What:真实 AI 创建两页 PPT 后,用户可打开页面级左右 diff、看到准确 `+2/-0`、切换审核/预览并回撤文件。
- Why:新建 PPT 必须同时验证文件交付、审核模式和回退闭环。
- How:通过真实 Workbench UI 请求 AI 创建两页 PPT,打开修改记录并操作审核模式和回退。
- Oracle:真实 PPT 创建、原子写入和 run 级修改记录对齐 `WB-PPT-001`、`WB-APPLY-002`;pptx 可解析,左右差异区和缩略图可见,全部页与变化页切换有效;回撤后新建文件被删除,对齐 `WB-APPLY-003`。
- How:通过真实 Workbench UI 请求 AI 创建两页 PPT,打开修改记录,操作审核/预览、缩放、拖拽和回退。
- Oracle:真实 PPT 创建、原子写入和 run 级修改记录对齐 `WB-PPT-001`、`WB-APPLY-002`;pptx 可解析,审核模式显示两页和 `+2/-0`,预览模式显示正式 PPT,现有缩放拖拽可用;回撤后新建文件被删除,对齐 `WB-APPLY-003`。
- Not:不逐字断言最终回答,不对页面审美打分。
#### `ppt-update-diff-interactions`
- 测试类型:`e2e`
- What:已有 PPT 更新后的左右差异、变化标记、缩略图筛选、缩放和拖动画布。
- What:已有 PPT 更新后的左右差异、绿色新增/修改/移动、红色删除、准确 `+N/-N`、缩略图筛选、审核/预览、缩放和拖动画布。
- Why:创建态与更新态的差异语义不同,不能只用新建 PPT case 代替。
- How:修改真实 PPT 的页面内容和顺序,打开修改记录并操作审核模式。
- Oracle:修改前后画面、变化页标记、全部页切换、同步切页、缩放和拖动均可用,对齐 `WB-PPT-002`。
- How:让真实 AI 对已有四页 PPT 主动移动一页、修改一页、删除中间页、新增一页,并要求末页完全不变;打开修改记录并操作审核/预览、筛选、同步切页、缩放、拖动和回退。
- Oracle:正式 PPT 内容和顺序正确;审核显示准确 `+3/-1`、三个绿色页面标记和一个红色删除标记;删除中间页不会把未改末页标绿,变化筛选为四页、全部模式为五个 before/after 对齐项;审核/预览切换、同步缩放拖动不变,回退后 bytes 与 baseline 完全一致,对齐 `WB-PPT-002`、`WB-APPLY-003`。
- Not:不评价页面设计质量。
#### `excel-update-diff-and-revert`
@@ -0,0 +1,273 @@
import type {
PptDiffElementChange,
PptDiffElementSnapshot,
PptDiffFrame,
PptDiffManifest,
PptDiffSlide,
PptDiffSlideSnapshot
} from './types'
type Match = {
before: PptDiffElementSnapshot
after: PptDiffElementSnapshot
}
const compareIds = (left: { elementId: string }, right: { elementId: string }): number =>
left.elementId.localeCompare(right.elementId)
const followLineage = (elementId: string, lineage: ReadonlyMap<string, string>): string => {
const seen = new Set<string>()
let current = elementId
while (!seen.has(current)) {
seen.add(current)
const next = lineage.get(current)
if (!next) break
current = next
}
return current
}
export const diffPptElements = (input: {
before: readonly PptDiffElementSnapshot[]
after: readonly PptDiffElementSnapshot[]
lineage?: ReadonlyMap<string, string>
movedCandidates?: ReadonlySet<string>
}): PptDiffElementChange[] => {
const lineage = input.lineage || new Map<string, string>()
const unmatchedBefore = new Map(input.before.map((element) => [element.elementId, element]))
const unmatchedAfter = new Map(input.after.map((element) => [element.elementId, element]))
const matches: Match[] = []
for (const before of input.before) {
const afterId = unmatchedAfter.has(before.elementId)
? before.elementId
: followLineage(before.elementId, lineage)
const after = unmatchedAfter.get(afterId)
if (!after) continue
matches.push({ before, after })
unmatchedBefore.delete(before.elementId)
unmatchedAfter.delete(after.elementId)
}
const beforeByFingerprint = new Map<string, PptDiffElementSnapshot[]>()
for (const element of unmatchedBefore.values()) {
const entries = beforeByFingerprint.get(element.fingerprint) || []
entries.push(element)
beforeByFingerprint.set(element.fingerprint, entries)
}
const afterByFingerprint = new Map<string, PptDiffElementSnapshot[]>()
for (const element of unmatchedAfter.values()) {
const entries = afterByFingerprint.get(element.fingerprint) || []
entries.push(element)
afterByFingerprint.set(element.fingerprint, entries)
}
for (const [fingerprint, beforeElements] of beforeByFingerprint) {
const afterElements = afterByFingerprint.get(fingerprint)
if (!afterElements) continue
const sortedBefore = [...beforeElements].sort(compareIds)
const sortedAfter = [...afterElements].sort(compareIds)
const pairCount = Math.min(sortedBefore.length, sortedAfter.length)
for (let index = 0; index < pairCount; index += 1) {
const before = sortedBefore[index]!
const after = sortedAfter[index]!
matches.push({ before, after })
unmatchedBefore.delete(before.elementId)
unmatchedAfter.delete(after.elementId)
}
}
const matchedBeforeIds = new Set(matches.map(({ before }) => before.elementId))
const beforeOrder = input.before
.filter((element) => matchedBeforeIds.has(element.elementId))
.sort((left, right) => left.zIndex - right.zIndex)
.map((element) => element.elementId)
const beforeIdByAfterId = new Map(
matches.map(({ before, after }) => [after.elementId, before.elementId])
)
const afterOrder = input.after
.filter((element) => beforeIdByAfterId.has(element.elementId))
.sort((left, right) => left.zIndex - right.zIndex)
.map((element) => beforeIdByAfterId.get(element.elementId)!)
const movedCandidateBeforeIds = new Set(
matches.flatMap(({ before, after }) =>
input.movedCandidates?.has(before.elementId) || input.movedCandidates?.has(after.elementId)
? [before.elementId]
: []
)
)
const movedElementIds = resolveMovedIds({
beforeIds: beforeOrder,
afterIds: afterOrder,
movedCandidates: movedCandidateBeforeIds
})
const changes: PptDiffElementChange[] = matches.flatMap(({ before, after }) =>
before.fingerprint === after.fingerprint && !movedElementIds.has(before.elementId)
? []
: [
{
kind: 'modified' as const,
beforeElementId: before.elementId,
afterElementId: after.elementId
}
]
)
changes.push(
...[...unmatchedBefore.values()].sort(compareIds).map((element) => ({
kind: 'deleted' as const,
beforeElementId: element.elementId
})),
...[...unmatchedAfter.values()].sort(compareIds).map((element) => ({
kind: 'added' as const,
afterElementId: element.elementId
}))
)
return changes
}
type LcsScore = { length: number; protectedCount: number; ids: string[] }
const betterScore = (left: LcsScore, right: LcsScore): LcsScore => {
if (left.length !== right.length) return left.length > right.length ? left : right
if (left.protectedCount !== right.protectedCount) {
return left.protectedCount > right.protectedCount ? left : right
}
return left.ids.join('\u0000') <= right.ids.join('\u0000') ? left : right
}
const resolveMovedIds = (input: {
beforeIds: readonly string[]
afterIds: readonly string[]
movedCandidates?: ReadonlySet<string>
}): Set<string> => {
const afterSet = new Set(input.afterIds)
const beforeSet = new Set(input.beforeIds)
const before = input.beforeIds.filter((id) => afterSet.has(id))
const after = input.afterIds.filter((id) => beforeSet.has(id))
if (
before.length === after.length &&
before.every((slideId, index) => slideId === after[index])
) {
return new Set()
}
const candidates = input.movedCandidates || new Set<string>()
const table: LcsScore[][] = Array.from({ length: before.length + 1 }, () =>
Array.from({ length: after.length + 1 }, () => ({
length: 0,
protectedCount: 0,
ids: []
}))
)
for (let left = before.length - 1; left >= 0; left -= 1) {
for (let right = after.length - 1; right >= 0; right -= 1) {
if (before[left] === after[right]) {
const next = table[left + 1]![right + 1]!
const slideId = before[left]!
table[left]![right] = {
length: next.length + 1,
protectedCount: next.protectedCount + (candidates.has(slideId) ? 0 : 1),
ids: [slideId, ...next.ids]
}
} else {
table[left]![right] = betterScore(table[left + 1]![right]!, table[left]![right + 1]!)
}
}
}
const stationary = new Set(table[0]![0]!.ids)
return new Set(before.filter((slideId) => !stationary.has(slideId)))
}
export const resolveMovedPptSlideIds = (input: {
beforeSlideIds: readonly string[]
afterSlideIds: readonly string[]
movedCandidates?: ReadonlySet<string>
}): Set<string> =>
resolveMovedIds({
beforeIds: input.beforeSlideIds,
afterIds: input.afterSlideIds,
movedCandidates: input.movedCandidates
})
export const buildPptDiffManifest = (input: {
beforeSlides: readonly PptDiffSlideSnapshot[]
afterSlides: readonly PptDiffSlideSnapshot[]
beforeFrames: ReadonlyMap<string, PptDiffFrame>
afterFrames: ReadonlyMap<string, PptDiffFrame>
operation: 'created' | 'updated'
movedCandidates?: ReadonlySet<string>
elementLineage?: ReadonlyMap<string, string>
movedElementCandidates?: ReadonlySet<string>
}): PptDiffManifest => {
const beforeById = new Map(input.beforeSlides.map((slide) => [slide.slideId, slide]))
const afterById = new Map(input.afterSlides.map((slide) => [slide.slideId, slide]))
const movedSlideIds = resolveMovedPptSlideIds({
beforeSlideIds: input.beforeSlides.map((slide) => slide.slideId),
afterSlideIds: input.afterSlides.map((slide) => slide.slideId),
movedCandidates: input.movedCandidates
})
const slides: PptDiffSlide[] = input.afterSlides.map((afterSlide) => {
const beforeSlide = beforeById.get(afterSlide.slideId)
const afterFrame = input.afterFrames.get(afterSlide.slideId)
if (!beforeSlide) {
return {
slideId: afterSlide.slideId,
changed: true,
changes: ['added'],
elementChanges: [],
positiveCount: 1,
negativeCount: 0,
after: afterFrame
}
}
const beforeFrame = input.beforeFrames.get(afterSlide.slideId)
const elementChanges = diffPptElements({
before: beforeSlide.elements,
after: afterSlide.elements,
lineage: input.elementLineage,
movedCandidates: input.movedElementCandidates
})
const pageModified = beforeSlide.pageFingerprint !== afterSlide.pageFingerprint
const contentModified = pageModified || elementChanges.length > 0
const moved = movedSlideIds.has(afterSlide.slideId)
const changes: PptDiffSlide['changes'] = []
if (contentModified) changes.push('modified')
if (moved) changes.push('moved')
return {
slideId: afterSlide.slideId,
changed: changes.length > 0,
changes,
elementChanges,
positiveCount:
elementChanges.filter((change) => change.kind !== 'deleted').length +
(pageModified ? 1 : 0) +
(moved ? 1 : 0),
negativeCount: elementChanges.filter((change) => change.kind === 'deleted').length,
before: beforeFrame,
after: afterFrame
}
})
const deletedSlides = input.beforeSlides.filter((slide) => !afterById.has(slide.slideId))
for (const beforeSlide of deletedSlides) {
slides.splice(Math.min(Math.max(0, beforeSlide.index - 1), slides.length), 0, {
slideId: beforeSlide.slideId,
changed: true,
changes: ['deleted'],
elementChanges: [],
positiveCount: 0,
negativeCount: 1,
before: input.beforeFrames.get(beforeSlide.slideId),
after: undefined
})
}
return {
version: 4,
operation: input.operation,
positiveCount: slides.reduce((sum, slide) => sum + slide.positiveCount, 0),
negativeCount: slides.reduce((sum, slide) => sum + slide.negativeCount, 0),
slides
}
}
+138 -69
View File
@@ -21,8 +21,14 @@ import {
findShapeById,
getShapeBoundsResolved,
getShapeDescription,
getShapeEffects,
getShapeFillColor,
getShapeChartSpec,
getShapeImageBrightness,
getShapeImageBytes,
getShapeImageContrast,
getShapeImageCrop,
getShapeImageOpacity,
getShapeId,
getShapeKind,
getShapePreset,
@@ -32,7 +38,12 @@ import {
getShapeStrokeWidth,
getShapeText,
getShapeTextAnchor,
getShapeTextAutoFit,
getShapeTextMargins,
getShapeTextWrap,
getShapeZIndex,
getSlideBackground,
getSlideBackgroundImageBytes,
getSlideNotes,
getSlideCharts,
getSlidePartName,
@@ -93,6 +104,7 @@ import {
PPT_CANVAS_WIDTH
} from './htmlCompiler'
import { renderLatexToSvg } from './formula'
import { buildPptDiffManifest } from './diff'
import {
pptEditSchema,
pptOutlineSchema,
@@ -107,8 +119,7 @@ import type {
PptCompiledElement,
PptCompiledSlide,
PptDiffFrame,
PptDiffManifest,
PptDiffSlide,
PptDiffSlideSnapshot,
PptOutlineItem,
PptReadbackElement,
PptReadbackSlide,
@@ -116,6 +127,8 @@ import type {
PptToolRuntime
} from './types'
export { buildPptDiffManifest } from './diff'
const DEFAULT_WIDTH = PPT_CANVAS_WIDTH
const DEFAULT_HEIGHT = PPT_CANVAS_HEIGHT
@@ -661,6 +674,63 @@ const readShapeDetails = (shape: SlideShapeData): JsonObject | undefined => {
return preset ? { shape: preset } : undefined
}
const readShapeType = (shape: SlideShapeData): string => {
const description = getShapeDescription(shape) || ''
if (description.startsWith('ppt-latex:')) return 'formula'
if (isTableShape(shape)) return 'table'
if (isChartShape(shape)) return 'chart'
return getShapeKind(shape)
}
const canonicalize = (value: unknown): unknown => {
if (value === undefined) return null
if (value instanceof Uint8Array) return hashBytes(value)
if (Array.isArray(value)) return value.map(canonicalize)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, canonicalize(entry)])
)
}
if (typeof value === 'number' && !Number.isFinite(value)) return String(value)
return value
}
const fingerprint = (value: unknown): string => hashBytes(JSON.stringify(canonicalize(value)))
const createPptDiffSlideSnapshots = (presentation: PresentationData): PptDiffSlideSnapshot[] =>
getSlides(presentation).map((slide, index) => ({
slideId: slideIdOf(slide),
index: index + 1,
pageFingerprint: fingerprint({
background: getSlideBackground(slide),
backgroundImage: getSlideBackgroundImageBytes(slide),
notes: getSlideNotes(slide) || ''
}),
elements: getSlideShapes(slide).map((shape) => ({
elementId: elementIdOf(slide, shape),
fingerprint: fingerprint({
type: readShapeType(shape),
text: getShapeText(shape),
description: getShapeDescription(shape),
bounds: boundsFromEmu(presentation, getShapeBoundsResolved(presentation, shape)),
style: readShapeStyle(shape),
details: readShapeDetails(shape),
effects: getShapeEffects(presentation, shape),
textMargins: getShapeTextMargins(shape),
textWrap: getShapeTextWrap(shape),
textAutoFit: getShapeTextAutoFit(shape),
image: getShapeImageBytes(shape),
imageCrop: getShapeImageCrop(shape),
imageOpacity: getShapeImageOpacity(shape),
imageBrightness: getShapeImageBrightness(shape),
imageContrast: getShapeImageContrast(shape)
}),
zIndex: getShapeZIndex(shape)
}))
}))
const slideReadback = (
presentation: PresentationData,
slide: SlideData,
@@ -673,16 +743,9 @@ const slideReadback = (
const latex = description.startsWith('ppt-latex:')
? description.slice('ppt-latex:'.length)
: undefined
const type = latex
? 'formula'
: isTableShape(shape)
? 'table'
: isChartShape(shape)
? 'chart'
: getShapeKind(shape)
return {
elementId: elementIdOf(slide, shape),
type,
type: readShapeType(shape),
zIndex: getShapeZIndex(shape),
bounds: boundsFromEmu(presentation, getShapeBoundsResolved(presentation, shape)),
text: getShapeText(shape) || undefined,
@@ -759,50 +822,6 @@ const renderPresentationSlides = async (input: {
return output
}
export const buildPptDiffManifest = (input: {
before: Map<string, PptDiffFrame & { hash: string }>
after: Map<string, PptDiffFrame & { hash: string }>
operation: 'created' | 'updated'
forceChangedSlideIds?: ReadonlySet<string>
}): PptDiffManifest => {
const afterEntries: PptDiffSlide[] = [...input.after.entries()]
.sort((a, b) => a[1].index - b[1].index)
.map(([slideId, after]) => {
const before = input.before.get(slideId)
const changes: PptDiffSlide['changes'] = []
if (!before) changes.push('added')
else {
if (
before.hash !== after.hash ||
(before.index === after.index && Boolean(input.forceChangedSlideIds?.has(slideId)))
) {
changes.push('modified')
}
if (before.index !== after.index) changes.push('moved')
}
return {
slideId,
changed: changes.length > 0,
changes,
before: before ? { index: before.index, svg: before.svg } : undefined,
after: { index: after.index, svg: after.svg }
}
})
const deleted = [...input.before.entries()]
.filter(([slideId]) => !input.after.has(slideId))
.sort((a, b) => a[1].index - b[1].index)
for (const [slideId, before] of deleted) {
afterEntries.splice(Math.min(Math.max(0, before.index - 1), afterEntries.length), 0, {
slideId,
changed: true,
changes: ['deleted'],
before: { index: before.index, svg: before.svg },
after: undefined
})
}
return { version: 3, operation: input.operation, slides: afterEntries }
}
const readImageDimensions = (bytes: Uint8Array): { width: number; height: number } | null => {
const buffer = Buffer.from(bytes)
if (buffer.length >= 24 && buffer.subarray(1, 4).toString('ascii') === 'PNG') {
@@ -888,6 +907,7 @@ const applyElementOperation = async (input: {
document: PptRuntimeDocument
slide: SlideData
workspaceRootPath: string
onReplaceElement: (beforeElementId: string, afterElementId: string) => void
}) => {
const op = input.operation
if (op.type === 'set_text') {
@@ -1019,6 +1039,7 @@ const applyElementOperation = async (input: {
bounds
})
setShapeZIndex(shape, zIndex)
input.onReplaceElement(op.elementId, elementIdOf(input.slide, shape))
return
}
@@ -1158,7 +1179,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
lastPersistedBytes: beforeBytes ? new Uint8Array(beforeBytes) : undefined,
presentation,
dirty: false,
changedSlideIds: new Set(),
movedSlideIds: new Set(),
movedElementIds: new Set(),
elementLineage: new Map(),
warnings: []
}
documents.set(sourcePath, document)
@@ -1313,10 +1336,13 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
const document = await loadDocument(args.sourcePath, create)
const presentationSnapshot = await savePresentation(document.presentation)
const dirtyBeforeAction = document.dirty
const changedSlideIdsBeforeAction = new Set(document.changedSlideIds)
const movedSlideIdsBeforeAction = new Set(document.movedSlideIds)
const movedElementIdsBeforeAction = new Set(document.movedElementIds)
const elementLineageBeforeAction = new Map(document.elementLineage)
const warningsBeforeAction = [...document.warnings]
try {
const changedSlideIds: string[] = []
const movedSlideIds = new Set<string>()
let outlineAnchorIndex: number | undefined
for (const action of args.actions) {
try {
@@ -1363,6 +1389,7 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
action.anchorSlideId
)
changedSlideIds.push(action.slideId)
movedSlideIds.add(action.slideId)
outlineAnchorIndex = targetIndex
} else if (action.type === 'duplicate_slide') {
const source = findSlide(document.presentation, action.slideId)
@@ -1407,7 +1434,18 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
} else if (action.type === 'edit_elements') {
const slide = findSlide(document.presentation, action.slideId)
for (const operation of action.operations) {
await applyElementOperation({ operation, document, slide, workspaceRootPath })
if (operation.type === 'set_z_order') {
document.movedElementIds.add(operation.elementId)
}
await applyElementOperation({
operation,
document,
slide,
workspaceRootPath,
onReplaceElement: (beforeElementId, afterElementId) => {
document.elementLineage.set(beforeElementId, afterElementId)
}
})
}
changedSlideIds.push(action.slideId)
} else if (action.type === 'set_notes') {
@@ -1425,7 +1463,7 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
}
const uniqueChanged = [...new Set(changedSlideIds)]
if (uniqueChanged.length > 0) document.dirty = true
uniqueChanged.forEach((slideId) => document.changedSlideIds.add(slideId))
movedSlideIds.forEach((slideId) => document.movedSlideIds.add(slideId))
const slides = getSlides(document.presentation)
const readbackSlides = uniqueChanged.flatMap((slideId) => {
const index = slides.findIndex((slide) => slideIdOf(slide) === slideId)
@@ -1473,7 +1511,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
} catch (error) {
document.presentation = await loadPresentation(presentationSnapshot)
document.dirty = dirtyBeforeAction
document.changedSlideIds = changedSlideIdsBeforeAction
document.movedSlideIds = movedSlideIdsBeforeAction
document.movedElementIds = movedElementIdsBeforeAction
document.elementLineage = elementLineageBeforeAction
document.warnings = warningsBeforeAction
if (create) documents.delete(sourcePath)
throw error
@@ -1498,6 +1538,30 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
: null
const beforeSlides = beforePresentation ? getSlides(beforePresentation) : []
const afterSlides = getSlides(afterPresentation)
const beforeSnapshots = beforePresentation
? createPptDiffSlideSnapshots(beforePresentation)
: []
const afterSnapshots = createPptDiffSlideSnapshots(afterPresentation)
const operation = beforeExists ? ('updated' as const) : ('created' as const)
const netManifest = buildPptDiffManifest({
beforeSlides: beforeSnapshots,
afterSlides: afterSnapshots,
beforeFrames: new Map(),
afterFrames: new Map(),
operation,
movedCandidates: document.movedSlideIds,
elementLineage: document.elementLineage,
movedElementCandidates: document.movedElementIds
})
if (netManifest.positiveCount === 0 && netManifest.negativeCount === 0) {
document.beforeBytes = new Uint8Array(afterBytes)
document.lastPersistedBytes = new Uint8Array(afterBytes)
document.dirty = false
document.movedSlideIds.clear()
document.movedElementIds.clear()
document.elementLineage.clear()
continue
}
const beforeFrames = beforePresentation
? await renderPresentationSlides({
presentation: beforePresentation,
@@ -1513,12 +1577,15 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
prefix: 'after'
})
const manifest = buildPptDiffManifest({
before: beforeFrames,
after: afterFrames,
operation: beforeExists ? 'updated' : 'created',
forceChangedSlideIds: document.changedSlideIds
beforeSlides: beforeSnapshots,
afterSlides: afterSnapshots,
beforeFrames,
afterFrames,
operation,
movedCandidates: document.movedSlideIds,
elementLineage: document.elementLineage,
movedElementCandidates: document.movedElementIds
})
const changedSlides = manifest.slides.filter((slide) => slide.changed)
await fs.writeFile(dirs.manifest, JSON.stringify(manifest, null, 2), 'utf8')
let beforeSnapshotPath: string | undefined
let beforeChecksum: string | undefined
@@ -1544,10 +1611,10 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
capability: 'ppt',
path: document.sourcePath,
fileName: document.sourceName,
operation: beforeExists ? 'updated' : 'created',
changeCount: changedSlides.length,
insertCount: changedSlides.filter((slide) => Boolean(slide.after)).length,
deleteCount: changedSlides.filter((slide) => Boolean(slide.before)).length,
operation,
changeCount: manifest.positiveCount + manifest.negativeCount,
insertCount: manifest.positiveCount,
deleteCount: manifest.negativeCount,
beforeSnapshotPath,
beforeChecksum,
afterChecksum: hashBytes(persistedBytes),
@@ -1559,7 +1626,9 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun
document.beforeBytes = new Uint8Array(afterBytes)
document.lastPersistedBytes = new Uint8Array(afterBytes)
document.dirty = false
document.changedSlideIds.clear()
document.movedSlideIds.clear()
document.movedElementIds.clear()
document.elementLineage.clear()
records.push(record)
}
return records
+28 -2
View File
@@ -66,7 +66,9 @@ export type PptRuntimeDocument = {
lastPersistedBytes?: Uint8Array
presentation: PresentationData
dirty: boolean
changedSlideIds: Set<string>
movedSlideIds: Set<string>
movedElementIds: Set<string>
elementLineage: Map<string, string>
warnings: string[]
}
@@ -101,17 +103,41 @@ export type PptDiffFrame = {
svg: string
}
export type PptDiffElementSnapshot = {
elementId: string
fingerprint: string
zIndex: number
}
export type PptDiffSlideSnapshot = {
slideId: string
index: number
pageFingerprint: string
elements: PptDiffElementSnapshot[]
}
export type PptDiffElementChange = {
kind: 'added' | 'deleted' | 'modified'
beforeElementId?: string
afterElementId?: string
}
export type PptDiffSlide = {
slideId: string
changed: boolean
changes: Array<'added' | 'deleted' | 'modified' | 'moved'>
elementChanges: PptDiffElementChange[]
positiveCount: number
negativeCount: number
before?: PptDiffFrame
after?: PptDiffFrame
}
export type PptDiffManifest = {
version: 3
version: 4
operation: 'created' | 'updated'
positiveCount: number
negativeCount: number
slides: PptDiffSlide[]
}
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import type { AiFileChangeRecord } from '../../../../shared/types'
import { assertSupportedPptDiffManifest } from '../../../../shared/pptDiffManifest'
import { workbenchSelectors } from '../../testing/selectors'
import PptSlideDeckViewer, { type PptViewerSlide } from '../fileRenders/PptSlideDeckViewer'
@@ -8,10 +9,23 @@ type DiffSlide = {
slideId: string
changed: boolean
changes: Array<'added' | 'deleted' | 'modified' | 'moved'>
elementChanges: Array<{
kind: 'added' | 'deleted' | 'modified'
beforeElementId?: string
afterElementId?: string
}>
positiveCount: number
negativeCount: number
before?: DiffFrame
after?: DiffFrame
}
type DiffManifest = { version: 3; operation: 'created' | 'updated'; slides: DiffSlide[] }
type DiffManifest = {
version: 4
operation: 'created' | 'updated'
positiveCount: number
negativeCount: number
slides: DiffSlide[]
}
const loadLocalUrl = async (filePath: string): Promise<string> => {
const bytes = await window.api.readFile(filePath)
@@ -51,8 +65,7 @@ const PptDiffViewer: React.FC<{ record: AiFileChangeRecord }> = ({ record }) =>
.then((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as DiffManifest)
.then((next) => {
if (cancelled) return
if (next.version !== 3 || !Array.isArray(next.slides))
throw new Error('PPT 审核内容格式已过期,请重新生成修改记录')
assertSupportedPptDiffManifest(next)
setManifest(next)
setChangedOnly(next.operation === 'updated')
})
+17
View File
@@ -0,0 +1,17 @@
export type SupportedPptDiffManifest = {
version: 4
slides: unknown[]
}
export function assertSupportedPptDiffManifest(
value: unknown
): asserts value is SupportedPptDiffManifest {
if (
!value ||
typeof value !== 'object' ||
(value as { version?: unknown }).version !== 4 ||
!Array.isArray((value as { slides?: unknown }).slides)
) {
throw new Error('PPT 审核内容格式已过期,请重新生成修改记录')
}
}
@@ -0,0 +1,452 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { getSlides, getSlideText, loadPresentation } from 'pptx-kit'
import { describe, it } from 'vitest'
import { createPptToolRuntime } from '../../../../../src/main/services/ai/human/tools/ppt/runtime'
import type { PptDiffManifest } from '../../../../../src/main/services/ai/human/tools/ppt/types'
import type { JsonObject } from '../../../../../src/shared/types'
import { createPptToolHarness } from '../../../../harness/backend/aiToolHarness'
import { runBackendCase } from '../../../../harness/backend/run-backend-case'
const slideHtml = (title: string, body = ''): string => `
<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
<h1 data-ppt-element="text" style="position:absolute;left:140px;top:100px;width:1500px;height:110px;color:#17324D;font-size:72px">${title}</h1>
<p data-ppt-element="text" style="position:absolute;left:140px;top:300px;width:1500px;height:100px;color:#334155;font-size:36px">${body}</p>
</section>`
const elementDeckHtml = `
<section data-ppt-slide style="position:relative;width:1920px;height:1080px;background:#FFFFFF">
<p data-ppt-element="text" style="position:absolute;left:100px;top:80px;width:420px;height:70px;color:#111827;font-size:28px">Layer Original</p>
<p data-ppt-element="text" style="position:absolute;left:100px;top:180px;width:420px;height:70px;color:#111827;font-size:28px">Text Original</p>
<p data-ppt-element="text" style="position:absolute;left:100px;top:280px;width:420px;height:70px;color:#111827;font-size:28px">Style Original</p>
<p data-ppt-element="text" style="position:absolute;left:100px;top:380px;width:420px;height:70px;color:#111827;font-size:28px">Move Original</p>
<p data-ppt-element="text" style="position:absolute;left:100px;top:480px;width:420px;height:70px;color:#111827;font-size:28px">Resize Original</p>
<p data-ppt-element="text" style="position:absolute;left:100px;top:580px;width:420px;height:70px;color:#111827;font-size:28px">Delete Original</p>
<p data-ppt-element="text" style="position:absolute;left:100px;top:680px;width:420px;height:70px;color:#111827;font-size:28px">Restore Original</p>
<span data-pptx-latex="E=mc^2" style="position:absolute;left:900px;top:180px;width:600px;height:180px"></span>
</section>`
const seedDeck = async (
workspaceRootPath: string,
sourcePath: string,
htmlSlides: string[]
): Promise<void> => {
const runtime = createPptToolRuntime({
ctx: { saveFileChangeRecord: async () => undefined } as never,
cache: {} as never,
runId: `seed-${sourcePath}`,
sessionId: 'ppt-diff-seed',
taskId: 'ppt-diff-seed',
workspaceRootPath,
emitActivity: () => undefined
})
await runtime.edit({
sourcePath,
actions: htmlSlides.map((html) => ({ type: 'insert_slide', position: 'after', html }))
})
}
const readManifest = async (previewPath: string): Promise<PptDiffManifest> =>
JSON.parse(await fs.readFile(previewPath, 'utf8')) as PptDiffManifest
describe('ai tool ppt net diff backend', () => {
it('ppt-diff-slide-and-element-net-semantics', async () => {
await runBackendCase({
caseId: 'ppt-diff-slide-and-element-net-semantics',
harnessMode: 'tool-real',
domain: 'ai',
suite: 'tool',
groups: ['ppt'],
description: '验证 PPT 页面与元素都按运行初始状态和最终状态生成净 Diff。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
await seedDeck(runtime.workspaceRoot, 'slide-net.pptx', [
slideHtml('Chain A'),
slideHtml('Delete A'),
slideHtml('Keep'),
slideHtml('Delete Middle'),
slideHtml('Move Me')
])
await seedDeck(runtime.workspaceRoot, 'element-net.pptx', [elementDeckHtml])
await seedDeck(runtime.workspaceRoot, 'zero-net.pptx', [
slideHtml('Zero A'),
slideHtml('Zero Middle'),
slideHtml('Zero Tail')
])
await seedDeck(runtime.workspaceRoot, 'notes-net.pptx', [
slideHtml('Notes Final'),
slideHtml('Notes Restore')
])
const harness = createPptToolHarness(runtime)
const slideOutline = await harness.invoke<JsonObject>('ppt_read_outline', {
sourcePath: 'slide-net.pptx',
ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }]
})
const slideIds = new Map(
(slideOutline.slides as Array<{ slideId: string; title?: string; text: string }>).map(
(slide) => [slide.text.trim(), slide.slideId]
)
)
const chainId = slideIds.get('Chain A')!
const deleteId = slideIds.get('Delete A')!
const keepId = slideIds.get('Keep')!
const deleteMiddleId = slideIds.get('Delete Middle')!
const moveId = slideIds.get('Move Me')!
await harness.invoke('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{ type: 'replace_text', slideIds: [chainId], query: 'Chain A', replace: 'Chain B' }
]
})
await harness.invoke('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{ type: 'replace_text', slideIds: [chainId], query: 'Chain B', replace: 'Chain C' }
]
})
await harness.invoke('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{ type: 'replace_text', slideIds: [deleteId], query: 'Delete A', replace: 'Delete B' },
{ type: 'delete_slides', slideIds: [deleteId] }
]
})
const inserted = await harness.invoke<JsonObject>('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{
type: 'insert_slide',
position: 'after',
anchorSlideId: chainId,
html: slideHtml('New Draft')
}
]
})
const insertedId = (inserted.postWriteReadback as { slides: Array<{ slideId: string }> })
.slides[0]!.slideId
await harness.invoke('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{
type: 'replace_text',
slideIds: [insertedId],
query: 'New Draft',
replace: 'New Final'
}
]
})
const temporary = await harness.invoke<JsonObject>('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{
type: 'insert_slide',
position: 'after',
anchorSlideId: insertedId,
html: slideHtml('Temporary Slide')
}
]
})
const temporaryId = (temporary.postWriteReadback as { slides: Array<{ slideId: string }> })
.slides[0]!.slideId
await harness.invoke('ppt_edit', {
sourcePath: 'slide-net.pptx',
actions: [
{ type: 'delete_slides', slideIds: [temporaryId, deleteMiddleId] },
{ type: 'move_slide', slideId: moveId, position: 'before', anchorSlideId: keepId }
]
})
const elementRead = await harness.invoke<JsonObject>('ppt_read_slides', {
sourcePath: 'element-net.pptx',
ranges: [{ type: 'slide_index', fromSlideIndex: 1, toSlideIndex: 1 }]
})
const elementSlide = (
elementRead.slides as Array<{
slideId: string
elements: Array<{
elementId: string
text?: string
type: string
bounds?: { x: number; y: number; width: number; height: number }
}>
}>
)[0]!
const byText = (text: string) =>
elementSlide.elements.find((element) => element.text === text)!
const formula = elementSlide.elements.find((element) => element.type === 'formula')!
const moveBounds = byText('Move Original').bounds!
const resizeBounds = byText('Resize Original').bounds!
await harness.invoke('ppt_edit', {
sourcePath: 'element-net.pptx',
actions: [
{
type: 'edit_elements',
slideId: elementSlide.slideId,
operations: [
{
type: 'set_text',
elementId: byText('Text Original').elementId,
text: 'Text Final'
},
{
type: 'set_style',
elementId: byText('Style Original').elementId,
style: { color: '#D14343', underline: true }
},
{
type: 'set_bounds',
elementId: byText('Move Original').elementId,
bounds: { ...moveBounds, x: moveBounds.x + 80 }
},
{
type: 'set_bounds',
elementId: byText('Resize Original').elementId,
bounds: { ...resizeBounds, width: resizeBounds.width + 120 }
},
{
type: 'set_z_order',
elementId: byText('Layer Original').elementId,
placement: 'front'
},
{ type: 'delete_element', elementId: byText('Delete Original').elementId },
{
type: 'set_text',
elementId: byText('Restore Original').elementId,
text: 'Restore Temporary'
},
{ type: 'update_formula', elementId: formula.elementId, latex: 'a^2+b^2=c^2' },
{
type: 'add_element',
element: {
type: 'text',
text: 'Added Draft',
bounds: { x: 900, y: 500, width: 500, height: 80 }
}
},
{
type: 'add_element',
element: {
type: 'text',
text: 'Temporary Element',
bounds: { x: 900, y: 650, width: 500, height: 80 }
}
}
]
},
{ type: 'set_notes', slideId: elementSlide.slideId, text: 'Temporary Notes' }
]
})
const elementAfterFirst = await harness.invoke<JsonObject>('ppt_read_slides', {
sourcePath: 'element-net.pptx',
ranges: [{ type: 'slide_ids', slideIds: [elementSlide.slideId] }]
})
const updatedElements = (
elementAfterFirst.slides as Array<{
elements: Array<{ elementId: string; text?: string; type: string }>
}>
)[0]!.elements
const updatedFormula = updatedElements.find((element) => element.type === 'formula')!
const added = updatedElements.find((element) => element.text === 'Added Draft')!
const temporaryElement = updatedElements.find(
(element) => element.text === 'Temporary Element'
)!
await harness.invoke('ppt_edit', {
sourcePath: 'element-net.pptx',
actions: [
{
type: 'edit_elements',
slideId: elementSlide.slideId,
operations: [
{
type: 'set_text',
elementId: byText('Restore Original').elementId,
text: 'Restore Original'
},
{ type: 'update_formula', elementId: updatedFormula.elementId, latex: 'E=mc^2' },
{ type: 'set_text', elementId: added.elementId, text: 'Added Final' },
{ type: 'delete_element', elementId: temporaryElement.elementId }
]
},
{ type: 'set_notes', slideId: elementSlide.slideId, text: '' }
]
})
const zeroOutline = await harness.invoke<JsonObject>('ppt_read_outline', {
sourcePath: 'zero-net.pptx',
ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }]
})
const zeroSlides = zeroOutline.slides as Array<{ slideId: string; text: string }>
const zeroId = zeroSlides.find((slide) => slide.text.trim() === 'Zero A')!.slideId
const zeroMiddleId = zeroSlides.find(
(slide) => slide.text.trim() === 'Zero Middle'
)!.slideId
const zeroTailId = zeroSlides.find((slide) => slide.text.trim() === 'Zero Tail')!.slideId
await harness.invoke('ppt_edit', {
sourcePath: 'zero-net.pptx',
actions: [
{ type: 'replace_text', slideIds: [zeroId], query: 'Zero A', replace: 'Zero B' },
{ type: 'replace_text', slideIds: [zeroId], query: 'Zero B', replace: 'Zero A' },
{
type: 'move_slide',
slideId: zeroTailId,
position: 'before',
anchorSlideId: zeroMiddleId
},
{
type: 'move_slide',
slideId: zeroTailId,
position: 'after',
anchorSlideId: zeroMiddleId
}
]
})
const zeroTemporary = await harness.invoke<JsonObject>('ppt_edit', {
sourcePath: 'zero-net.pptx',
actions: [
{
type: 'insert_slide',
position: 'after',
anchorSlideId: zeroId,
html: slideHtml('Zero Temporary')
}
]
})
const zeroTemporaryId = (
zeroTemporary.postWriteReadback as { slides: Array<{ slideId: string }> }
).slides[0]!.slideId
await harness.invoke('ppt_edit', {
sourcePath: 'zero-net.pptx',
actions: [{ type: 'delete_slides', slideIds: [zeroTemporaryId] }]
})
const notesOutline = await harness.invoke<JsonObject>('ppt_read_outline', {
sourcePath: 'notes-net.pptx',
ranges: [{ type: 'percent', fromPercent: 0, toPercent: 100 }]
})
const notesSlides = notesOutline.slides as Array<{ slideId: string; text: string }>
const notesFinalId = notesSlides.find(
(slide) => slide.text.trim() === 'Notes Final'
)!.slideId
const notesRestoreId = notesSlides.find(
(slide) => slide.text.trim() === 'Notes Restore'
)!.slideId
await harness.invoke('ppt_edit', {
sourcePath: 'notes-net.pptx',
actions: [
{ type: 'set_notes', slideId: notesFinalId, text: 'Final speaker notes' },
{ type: 'set_notes', slideId: notesRestoreId, text: 'Temporary speaker notes' }
]
})
await harness.invoke('ppt_edit', {
sourcePath: 'notes-net.pptx',
actions: [{ type: 'set_notes', slideId: notesRestoreId, text: '' }]
})
const records = await harness.runtime.commitAll()
const recordByName = new Map(records.map((record) => [record.fileName, record]))
const slideRecord = recordByName.get('slide-net.pptx')!
const elementRecord = recordByName.get('element-net.pptx')!
const notesRecord = recordByName.get('notes-net.pptx')!
const slideManifest = await readManifest(slideRecord.previewPath!)
const elementManifest = await readManifest(elementRecord.previewPath!)
const notesManifest = await readManifest(notesRecord.previewPath!)
const slideFrameTexts = await Promise.all(
slideManifest.slides.flatMap((slide) =>
[slide.before?.svg, slide.after?.svg]
.filter((filePath): filePath is string => Boolean(filePath))
.map((filePath) => fs.readFile(filePath, 'utf8'))
)
)
const finalSlidePresentation = await loadPresentation(
new Uint8Array(await fs.readFile(path.join(runtime.workspaceRoot, 'slide-net.pptx')))
)
return {
recordNames: records.map((record) => record.fileName).sort(),
slideRecord,
elementRecord,
notesRecord,
slideManifest,
elementManifest,
notesManifest,
slideFrameText: slideFrameTexts.join('\n'),
finalSlideTexts: getSlides(finalSlidePresentation).map((slide) => getSlideText(slide))
}
},
assert: async (result, runtime) => {
const slideById = new Map(
result.slideManifest.slides.map((slide) => [slide.slideId, slide])
)
const changedSlides = result.slideManifest.slides.filter((slide) => slide.changed)
const unchangedSlides = result.slideManifest.slides.filter((slide) => !slide.changed)
const elementSlide = result.elementManifest.slides.find((slide) => slide.changed)!
runtime.assertThat(
JSON.stringify(result.recordNames) ===
JSON.stringify(['element-net.pptx', 'notes-net.pptx', 'slide-net.pptx']) &&
result.slideManifest.version === 4 &&
result.slideManifest.positiveCount === 3 &&
result.slideManifest.negativeCount === 2 &&
result.slideRecord.insertCount === 3 &&
result.slideRecord.deleteCount === 2 &&
result.slideRecord.changeCount === 5 &&
changedSlides.length === 5 &&
unchangedSlides.length === 1 &&
unchangedSlides[0]?.changes.length === 0 &&
[...slideById.values()].filter((slide) => slide.changes.includes('moved')).length ===
1 &&
result.slideFrameText.includes('Chain A') &&
result.slideFrameText.includes('Chain C') &&
!result.slideFrameText.includes('Chain B') &&
result.slideFrameText.includes('Delete A') &&
!result.slideFrameText.includes('Delete B') &&
result.slideFrameText.includes('New Final') &&
!result.slideFrameText.includes('New Draft') &&
!result.slideFrameText.includes('Temporary Slide') &&
JSON.stringify(result.finalSlideTexts.map((text) => text.trim())) ===
JSON.stringify(['Chain C', 'New Final', 'Move Me', 'Keep']),
{
name: 'ppt-slide-net-diff-collapses-intermediate-and-structural-noise',
message: 'PPT 页面 Diff 应只保留最终净变化,插入删除造成的页码偏移不得标记移动。',
data: result
}
)
runtime.assertThat(
result.elementManifest.version === 4 &&
result.elementManifest.positiveCount === 6 &&
result.elementManifest.negativeCount === 1 &&
result.elementRecord.insertCount === 6 &&
result.elementRecord.deleteCount === 1 &&
result.elementRecord.changeCount === 7 &&
JSON.stringify(elementSlide.changes) === JSON.stringify(['modified']) &&
elementSlide.elementChanges.filter((change) => change.kind === 'modified').length ===
5 &&
elementSlide.elementChanges.filter((change) => change.kind === 'added').length === 1 &&
elementSlide.elementChanges.filter((change) => change.kind === 'deleted').length === 1,
{
name: 'ppt-element-net-diff-counts-final-add-modify-move-and-delete',
message: 'PPT 元素加减数量必须来自 baseline 与最终状态,同一元素多次修改只计一次。',
data: result.elementManifest
}
)
runtime.assertThat(
result.notesManifest.version === 4 &&
result.notesManifest.positiveCount === 1 &&
result.notesManifest.negativeCount === 0 &&
result.notesRecord.insertCount === 1 &&
result.notesRecord.deleteCount === 0 &&
result.notesRecord.changeCount === 1 &&
result.notesManifest.slides.filter((slide) => slide.changed).length === 1 &&
result.notesManifest.slides.filter((slide) => !slide.changed).length === 1,
{
name: 'ppt-page-notes-net-diff-collapses-restored-notes',
message: 'PPT 页面备注应按最终净变化计数,恢复原备注的页面不得留下修改。',
data: result.notesManifest
}
)
}
})
}, 120_000)
})
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
buildPptDiffManifest,
diffPptElements,
resolveMovedPptSlideIds
} from '../../../../src/main/services/ai/human/tools/ppt/diff'
import type {
PptDiffElementSnapshot,
PptDiffSlideSnapshot
} from '../../../../src/main/services/ai/human/tools/ppt/types'
const element = (elementId: string, fingerprint: string, zIndex = 0): PptDiffElementSnapshot => ({
elementId,
fingerprint,
zIndex
})
const slide = (
slideId: string,
index: number,
elements: PptDiffElementSnapshot[] = [],
pageFingerprint = 'page'
): PptDiffSlideSnapshot => ({ slideId, index, elements, pageFingerprint })
const manifest = (input: {
before: PptDiffSlideSnapshot[]
after: PptDiffSlideSnapshot[]
movedCandidates?: Set<string>
movedElementCandidates?: Set<string>
lineage?: Map<string, string>
}) =>
buildPptDiffManifest({
beforeSlides: input.before,
afterSlides: input.after,
beforeFrames: new Map(),
afterFrames: new Map(),
operation: 'updated',
movedCandidates: input.movedCandidates,
movedElementCandidates: input.movedElementCandidates,
elementLineage: input.lineage
})
describe('ppt net diff', () => {
it('does not mark later slides as moved after middle insertion or deletion', () => {
expect(
resolveMovedPptSlideIds({
beforeSlideIds: ['A', 'B', 'C', 'D'],
afterSlideIds: ['A', 'C', 'D']
})
).toEqual(new Set())
expect(
resolveMovedPptSlideIds({
beforeSlideIds: ['A', 'B', 'C', 'D'],
afterSlideIds: ['A', 'X', 'B', 'C', 'D']
})
).toEqual(new Set())
})
it('marks only the explicitly moved slide and collapses a move restored to baseline', () => {
expect(
resolveMovedPptSlideIds({
beforeSlideIds: ['A', 'B', 'C', 'D'],
afterSlideIds: ['B', 'C', 'A', 'D'],
movedCandidates: new Set(['A'])
})
).toEqual(new Set(['A']))
expect(
resolveMovedPptSlideIds({
beforeSlideIds: ['A', 'B', 'C'],
afterSlideIds: ['A', 'B', 'C'],
movedCandidates: new Set(['A'])
})
).toEqual(new Set())
})
it('uses stable ids, replacement lineage, and exact fingerprints for element matching', () => {
expect(
diffPptElements({
before: [element('same', 'A'), element('formula-old', 'B'), element('renumbered-old', 'C')],
after: [
element('same', 'A2'),
element('formula-new', 'B2'),
element('renumbered-new', 'C')
],
lineage: new Map([['formula-old', 'formula-new']])
})
).toEqual([
{ kind: 'modified', beforeElementId: 'same', afterElementId: 'same' },
{
kind: 'modified',
beforeElementId: 'formula-old',
afterElementId: 'formula-new'
}
])
})
it('counts element additions, deletions, modifications, and slide movement as net changes', () => {
const result = manifest({
before: [slide('A', 1, [element('keep', 'old'), element('delete', 'old')])],
after: [slide('A', 1, [element('keep', 'new'), element('add', 'new')])],
movedCandidates: new Set(['A'])
})
expect(result.slides[0]).toMatchObject({
changes: ['modified'],
positiveCount: 2,
negativeCount: 1
})
expect({ positive: result.positiveCount, negative: result.negativeCount }).toEqual({
positive: 2,
negative: 1
})
const movedAndModified = manifest({
before: [slide('A', 1, [element('one', 'old'), element('two', 'old')]), slide('B', 2)],
after: [slide('B', 1), slide('A', 2, [element('one', 'new'), element('two', 'new')])],
movedCandidates: new Set(['A'])
})
expect(movedAndModified.slides.find((item) => item.slideId === 'A')).toMatchObject({
changes: ['modified', 'moved'],
positiveCount: 3,
negativeCount: 0
})
})
it('collapses restored edits and temporary elements to zero net changes', () => {
const result = manifest({
before: [slide('A', 1, [element('one', 'baseline')])],
after: [slide('A', 1, [element('one', 'baseline')])],
movedCandidates: new Set(['A'])
})
expect(result.slides[0]).toMatchObject({
changed: false,
changes: [],
elementChanges: [],
positiveCount: 0,
negativeCount: 0
})
expect(result.positiveCount + result.negativeCount).toBe(0)
})
it('counts page-level background or notes fingerprints once and collapses restoration', () => {
const changed = manifest({
before: [slide('A', 1, [], 'page-before')],
after: [slide('A', 1, [], 'page-after')]
})
expect(changed.slides[0]).toMatchObject({
changes: ['modified'],
positiveCount: 1,
negativeCount: 0
})
const restored = manifest({
before: [slide('A', 1, [], 'same-page')],
after: [slide('A', 1, [], 'same-page')]
})
expect(restored.positiveCount + restored.negativeCount).toBe(0)
})
it('counts only the element explicitly moved in z-order and ignores natural renumbering', () => {
expect(
diffPptElements({
before: [element('A', 'same', 0), element('B', 'same', 1), element('C', 'same', 2)],
after: [element('B', 'same', 0), element('C', 'same', 1), element('A', 'same', 2)],
movedCandidates: new Set(['A'])
})
).toEqual([{ kind: 'modified', beforeElementId: 'A', afterElementId: 'A' }])
expect(
diffPptElements({
before: [element('A', 'same', 0), element('B', 'same', 1), element('C', 'same', 2)],
after: [element('A', 'same', 0), element('C', 'same', 1)]
})
).toEqual([{ kind: 'deleted', beforeElementId: 'B' }])
})
it('subsumes child elements when an entire slide is added or deleted', () => {
const result = manifest({
before: [slide('deleted', 1, [element('old-1', 'A'), element('old-2', 'B')])],
after: [slide('added', 1, [element('new-1', 'A'), element('new-2', 'B')])]
})
expect(
result.slides.map((item) => [item.changes, item.positiveCount, item.negativeCount])
).toEqual([
[['deleted'], 0, 1],
[['added'], 1, 0]
])
})
})
+35 -13
View File
@@ -243,26 +243,48 @@ describe('ai unit ppt tool contract', () => {
).resolves.toBeTruthy()
})
it('emits v3 SVG created and updated diff semantics', () => {
const before = new Map([
['slide-1', { index: 1, svg: 'before-1.svg', hash: 'same' }],
['slide-2', { index: 2, svg: 'before-2.svg', hash: 'old' }]
it('emits v4 net diff semantics without treating structural shifts as moves', () => {
const beforeSlides = [
{ slideId: 'slide-1', index: 1, pageFingerprint: 'same', elements: [] },
{ slideId: 'slide-2', index: 2, pageFingerprint: 'old', elements: [] }
]
const afterSlides = [
{ slideId: 'slide-2', index: 1, pageFingerprint: 'new', elements: [] },
{ slideId: 'slide-3', index: 2, pageFingerprint: 'new', elements: [] }
]
const beforeFrames = new Map([
['slide-1', { index: 1, svg: 'before-1.svg' }],
['slide-2', { index: 2, svg: 'before-2.svg' }]
])
const after = new Map([
['slide-2', { index: 1, svg: 'after-2.svg', hash: 'new' }],
['slide-3', { index: 2, svg: 'after-3.svg', hash: 'new' }]
const afterFrames = new Map([
['slide-2', { index: 1, svg: 'after-2.svg' }],
['slide-3', { index: 2, svg: 'after-3.svg' }]
])
const manifest = buildPptDiffManifest({ before, after, operation: 'updated' })
expect(manifest.version).toBe(3)
const manifest = buildPptDiffManifest({
beforeSlides,
afterSlides,
beforeFrames,
afterFrames,
operation: 'updated'
})
expect(manifest.version).toBe(4)
expect(manifest.slides.map((slide) => slide.changes)).toEqual([
['deleted'],
['modified', 'moved'],
['modified'],
['added']
])
expect({ positive: manifest.positiveCount, negative: manifest.negativeCount }).toEqual({
positive: 2,
negative: 1
})
expect(
buildPptDiffManifest({ before: new Map(), after, operation: 'created' }).slides.every(
(slide) => slide.changes.includes('added')
)
buildPptDiffManifest({
beforeSlides: [],
afterSlides,
beforeFrames: new Map(),
afterFrames,
operation: 'created'
}).slides.every((slide) => slide.changes.includes('added'))
).toBe(true)
})
})
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { assertSupportedPptDiffManifest } from '../../../../src/shared/pptDiffManifest'
describe('ppt diff manifest version', () => {
it('accepts v4 and explicitly rejects legacy v3 artifacts', () => {
expect(() => assertSupportedPptDiffManifest({ version: 4, slides: [] })).not.toThrow()
expect(() => assertSupportedPptDiffManifest({ version: 3, slides: [] })).toThrow(
'PPT 审核内容格式已过期,请重新生成修改记录'
)
})
})
@@ -66,6 +66,16 @@ describe('workbench result e2e', () => {
state: 'visible',
timeout: 30_000
})
const reviewToolbarText = (
(await getWorkbenchLocator(page, workbenchSelectors.documentToolbar).textContent()) ||
''
).trim()
const documentModeToggle = getWorkbenchLocator(
page,
workbenchSelectors.documentModeToggle
)
const documentModeTag = getWorkbenchLocator(page, workbenchSelectors.documentModeTag)
const initialModeTag = ((await documentModeTag.textContent()) || '').trim()
const beforeVisible = await getWorkbenchLocator(
page,
workbenchSelectors.pptDiffBefore
@@ -114,6 +124,17 @@ describe('workbench result e2e', () => {
await page.mouse.up()
}
const draggedTransform = (await transform.getAttribute('style')) || ''
await documentModeToggle.click()
await page
.getByTestId('ppt-preview-viewer')
.waitFor({ state: 'visible', timeout: 30_000 })
const previewModeTag = ((await documentModeTag.textContent()) || '').trim()
await documentModeToggle.click()
await getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer).waitFor({
state: 'visible',
timeout: 30_000
})
const restoredModeTag = ((await documentModeTag.textContent()) || '').trim()
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await page.getByText('回退这次修改?').waitFor({ state: 'visible' })
await page.getByText('文件将恢复到这次修改发生前的状态。').waitFor({ state: 'visible' })
@@ -127,6 +148,10 @@ describe('workbench result e2e', () => {
slideCount: getSlides(presentation).length,
slideText,
applyEntryText,
reviewToolbarText,
initialModeTag,
previewModeTag,
restoredModeTag,
beforeVisible,
afterVisible,
thumbnailCount,
@@ -151,6 +176,11 @@ describe('workbench result e2e', () => {
result.slideCount === 2 &&
result.slideText.includes(MARKER) &&
result.applyEntryText.includes(FILE_NAME) &&
result.reviewToolbarText.includes('+2') &&
result.reviewToolbarText.includes('-0') &&
result.initialModeTag === '审核' &&
result.previewModeTag === '预览' &&
result.restoredModeTag === '审核' &&
!result.beforeVisible &&
result.afterVisible &&
result.thumbnailCount === 2 &&
@@ -106,13 +106,22 @@ describe('workbench result e2e', () => {
const updatedPresentation = await loadPresentation(
new Uint8Array(await fs.readFile(filePath))
)
const updatedText = getSlides(updatedPresentation)
.map((slide) => getSlideText(slide))
.join('\n')
const updatedSlideTexts = getSlides(updatedPresentation).map((slide) =>
getSlideText(slide).trim()
)
const updatedText = updatedSlideTexts.join('\n')
const applyEntry = await waitForApplyEntry(page)
await applyEntry.click()
const viewer = getWorkbenchLocator(page, workbenchSelectors.pptDiffViewer)
await viewer.waitFor({ state: 'visible', timeout: 30_000 })
const documentToolbar = getWorkbenchLocator(page, workbenchSelectors.documentToolbar)
const reviewToolbarText = ((await documentToolbar.textContent()) || '').trim()
const documentModeToggle = getWorkbenchLocator(
page,
workbenchSelectors.documentModeToggle
)
const documentModeTag = getWorkbenchLocator(page, workbenchSelectors.documentModeTag)
const initialModeTag = ((await documentModeTag.textContent()) || '').trim()
const beforeSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffBefore)
const afterSurface = getWorkbenchLocator(page, workbenchSelectors.pptDiffAfter)
const beforeVisible = await beforeSurface.isVisible()
@@ -158,6 +167,21 @@ describe('workbench result e2e', () => {
)
.locator('button')
.count()
const allGreenMarkerCount = await viewer
.locator(
'[aria-label="新增页"], [aria-label="内容已修改"], [aria-label="位置已移动"], [aria-label="内容已修改,位置已移动"]'
)
.count()
await documentModeToggle.click()
await page
.getByTestId('ppt-preview-viewer')
.waitFor({ state: 'visible', timeout: 30_000 })
const previewModeTag = ((await documentModeTag.textContent()) || '').trim()
const reviewViewerHiddenInPreview = !(await viewer.isVisible().catch(() => false))
await documentModeToggle.click()
await viewer.waitFor({ state: 'visible', timeout: 30_000 })
const restoredModeTag = ((await documentModeTag.textContent()) || '').trim()
await getWorkbenchLocator(page, workbenchSelectors.applyRevertButton).click()
await page.getByText('回退这次修改?').waitFor({ state: 'visible' })
@@ -171,11 +195,18 @@ describe('workbench result e2e', () => {
return {
updatedText,
updatedSlideTexts,
reviewToolbarText,
initialModeTag,
previewModeTag,
restoredModeTag,
reviewViewerHiddenInPreview,
beforeVisible,
afterVisible,
changedThumbnailCount,
allThumbnailCount,
greenMarkerCount,
allGreenMarkerCount,
redMarkerCount,
inactiveThumbnailClass,
beforeZoomTransform,
@@ -194,11 +225,23 @@ describe('workbench result e2e', () => {
result.updatedText.includes('市场信号已确认') &&
result.updatedText.includes('风险与应对') &&
!result.updatedText.includes('产品进展') &&
result.updatedSlideTexts.length === 4 &&
result.updatedSlideTexts[0]?.includes('交付质量') &&
result.updatedSlideTexts[1]?.includes('市场信号已确认') &&
result.updatedSlideTexts[2]?.includes('风险与应对') &&
result.updatedSlideTexts[3]?.includes('附录数据') &&
result.reviewToolbarText.includes('+3') &&
result.reviewToolbarText.includes('-1') &&
result.initialModeTag === '审核' &&
result.previewModeTag === '预览' &&
result.restoredModeTag === '审核' &&
result.reviewViewerHiddenInPreview &&
result.beforeVisible &&
result.afterVisible &&
result.changedThumbnailCount === 4 &&
result.allThumbnailCount === 5 &&
result.greenMarkerCount >= 3 &&
result.greenMarkerCount === 3 &&
result.allGreenMarkerCount === 3 &&
result.redMarkerCount === 1 &&
result.inactiveThumbnailClass.includes('border-transparent') &&
result.beforeZoomTransform === result.afterZoomTransform &&