feat: enhance PPT tools with detailed slide reading options and update schemas
This commit is contained in:
1 parent
4b5c0ee988
commit
caa1873386
8 files changed
+85
-22
No files matched your search
+1
-1
@@ -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 | 普通文本读写搜索、命令执行、网页抓取与内置浏览器操作 |
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user