feat: enhance PPT tools with detailed slide reading options and update schemas

This commit is contained in:
Yoan Huang committed 2026-08-29 18:50:18 +08:00
1 parent 4b5c0ee988
commit caa1873386
8 files changed
+85 -22

No files matched your search

+1 -1
View File
@@ -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 | 普通文本读写搜索、命令执行、网页抓取与内置浏览器操作 |
+1 -1
View File
@@ -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 都保持调用前状态。
@@ -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(
@@ -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)
})
}
}
@@ -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
@@ -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<
@@ -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<JsonObject>('ppt_read_slides', {
sourcePath,
detail: 'images',
ranges: [{ type: 'slide_ids', slideIds: [firstSlideId] }]
})
const failedDelete = await harness.invoke<JsonObject>('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('不应落盘的标题') &&
@@ -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')
})