From caa1873386ea5f11d33f307a2242caec4b24ff07 Mon Sep 17 00:00:00 2001 From: Yoan Huang Date: Sat, 29 Aug 2026 18:50:18 +0800 Subject: [PATCH] feat: enhance PPT tools with detailed slide reading options and update schemas --- doc/architecture.md | 2 +- doc/prd/workbench.md | 2 +- src/main/services/ai/human/tools/ppt/color.ts | 5 ++- .../services/ai/human/tools/ppt/runtime.ts | 15 +++++-- .../services/ai/human/tools/ppt/schemas.ts | 22 ++++++---- .../services/ai/human/tools/ppt/toolSpecs.ts | 2 +- .../ppt/ppt-create-read-edit-commit.test.ts | 42 +++++++++++++++---- tests/case/unit/ai/ppt-tool-contract.test.ts | 17 ++++++++ 8 files changed, 85 insertions(+), 22 deletions(-) diff --git a/doc/architecture.md b/doc/architecture.md index 3db96bdf..a8eef217 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -127,7 +127,7 @@ DOCX 读写工具族已按上述原则落地,作为后续 Excel / PPT / PDF | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | DOCX | 文档聚合模型 + story 内容树;root、可编辑子节点和 visual 共用一个 `nodesById`,visual 只使用 `nodeId`;读写拆成两个 skill;Source / Working 双态 + 净 Diff 审核 | | PDF | 页级读取与检索;Worker 隔离解析;读取页面同时返回 pageImagePath | -| PPT | 结构化页规格创建与整页替换,按 slideId / elementId 锚点编辑;读取页面同时返回 slideImagePath | +| PPT | 结构化页规格创建与整页替换,按 slideId / elementId 锚点编辑;`ppt_read_slides` 默认可读完整元素,`detail=images` 时仅返回 slideImagePath 供视觉巡检 | | Excel | 工作簿 / 工作表 / 单元格范围读写 | | KB | 混合召回检索工作区已索引文档 | | Text / Terminal / Web | 普通文本读写搜索、命令执行、网页抓取与内置浏览器操作 | diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index a851d64e..dff2c753 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -577,7 +577,7 @@ - `WB-PPT-001-AC3` 文本、形状、线条、图片、表格、图表和公式按支持类型转换,不把整页默认导出为截图。 - `WB-PPT-001-AC4` AI 可使用真实 slideId 和 elementId 继续编辑已有页面和元素。 - `WB-PPT-001-AC5` 公式由 LaTeX 渲染为 SVG;公式整体可移动和缩放,内部字符为只读内容。 - - `WB-PPT-001-AC6` `ppt_read_outline` 支持 percent 和 slide_index;`ppt_read_slides` 支持 slide_index 和 slide_ids,并返回可交给 `read_image` 的 `slideImagePath`;`ppt_search` 使用可选 `scope.slideIds`。 + - `WB-PPT-001-AC6` `ppt_read_outline` 支持 percent 和 slide_index;`ppt_read_slides` 支持 slide_index 和 slide_ids,默认 `detail=full` 返回元素与可交给 `read_image` 的 `slideImagePath`,`detail=images` 仅返回 `slideId` / 页码 / `slideImagePath`;`ppt_search` 使用可选 `scope.slideIds`。 - `WB-PPT-001-AC7` `ppt_edit` 成功后只返回继续写所需锚点:`sourcePath`、`slideCount`、相关 `slideId`、`createdElementIds`(insert / replace / add_element / duplicate);不返回变更清单或整页 readback。 - `WB-PPT-001-AC8` 元素编辑支持文字、位置、样式、图层、复制、删除、新增、图片、表格、图表和公式更新。 - `WB-PPT-001-AC9` 一次 `ppt_edit` 失败时,文件和活动 session 都保持调用前状态。 diff --git a/src/main/services/ai/human/tools/ppt/color.ts b/src/main/services/ai/human/tools/ppt/color.ts index 01705b6e..8f574ee4 100644 --- a/src/main/services/ai/human/tools/ppt/color.ts +++ b/src/main/services/ai/human/tools/ppt/color.ts @@ -11,13 +11,14 @@ export const normalizePptColor = ( if (!raw || raw === 'transparent' || /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/i.test(raw)) { return fallback } - const short = /^#([0-9a-f]{3})$/i.exec(raw) + // Accept `#RGB` / `#RRGGBB` and bare `RGB` / `RRGGBB` (models often omit `#`). + const short = /^#?([0-9a-f]{3})$/i.exec(raw) if (short) return `#${short[1]! .split('') .map((part) => `${part}${part}`) .join('')}`.toUpperCase() - const hex = /^#([0-9a-f]{6})$/i.exec(raw) + const hex = /^#?([0-9a-f]{6})$/i.exec(raw) if (hex) return `#${hex[1]!.toUpperCase()}` const rgb = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)\s*[, ]\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec( diff --git a/src/main/services/ai/human/tools/ppt/runtime.ts b/src/main/services/ai/human/tools/ppt/runtime.ts index 63094fd9..465f5c2e 100644 --- a/src/main/services/ai/human/tools/ppt/runtime.ts +++ b/src/main/services/ai/human/tools/ppt/runtime.ts @@ -1082,12 +1082,21 @@ export const createPptToolRuntime = (input: PptToolRuntime['input']): PptToolRun const document = await loadDocument(args.sourcePath) const slides = getSlides(document.presentation) const indices = resolveSlideIndices(slides, args.ranges) + const detail = args.detail ?? 'full' return { sourcePath: args.sourcePath, slideCount: slides.length, - slides: indices.map((index) => - slideReadback(document.presentation, slides[index]!, index, true) - ), + detail, + slides: indices.map((index) => { + const slide = slides[index]! + if (detail === 'images') { + return { + slideId: slideIdOf(slide), + slideIndex: index + 1 + } + } + return slideReadback(document.presentation, slide, index, true) + }) } } diff --git a/src/main/services/ai/human/tools/ppt/schemas.ts b/src/main/services/ai/human/tools/ppt/schemas.ts index 8c1bff8c..9de00b2b 100644 --- a/src/main/services/ai/human/tools/ppt/schemas.ts +++ b/src/main/services/ai/human/tools/ppt/schemas.ts @@ -30,10 +30,10 @@ const pptPointSchema = z export const pptElementStyleSchema = z .object({ - fill: z.string().optional().describe('填充色'), - stroke: z.string().optional().describe('描边色'), + fill: z.string().optional().describe('填充色;#RRGGBB / RRGGBB / rgb()'), + stroke: z.string().optional().describe('描边色;#RRGGBB / RRGGBB / rgb()'), strokeWidth: z.number().min(0).optional().describe('描边宽度'), - color: z.string().optional().describe('文字颜色'), + color: z.string().optional().describe('文字颜色;#RRGGBB / RRGGBB / rgb()'), fontFamily: z.string().optional().describe('字体'), fontSize: z .number() @@ -100,7 +100,13 @@ export const pptOutlineSchema = z export const pptReadSlidesSchema = z .object({ sourcePath: sourcePathSchema, - ranges: z.array(pptSlideRangeSchema).min(1).describe('精读区间') + ranges: z.array(pptSlideRangeSchema).min(1).describe('精读区间'), + detail: z + .enum(['full', 'images']) + .optional() + .describe( + 'full(默认)=页面元素+slideImagePath;images=仅 slideId/页码/slideImagePath,供视觉巡检省上下文' + ) }) .strict() @@ -154,7 +160,7 @@ export const pptElementSchema = z type: z.literal('line').describe('直线'), from: pptPointSchema.describe('起点'), to: pptPointSchema.describe('终点'), - color: z.string().optional().describe('线条颜色'), + color: z.string().optional().describe('线条颜色;#RRGGBB / RRGGBB / rgb()'), width: z.number().positive().optional().describe('线条宽度'), arrowEnd: z.boolean().optional().describe('终点是否带箭头') }) @@ -297,7 +303,7 @@ const pptSlideLocationSchema = z const pptSlideDefaultsSchema = z .object({ - color: z.string().optional().describe('默认文字颜色'), + color: z.string().optional().describe('默认文字颜色;#RRGGBB / RRGGBB / rgb()'), fontFamily: z.string().optional().describe('默认字体'), fontSize: z .number() @@ -312,7 +318,9 @@ const pptSlideSpecFields = { background: z .string() .optional() - .describe('页面背景色;insert_slide 省略为白色,replace_slide 省略则保留原值'), + .describe( + '页面背景色;#RRGGBB / RRGGBB / rgb();insert_slide 省略为白色,replace_slide 省略则保留原值' + ), notes: z.string().optional().describe('讲者备注;replace_slide 省略则保留原值'), defaults: pptSlideDefaultsSchema.optional().describe('本页未设样式的文字默认值'), elements: z diff --git a/src/main/services/ai/human/tools/ppt/toolSpecs.ts b/src/main/services/ai/human/tools/ppt/toolSpecs.ts index 44708a73..ea69b498 100644 --- a/src/main/services/ai/human/tools/ppt/toolSpecs.ts +++ b/src/main/services/ai/human/tools/ppt/toolSpecs.ts @@ -55,7 +55,7 @@ export const PPT_READ_SLIDES_TOOL_SPEC = { outputSchema: pptReadSlidesOutputSchema, promptSpec: { name: 'ppt_read_slides', - description: '按页码或 slideId 读取 PPTX 页面和元素,并返回可交给 read_image 的 slideImagePath。' + description: '按页码或 slideId 读取 PPTX 页面,并返回可交给 read_image 的 slideImagePath。' }, resolveExecutionMeta: readExecutionMeta } satisfies PptToolSpec< diff --git a/tests/case/ai/tool/ppt/ppt-create-read-edit-commit.test.ts b/tests/case/ai/tool/ppt/ppt-create-read-edit-commit.test.ts index 94ee3a04..c136d39d 100644 --- a/tests/case/ai/tool/ppt/ppt-create-read-edit-commit.test.ts +++ b/tests/case/ai/tool/ppt/ppt-create-read-edit-commit.test.ts @@ -14,19 +14,19 @@ const coverSlide = { { type: 'shape' as const, shape: 'roundRect' as const, - bounds: { x: 100, y: 90, width: 1720, height: 220 }, + bounds: { x: 50, y: 45, width: 860, height: 110 }, style: { fill: '#17324D' } }, { type: 'text' as const, text: '2026 产品复盘', - bounds: { x: 170, y: 135, width: 1450, height: 120 }, + bounds: { x: 85, y: 68, width: 790, height: 60 }, style: { color: '#FFFFFF', fontSize: 36, bold: true } }, { type: 'text' as const, text: '收入增长 18%,交付质量继续提升。', - bounds: { x: 170, y: 420, width: 1480, height: 120 }, + bounds: { x: 85, y: 210, width: 790, height: 60 }, style: { color: '#25313C', fontSize: 18 } } ] @@ -39,23 +39,23 @@ const detailSlide = { { type: 'text' as const, text: '核心结论', - bounds: { x: 140, y: 100, width: 1500, height: 110 }, + bounds: { x: 70, y: 50, width: 820, height: 55 }, style: { color: '#17324D', fontSize: 36, bold: true } }, { type: 'text' as const, text: '下一季度重点:缩短反馈周期。', - bounds: { x: 160, y: 300, width: 1450, height: 120 }, + bounds: { x: 80, y: 150, width: 800, height: 60 }, style: { color: '#333333', fontSize: 18 } }, { type: 'formula' as const, latex: 'E=mc^2', - bounds: { x: 160, y: 520, width: 600, height: 160 } + bounds: { x: 80, y: 260, width: 300, height: 80 } }, { type: 'chart' as const, - bounds: { x: 900, y: 500, width: 760, height: 420 }, + bounds: { x: 450, y: 250, width: 380, height: 210 }, chartType: 'column' as const, categories: ['Q1', 'Q2'], series: [{ name: '收入', values: [12, 18] }] @@ -94,6 +94,11 @@ describe('ai tool ppt backend', () => { sourcePath, ranges: [{ type: 'slide_ids', slideIds: [firstSlideId] }] }) + const readImagesOnly = await harness.invoke('ppt_read_slides', { + sourcePath, + detail: 'images', + ranges: [{ type: 'slide_ids', slideIds: [firstSlideId] }] + }) const failedDelete = await harness.invoke('ppt_edit', { sourcePath, type: 'delete_slides', @@ -149,6 +154,7 @@ describe('ai tool ppt backend', () => { inserted, overview, readSlides, + readImagesOnly, failedDelete, overviewAfterFailedDelete, replaced, @@ -174,6 +180,20 @@ describe('ai tool ppt backend', () => { const readSlideImagePath = String( (result.readSlides.slides as Array<{ slideImagePath?: string }>)[0]?.slideImagePath || '' ) + const imagesOnlySlide = ( + result.readImagesOnly.slides as Array<{ + slideId?: string + slideIndex?: number + slideImagePath?: string + elements?: unknown + text?: string + }> + )[0] + const imagesOnlyPath = String(imagesOnlySlide?.slideImagePath || '') + const imagesOnlyExists = await fs + .access(imagesOnlyPath) + .then(() => true) + .catch(() => false) const readSlideImageExists = await fs .access(readSlideImagePath) .then(() => true) @@ -188,6 +208,14 @@ describe('ai tool ppt backend', () => { result.overview.slideCount === 2 && Array.isArray(result.readSlides.slides) && result.readSlides.slides.length === 1 && + result.readImagesOnly.detail === 'images' && + Array.isArray(result.readImagesOnly.slides) && + result.readImagesOnly.slides.length === 1 && + typeof imagesOnlySlide?.slideId === 'string' && + typeof imagesOnlySlide?.slideIndex === 'number' && + imagesOnlySlide?.elements === undefined && + imagesOnlySlide?.text === undefined && + imagesOnlyExists && result.failedDelete.ok === false && result.overviewAfterFailedDelete.slideCount === 2 && !JSON.stringify(result.overviewAfterFailedDelete).includes('不应落盘的标题') && diff --git a/tests/case/unit/ai/ppt-tool-contract.test.ts b/tests/case/unit/ai/ppt-tool-contract.test.ts index 53f10aee..769d69ff 100644 --- a/tests/case/unit/ai/ppt-tool-contract.test.ts +++ b/tests/case/unit/ai/ppt-tool-contract.test.ts @@ -63,6 +63,20 @@ describe('ai unit ppt tool contract', () => { ranges: [{ type: 'slide_ids', slideIds: ['/ppt/slides/slide1.xml'] }] }).success ).toBe(true) + expect( + pptReadSlidesSchema.safeParse({ + sourcePath: 'deck.pptx', + ranges: [{ type: 'slide_index', fromSlideIndex: 1, toSlideIndex: 1 }], + detail: 'images' + }).success + ).toBe(true) + expect( + pptReadSlidesSchema.safeParse({ + sourcePath: 'deck.pptx', + ranges: [{ type: 'slide_index', fromSlideIndex: 1, toSlideIndex: 1 }], + detail: 'structure' + }).success + ).toBe(false) expect( pptSearchSchema.safeParse({ sourcePath: 'deck.pptx', @@ -212,6 +226,9 @@ describe('ai unit ppt tool contract', () => { expect(normalizePptColor('rgb(12, 34, 56)')).toBe('#0C2238') expect(normalizePptColor('rgba(12, 34, 56, 0)')).toBeUndefined() expect(normalizePptColor('#abc')).toBe('#AABBCC') + expect(normalizePptColor('abc')).toBe('#AABBCC') + expect(normalizePptColor('1FA8A0')).toBe('#1FA8A0') + expect(normalizePptColor('ffffff')).toBe('#FFFFFF') expect(normalizePptColor('rebeccapurple', '#FFFFFF')).toBe('#FFFFFF') })