From ae4cd1c56ac22fc2d4606ba4e51d8d867fdcb131 Mon Sep 17 00:00:00 2001 From: Yoan Huang Date: Sun, 30 Aug 2026 12:16:27 +0800 Subject: [PATCH] Refactor Excel tool operations and update test cases - Consolidated Excel edit operations to use a unified API structure. - Updated test cases to reflect changes in Excel read and edit functionalities. - Replaced deprecated methods with new ones for reading outlines, searching, and editing cells. - Enhanced error handling and validation in test cases for better reliability. - Added new test fixtures for comprehensive testing of Excel functionalities. - Improved assertions in tests to ensure accurate validation of expected outcomes. --- doc/architecture.md | 3 +- doc/prd/workbench.md | 8 +- doc/test-plan/ai-test.md | 60 +- doc/test-plan/workbench-test.md | 2 +- .../human/shared/modelToolResultRenderers.ts | 134 ++++ src/main/services/ai/human/skills.ts | 2 +- .../ai/human/tools/excel/constants.ts | 2 + .../services/ai/human/tools/excel/index.ts | 57 +- .../ai/human/tools/excel/outputSchemas.ts | 157 +++-- .../services/ai/human/tools/excel/runtime.ts | 585 ++++++++++-------- .../services/ai/human/tools/excel/schemas.ts | 323 ++++++---- .../ai/human/tools/excel/toolSpecs.ts | 92 +-- .../services/ai/human/tools/excel/types.ts | 24 +- .../src/components/workbench/utils.ts | 10 +- src/shared/assistantExecutionSummary.ts | 4 +- src/shared/runtimeCapabilities.ts | 4 +- ...human-excel-business-analysis-live.test.ts | 215 ------- ...n-excel-create-monthly-budget-live.test.ts | 101 +++ ...uman-excel-edit-weekly-review-live.test.ts | 105 ++++ ...uman-excel-read-weekly-review-live.test.ts | 66 ++ .../mock-llm/human-excel-read-edit.test.ts | 20 +- .../tool/excel/excel-create-read-edit.test.ts | 86 ++- ...excel-diff-row-structure-semantics.test.ts | 204 +++--- ...xcel-edit-operations-and-atomicity.test.ts | 250 +++++--- ...-read-search-and-format-boundaries.test.ts | 140 +++-- .../hand/hand-excel-read-write-read.test.ts | 17 +- tests/case/unit/ai/excel-contracts.test.ts | 16 +- .../workbench/assistant-process-icons.test.ts | 6 +- .../excel-update-diff-and-revert.e2e.test.ts | 2 +- .../fixtures/workspace/operating-review.xlsx | Bin 0 -> 9582 bytes 30 files changed, 1584 insertions(+), 1111 deletions(-) create mode 100644 src/main/services/ai/human/tools/excel/constants.ts delete mode 100644 tests/case/ai/human/live/human-excel-business-analysis-live.test.ts create mode 100644 tests/case/ai/human/live/human-excel-create-monthly-budget-live.test.ts create mode 100644 tests/case/ai/human/live/human-excel-edit-weekly-review-live.test.ts create mode 100644 tests/case/ai/human/live/human-excel-read-weekly-review-live.test.ts create mode 100644 tests/fixtures/workspace/operating-review.xlsx diff --git a/doc/architecture.md b/doc/architecture.md index a8eef217..dd6034be 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -106,6 +106,7 @@ AI 通过工具操作本地环境。工具应像「手」:模型指定**位置 6. **描述声明边界,不教流程** — skill / tool 描述只说明能做什么、不能做什么、参数含义;组合策略由模型自主决定。 7. **工具简单可理解** — 作者语言贴近模型对页面的心智模型,所用即所想。 8. **写回只给新锚点,验证靠再读** — 写工具只返回继续写所需的 id;对错由后续 read 确认,不返回变更清单或整页 readback。 +9. **非必要字段不要设计**- 只设计真正需要的字段,给必要字段不要设计进来。 **运行时分层** @@ -128,7 +129,7 @@ DOCX 读写工具族已按上述原则落地,作为后续 Excel / PPT / PDF | DOCX | 文档聚合模型 + story 内容树;root、可编辑子节点和 visual 共用一个 `nodesById`,visual 只使用 `nodeId`;读写拆成两个 skill;Source / Working 双态 + 净 Diff 审核 | | PDF | 页级读取与检索;Worker 隔离解析;读取页面同时返回 pageImagePath | | PPT | 结构化页规格创建与整页替换,按 slideId / elementId 锚点编辑;`ppt_read_slides` 默认可读完整元素,`detail=images` 时仅返回 slideImagePath 供视觉巡检 | -| Excel | 工作簿 / 工作表 / 单元格范围读写 | +| Excel | 工作表地图 + A1 范围;`excel_read` 四种 type(workbook / range / style / search);`excel_edit` 一次一个 type;写回成功 `{}`(当前无系统生成锚点) | | KB | 混合召回检索工作区已索引文档 | | Text / Terminal / Web | 普通文本读写搜索、命令执行、网页抓取与内置浏览器操作 | diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index dda78bf1..39a275b6 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -607,11 +607,11 @@ #### WB-EXCEL-001 AI 读取和编辑 Excel -- 系统行为:AI 通过 `excel-read` 读取工作簿概览、搜索值或公式并按 A1 范围读取单元格;通过 `excel-write` 创建或编辑工作表、单元格、公式、格式和结构。`excel_edit` 的入参统一为 `sourcePath + actions[]`,不再提供顶层 `action`、`sheets` 或 `operations`。路径不存在时创建,存在时编辑。读取支持 `xlsx`、`xlsm`、`xltx`、`xltm`,写入只支持 `xlsx`。Excel 文件不进入本地知识库内容索引,只索引文件名、文档标题与工作表名等元数据(不含单元格内容),具体单元格数据仍由 Excel 专用工具按工作区路径处理。 +- 系统行为:AI 通过 `excel-read` 的 `excel_read` 读取工作簿地图、A1 范围的值与公式、样式,或按格子值/公式搜索;通过 `excel-write` 的 `excel_edit` 一次一个 type 创建或编辑工作表、范围、样式和结构。路径只出现在入参。路径不存在时第一次 `add_sheet` 创建工作簿(不自动生成 Sheet1),存在时编辑。读取支持 `xlsx`、`xlsm`、`xltx`、`xltm`,写入只支持 `xlsx`。Excel 文件不进入本地知识库内容索引,只索引文件名、文档标题与工作表名等元数据(不含单元格内容),具体单元格数据仍由 Excel 专用工具按工作区路径处理。产品侧 Diff / 修改记录 / 回退仍由系统维护,不投影给模型。 - 验收: - - `WB-EXCEL-001-AC1` `excel_read_outline` 返回工作表、使用范围、表格和公式数量概览;`excel_search` 可搜索单元格值、显示值、公式、工作表名和表格列名;`excel_read_cells` 可按多个工作表和 A1 范围返回地址、值、显示值、公式、类型与数字格式。 - - `WB-EXCEL-001-AC2` `excel_edit` 支持创建或更新 `xlsx`,可设置或删除单元格、批量写范围、清空范围、插入或删除行、设置列宽、增加或删除或重命名工作表、合并单元格,并保留未修改的工作簿结构与样式。 - - `WB-EXCEL-001-AC3` 同一次 `excel_edit` 的任一操作失败时不产生部分写入;文件在连续编辑之间被外部修改时拒绝覆盖并提示冲突;无实际变化时不生成修改记录。 + - `WB-EXCEL-001-AC1` `excel_read` 按 type 工作:`workbook` 返回各表 `name/range/rowCount/columnCount`(空表 `range` 为 null);`range` 返回网格,公式格为 `{ formula, value? }`,字面量为原值;`style` 只返回有格式的 `bold/fontColor/fillColor/numberFormat`,否则为 `null`;`search` 只搜单元格值和公式,返回 `{ truncated, matches: [{ sheetName, address, value? 或 formula }] }`。 + - `WB-EXCEL-001-AC2` `excel_edit` 一次一个 type:`add_sheet` / `delete_sheet` / `rename_sheet` / `set_range` / `clear_range` / `merge_cells` / `unmerge_cells` / 插删行列 / `set_style` / `set_column_width` / `set_row_height` / `auto_fit`。`set_range` 用 `range` 定位:单格为起点贴 `values`,矩形必须与 `values` 行列一致;不传 style 则保留原样式。成功返回 `{}`。 + - `WB-EXCEL-001-AC3` 单次 `excel_edit` 失败时不产生部分写入;文件在连续编辑之间被外部修改时拒绝覆盖并提示冲突;无实际变化时不生成修改记录。 - `WB-EXCEL-001-AC4` `xlsm`、`xltx`、`xltm` 可以读取但不能写回;拒绝写入时源文件保持不变,并明确说明是为了避免丢失宏或模板信息。 - `WB-EXCEL-001-AC5` Excel 创建或编辑成功后,正式文件中的值与公式可被其他程序立即读取,正式文件不包含审核模式使用的红绿边框。 - `WB-EXCEL-001-AC6` 新建工作簿只包含 `add_sheet` 显式创建的 Sheet,不自动生成 `Sheet1`;最终至少保留一个 Sheet,删除最后一个 Sheet 必须失败。 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 54bf3e25..58ac11fb 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -6,8 +6,9 @@ | --------------------------------------------------- | ---- | ------------: | -------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | human-risk-report-draft-live | 完成 | 97 | deepseek-v4-flash | 2026-08-13 20:15 CST | tests/logs/ai/human/live/human-risk-report-draft-live/20260813-201549-550 | 真实读取模板、上周周报与工作文档,新建周报并 apply,原文件全部未改。 | | human-context-compaction-natural-trigger-live | 完成 | 97 | deepseek-v4-flash | 2026-08-13 19:56 CST | tests/logs/ai/human/live/human-context-compaction-natural-trigger-live/20260813-195642-816 | 真实长任务自然触发多轮压缩(usage_plus_delta),摘要结构与关键事实保留,压缩后连续推进并完成周报交付。 | -| human-excel-write-reconciliation-live | 完成 | 96 | deepseek-v4-flash | 2026-08-14 18:39 CST | tests/logs/ai/human/live/human-excel-write-reconciliation-live/20260814-183949-614 | 终端真实计算核对,四笔状态精确标记,SUMIF 公式正确,样式保持。 | -| human-excel-read-sales-gap-analysis-live | 完成 | 95 | deepseek-v4-flash | 2026-08-13 21:02 CST | tests/logs/ai/human/live/human-excel-read-sales-gap-analysis-live/20260813-210226-107 | 真实读取多 Sheet 并终端/python 计算差额,对话给出 P-100/P-300 优先级,源工作簿未改。 | +| human-excel-read-weekly-review-live | 完成 | 92 | qwen3.8-flash | 2026-08-30 11:52 CST | tests/logs/ai/human/live/human-excel-read-weekly-review-live/20260830-115249-268 | 只读讲清四表、Summary 公式与 P-300 异常;hash 不变、有 excel_read 无 excel_edit。过程偏多终端辅助。 | +| human-excel-edit-weekly-review-live | 完成 | 94 | qwen3.8-flash | 2026-08-30 11:52 CST | tests/logs/ai/human/live/human-excel-edit-weekly-review-live/20260830-115249-269 | 先读后写:表头含 CNY、P-300=680000、Summary 净额公式;Notes/P-200/Min margin 未动,1 条修改记录。 | +| human-excel-create-monthly-budget-live | 完成 | 93 | qwen3.8-flash | 2026-08-30 12:08 CST | tests/logs/ai/human/live/human-excel-create-monthly-budget-live/20260830-120845-565 | 空工作区建成 Departments/Summary,SUM+SUMIF 与千分位格式,可解析且 1 条 excel 修改记录。 | | human-kb-guided-docx-comparison-draft-live | 完成 | 86 | deepseek-v4-flash | 2026-08-27 22:47 CST | tests/logs/ai/human/live/human-kb-guided-docx-comparison-draft-live/20260827-224726-600 | KB 缩圈后双目标 docx 成功回写,来源课件未改,Oracle 通过。 | | human-ppt-create-executive-deck-live | 完成 | 58 | deepseek-v4-flash | 2026-08-29 11:28 CST | tests/logs/ai/human/live/human-ppt-create-executive-deck-live/20260829-112844-003 | 文案完整但 KPI 等页字号(磅)相对 bounds 过高导致叠字;视觉不可用,分数下调。 | | human-ppt-edit-management-review-live | 完成 | 94 | deepseek-v4-flash | 2026-08-29 11:34 CST | tests/logs/ai/human/live/human-ppt-edit-management-review-live/20260829-113439-010 | 富稿上插摘要、Presenter→李然、2002→2015,Agenda/Quote 未动,出画布未新增。 | @@ -329,28 +330,28 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm ##### `excel-create-read-edit` - 测试类型:`backend / tool` -- What:Excel 专用工具创建工作簿、批量写入数据和公式,并继续执行概览、搜索、范围读取和修改。 +- What:Excel 专用工具创建工作簿、用 `set_range` 写入数据和公式,并继续执行 workbook / search / range 读取和修改。 - Why:Excel 的值与公式必须由程序真实读写,不能用模型推算或用伪造工具结果代替。 -- How:真实调用 `excel_edit` 创建 xlsx,再调用三个只读工具并继续修改同一路径;另对已有 xlsx 执行修改和删除单元格,最后直接解析正式文件与 diff xlsx 文件。 +- How:真实调用 `excel_edit` 一次一个 type 创建 xlsx,再调用 `excel_read` 的 workbook/search/range,并继续修改同一路径;另对已有 xlsx 执行修改和清空单元格,最后直接解析正式文件与 diff xlsx 文件。修改记录从 `listFileChangeRecords` 读取,不依赖工具写回。 - Oracle:新文件只包含显式新增的 Sheet,没有默认 `Sheet1`;正式文件包含准确值和公式且不带 diff 边框;diff 文件对新增、修改显示绿色中等粗细边框,对删除显示红色中等粗细边框并保留修改前值;同一路径只保留一条修改记录,对齐 `WB-EXCEL-001`、`WB-EXCEL-002-AC2`、`WB-EXCEL-002-AC3`。 - Not:不验证 human 是否会选择 Excel 技能,也不评价表格视觉设计。 ##### `excel-read-search-and-format-boundaries` - 测试类型:`backend` -- What:Excel outline、search、read-cells 的范围、格式与错误边界。 -- Why:当前单一 happy path 无法保护多 Sheet、regex、大结果截断和 Office Excel 格式兼容。 -- How:真实读取 `xlsx/xlsm/xltx/xltm`,覆盖 Sheet scope、多 ranges、literal/regex、500 条截断,以及非法路径、range、regex 和格式。 -- Oracle:结果 Sheet/range/值稳定;搜索最多返回 500 条并显式标记截断;合法格式可读,非法输入稳定失败且不产生修改记录。 +- What:`excel_read` 四种 type 的范围、格式与错误边界。 +- Why:当前单一 happy path 无法保护多 Sheet、regex、大结果截断、格子预算和 Office Excel 格式兼容。 +- How:真实读取 `xlsx/xlsm/xltx/xltm`,覆盖 `sheetName` 限定、网格 range、literal/regex、500 条 `truncated`,超格子预算失败,以及非法路径、range、regex 和格式。 +- Oracle:结果 Sheet/range/值稳定;搜索最多返回 500 条并显式 `truncated`;搜索只命中单元格值和公式,不搜表名;合法格式可读,非法输入稳定失败且不产生修改记录。 - Not:不验证模型如何选择读取范围。 ##### `excel-edit-operations-and-atomicity` - 测试类型:`backend` -- What:Excel 全部编辑 action、样式、工作簿结构、原子写入和累计修改记录,并校验非行位移类 diff xlsx 语义。 +- What:Excel 全部编辑 type、样式、工作簿结构、原子写入和累计修改记录,并校验非行位移类 diff xlsx 语义。 - Why:写入失败、外部并发修改或 record 持久化失败都不能留下半提交状态;同一次 run 的多轮编辑必须相对首个 baseline 生成累计 Diff;删除 Sheet、清空范围和边框粗细不能只靠 UI e2e 间接发现。 -- How:真实覆盖单元格写入/清空、公式、样式、行列与 Sheet 操作、no-op、外部修改冲突、record 持久化失败回滚和同 run 两轮编辑;直接解析 diff xlsx 检查 sheet 顺序、tab 色、单元格值、边框、行高、列宽和合并单元格。 -- Oracle:每个 action 落盘后可读;删除最后一个 Sheet 失败;新增 Sheet 在 Diff 中为绿色标签;删除 Sheet 按 baseline 原位置恢复完整旧内容并显示红色标签;清空范围保留修改前值并显示红色中等粗细边框;源 xlsx 不包含 diff 标记;no-op 不产生记录;冲突与持久化失败恢复到安全状态;最终仅一条累计 record,Diff 相对首个 baseline。 +- How:真实覆盖 `set_range`/清空、公式、样式、行列与 Sheet 操作、no-op、外部修改冲突、record 持久化失败回滚和同 run 两轮编辑;直接解析 diff xlsx 检查 sheet 顺序、tab 色、单元格值、边框、行高、列宽和合并单元格。 +- Oracle:每个 type 落盘后可读;删除最后一个 Sheet 失败;新增 Sheet 在 Diff 中为绿色标签;删除 Sheet 按 baseline 原位置恢复完整旧内容并显示红色标签;清空范围保留修改前值并显示红色中等粗细边框;源 xlsx 不包含 diff 标记;no-op 不产生记录;冲突与持久化失败恢复到安全状态;最终仅一条累计 record,Diff 相对首个 baseline。 - Not:不验证 Workbench 的 Diff 交互。 ##### `excel-diff-row-structure-semantics` @@ -376,7 +377,7 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm - 测试类型:`backend` - What:hand 在同一次调用中允许 Excel 读取、写入、再读取的真实串联。 - Why:Excel 工具单测通过不代表 hand 的 schema 预检、调用预算和 artifact 合并可以承载完整链路。 -- How:真实执行 `excel_read_outline -> excel_edit -> excel_read_cells`。 +- How:真实执行 `excel_read(workbook) -> excel_edit -> excel_read(range)`。 - Oracle:三步均成功;后读可见前写结果;最终 artifact 和修改记录只指向目标工作簿。 - Not:不验证 human 是否自主选择这条顺序。 @@ -617,7 +618,7 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm - 测试类型:`backend` - What:human 学习 Excel 能力后读取已有工作簿、修改并复读验证。 - Why:补齐 Excel 在真实 human graph 中完全缺失的 non-live 回归。 -- How:只脚本化 LLM 决策,真实走 learning、hand、Excel runtime、apply/artifact 与 brain 交付。 +- How:只脚本化 LLM 决策,真实走 learning、hand、Excel runtime、apply/artifact 与 brain 交付。脚本调用 `excel_read` / `excel_edit` 学技能后读-写-复读。脚本调用 `excel_read` / `excel_edit` 学技能后读-写-复读。 - Oracle:任务完成;调用包含读、写、复读;正式文件、累计 record、artifact 与最终答复一致。 - Not:不验证真实模型的工具选择能力。 @@ -839,23 +840,32 @@ DOCX tool 回归以 7 个公开工具、story 内容树、稳定 `nodeId`、segm #### live 类别 -##### `human-excel-read-sales-gap-analysis-live` +##### `human-excel-read-weekly-review-live` - 测试类型:`live` -- What:真实模型在销售周会前审阅多 Sheet 工作簿,并给出未达标产品和优先级分析。 -- Why:用户只表达经营诉求;模型必须自行选择读取和真实计算能力。 -- How:不出现工具名,不修改工作簿;要求计算实际额、目标额和差额后直接向用户汇报。 -- Oracle:源文件完整;发生真实 Excel 读取和终端计算;结论由 raw logs 人工评估。 -- Not:不生成仅供测试消费的 JSON,不断言最终回答文案。 +- What:周会前审阅检入的复杂经营 xlsx,口头讲清表结构、关键公式和异常数字,不改文件。 +- Why:真实查看场景必须用现成经营表,不能在 live `execute` 里现造表。 +- How:工作区放入 `tests/fixtures/workspace` 中的复杂 xlsx;用户只描述审阅诉求,不出现工具名。 +- Oracle:源文件 hash 不变;发生 `excel_read`;无 `excel_edit`。结论由 raw logs 人工评估。 +- Not:不使用 `销售额统计表.xlsx`;不在 execute 里用 ExcelJS 现造表。 -##### `human-excel-write-reconciliation-live` +##### `human-excel-edit-weekly-review-live` - 测试类型:`live` -- What:真实模型在财务结账场景中更新既有对账工作簿。 -- Why:对账、精确标记、公式和写后复核是实际 Excel 交付能力。 -- How:用户只描述销售与回款核对、待补票标记和汇总需求;模型自行选择计算手段。 -- Oracle:正式工作簿的目标单元格、公式、样式和非目标数据正确;发生真实终端计算、Excel 写入和写后复核。 -- Not:不生成额外 JSON,不断言最终回答文案。 +- What:在同一份检入的复杂经营 xlsx 上更正/补公式/改表头,并保护部分区域。 +- Why:真实编辑必须先读后写,且不能破坏保护区。 +- How:与查看 live 共用同一 fixture;用户描述要改的几处和不可动区域。 +- Oracle:先读后写;目标单元格正确;保护区原值保留;1 条修改记录。 +- Not:不在 execute 里现造表。 + +##### `human-excel-create-monthly-budget-live` + +- 测试类型:`live` +- What:空工作区从零做月度预算(多表、表头、SUM/SUMIF、金额格式)。 +- Why:新建场景覆盖工作簿创建、跨表公式和数字格式。 +- How:空工作区,用户描述月度预算交付要求。 +- Oracle:文件可解析;汇总是公式;1 条 excel 修改记录。 +- Not:不评价表格视觉设计。 ##### `human-legacy-doc-reading-live` diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index 9564eb00..aba55dd0 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -470,7 +470,7 @@ tests/case/workbench/ - What:真实 AI 修改已有 Excel 后,用户在同一个只读网格中查看红绿单元格边框,并能回退到原始文件。 - Why:Excel 修改预览不能依赖模型描述,也不适合 PPT 式左右画面对比,必须直接验证工作簿数据、公式和单元格级 diff。 - How:通过真实 Workbench UI 用自然语言请求 AI 对已有 xlsx 分两轮修改;打开修改记录,显式切换“预览/审核”,在两种模式下尝试编辑、切换工作表和拖列宽;通过 E2E no-op 成功分支打开关联应用,再执行回退。 -- Oracle:正式 xlsx 的值和公式准确且没有 diff 边框;同一 run 两轮修改只形成相对首个 baseline 的累计记录;两种模式都通过 Univer 只读预览、可切换工作表且不出现 PPT 前后预览;已应用记录成功交给外部应用后显示“关闭后重新打开”提示;回退后 bytes 与修改前一致;真实调用包含 Excel 读工具和至少两次 `excel_edit`;对齐 `WB-PANEL-003-AC19`、`WB-EXCEL-001-AC5`、`WB-EXCEL-002-AC1`、`WB-EXCEL-002-AC3`、`WB-EXCEL-002-AC4`。 +- Oracle:正式 xlsx 的值和公式准确且没有 diff 边框;同一 run 两轮修改只形成相对首个 baseline 的累计记录;两种模式都通过 Univer 只读预览、可切换工作表且不出现 PPT 前后预览;已应用记录成功交给外部应用后显示“关闭后重新打开”提示;回退后 bytes 与修改前一致;真实调用包含 `excel_read` 和至少两次 `excel_edit`;对齐 `WB-PANEL-003-AC19`、`WB-EXCEL-001-AC5`、`WB-EXCEL-002-AC1`、`WB-EXCEL-002-AC3`、`WB-EXCEL-002-AC4`。 - Not:不逐字断言最终回答,不把模型直接计算的文本结果当作数据正确性证据;不在 Workbench e2e 枚举新增、修改、删除、清空、删除 Sheet 的全部 diff 细节,这些由 AI tool Excel case 直接解析 diff xlsx 覆盖。 #### `excel-create-diff-and-revert` diff --git a/src/main/services/ai/human/shared/modelToolResultRenderers.ts b/src/main/services/ai/human/shared/modelToolResultRenderers.ts index 3ac974f1..ef50982e 100644 --- a/src/main/services/ai/human/shared/modelToolResultRenderers.ts +++ b/src/main/services/ai/human/shared/modelToolResultRenderers.ts @@ -400,6 +400,140 @@ export const renderTextFileSearchModelResult: ModelResultRenderer = ({ data }) = ].join('\n') } +const formatExcelRangeCell = (value: JsonValue | undefined): string => { + if (value === null || value === undefined) return '' + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + if (!isObject(value)) return '' + const formula = readString(value.formula) + if (!formula) return '' + const result = value.value + const serialized = + result === null || result === undefined + ? undefined + : typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean' + ? String(result) + : undefined + return serialized ? `=${formula} => ${serialized}` : `=${formula}` +} + +const formatExcelStyleCell = (value: JsonValue | undefined): string | undefined => { + if (!isObject(value)) return undefined + const parts = [ + value.bold === true ? 'bold' : '', + readString(value.fontColor) ? `fontColor=${readString(value.fontColor)}` : '', + readString(value.fillColor) ? `fillColor=${readString(value.fillColor)}` : '', + readString(value.numberFormat) ? `numberFormat=${readString(value.numberFormat)}` : '' + ].filter(Boolean) + return parts.length ? parts.join(', ') : undefined +} + +const columnLettersFromIndex = (column: number): string => { + let value = column + let result = '' + while (value > 0) { + const remainder = (value - 1) % 26 + result = String.fromCharCode(65 + remainder) + result + value = Math.floor((value - 1) / 26) + } + return result +} + +const parseExcelA1 = ( + range: string +): { top: number; left: number } | undefined => { + const match = range.trim().match(/^\$?([A-Za-z]+)\$?(\d+)/) + if (!match) return undefined + const left = match[1] + .toUpperCase() + .split('') + .reduce((total, letter) => total * 26 + letter.charCodeAt(0) - 64, 0) + return { top: Number(match[2]), left } +} + +export const renderExcelReadModelResult: ModelResultRenderer = ({ args, data }) => { + const obj = isObject(data) ? data : {} + const type = typeof args.type === 'string' ? args.type : undefined + if (type === 'workbook' || Array.isArray(obj.sheets)) { + const sheets = Array.isArray(obj.sheets) ? obj.sheets.filter(isObject) : [] + return [ + 'Excel workbook read completed.', + 'Sheets:', + ...(sheets.length + ? sheets.map((sheet, index) => { + const range = readString(sheet.range) || 'empty' + return ` ${index + 1}. ${readString(sheet.name) || ''} ${range} rows=${readNumber(sheet.rowCount) ?? 0} cols=${readNumber(sheet.columnCount) ?? 0}` + }) + : [' none']) + ].join('\n') + } + if (type === 'search' || Array.isArray(obj.matches)) { + const matches = Array.isArray(obj.matches) ? obj.matches.filter(isObject) : [] + return [ + 'Excel search completed.', + `truncated: ${String(obj.truncated === true)}`, + 'Matches:', + ...(matches.length + ? matches.map((item) => { + const formula = readString(item.formula) + const value = + typeof item.value === 'string' || + typeof item.value === 'number' || + typeof item.value === 'boolean' + ? String(item.value) + : item.value === null + ? '' + : '' + const shown = formula ? `=${formula}` : value + return ` ${readString(item.sheetName) || ''}!${readString(item.address) || ''} ${shown}`.trimEnd() + }) + : [' none']) + ].join('\n') + } + const sheetName = readString(obj.sheetName) || '' + const range = readString(obj.range) || '' + const rows = Array.isArray(obj.rows) ? obj.rows : [] + const origin = parseExcelA1(range) + if (type === 'style') { + const lines: string[] = [] + rows.forEach((row, rowIndex) => { + if (!Array.isArray(row) || !origin) return + row.forEach((cell, columnIndex) => { + const formatted = formatExcelStyleCell(cell) + if (!formatted) return + const address = `${columnLettersFromIndex(origin.left + columnIndex)}${origin.top + rowIndex}` + lines.push(` ${address}: ${formatted}`) + }) + }) + return [ + 'Excel style read completed.', + `Sheet: ${sheetName}`, + `Range: ${range}`, + ...(lines.length ? lines : [' none']) + ].join('\n') + } + return [ + 'Excel range read completed.', + `Sheet: ${sheetName}`, + `Range: ${range}`, + ...(rows.length + ? rows.map((row) => + Array.isArray(row) + ? `| ${row.map((cell) => formatExcelRangeCell(cell)).join(' | ')} |` + : '' + ) + : [' none']) + ] + .filter((line) => line !== '') + .join('\n') +} + +export const renderExcelEditModelResult: ModelResultRenderer = ({ args }) => { + const type = typeof args.type === 'string' ? args.type : 'edit' + return `Excel ${type} completed.` +} + export const renderPlanUpdateModelResult: ModelResultRenderer = () => 'Plan updated successfully.' export const renderLearnSkillModelResult: ModelResultRenderer = ({ args, data }) => { diff --git a/src/main/services/ai/human/skills.ts b/src/main/services/ai/human/skills.ts index 731bb518..c31a4468 100644 --- a/src/main/services/ai/human/skills.ts +++ b/src/main/services/ai/human/skills.ts @@ -89,7 +89,7 @@ export const RUNTIME_SKILL_CATALOG = { source: 'external', countsTowardToolBudget: true, title: 'excel-read', - description: '读取 Excel 工作簿、工作表、单元格范围、公式和值。' + description: '读取 Excel 工作簿地图、单元格范围的值与公式、样式,以及按格子搜索。' }, 'excel-write': { familyId: 'excel', diff --git a/src/main/services/ai/human/tools/excel/constants.ts b/src/main/services/ai/human/tools/excel/constants.ts new file mode 100644 index 00000000..5f84cf96 --- /dev/null +++ b/src/main/services/ai/human/tools/excel/constants.ts @@ -0,0 +1,2 @@ +export const EXCEL_SEARCH_MAX_MATCHES = 500 +export const EXCEL_READ_RANGE_MAX_CELLS = 2_000 diff --git a/src/main/services/ai/human/tools/excel/index.ts b/src/main/services/ai/human/tools/excel/index.ts index 345268ed..92277126 100644 --- a/src/main/services/ai/human/tools/excel/index.ts +++ b/src/main/services/ai/human/tools/excel/index.ts @@ -5,27 +5,18 @@ import { createWorkspaceBoundToolFamily } from '../utils/workspaceGuards' import { createToolEventEmitter } from '../utils/toolEvents' import { withToolLifecycle } from '../utils/lifecycle' import { createExcelToolRuntime, toExcelToolFailure } from './runtime' -import { - excelEditSchema, - excelReadCellsSchema, - excelReadOutlineSchema, - excelSearchSchema -} from './schemas' -import { - EXCEL_EDIT_TOOL_SPEC, - EXCEL_READ_CELLS_TOOL_SPEC, - EXCEL_READ_OUTLINE_TOOL_SPEC, - EXCEL_SEARCH_TOOL_SPEC -} from './toolSpecs' +import { excelEditSchema, excelReadSchema } from './schemas' +import { EXCEL_EDIT_TOOL_SPEC, EXCEL_READ_TOOL_SPEC } from './toolSpecs' import type { ExcelCapabilityToolsInput } from './types' -type ExcelToolName = 'excel_read_outline' | 'excel_search' | 'excel_read_cells' | 'excel_edit' +type ExcelToolName = 'excel_read' | 'excel_edit' -const ACTIVITY: Record = { - excel_read_outline: '读取 Excel 概览', - excel_search: '搜索 Excel', - excel_read_cells: '读取 Excel 单元格', - excel_edit: '编辑 Excel' +const readActivity = (args: JsonObject): string => { + const type = typeof args.type === 'string' ? args.type : 'workbook' + if (type === 'search') return '搜索 Excel' + if (type === 'style') return '读取 Excel 样式' + if (type === 'range') return '读取 Excel 范围' + return '读取 Excel 工作簿' } export const buildExcelCapabilityTools = (input: ExcelCapabilityToolsInput) => { @@ -42,6 +33,7 @@ export const buildExcelCapabilityTools = (input: ExcelCapabilityToolsInput) => { description: string schema: TSchema execute: (args: never) => Promise + summary: (args: JsonObject) => string }) => tool( async (args) => @@ -51,10 +43,11 @@ export const buildExcelCapabilityTools = (input: ExcelCapabilityToolsInput) => { args: args as JsonObject, execute: async (session) => { input.ctx.checkCancelled() + const summary = params.summary(args as JsonObject) input.emitActivity({ actionType: params.name, status: 'started', - summary: ACTIVITY[params.name] + summary }) try { const result = await params.execute(args as never) @@ -62,7 +55,7 @@ export const buildExcelCapabilityTools = (input: ExcelCapabilityToolsInput) => { input.emitActivity({ actionType: params.name, status: 'completed', - summary: `${ACTIVITY[params.name]}完成` + summary: `${summary}完成` }) return result } catch (error) { @@ -92,28 +85,18 @@ export const buildExcelCapabilityTools = (input: ExcelCapabilityToolsInput) => { runtime, tools: [ createTool({ - name: 'excel_read_outline', - description: EXCEL_READ_OUTLINE_TOOL_SPEC.promptSpec.description, - schema: excelReadOutlineSchema, - execute: runtime.outline as never - }), - createTool({ - name: 'excel_search', - description: EXCEL_SEARCH_TOOL_SPEC.promptSpec.description, - schema: excelSearchSchema, - execute: runtime.search as never - }), - createTool({ - name: 'excel_read_cells', - description: EXCEL_READ_CELLS_TOOL_SPEC.promptSpec.description, - schema: excelReadCellsSchema, - execute: runtime.readCells as never + name: 'excel_read', + description: EXCEL_READ_TOOL_SPEC.promptSpec.description, + schema: excelReadSchema, + execute: runtime.read as never, + summary: readActivity }), createTool({ name: 'excel_edit', description: EXCEL_EDIT_TOOL_SPEC.promptSpec.description, schema: excelEditSchema, - execute: runtime.edit as never + execute: runtime.edit as never, + summary: () => '编辑 Excel' }) ] } diff --git a/src/main/services/ai/human/tools/excel/outputSchemas.ts b/src/main/services/ai/human/tools/excel/outputSchemas.ts index 54ec06b8..2daacb19 100644 --- a/src/main/services/ai/human/tools/excel/outputSchemas.ts +++ b/src/main/services/ai/human/tools/excel/outputSchemas.ts @@ -1,91 +1,82 @@ import { z } from 'zod/v4' -const excelCellOutputSchema = z.object({ - sheetName: z.string(), - address: z.string(), - value: z.any(), - displayValue: z.string(), - formula: z.string().optional(), - type: z.string(), - numberFormat: z.string().optional() -}) - -const excelChangeOutputSchema = z.object({ - sheetName: z.string(), - address: z.string(), - kind: z.enum(['added', 'modified', 'deleted']) -}) - -export const excelReadOutlineOutputSchema = z.object({ - sourcePath: z.string(), - sheetCount: z.number().int().min(0), - sheets: z.array( - z.object({ - name: z.string(), - index: z.number().int().min(0), - range: z.string().nullable(), - rowCount: z.number().int().min(0), - columnCount: z.number().int().min(0), - formulaCount: z.number().int().min(0), - tables: z.array(z.string()) +const rangeCellOutputSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z + .object({ + formula: z.string(), + value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional() }) - ) -}) + .strict() +]) -export const excelSearchOutputSchema = z.object({ - sourcePath: z.string(), - query: z.string(), - matches: z.array( - z.object({ - matchType: z.enum(['sheet_name', 'table_name', 'table_column']).optional(), - sheetName: z.string(), - tableName: z.string().optional(), - columnName: z.string().optional(), - address: z.string().optional(), - value: z.any().optional(), - displayValue: z.string().optional(), - formula: z.string().optional(), - type: z.string().optional(), +const styleCellOutputSchema = z.union([ + z.null(), + z + .object({ + bold: z.boolean().optional(), + fontColor: z.string().optional(), + fillColor: z.string().optional(), numberFormat: z.string().optional() }) - ), - truncated: z.boolean() -}) + .strict() +]) -export const excelReadCellsOutputSchema = z.object({ - sourcePath: z.string(), - cells: z.array(excelCellOutputSchema) -}) +export const excelReadWorkbookOutputSchema = z + .object({ + sheets: z.array( + z + .object({ + name: z.string(), + range: z.string().nullable(), + rowCount: z.number().int().min(0), + columnCount: z.number().int().min(0) + }) + .strict() + ) + }) + .strict() -export const excelEditOutputSchema = z.object({ - sourcePath: z.string(), - changed: z.boolean(), - changes: z.array(excelChangeOutputSchema), - diffPath: z.string().optional(), - record: z - .object({ - id: z.string(), - sessionId: z.string(), - runId: z.string(), - status: z.string(), - capability: z.literal('excel'), - path: z.string(), - fileName: z.string(), - operation: z.enum(['created', 'updated']), - source: z.literal('excel_tool'), - beforePath: z.string().optional(), - afterPath: z.string(), - changeCount: z.number().int().min(0), - insertCount: z.number().int().min(0), - deleteCount: z.number().int().min(0), - beforeSnapshotPath: z.string().optional(), - afterSnapshotPath: z.string().optional(), - beforeChecksum: z.string().optional(), - afterChecksum: z.string(), - previewPath: z.string(), - runSummary: z.string(), - createdAt: z.number(), - updatedAt: z.number() - }) - .optional() -}) +export const excelReadRangeOutputSchema = z + .object({ + sheetName: z.string(), + range: z.string(), + rows: z.array(z.array(rangeCellOutputSchema)) + }) + .strict() + +export const excelReadStyleOutputSchema = z + .object({ + sheetName: z.string(), + range: z.string(), + rows: z.array(z.array(styleCellOutputSchema)) + }) + .strict() + +export const excelSearchOutputSchema = z + .object({ + truncated: z.boolean(), + matches: z.array( + z + .object({ + sheetName: z.string(), + address: z.string(), + value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(), + formula: z.string().optional() + }) + .strict() + ) + }) + .strict() + +export const excelReadOutputSchema = z.union([ + excelReadWorkbookOutputSchema, + excelReadRangeOutputSchema, + excelReadStyleOutputSchema, + excelSearchOutputSchema +]) + +export const excelEditOutputSchema = z.object({}).strict() diff --git a/src/main/services/ai/human/tools/excel/runtime.ts b/src/main/services/ai/human/tools/excel/runtime.ts index 9b98a4fe..be40a405 100644 --- a/src/main/services/ai/human/tools/excel/runtime.ts +++ b/src/main/services/ai/human/tools/excel/runtime.ts @@ -8,7 +8,8 @@ import { isPathInsideRoot, resolveToolPath } from '../terminal/commandPolicy' import { createToolEventEmitter } from '../utils/toolEvents' import { emitFileArtifactEvent } from '../../../events/fileArtifacts' import type { ExcelCapabilityToolsInput, ExcelRuntimeState, ExcelToolRuntime } from './types' -import type { ExcelEditInput } from './schemas' +import type { ExcelEditInput, ExcelReadInput, ExcelStyle } from './schemas' +import { EXCEL_READ_RANGE_MAX_CELLS, EXCEL_SEARCH_MAX_MATCHES } from './constants' const EXCEL_READ_EXTENSIONS = new Set(['.xlsx', '.xlsm', '.xltx', '.xltm']) const EXCEL_WRITE_EXTENSIONS = new Set(['.xlsx']) @@ -36,53 +37,22 @@ const getStyleId = (cell: ExcelJS.Cell): number => { return typeof value === 'number' ? value : 0 } -const getTableNames = (sheet: ExcelJS.Worksheet): string[] => { - const candidate: unknown = sheet - if (!candidate || typeof candidate !== 'object' || !('tables' in candidate)) return [] - const tables = (candidate as { tables?: unknown }).tables - return tables && typeof tables === 'object' ? Object.keys(tables) : [] +const getFormula = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object') return undefined + if ('formula' in value) { + const formula = String((value as { formula?: unknown }).formula || '') + return formula || undefined + } + if ('sharedFormula' in value) { + const formula = String((value as { sharedFormula?: unknown }).sharedFormula || '') + return formula || undefined + } + return undefined } -const getTableColumns = (table: ExcelJS.Table): Array<{ name: string }> => { - const candidate: unknown = table - if (!candidate || typeof candidate !== 'object' || !('table' in candidate)) return [] - const model = (candidate as { table?: { columns?: unknown } }).table - if (!model || !Array.isArray(model.columns)) return [] - return model.columns.filter((column): column is { name: string } => - Boolean( - column && - typeof column === 'object' && - typeof (column as { name?: unknown }).name === 'string' - ) - ) -} - -const getFormula = (value: unknown): string | undefined => - value && typeof value === 'object' && 'formula' in value - ? String((value as { formula?: unknown }).formula || '') || undefined - : undefined - -const getCellType = (cell: ExcelJS.Cell): string => { - if (getFormula(cell.value)) return 'formula' - if (cell.value === null || cell.value === undefined || cell.value === '') return 'blank' - if (typeof cell.value === 'number') return 'number' - if (typeof cell.value === 'boolean') return 'boolean' - if (cell.value instanceof Date) return 'date' - return 'text' -} - -const getCellSnapshot = (cell: ExcelJS.Cell) => ({ - address: cell.address, - value: cell.value, - displayValue: cell.text, - formula: getFormula(cell.value), - type: getCellType(cell), - numberFormat: cell.numFmt || undefined -}) - -const usedRange = (sheet: ExcelJS.Worksheet): string | null => { - if (sheet.actualRowCount === 0 || sheet.actualColumnCount === 0) return null - return `A1:${sheet.getCell(sheet.rowCount, sheet.columnCount).address}` +const getFormulaResult = (value: unknown): unknown => { + if (!value || typeof value !== 'object' || !('result' in value)) return undefined + return (value as { result?: unknown }).result } const columnNumber = (letters: string): number => @@ -102,6 +72,12 @@ const columnLetters = (column: number): string => { return result } +const usedRange = (sheet: ExcelJS.Worksheet): string | null => { + if (sheet.actualRowCount === 0 || sheet.actualColumnCount === 0) return null + const dimensions = sheet.dimensions + return `${columnLetters(dimensions.left)}${dimensions.top}:${sheet.getCell(dimensions.bottom, dimensions.right).address}` +} + const parseRange = ( range: string ): { top: number; left: number; bottom: number; right: number } => { @@ -665,14 +641,17 @@ const buildDiffWorkbook = async (input: { return { bytes: Buffer.from(bytes), changes } } -const applyStyle = (cell: ExcelJS.Cell, style: Record | undefined): void => { +const applyStyle = (cell: ExcelJS.Cell, style: ExcelStyle | undefined): void => { if (!style) return + const currentFont = cell.font || {} if (typeof style.bold === 'boolean' || typeof style.italic === 'boolean' || style.fontColor) { cell.font = { - ...cell.font, - bold: typeof style.bold === 'boolean' ? style.bold : cell.font.bold, - italic: typeof style.italic === 'boolean' ? style.italic : cell.font.italic, - color: style.fontColor ? { argb: String(style.fontColor).replace(/^#/, '') } : cell.font.color + ...currentFont, + bold: typeof style.bold === 'boolean' ? style.bold : currentFont.bold, + italic: typeof style.italic === 'boolean' ? style.italic : currentFont.italic, + color: style.fontColor + ? { argb: String(style.fontColor).replace(/^#/, '') } + : currentFont.color } } if (style.fillColor) @@ -683,14 +662,255 @@ const applyStyle = (cell: ExcelJS.Cell, style: Record | undefin } if (style.numberFormat) cell.numFmt = String(style.numberFormat) if (style.horizontalAlignment || style.verticalAlignment) { + const currentAlignment = cell.alignment || {} cell.alignment = { - ...cell.alignment, + ...currentAlignment, horizontal: style.horizontalAlignment as ExcelJS.Alignment['horizontal'], vertical: style.verticalAlignment as ExcelJS.Alignment['vertical'] } } } +const toColumnNumber = (column: string | number): number => + typeof column === 'number' ? column : columnNumber(String(column)) + +const requireSheet = (workbook: ExcelJS.Workbook, sheetName: string): ExcelJS.Worksheet => { + const sheet = workbook.getWorksheet(sheetName) + if (!sheet) throw new Error(`工作表不存在: ${sheetName}`) + return sheet +} + +const serializeLiteral = (value: unknown, fallbackText: string): string | number | boolean | null => { + if (value === null || value === undefined || value === '') return null + if (typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'string') return value + if (value instanceof Date) return value.toISOString() + if (typeof value === 'object' && 'text' in value) { + const text = (value as { text?: unknown }).text + if (typeof text === 'string') return text + } + if (typeof value === 'object' && 'richText' in value) { + const richText = (value as { richText?: Array<{ text?: string }> }).richText + if (Array.isArray(richText)) return richText.map((part) => part.text || '').join('') + } + return fallbackText || null +} + +const projectRangeCell = (cell: ExcelJS.Cell): string | number | boolean | null | { formula: string; value?: string | number | boolean | null } => { + const formula = getFormula(cell.value) + if (formula) { + const result = getFormulaResult(cell.value) + const serialized = result === undefined ? undefined : serializeLiteral(result, cell.text) + return serialized === undefined || serialized === null + ? { formula } + : { formula, value: serialized } + } + return serializeLiteral(cell.value, cell.text) +} + +const projectStyleCell = ( + cell: ExcelJS.Cell +): { bold?: boolean; fontColor?: string; fillColor?: string; numberFormat?: string } | null => { + const style: { bold?: boolean; fontColor?: string; fillColor?: string; numberFormat?: string } = {} + if (cell.font?.bold) style.bold = true + const fontColor = cell.font?.color?.argb + if (fontColor) style.fontColor = fontColor + const fill = cell.fill + if (fill && fill.type === 'pattern' && fill.fgColor?.argb) style.fillColor = fill.fgColor.argb + if (cell.numFmt) style.numberFormat = cell.numFmt + return Object.keys(style).length > 0 ? style : null +} + +const assertRangeBudget = (range: { top: number; left: number; bottom: number; right: number }): void => { + const cellCount = (range.bottom - range.top + 1) * (range.right - range.left + 1) + if (cellCount > EXCEL_READ_RANGE_MAX_CELLS) { + throw new Error( + `range 超过 ${String(EXCEL_READ_RANGE_MAX_CELLS)} 个格子,请缩小 range 后重试` + ) + } +} + +const readGrid = ( + sheet: ExcelJS.Worksheet, + range: { top: number; left: number; bottom: number; right: number }, + project: (cell: ExcelJS.Cell) => T +): T[][] => { + const rows: T[][] = [] + for (let row = range.top; row <= range.bottom; row += 1) { + const cells: T[] = [] + for (let column = range.left; column <= range.right; column += 1) { + cells.push(project(sheet.getCell(row, column))) + } + rows.push(cells) + } + return rows +} + +const toCellValue = (value: unknown): ExcelJS.CellValue => { + if (value && typeof value === 'object' && 'formula' in value) { + const formula = String((value as { formula: string }).formula).replace(/^=/, '') + return { formula } + } + return value as ExcelJS.CellValue +} + +const applyOperation = (input: { + workbook: ExcelJS.Workbook + operation: ExcelEditInput + sheetLineage: Map + forcedChanges: CellChange[] +}): void => { + const { workbook, operation, sheetLineage, forcedChanges } = input + if (operation.type === 'add_sheet') { + if (workbook.getWorksheet(operation.sheetName)) + throw new Error(`工作表已存在: ${operation.sheetName}`) + workbook.addWorksheet(operation.sheetName) + sheetLineage.set(operation.sheetName, null) + forcedChanges.push({ sheetName: operation.sheetName, address: 'A1', kind: 'added' }) + return + } + if (operation.type === 'delete_sheet') { + const sheet = requireSheet(workbook, operation.sheetName) + if (workbook.worksheets.length === 1) throw new Error('Excel 最终必须至少保留一个工作表') + workbook.removeWorksheet(sheet.id) + sheetLineage.delete(sheet.name) + forcedChanges.push({ sheetName: sheet.name, address: 'A1', kind: 'deleted' }) + return + } + if (operation.type === 'rename_sheet') { + const sheet = requireSheet(workbook, operation.sheetName) + if (workbook.getWorksheet(operation.newName)) + throw new Error(`工作表已存在: ${operation.newName}`) + const previousName = sheet.name + sheet.name = operation.newName + const baselineName = sheetLineage.get(previousName) ?? null + sheetLineage.delete(previousName) + sheetLineage.set(sheet.name, baselineName) + forcedChanges.push( + { sheetName: previousName, address: 'A1', kind: 'deleted' }, + { sheetName: sheet.name, address: 'A1', kind: 'added' } + ) + return + } + + const sheet = requireSheet(workbook, operation.sheetName) + if (operation.type === 'set_range') { + const range = parseRange(operation.range) + const isAnchor = range.top === range.bottom && range.left === range.right + const rowCount = operation.values.length + if (!isAnchor) { + const expectedRows = range.bottom - range.top + 1 + const expectedColumns = range.right - range.left + 1 + if (rowCount !== expectedRows || operation.values.some((row) => row.length !== expectedColumns)) { + throw new Error( + `set_range 的 values 行列必须与 range ${operation.range} 一致(${String(expectedRows)}x${String(expectedColumns)})` + ) + } + } + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + const values = operation.values[rowIndex] + for (let columnIndex = 0; columnIndex < values.length; columnIndex += 1) { + const cell = sheet.getCell(range.top + rowIndex, range.left + columnIndex) + cell.value = toCellValue(values[columnIndex]) + applyStyle(cell, operation.style) + } + } + return + } + if (operation.type === 'clear_range') { + const range = parseRange(operation.range) + for (let row = range.top; row <= range.bottom; row += 1) { + for (let column = range.left; column <= range.right; column += 1) { + sheet.getCell(row, column).value = null + } + } + return + } + if (operation.type === 'merge_cells') { + const firstCell = sheet.getCell(operation.range.split(':')[0]) + const wasMerged = firstCell.isMerged + sheet.mergeCells(operation.range) + if (!wasMerged) + forcedChanges.push({ sheetName: sheet.name, address: firstCell.address, kind: 'modified' }) + return + } + if (operation.type === 'unmerge_cells') { + const firstCell = sheet.getCell(operation.range.split(':')[0]) + if (!firstCell.isMerged) return + sheet.unMergeCells(operation.range) + forcedChanges.push({ sheetName: sheet.name, address: firstCell.address, kind: 'modified' }) + return + } + if (operation.type === 'insert_rows') { + sheet.spliceRows(operation.startRow, 0, ...Array.from({ length: operation.count }, () => [])) + return + } + if (operation.type === 'delete_rows') { + sheet.spliceRows(operation.startRow, operation.count) + return + } + if (operation.type === 'insert_columns') { + const startColumn = toColumnNumber(operation.startColumn) + sheet.spliceColumns(startColumn, 0, ...Array.from({ length: operation.count }, () => [])) + return + } + if (operation.type === 'delete_columns') { + sheet.spliceColumns(toColumnNumber(operation.startColumn), operation.count) + return + } + if (operation.type === 'set_style') { + const range = parseRange(operation.range) + for (let row = range.top; row <= range.bottom; row += 1) { + for (let column = range.left; column <= range.right; column += 1) { + applyStyle(sheet.getCell(row, column), operation.style) + } + } + return + } + if (operation.type === 'set_column_width') { + const targetColumn = sheet.getColumn(operation.column) + const previousWidth = targetColumn.width + targetColumn.width = operation.width + const column = + typeof operation.column === 'number' + ? columnLetters(operation.column) + : String(operation.column).toUpperCase() + if (previousWidth !== targetColumn.width) + forcedChanges.push({ sheetName: sheet.name, address: `${column}1`, kind: 'modified' }) + return + } + if (operation.type === 'set_row_height') { + const row = sheet.getRow(operation.row) + const previousHeight = row.height + row.height = operation.height + if (previousHeight !== row.height) + forcedChanges.push({ + sheetName: sheet.name, + address: `A${String(operation.row)}`, + kind: 'modified' + }) + return + } + const range = parseRange(operation.range) + for (let column = range.left; column <= range.right; column += 1) { + let maxLength = 8 + for (let row = range.top; row <= range.bottom; row += 1) { + const text = sheet.getCell(row, column).text || '' + maxLength = Math.max(maxLength, Array.from(text).length + 2) + } + const targetColumn = sheet.getColumn(column) + const previousWidth = targetColumn.width + targetColumn.width = Math.min(maxLength, 60) + if (previousWidth !== targetColumn.width) { + forcedChanges.push({ + sheetName: sheet.name, + address: `${columnLetters(column)}1`, + kind: 'modified' + }) + } + } +} + export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelToolRuntime => { const state: ExcelRuntimeState = { toolEvents: createToolEventEmitter({ @@ -718,97 +938,72 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT return { workbook: await loadWorkbook(bytes), bytes } } - const outline = async (args: { sourcePath: string }): Promise => { + const read = async (args: ExcelReadInput): Promise => { const sourcePath = resolvePath(args.sourcePath) const { workbook } = await getWorkbook(sourcePath) - const sheets = workbook.worksheets.map((sheet) => ({ - name: sheet.name, - index: workbook.worksheets.indexOf(sheet), - range: usedRange(sheet), - rowCount: sheet.actualRowCount, - columnCount: sheet.actualColumnCount, - formulaCount: collectCellMap(workbook).size - ? Array.from(collectCellMap(workbook).values()).filter( - (cell) => cell.worksheet.name === sheet.name && Boolean(getFormula(cell.value)) - ).length - : 0, - tables: getTableNames(sheet) - })) - return toJsonObject({ sourcePath, sheetCount: sheets.length, sheets }) - } - - const search = async (args: { - sourcePath: string - query: string - mode?: 'literal' | 'regex' - scope?: { sheetNames?: string[] } - }): Promise => { - const sourcePath = resolvePath(args.sourcePath) - const { workbook } = await getWorkbook(sourcePath) - const matcher = args.mode === 'regex' ? new RegExp(args.query, 'i') : null - const isMatch = (value: string): boolean => - matcher ? matcher.test(value) : value.toLowerCase().includes(args.query.toLowerCase()) - const names = args.scope?.sheetNames ? new Set(args.scope.sheetNames) : null - const matches: JsonObject[] = [] - for (const sheet of workbook.worksheets) { - if (names && !names.has(sheet.name)) continue - if (isMatch(sheet.name)) { - matches.push(toJsonObject({ matchType: 'sheet_name', sheetName: sheet.name })) - } - for (const tableName of getTableNames(sheet)) { - const table = sheet.getTable(tableName) - if (isMatch(tableName)) { - matches.push(toJsonObject({ matchType: 'table_name', sheetName: sheet.name, tableName })) - } - for (const column of getTableColumns(table)) { - if (isMatch(column.name)) { + if (args.type === 'workbook') { + return toJsonObject({ + sheets: workbook.worksheets.map((sheet) => ({ + name: sheet.name, + range: usedRange(sheet), + rowCount: sheet.actualRowCount, + columnCount: sheet.actualColumnCount + })) + }) + } + if (args.type === 'search') { + const matcher = args.mode === 'regex' ? new RegExp(args.query, 'i') : null + const isMatch = (value: string): boolean => + matcher ? matcher.test(value) : value.toLowerCase().includes(args.query.toLowerCase()) + const matches: Array<{ + sheetName: string + address: string + value?: string | number | boolean | null + formula?: string + }> = [] + for (const sheet of workbook.worksheets) { + if (args.sheetName && sheet.name !== args.sheetName) continue + const maxRow = Math.max(sheet.rowCount, 1) + const maxColumn = Math.max(sheet.columnCount, 1) + for (let row = 1; row <= maxRow; row += 1) { + for (let column = 1; column <= maxColumn; column += 1) { + const cell = sheet.getCell(row, column) + const formula = getFormula(cell.value) + const matched = isMatch(cell.text) || isMatch(formula || '') + if (!matched) continue matches.push( - toJsonObject({ - matchType: 'table_column', - sheetName: sheet.name, - tableName, - columnName: column.name - }) + formula + ? { sheetName: sheet.name, address: cell.address, formula } + : { + sheetName: sheet.name, + address: cell.address, + value: serializeLiteral(cell.value, cell.text) + } ) + if (matches.length >= EXCEL_SEARCH_MAX_MATCHES) { + return toJsonObject({ truncated: true, matches }) + } } } } - const maxRow = Math.max(sheet.rowCount, 1) - const maxColumn = Math.max(sheet.columnCount, 1) - for (let row = 1; row <= maxRow; row += 1) { - for (let column = 1; column <= maxColumn; column += 1) { - const cell = sheet.getCell(row, column) - const matched = isMatch(cell.text) || isMatch(getFormula(cell.value) || '') - if (!matched) continue - matches.push(toJsonObject({ sheetName: sheet.name, ...getCellSnapshot(cell) })) - if (matches.length >= 500) - return toJsonObject({ sourcePath, query: args.query, matches, truncated: true }) - } - } + return toJsonObject({ truncated: false, matches }) } - return toJsonObject({ sourcePath, query: args.query, matches, truncated: false }) - } - const readCells = async (args: { - sourcePath: string - ranges: Array<{ sheetName: string; range: string }> - }): Promise => { - const sourcePath = resolvePath(args.sourcePath) - const { workbook } = await getWorkbook(sourcePath) - const results: JsonObject[] = [] - for (const request of args.ranges) { - const sheet = workbook.getWorksheet(request.sheetName) - if (!sheet) throw new Error(`工作表不存在: ${request.sheetName}`) - const range = parseRange(request.range) - for (let row = range.top; row <= range.bottom; row += 1) { - for (let column = range.left; column <= range.right; column += 1) { - results.push( - toJsonObject({ sheetName: sheet.name, ...getCellSnapshot(sheet.getCell(row, column)) }) - ) - } - } + const sheet = requireSheet(workbook, args.sheetName) + const range = parseRange(args.range) + assertRangeBudget(range) + if (args.type === 'style') { + return toJsonObject({ + sheetName: sheet.name, + range: args.range, + rows: readGrid(sheet, range, projectStyleCell) + }) } - return toJsonObject({ sourcePath, cells: results }) + return toJsonObject({ + sheetName: sheet.name, + range: args.range, + rows: readGrid(sheet, range, projectRangeCell) + }) } const edit = async (args: ExcelEditInput): Promise => { @@ -833,109 +1028,7 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT workbook.worksheets.map((sheet) => [sheet.name, sheet.name] as const) ) const forcedChanges: CellChange[] = [] - for (const operation of args.actions) { - if (operation.type === 'add_sheet') { - if (workbook.getWorksheet(String(operation.sheetName))) - throw new Error(`工作表已存在: ${String(operation.sheetName)}`) - workbook.addWorksheet(String(operation.sheetName)) - sheetLineage.set(String(operation.sheetName), null) - forcedChanges.push({ - sheetName: String(operation.sheetName), - address: 'A1', - kind: 'added' - }) - } else if (operation.type === 'delete_sheet') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - if (workbook.worksheets.length === 1) throw new Error('Excel 最终必须至少保留一个工作表') - workbook.removeWorksheet(sheet.id) - sheetLineage.delete(sheet.name) - forcedChanges.push({ sheetName: sheet.name, address: 'A1', kind: 'deleted' }) - } else if (operation.type === 'rename_sheet') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - if (workbook.getWorksheet(String(operation.newName))) - throw new Error(`工作表已存在: ${String(operation.newName)}`) - const previousName = sheet.name - sheet.name = String(operation.newName) - const baselineName = sheetLineage.get(previousName) ?? null - sheetLineage.delete(previousName) - sheetLineage.set(sheet.name, baselineName) - forcedChanges.push( - { sheetName: previousName, address: 'A1', kind: 'deleted' }, - { sheetName: sheet.name, address: 'A1', kind: 'added' } - ) - } else if (operation.type === 'merge_cells') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - const firstCell = sheet.getCell(String(operation.range).split(':')[0]) - const wasMerged = firstCell.isMerged - sheet.mergeCells(String(operation.range)) - if (!wasMerged) - forcedChanges.push({ - sheetName: sheet.name, - address: firstCell.address, - kind: 'modified' - }) - } else if (operation.type === 'set_range') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - const start = parseRange(String(operation.startCell)) - for (let rowIndex = 0; rowIndex < operation.values.length; rowIndex += 1) { - const values = operation.values[rowIndex] - for (let columnIndex = 0; columnIndex < values.length; columnIndex += 1) { - const cell = sheet.getCell(start.top + rowIndex, start.left + columnIndex) - const value = values[columnIndex] - cell.value = - value && typeof value === 'object' && 'formula' in value - ? { formula: String(value.formula) } - : (value as ExcelJS.CellValue) - applyStyle(cell, operation.style as Record | undefined) - } - } - } else if (operation.type === 'clear_range') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - const range = parseRange(String(operation.range)) - for (let row = range.top; row <= range.bottom; row += 1) { - for (let column = range.left; column <= range.right; column += 1) { - sheet.getCell(row, column).value = null - } - } - } else if (operation.type === 'insert_rows' || operation.type === 'delete_rows') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - const count = Number(operation.count) - sheet.spliceRows( - Number(operation.startRow), - operation.type === 'delete_rows' ? count : 0, - ...Array.from({ length: operation.type === 'insert_rows' ? count : 0 }, () => []) - ) - } else if (operation.type === 'set_column_width') { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - const targetColumn = sheet.getColumn(operation.column as string | number) - const previousWidth = targetColumn.width - targetColumn.width = Number(operation.width) - const column = - typeof operation.column === 'number' - ? columnLetters(operation.column) - : String(operation.column).toUpperCase() - if (previousWidth !== targetColumn.width) - forcedChanges.push({ sheetName: sheet.name, address: `${column}1`, kind: 'modified' }) - } else { - const sheet = workbook.getWorksheet(String(operation.sheetName)) - if (!sheet) throw new Error(`工作表不存在: ${String(operation.sheetName)}`) - const cell = sheet.getCell(String(operation.address)) - if (operation.type === 'delete_cell') { - cell.value = null - } else { - if (operation.formula !== undefined) cell.value = { formula: String(operation.formula) } - else cell.value = operation.value as ExcelJS.CellValue - applyStyle(cell, operation.style as Record | undefined) - } - } - } + applyOperation({ workbook, operation: args, sheetLineage, forcedChanges }) if (workbook.worksheets.length === 0) throw new Error('Excel 最终必须至少保留一个工作表') workbook.calcProperties.fullCalcOnLoad = true const afterBytes = Buffer.from(await workbook.xlsx.writeBuffer()) @@ -945,17 +1038,13 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT compareWorkbooks(beforeWorkbook, afterWorkbook), forcedChanges ) - if (operationChanges.length === 0) - return toJsonObject({ sourcePath, changed: false, changes: [] }) + if (operationChanges.length === 0) return toJsonObject({}) + await fs.mkdir(path.dirname(sourcePath), { recursive: true }) if (!isPathInsideRoot(sourcePath, input.workspaceRootPath)) { await atomicReplace(sourcePath, afterBytes) state.currentBytesByPath.set(sourcePath, afterBytes) state.sheetLineageByPath.set(sourcePath, sheetLineage) - return toJsonObject({ - sourcePath, - changed: true, - changes: operationChanges - }) + return toJsonObject({}) } const baseline = state.baselineBytesByPath.get(sourcePath) const baselineWorkbook = baseline ? await loadWorkbook(baseline) : null @@ -1030,16 +1119,10 @@ export const createExcelToolRuntime = (input: ExcelCapabilityToolsInput): ExcelT insertCount: record.insertCount, deleteCount: record.deleteCount }) - return toJsonObject({ - sourcePath, - changed: true, - changes: operationChanges, - diffPath: previewPath, - record - }) + return toJsonObject({}) } - return { outline, search, readCells, edit } + return { read, edit } } export const toExcelToolFailure = (sourcePath: string, error: unknown): JsonObject => ({ diff --git a/src/main/services/ai/human/tools/excel/schemas.ts b/src/main/services/ai/human/tools/excel/schemas.ts index 52516136..90363608 100644 --- a/src/main/services/ai/human/tools/excel/schemas.ts +++ b/src/main/services/ai/human/tools/excel/schemas.ts @@ -1,149 +1,200 @@ import { z } from 'zod/v4' -const sourcePathSchema = z.string().trim().min(1) -const sheetNameSchema = z.string().trim().min(1) - -export const excelReadOutlineSchema = z.object({ sourcePath: sourcePathSchema }).strict() - -export const excelSearchSchema = z - .object({ - sourcePath: sourcePathSchema, - query: z.string().min(1), - mode: z.enum(['literal', 'regex']).optional(), - scope: z - .object({ sheetNames: z.array(sheetNameSchema).min(1).optional() }) - .strict() - .optional() - }) - .strict() - -export const excelReadCellsSchema = z - .object({ - sourcePath: sourcePathSchema, - ranges: z - .array(z.object({ sheetName: sheetNameSchema, range: z.string().trim().min(1) }).strict()) - .min(1) - }) - .strict() +const sourcePathSchema = z.string().trim().min(1).describe('Excel 文件本机绝对路径') +const sheetNameSchema = z.string().trim().min(1).describe('工作表名') +const a1RangeSchema = z.string().trim().min(1).describe('A1 范围,如 A1 或 A1:C20') const styleSchema = z .object({ - bold: z.boolean().optional(), - italic: z.boolean().optional(), - fontColor: z.string().optional(), - fillColor: z.string().optional(), - numberFormat: z.string().optional(), - horizontalAlignment: z.enum(['left', 'center', 'right']).optional(), - verticalAlignment: z.enum(['top', 'middle', 'bottom']).optional() + bold: z.boolean().optional().describe('是否加粗'), + italic: z.boolean().optional().describe('是否斜体'), + fontColor: z.string().optional().describe('字体颜色,如 FF0000'), + fillColor: z.string().optional().describe('填充色,如 FFD14343'), + numberFormat: z.string().optional().describe('数字格式,如 #,##0 或 0%'), + horizontalAlignment: z.enum(['left', 'center', 'right']).optional().describe('水平对齐'), + verticalAlignment: z.enum(['top', 'middle', 'bottom']).optional().describe('垂直对齐') }) .strict() + .meta({ id: 'ExcelStyle' }) const cellValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) -const rangeCellSchema = z.union([cellValueSchema, z.object({ formula: z.string() }).strict()]) -const setCellSchema = z - .object({ - type: z.literal('set_cell'), - sheetName: sheetNameSchema, - address: z.string().trim().min(1), - value: cellValueSchema.optional(), - formula: z.string().optional(), - style: styleSchema.optional() - }) - .strict() - .refine((value) => value.value !== undefined || value.formula !== undefined, { - message: 'set_cell requires value or formula' - }) - .meta({ 'x-constraint': 'set_cell requires value or formula' }) - -const deleteCellSchema = z - .object({ - type: z.literal('delete_cell'), - sheetName: sheetNameSchema, - address: z.string().trim().min(1) - }) - .strict() - -const setRangeSchema = z - .object({ - type: z.literal('set_range'), - sheetName: sheetNameSchema, - startCell: z.string().trim().min(1), - values: z.array(z.array(rangeCellSchema).min(1)).min(1), - style: styleSchema.optional() - }) - .strict() - -const clearRangeSchema = z - .object({ - type: z.literal('clear_range'), - sheetName: sheetNameSchema, - range: z.string().trim().min(1) - }) - .strict() - -const insertRowsSchema = z - .object({ - type: z.literal('insert_rows'), - sheetName: sheetNameSchema, - startRow: z.number().int().min(1), - count: z.number().int().min(1) - }) - .strict() - -const deleteRowsSchema = z - .object({ - type: z.literal('delete_rows'), - sheetName: sheetNameSchema, - startRow: z.number().int().min(1), - count: z.number().int().min(1) - }) - .strict() - -const setColumnWidthSchema = z - .object({ - type: z.literal('set_column_width'), - sheetName: sheetNameSchema, - column: z.union([z.string().trim().min(1), z.number().int().min(1)]), - width: z.number().positive() - }) - .strict() - -const addSheetSchema = z - .object({ type: z.literal('add_sheet'), sheetName: sheetNameSchema }) - .strict() -const deleteSheetSchema = z - .object({ type: z.literal('delete_sheet'), sheetName: sheetNameSchema }) - .strict() -const renameSheetSchema = z - .object({ type: z.literal('rename_sheet'), sheetName: sheetNameSchema, newName: sheetNameSchema }) - .strict() -const mergeCellsSchema = z - .object({ - type: z.literal('merge_cells'), - sheetName: sheetNameSchema, - range: z.string().trim().min(1) - }) - .strict() - -export const excelEditOperationSchema = z.discriminatedUnion('type', [ - setCellSchema, - deleteCellSchema, - setRangeSchema, - clearRangeSchema, - insertRowsSchema, - deleteRowsSchema, - setColumnWidthSchema, - addSheetSchema, - deleteSheetSchema, - renameSheetSchema, - mergeCellsSchema +const rangeCellSchema = z.union([ + cellValueSchema, + z.object({ formula: z.string().min(1).describe('公式,不含前导 =') }).strict() ]) -export const excelEditSchema = z - .object({ - sourcePath: sourcePathSchema, - actions: z.array(excelEditOperationSchema).min(1) - }) - .strict() +const editBase = { sourcePath: sourcePathSchema } +export const excelReadSchema = z.discriminatedUnion('type', [ + z + .object({ + type: z.literal('workbook').describe('读取工作簿地图'), + sourcePath: sourcePathSchema + }) + .strict(), + z + .object({ + type: z.literal('range').describe('读取一块网格的值和公式'), + sourcePath: sourcePathSchema, + sheetName: sheetNameSchema, + range: a1RangeSchema + }) + .strict(), + z + .object({ + type: z.literal('style').describe('读取一块网格的格式'), + sourcePath: sourcePathSchema, + sheetName: sheetNameSchema, + range: a1RangeSchema + }) + .strict(), + z + .object({ + type: z.literal('search').describe('按值或公式搜索格子'), + sourcePath: sourcePathSchema, + query: z.string().min(1).describe('搜索文字或正则'), + sheetName: sheetNameSchema.optional().describe('限定工作表;省略则全书'), + mode: z.enum(['literal', 'regex']).optional().describe('literal 或 regex,缺省 literal') + }) + .strict() +]) + +export const excelEditSchema = z.discriminatedUnion('type', [ + z + .object({ + ...editBase, + type: z.literal('add_sheet').describe('新增工作表'), + sheetName: sheetNameSchema + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('delete_sheet').describe('删除工作表'), + sheetName: sheetNameSchema + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('rename_sheet').describe('重命名工作表'), + sheetName: sheetNameSchema, + newName: sheetNameSchema.describe('新工作表名') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('set_range').describe('写入一块数据或公式'), + sheetName: sheetNameSchema, + range: a1RangeSchema.describe('单格为起点,或与 values 行列一致的矩形'), + values: z.array(z.array(rangeCellSchema).min(1)).min(1).describe('二维值,从上到下、从左到右'), + style: styleSchema.optional().describe('可选;省略则保留原样式') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('clear_range').describe('清空范围中的值'), + sheetName: sheetNameSchema, + range: a1RangeSchema + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('merge_cells').describe('合并单元格'), + sheetName: sheetNameSchema, + range: a1RangeSchema + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('unmerge_cells').describe('取消合并'), + sheetName: sheetNameSchema, + range: a1RangeSchema + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('insert_rows').describe('插入空行'), + sheetName: sheetNameSchema, + startRow: z.number().int().min(1).describe('起始行号,从 1 开始'), + count: z.number().int().min(1).describe('插入行数') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('delete_rows').describe('删除行'), + sheetName: sheetNameSchema, + startRow: z.number().int().min(1).describe('起始行号,从 1 开始'), + count: z.number().int().min(1).describe('删除行数') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('insert_columns').describe('插入空列'), + sheetName: sheetNameSchema, + startColumn: z + .union([z.string().trim().min(1), z.number().int().min(1)]) + .describe('起始列,字母或从 1 开始的列号'), + count: z.number().int().min(1).describe('插入列数') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('delete_columns').describe('删除列'), + sheetName: sheetNameSchema, + startColumn: z + .union([z.string().trim().min(1), z.number().int().min(1)]) + .describe('起始列,字母或从 1 开始的列号'), + count: z.number().int().min(1).describe('删除列数') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('set_style').describe('只改样式,不改值'), + sheetName: sheetNameSchema, + range: a1RangeSchema, + style: styleSchema + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('set_column_width').describe('设置列宽'), + sheetName: sheetNameSchema, + column: z + .union([z.string().trim().min(1), z.number().int().min(1)]) + .describe('列字母或从 1 开始的列号'), + width: z.number().positive().describe('列宽') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('set_row_height').describe('设置行高'), + sheetName: sheetNameSchema, + row: z.number().int().min(1).describe('行号,从 1 开始'), + height: z.number().positive().describe('行高') + }) + .strict(), + z + .object({ + ...editBase, + type: z.literal('auto_fit').describe('按内容自适应列宽'), + sheetName: sheetNameSchema, + range: a1RangeSchema + }) + .strict() +]) + +export type ExcelReadInput = z.infer export type ExcelEditInput = z.infer +export type ExcelStyle = z.infer diff --git a/src/main/services/ai/human/tools/excel/toolSpecs.ts b/src/main/services/ai/human/tools/excel/toolSpecs.ts index 242dbb26..b0e076fd 100644 --- a/src/main/services/ai/human/tools/excel/toolSpecs.ts +++ b/src/main/services/ai/human/tools/excel/toolSpecs.ts @@ -4,18 +4,13 @@ import type { RuntimeToolName } from '../../../../../../shared/runtimeCapabiliti import type { RuntimeSkillId } from '../../types' import type { HumanToolSpec } from '../types' import { createStaticExecutionMetaResolver, readStringArg } from '../utils/toolSpecUtils' +import { excelEditSchema, excelReadSchema } from './schemas' +import { excelEditOutputSchema, excelReadOutputSchema } from './outputSchemas' import { - excelEditSchema, - excelReadCellsSchema, - excelReadOutlineSchema, - excelSearchSchema -} from './schemas' -import { - excelEditOutputSchema, - excelReadCellsOutputSchema, - excelReadOutlineOutputSchema, - excelSearchOutputSchema -} from './outputSchemas' + renderExcelEditModelResult, + renderExcelReadModelResult +} from '../../shared/modelToolResultRenderers' +import { createRetryModelResultPolicy } from '../modelResultPolicies' type ExcelSpec< TArgs extends JsonObject, @@ -35,57 +30,28 @@ const writeMeta = createStaticExecutionMetaResolver({ defaultResourceKey: 'excel' }) -export const EXCEL_READ_OUTLINE_TOOL_SPEC = { - name: 'excel_read_outline', +export const EXCEL_READ_TOOL_SPEC = { + name: 'excel_read', familyId: 'excel', skillIds: ['excel-read'], - inputSchema: excelReadOutlineSchema, - outputSchema: excelReadOutlineOutputSchema, + inputSchema: excelReadSchema, + outputSchema: excelReadOutputSchema, promptSpec: { - name: 'excel_read_outline', - description: '读取 Excel 工作簿、工作表、使用范围、表格和公式数量概览。' + name: 'excel_read', + description: + '读取 Excel:workbook 看表清单,range 读值和公式,style 读格式,search 按格子值或公式搜索。' }, - resolveExecutionMeta: readMeta + resolveExecutionMeta: readMeta, + modelResult: { + renderSuccess: renderExcelReadModelResult, + overflowPolicy: createRetryModelResultPolicy( + 'Call excel_read again with a smaller range, a narrower sheetName, or a more specific search query.' + ) + } } satisfies ExcelSpec< - z.infer, - z.infer, - 'excel_read_outline', - 'excel-read' -> - -export const EXCEL_SEARCH_TOOL_SPEC = { - name: 'excel_search', - familyId: 'excel', - skillIds: ['excel-read'], - inputSchema: excelSearchSchema, - outputSchema: excelSearchOutputSchema, - promptSpec: { - name: 'excel_search', - description: '搜索 Excel 单元格值、显示值、公式、工作表名和表格列名。' - }, - resolveExecutionMeta: readMeta -} satisfies ExcelSpec< - z.infer, - z.infer, - 'excel_search', - 'excel-read' -> - -export const EXCEL_READ_CELLS_TOOL_SPEC = { - name: 'excel_read_cells', - familyId: 'excel', - skillIds: ['excel-read'], - inputSchema: excelReadCellsSchema, - outputSchema: excelReadCellsOutputSchema, - promptSpec: { - name: 'excel_read_cells', - description: '按工作表和 A1 范围读取 Excel 单元格地址、值、显示值、公式、类型和数字格式。' - }, - resolveExecutionMeta: readMeta -} satisfies ExcelSpec< - z.infer, - z.infer, - 'excel_read_cells', + z.infer, + z.infer, + 'excel_read', 'excel-read' > @@ -98,9 +64,10 @@ export const EXCEL_EDIT_TOOL_SPEC = { promptSpec: { name: 'excel_edit', description: - '创建或编辑 Excel 工作簿中的工作表、单元格、公式、格式和结构,并返回写入后的回读信息。' + '一次一个 type 创建或编辑 Excel:工作表、范围值/公式、样式、合并、行列和列宽行高。' }, - resolveExecutionMeta: writeMeta + resolveExecutionMeta: writeMeta, + modelResult: { renderSuccess: renderExcelEditModelResult } } satisfies ExcelSpec< z.infer, z.infer, @@ -108,9 +75,4 @@ export const EXCEL_EDIT_TOOL_SPEC = { 'excel-write' > -export const EXCEL_TOOL_SPECS = [ - EXCEL_READ_OUTLINE_TOOL_SPEC, - EXCEL_SEARCH_TOOL_SPEC, - EXCEL_READ_CELLS_TOOL_SPEC, - EXCEL_EDIT_TOOL_SPEC -] as const +export const EXCEL_TOOL_SPECS = [EXCEL_READ_TOOL_SPEC, EXCEL_EDIT_TOOL_SPEC] as const diff --git a/src/main/services/ai/human/tools/excel/types.ts b/src/main/services/ai/human/tools/excel/types.ts index dd21233a..d341eb2b 100644 --- a/src/main/services/ai/human/tools/excel/types.ts +++ b/src/main/services/ai/human/tools/excel/types.ts @@ -1,30 +1,10 @@ import type { AiFileChangeRecord, JsonObject } from '../../../../../../shared/types' import type { AiRunContext } from '../../../types' import type { ToolEventEmitter } from '../utils/toolEvents' -import type { ExcelEditInput } from './schemas' - -export type ExcelCellSnapshot = { - address: string - value: unknown - displayValue: string - formula?: string - type: string - numberFormat?: string - style?: JsonObject -} +import type { ExcelEditInput, ExcelReadInput } from './schemas' export type ExcelToolRuntime = { - outline: (args: { sourcePath: string }) => Promise - search: (args: { - sourcePath: string - query: string - mode?: 'literal' | 'regex' - scope?: { sheetNames?: string[] } - }) => Promise - readCells: (args: { - sourcePath: string - ranges: Array<{ sheetName: string; range: string }> - }) => Promise + read: (args: ExcelReadInput) => Promise edit: (args: ExcelEditInput) => Promise } diff --git a/src/renderer/src/components/workbench/utils.ts b/src/renderer/src/components/workbench/utils.ts index d99f4459..a7b1a2b8 100644 --- a/src/renderer/src/components/workbench/utils.ts +++ b/src/renderer/src/components/workbench/utils.ts @@ -222,9 +222,13 @@ const TOOL_ITEM_RENDERERS = { ppt_read_slides: (input) => sentence(text('读取 PPT 页面 '), text(readToolFileLabel(input))), ppt_search: (input) => sentence(text('搜索 PPT '), text(readToolQueryLabel(input))), ppt_edit: (input) => sentence(text('编辑 PPT '), text(readToolFileLabel(input))), - excel_read_outline: (input) => sentence(text('查看 Excel 结构 '), text(readToolFileLabel(input))), - excel_search: (input) => sentence(text('搜索 Excel '), text(readToolQueryLabel(input))), - excel_read_cells: (input) => sentence(text('读取 Excel 单元格 '), text(readToolFileLabel(input))), + excel_read: (input) => { + const type = readStringValue(input || {}, 'type') || '' + if (type === 'search') return sentence(text('搜索 Excel '), text(readToolQueryLabel(input))) + if (type === 'style') return sentence(text('读取 Excel 样式 '), text(readToolFileLabel(input))) + if (type === 'range') return sentence(text('读取 Excel 范围 '), text(readToolFileLabel(input))) + return sentence(text('查看 Excel 结构 '), text(readToolFileLabel(input))) + }, excel_edit: (input) => sentence(text('编辑 Excel '), text(readToolFileLabel(input))), web_search: (input) => { const url = readStringValue(input || {}, 'url') diff --git a/src/shared/assistantExecutionSummary.ts b/src/shared/assistantExecutionSummary.ts index 6124aa0e..4871a45c 100644 --- a/src/shared/assistantExecutionSummary.ts +++ b/src/shared/assistantExecutionSummary.ts @@ -83,9 +83,7 @@ const TOOL_UI_KINDS = { ppt_read_slides: 'ppt-read', ppt_search: 'ppt-search', ppt_edit: 'ppt-edit', - excel_read_outline: 'excel-read', - excel_search: 'excel-read', - excel_read_cells: 'excel-read', + excel_read: 'excel-read', excel_edit: 'excel-edit', web_search: 'web-search', browser_use: 'browser', diff --git a/src/shared/runtimeCapabilities.ts b/src/shared/runtimeCapabilities.ts index 1ce8db26..6ce41894 100644 --- a/src/shared/runtimeCapabilities.ts +++ b/src/shared/runtimeCapabilities.ts @@ -40,9 +40,7 @@ export const RUNTIME_TOOL_CATALOG = { ppt_read_slides: { familyId: 'ppt', skillId: 'ppt-read' }, ppt_search: { familyId: 'ppt', skillId: 'ppt-read' }, ppt_edit: { familyId: 'ppt', skillId: 'ppt-write' }, - excel_read_outline: { familyId: 'excel', skillId: 'excel-read' }, - excel_search: { familyId: 'excel', skillId: 'excel-read' }, - excel_read_cells: { familyId: 'excel', skillId: 'excel-read' }, + excel_read: { familyId: 'excel', skillId: 'excel-read' }, excel_edit: { familyId: 'excel', skillId: 'excel-write' }, web_search: { familyId: 'web', skillId: 'web-browsing' }, browser_use: { familyId: 'web', skillId: 'web-browsing' }, diff --git a/tests/case/ai/human/live/human-excel-business-analysis-live.test.ts b/tests/case/ai/human/live/human-excel-business-analysis-live.test.ts deleted file mode 100644 index 13699f8a..00000000 --- a/tests/case/ai/human/live/human-excel-business-analysis-live.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { createHash } from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' -import ExcelJS from 'exceljs' -import { describe, it } from 'vitest' -import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' -import { runBackendCase } from '../../../../harness/backend/run-backend-case' -import { filterAiRunEventsByType, runWithStructuredOutputRetry } from '../testUtils' - -const hashFile = async (filePath: string): Promise => - createHash('sha256').update(await fs.readFile(filePath)).digest('hex') - -const writeWorkbook = async (filePath: string, build: (workbook: ExcelJS.Workbook) => void): Promise => { - const workbook = new ExcelJS.Workbook() - build(workbook) - await fs.writeFile(filePath, Buffer.from(await workbook.xlsx.writeBuffer())) -} - -const addHeaderStyle = (workbook: ExcelJS.Workbook, color: string): void => { - for (const sheet of workbook.worksheets) { - sheet.getRow(1).font = { bold: true } - sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: color } } - } -} - -const buildSalesGapWorkbook = async (filePath: string): Promise => - await writeWorkbook(filePath, (workbook) => { - workbook.addWorksheet('Orders').addRows([ - ['Product', 'Region', 'Actual sales'], - ['P-100', 'East', 820000], - ['P-200', 'East', 1040000], - ['P-300', 'North', 650000], - ['P-400', 'South', 910000] - ]) - workbook.addWorksheet('Targets').addRows([ - ['Product', 'Region', 'Target sales'], - ['P-100', 'East', 1000000], - ['P-200', 'East', 1000000], - ['P-300', 'North', 800000], - ['P-400', 'South', 900000] - ]) - workbook.addWorksheet('Owners').addRows([ - ['Product', 'Owner'], - ['P-100', 'Chen Lin'], - ['P-200', 'Zhao Jing'], - ['P-300', 'Zhou Yu'], - ['P-400', 'Sun Yue'] - ]) - workbook.addWorksheet('Notes').addRows([ - ['Note'], - ['P-200 has a refund record, but that does not change its quarterly target comparison.'] - ]) - addHeaderStyle(workbook, 'FFDDEBF7') - }) - -const buildReconciliationWorkbook = async (filePath: string): Promise => - await writeWorkbook(filePath, (workbook) => { - const sales = workbook.addWorksheet('Sales') - sales.addRows([ - ['Order ID', 'Customer', 'Amount', 'Invoice status'], - ['SO-1001', 'Hangzhou Northstar', 120000, ''], - ['SO-1002', 'Shanghai Voyager', 80000, 'Invoiced'], - ['SO-1003', 'Ningbo Dawn', 60000, 'Not invoiced'], - ['SO-1004', 'Suzhou Cloudfan', 150000, ''] - ]) - workbook.addWorksheet('Payments').addRows([ - ['Order ID', 'Payment amount'], - ['SO-1001', 120000], - ['SO-1002', 80000], - ['SO-1004', 150000] - ]) - workbook.addWorksheet('Reconciliation').addRows([ - ['Metric', 'Value'], - ['Total sales', { formula: 'SUM(Sales!C2:C5)' }] - ]) - addHeaderStyle(workbook, 'FFE2F0D9') - sales.getColumn(3).numFmt = '#,##0' - sales.getColumn(4).width = 18 - }) - -const hasSuccessfulToolResult = ( - events: ReturnType, - toolName: string -): boolean => events.some((event) => event.toolName === toolName && event.result?.ok === true) - -describe('ai human live excel backend', () => { - it('human-excel-read-sales-gap-analysis-live', async () => { - await runWithStructuredOutputRetry(async () => { - await runBackendCase({ - caseId: 'human-excel-read-sales-gap-analysis-live', - harnessMode: 'live', - domain: 'ai', - suite: 'human', - groups: ['live'], - description: '用真实本地计算分析销售工作簿,并以对话方式汇报业务优先级。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - const sourcePath = path.join(runtime.workspaceRoot, 'sales-operations.xlsx') - await buildSalesGapWorkbook(sourcePath) - const beforeHash = await hashFile(sourcePath) - const output = await createHumanTestRunner(runtime, {}).run({ - taskId: runtime.taskId, - userInput: - '请为销售会议审阅 sales-operations.xlsx:找出低于目标的产品、所属区域和负责人;请实际执行本地计算(例如使用终端)算出实际销售额、目标销售额和差额,再告诉我该优先处理哪些项。不要修改工作簿。' - }) - return { - output, - beforeHash, - afterHash: await hashFile(sourcePath), - events: runtime.eventCollector.snapshot() - } - }, - assert: async (result, runtime) => { - const starts = filterAiRunEventsByType(result.events, 'tool_call_started') - const results = filterAiRunEventsByType(result.events, 'tool_call_result') - const toolNames = starts.map((event) => event.toolName) - runtime.assertThat( - result.output.status === 'completed' && - result.beforeHash === result.afterHash && - !toolNames.includes('excel_edit') && - (toolNames.some((name) => name.startsWith('excel_read') || name === 'excel_search') || - toolNames.includes('terminal_run')) && - hasSuccessfulToolResult(results, 'terminal_run'), - { - name: 'human-live-excel-read-preserves-source-and-runs-real-calculation', - message: 'The live model must read the workbook, complete a real local calculation, and preserve the source.', - data: { toolNames } - } - ) - } - }) - }) - }) - - it('human-excel-write-reconciliation-live', async () => { - await runWithStructuredOutputRetry(async () => { - await runBackendCase({ - caseId: 'human-excel-write-reconciliation-live', - harnessMode: 'live', - domain: 'ai', - suite: 'human', - groups: ['live'], - description: '用本地计算更新财务对账工作簿,并核对交付后的工作簿。', - fixtures: [], - runMode: 'single', - execute: async (runtime) => { - const sourcePath = path.join(runtime.workspaceRoot, 'finance-reconciliation.xlsx') - await buildReconciliationWorkbook(sourcePath) - const before = new ExcelJS.Workbook() - await before.xlsx.load((await fs.readFile(sourcePath)) as never) - const originalHeaderFill = before.getWorksheet('Sales')?.getCell('A1').fill.fgColor?.argb - const output = await createHumanTestRunner(runtime, {}).run({ - taskId: runtime.taskId, - userInput: - '请为财务结账更新 finance-reconciliation.xlsx:对比 Sales 和 Payments,把已付款但状态为空或 Not invoiced 的订单标记为 Invoice required,然后在 Reconciliation 里新增 Invoice required 订单数量和金额的公式。保留现有的金额、公式、样式和未涉及的订单。请实际执行本地计算核对销售与回款(例如使用终端),编辑后再核对工作簿。' - }) - const workbook = new ExcelJS.Workbook() - await workbook.xlsx.load((await fs.readFile(sourcePath)) as never) - const sales = workbook.getWorksheet('Sales')! - const summary = workbook.getWorksheet('Reconciliation')! - return { - output, - events: runtime.eventCollector.snapshot(), - statuses: ['SO-1001', 'SO-1002', 'SO-1003', 'SO-1004'].map((orderId, index) => { - const rowIndex = index + 2 - const invoiceStatus = sales.getCell(`D${String(rowIndex)}`).value - const reconciliationStatus = sales.getCell(`E${String(rowIndex)}`).value - return { - orderId, - value: - invoiceStatus === '' || invoiceStatus == null - ? reconciliationStatus - : invoiceStatus - } - }), - summaryRows: summary.getSheetValues(), - summaryFormulas: summary.getColumn(2).values, - headerFill: sales.getCell('A1').fill.fgColor?.argb, - originalHeaderFill - } - }, - assert: async (result, runtime) => { - const starts = filterAiRunEventsByType(result.events, 'tool_call_started') - const results = filterAiRunEventsByType(result.events, 'tool_call_result') - const toolNames = starts.map((event) => event.toolName) - const values = new Map(result.statuses.map((item) => [item.orderId, item.value])) - const artifacts = filterAiRunEventsByType(result.events, 'file_artifact').filter( - (artifact) => artifact.capability === 'excel' && artifact.operation === 'updated' - ) - runtime.assertThat( - result.output.status === 'completed' && - toolNames.some((name) => name.startsWith('excel_read') || name === 'excel_search') && - hasSuccessfulToolResult(results, 'terminal_run') && - hasSuccessfulToolResult(results, 'excel_edit') && - values.get('SO-1001') === 'Invoice required' && - values.get('SO-1004') === 'Invoice required' && - values.get('SO-1002') === 'Invoiced' && - values.get('SO-1003') === 'Not invoiced' && - /Invoice required[^"]*count/.test(JSON.stringify(result.summaryRows)) && - /Invoice required[^"]*amount/.test(JSON.stringify(result.summaryRows)) && - JSON.stringify(result.summaryFormulas).includes('SUMIF') && - result.headerFill === result.originalHeaderFill && - artifacts.length >= 1, - { - name: 'human-live-excel-write-reconciles-real-workbook', - message: 'The live model must update only the requested reconciliation data and preserve the workbook.', - data: { toolNames, statuses: result.statuses, artifacts } - } - ) - } - }) - }) - }) -}) diff --git a/tests/case/ai/human/live/human-excel-create-monthly-budget-live.test.ts b/tests/case/ai/human/live/human-excel-create-monthly-budget-live.test.ts new file mode 100644 index 00000000..9db375bc --- /dev/null +++ b/tests/case/ai/human/live/human-excel-create-monthly-budget-live.test.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import ExcelJS from 'exceljs' +import { describe, it } from 'vitest' +import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../../harness/backend/run-backend-case' +import { filterAiRunEventsByType, runWithStructuredOutputRetry } from '../testUtils' + +const FILE_NAME = 'monthly-budget.xlsx' + +const getFormula = (value: unknown): string => { + if (!value || typeof value !== 'object' || !('formula' in value)) return '' + return String((value as { formula?: unknown }).formula || '') +} + +describe('ai human live excel create backend', () => { + it('human-excel-create-monthly-budget-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-excel-create-monthly-budget-live', + harnessMode: 'live', + domain: 'ai', + suite: 'human', + groups: ['live'], + description: '空工作区从零做月度预算:多表、表头、SUM/SUMIF 和金额格式。', + fixtures: [], + runMode: 'single', + execute: async (runtime) => { + const sourcePath = path.join(runtime.workspaceRoot, FILE_NAME) + const eventOffset = runtime.eventCollector.snapshot().length + const output = await createHumanTestRunner(runtime, {}).run({ + taskId: runtime.taskId, + userInput: [ + `请直接在工作区创建“${FILE_NAME}”,做一份很小的月度预算即可:`, + '只要 2 个工作表:Departments 和 Summary。', + 'Departments 写表头(部门、科目、金额)和 3 行示例数据。', + 'Summary 用 SUM 或 SUMIF 汇总金额,不要手填合计;金额用千分位格式。', + '不要再扩展月份列或大表。完成后确认文件可打开并停止。' + ].join('') + }) + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load((await fs.readFile(sourcePath)) as never) + const formulas = workbook.worksheets.flatMap((sheet) => { + const found: string[] = [] + sheet.eachRow((row) => + row.eachCell((cell) => { + const formula = getFormula(cell.value) + if (formula) found.push(formula) + }) + ) + return found + }) + const numberFormats = workbook.worksheets.flatMap((sheet) => { + const found: string[] = [] + sheet.eachRow((row) => + row.eachCell((cell) => { + if (cell.numFmt) found.push(cell.numFmt) + }) + ) + return found + }) + const excelRecords = ( + await runtime.createAiRunContext().listFileChangeRecords({ runId: runtime.runId }) + ).filter((record) => record.capability === 'excel') + return { + output, + events: runtime.eventCollector.snapshot().slice(eventOffset), + sheetCount: workbook.worksheets.length, + sheetNames: workbook.worksheets.map((sheet) => sheet.name), + formulas, + numberFormats, + artifacts: excelRecords.length + } + }, + assert: async (result, runtime) => { + const starts = filterAiRunEventsByType(result.events, 'tool_call_started') + const toolNames = starts.map((event) => event.toolName) + runtime.assertThat( + result.output.status === 'completed' && + toolNames.includes('excel_edit') && + result.sheetCount >= 2 && + result.formulas.some((formula) => /SUM(IF)?\(/i.test(formula)) && + result.numberFormats.some((format) => /#,##0/.test(format) || /0\.00/.test(format)) && + result.artifacts === 1, + { + name: 'human-live-excel-create-monthly-budget', + message: 'live human 应从零创建可解析的月度预算:多表、汇总为公式,并交付 1 个 artifact。', + data: { + toolNames, + sheetNames: result.sheetNames, + formulas: result.formulas, + numberFormats: result.numberFormats, + artifacts: result.artifacts + } + } + ) + } + }) + }) + }) +}) diff --git a/tests/case/ai/human/live/human-excel-edit-weekly-review-live.test.ts b/tests/case/ai/human/live/human-excel-edit-weekly-review-live.test.ts new file mode 100644 index 00000000..7049e774 --- /dev/null +++ b/tests/case/ai/human/live/human-excel-edit-weekly-review-live.test.ts @@ -0,0 +1,105 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import ExcelJS from 'exceljs' +import { describe, it } from 'vitest' +import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../../harness/backend/run-backend-case' +import { filterAiRunEventsByType, runWithStructuredOutputRetry } from '../testUtils' + +const FIXTURE_PATH = 'tests/fixtures/workspace/operating-review.xlsx' as const +const WORKSPACE_RELATIVE = 'operating-review.xlsx' + +const getFormula = (value: unknown): string => { + if (!value || typeof value !== 'object' || !('formula' in value)) return '' + return String((value as { formula?: unknown }).formula || '') +} + +describe('ai human live excel edit backend', () => { + it('human-excel-edit-weekly-review-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-excel-edit-weekly-review-live', + harnessMode: 'live', + domain: 'ai', + suite: 'human', + groups: ['live'], + description: '在既有经营工作簿上更正数字、补公式、改表头,并保护部分区域。', + fixtures: [FIXTURE_PATH], + runMode: 'single', + execute: async (runtime) => { + const sourcePath = path.join(runtime.workspaceRoot, WORKSPACE_RELATIVE) + const eventOffset = runtime.eventCollector.snapshot().length + const output = await createHumanTestRunner(runtime, {}).run({ + taskId: runtime.taskId, + userInput: [ + `请更新工作区中的 ${WORKSPACE_RELATIVE}:`, + '1)把 Orders 表头 “Actual sales” 改成 “Actual sales (CNY)”;', + '2)把 P-300 的 Actual sales 更正为 680000;', + '3)在 Summary 增加一行 Net after returns,公式为 SUM(Orders!D2:D6)-SUM(Orders!E2:E6)。', + 'Notes 整表、P-200 整行、Targets 的 Min margin 列必须保持原样。' + ].join('') + }) + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load((await fs.readFile(sourcePath)) as never) + const orders = workbook.getWorksheet('Orders') + const targets = workbook.getWorksheet('Targets') + const summary = workbook.getWorksheet('Summary') + const notes = workbook.getWorksheet('Notes') + const records = await runtime.createAiRunContext().listFileChangeRecords({ + runId: runtime.runId + }) + return { + output, + events: runtime.eventCollector.snapshot().slice(eventOffset), + header: String(orders?.getCell('D1').value || ''), + p300: orders?.getCell('D4').value, + p200: orders?.getCell('D3').value, + p200Owner: orders?.getCell('C3').value, + targetMargins: [2, 3, 4, 5, 6].map((row) => targets?.getCell(`D${String(row)}`).value), + notesText: [2, 3, 4].map((row) => String(notes?.getCell(`C${String(row)}`).value || '')), + summaryFormulas: summary + ? Array.from({ length: Math.max(summary.rowCount, 1) }, (_, index) => + getFormula(summary.getCell(index + 1, 2).value) + ) + : [], + recordCount: records.filter((record) => record.path === sourcePath).length + } + }, + assert: async (result, runtime) => { + const starts = filterAiRunEventsByType(result.events, 'tool_call_started') + const toolNames = starts.map((event) => event.toolName) + const readBeforeEdit = + toolNames.indexOf('excel_read') !== -1 && + toolNames.indexOf('excel_read') < toolNames.indexOf('excel_edit') + runtime.assertThat( + result.output.status === 'completed' && + readBeforeEdit && + toolNames.includes('excel_edit') && + /CNY/i.test(result.header) && + result.p300 === 680000 && + result.p200 === 1040000 && + result.p200Owner === 'Zhao Jing' && + JSON.stringify(result.targetMargins) === JSON.stringify([0.18, 0.16, 0.14, 0.15, 0.12]) && + result.notesText[0].includes('P-300 return rate') && + result.notesText[1].includes('P-200 refund') && + result.notesText[2].includes('West region target') && + result.summaryFormulas.some((formula) => /SUM\(Orders!D2:D6\)/i.test(formula)) && + result.summaryFormulas.some((formula) => /SUM\(Orders!E2:E6\)/i.test(formula)) && + result.recordCount === 1, + { + name: 'human-live-excel-weekly-review-edit-preserves-protected-areas', + message: 'live 模型应先读后写,完成指定更正并保护 Notes、P-200 和目标利润率。', + data: { + toolNames, + header: result.header, + p300: result.p300, + recordCount: result.recordCount, + summaryFormulas: result.summaryFormulas + } + } + ) + } + }) + }) + }) +}) diff --git a/tests/case/ai/human/live/human-excel-read-weekly-review-live.test.ts b/tests/case/ai/human/live/human-excel-read-weekly-review-live.test.ts new file mode 100644 index 00000000..4ce7d5fd --- /dev/null +++ b/tests/case/ai/human/live/human-excel-read-weekly-review-live.test.ts @@ -0,0 +1,66 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { describe, it } from 'vitest' +import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../../harness/backend/run-backend-case' +import { filterAiRunEventsByType, runWithStructuredOutputRetry } from '../testUtils' + +const FIXTURE_PATH = 'tests/fixtures/workspace/operating-review.xlsx' as const +const WORKSPACE_RELATIVE = 'operating-review.xlsx' + +const hashFile = async (filePath: string): Promise => + createHash('sha256') + .update(await fs.readFile(filePath)) + .digest('hex') + +describe('ai human live excel read backend', () => { + it('human-excel-read-weekly-review-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-excel-read-weekly-review-live', + harnessMode: 'live', + domain: 'ai', + suite: 'human', + groups: ['live'], + description: '周会前审阅经营工作簿,讲清表结构、关键公式和异常数字,且不改文件。', + fixtures: [FIXTURE_PATH], + runMode: 'single', + execute: async (runtime) => { + const sourcePath = path.join(runtime.workspaceRoot, WORKSPACE_RELATIVE) + const beforeHash = await hashFile(sourcePath) + const eventOffset = runtime.eventCollector.snapshot().length + const output = await createHumanTestRunner(runtime, {}).run({ + taskId: runtime.taskId, + userInput: [ + `请审阅工作区中的 ${WORKSPACE_RELATIVE},为销售周会准备口头简报。`, + '请讲清有哪些工作表、Orders/Targets/Summary 怎么对应、Summary 里的关键公式,以及异常数字(尤其 P-300 实际销售额和退货)。', + '不要修改工作簿。' + ].join('') + }) + return { + output, + beforeHash, + afterHash: await hashFile(sourcePath), + events: runtime.eventCollector.snapshot().slice(eventOffset) + } + }, + assert: async (result, runtime) => { + const starts = filterAiRunEventsByType(result.events, 'tool_call_started') + const toolNames = starts.map((event) => event.toolName) + runtime.assertThat( + result.output.status === 'completed' && + result.beforeHash === result.afterHash && + toolNames.includes('excel_read') && + !toolNames.includes('excel_edit'), + { + name: 'human-live-excel-weekly-review-read-only', + message: 'live 模型应读取经营工作簿并保持源文件不变。', + data: { toolNames } + } + ) + } + }) + }) + }) +}) diff --git a/tests/case/ai/human/mock-llm/human-excel-read-edit.test.ts b/tests/case/ai/human/mock-llm/human-excel-read-edit.test.ts index 81d3e24f..f737db44 100644 --- a/tests/case/ai/human/mock-llm/human-excel-read-edit.test.ts +++ b/tests/case/ai/human/mock-llm/human-excel-read-edit.test.ts @@ -42,15 +42,15 @@ describe('ai human mock-llm excel backend', () => { }, { output: buildToolDecision({ - toolName: 'excel_read_outline', - args: { sourcePath }, + toolName: 'excel_read', + args: { type: 'workbook', sourcePath }, summary: '读取工作簿概览' }) }, { output: buildToolDecision({ - toolName: 'excel_read_cells', - args: { sourcePath, ranges: [{ sheetName: 'Data', range: 'A1:B2' }] }, + toolName: 'excel_read', + args: { type: 'range', sourcePath, sheetName: 'Data', range: 'A1:B2' }, summary: '读取目标数据' }) }, @@ -58,16 +58,19 @@ describe('ai human mock-llm excel backend', () => { output: buildToolDecision({ toolName: 'excel_edit', args: { + type: 'set_range', sourcePath, - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'B2', value: 20 }] + sheetName: 'Data', + range: 'B2', + values: [[20]] }, summary: '更新金额' }) }, { output: buildToolDecision({ - toolName: 'excel_read_cells', - args: { sourcePath, ranges: [{ sheetName: 'Data', range: 'A1:B2' }] }, + toolName: 'excel_read', + args: { type: 'range', sourcePath, sheetName: 'Data', range: 'A1:B2' }, summary: '复读确认结果' }) }, @@ -99,8 +102,7 @@ describe('ai human mock-llm excel backend', () => { ) runtime.assertThat( result.output.status === 'completed' && - starts.some((event) => event.toolName === 'excel_read_outline') && - starts.filter((event) => event.toolName === 'excel_read_cells').length === 2 && + starts.filter((event) => event.toolName === 'excel_read').length === 3 && starts.some((event) => event.toolName === 'excel_edit') && result.value === 20 && Boolean(excelArtifact?.applyId), diff --git a/tests/case/ai/tool/excel/excel-create-read-edit.test.ts b/tests/case/ai/tool/excel/excel-create-read-edit.test.ts index b5089c23..24569d46 100644 --- a/tests/case/ai/tool/excel/excel-create-read-edit.test.ts +++ b/tests/case/ai/tool/excel/excel-create-read-edit.test.ts @@ -19,36 +19,48 @@ describe('ai tool excel backend', () => { execute: async (runtime) => { const harness = createExcelToolHarness(runtime) const sourcePath = path.join(runtime.workspaceRoot, 'generated-report.xlsx') - const created = await harness.invoke>('excel_edit', { + const recordOf = async (filePath: string) => + (await harness.ctx.listFileChangeRecords({ runId: runtime.runId })).find( + (record) => record.path === filePath + ) + await harness.invoke('excel_edit', { + type: 'add_sheet', sourcePath, - actions: [ - { type: 'add_sheet', sheetName: 'Data' }, - { - type: 'set_range', - sheetName: 'Data', - startCell: 'A1', - values: [ - ['Item', 'Amount', 'Double'], - ['A', 12, { formula: 'B2*2' }], - ['B', 8, { formula: 'B3*2' }] - ] - } + sheetName: 'Data' + }) + const created = await harness.invoke>('excel_edit', { + type: 'set_range', + sourcePath, + sheetName: 'Data', + range: 'A1', + values: [ + ['Item', 'Amount', 'Double'], + ['A', 12, { formula: 'B2*2' }], + ['B', 8, { formula: 'B3*2' }] ] }) - const outline = await harness.invoke>('excel_read_outline', { + const outline = await harness.invoke>('excel_read', { + type: 'workbook', sourcePath }) - const search = await harness.invoke>('excel_search', { + const search = await harness.invoke>('excel_read', { + type: 'search', sourcePath, query: 'B2*2' }) - const read = await harness.invoke>('excel_read_cells', { + const read = await harness.invoke>('excel_read', { + type: 'range', sourcePath, - ranges: [{ sheetName: 'Data', range: 'A1:C3' }] + sheetName: 'Data', + range: 'A1:C3' }) - const createdUpdated = await harness.invoke>('excel_edit', { + const createdRecordId = (await recordOf(sourcePath))?.id + await harness.invoke('excel_edit', { + type: 'set_range', sourcePath, - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'B2', value: 20 }] + sheetName: 'Data', + range: 'B2', + values: [[20]] }) const createdWorkbook = new ExcelJS.Workbook() await createdWorkbook.xlsx.load((await fs.readFile(sourcePath)) as never) @@ -59,20 +71,27 @@ describe('ai tool excel backend', () => { ['A', 12, { formula: 'B2*2' }], ['B', 8, { formula: 'B3*2' }] ]) - await fs.writeFile(existingSourcePath, Buffer.from(await existingWorkbook.xlsx.writeBuffer())) - const updated = await harness.invoke>('excel_edit', { + await fs.writeFile( + existingSourcePath, + Buffer.from(await existingWorkbook.xlsx.writeBuffer()) + ) + await harness.invoke('excel_edit', { + type: 'set_range', sourcePath: existingSourcePath, - actions: [ - { type: 'set_cell', sheetName: 'Data', address: 'B2', value: 20 }, - { type: 'delete_cell', sheetName: 'Data', address: 'A3' } - ] + sheetName: 'Data', + range: 'B2', + values: [[20]] + }) + await harness.invoke('excel_edit', { + type: 'clear_range', + sourcePath: existingSourcePath, + sheetName: 'Data', + range: 'A3' }) const targetPath = existingSourcePath const actual = new ExcelJS.Workbook() await actual.xlsx.load((await fs.readFile(targetPath)) as never) - const previewPath = String( - (updated.record as { previewPath?: string } | undefined)?.previewPath || '' - ) + const previewPath = String((await recordOf(existingSourcePath))?.previewPath || '') const diff = new ExcelJS.Workbook() await diff.xlsx.load((await fs.readFile(previewPath)) as never) return { @@ -80,7 +99,6 @@ describe('ai tool excel backend', () => { outline, search, read, - updated, actualValue: actual.getWorksheet('Data')?.getCell('B2').value, actualFormula: ( actual.getWorksheet('Data')?.getCell('C2').value as { formula?: string } | undefined @@ -92,11 +110,12 @@ describe('ai tool excel backend', () => { deletedBorder: diff.getWorksheet('Data')?.getCell('A3').border.left?.color?.argb, deletedBorderStyle: diff.getWorksheet('Data')?.getCell('A3').border.left?.style, createdSheetNames: createdWorkbook.worksheets.map((sheet) => sheet.name), - createdRecordId: (created.record as { id?: string } | undefined)?.id, - createdUpdatedRecordId: (createdUpdated.record as { id?: string } | undefined)?.id + createdRecordId, + createdUpdatedRecordId: (await recordOf(sourcePath))?.id } }, assert: async (result, runtime) => { + const sheets = result.outline.sheets as Array<{ name: string }> | undefined runtime.assertThat( result.actualValue === 20 && result.actualFormula === 'B2*2' && @@ -110,7 +129,10 @@ describe('ai tool excel backend', () => { result.createdRecordId !== undefined && result.createdRecordId === result.createdUpdatedRecordId && JSON.stringify(result.search).includes('C2') && - JSON.stringify(result.read).includes('B2*2'), + JSON.stringify(result.read).includes('B2*2') && + Array.isArray(sheets) && + sheets.some((sheet) => sheet.name === 'Data') && + Object.keys(result.created).length === 0, { name: 'excel-dedicated-tools-and-clean-source-diff', message: 'Excel 工具应完成读写,实际文件无差异边框,diff 文件按规则显示红绿边框。', diff --git a/tests/case/ai/tool/excel/excel-diff-row-structure-semantics.test.ts b/tests/case/ai/tool/excel/excel-diff-row-structure-semantics.test.ts index c3b76245..22b8c797 100644 --- a/tests/case/ai/tool/excel/excel-diff-row-structure-semantics.test.ts +++ b/tests/case/ai/tool/excel/excel-diff-row-structure-semantics.test.ts @@ -57,49 +57,69 @@ describe('ai tool excel row diff backend', () => { ) const harness = createExcelToolHarness(runtime) - const edited = await harness.invoke<{ - record: { previewPath: string; insertCount: number; deleteCount: number } - }>('excel_edit', { + const recordOf = async (filePath: string) => { + const record = (await harness.ctx.listFileChangeRecords({ runId: runtime.runId })).find( + (item) => item.path === filePath + ) + if (!record?.previewPath) throw new Error(`missing preview for ${filePath}`) + return record as { previewPath: string; insertCount: number; deleteCount: number } + } + await harness.invoke('excel_edit', { + type: 'delete_rows', sourcePath, - actions: [{ type: 'delete_rows', sheetName: '阶段涨幅', startRow: 3, count: 1 }] + sheetName: '阶段涨幅', + startRow: 3, + count: 1 }) - const inserted = await harness.invoke<{ - record: { previewPath: string; insertCount: number; deleteCount: number } - }>('excel_edit', { + const edited = await recordOf(sourcePath) + await harness.invoke('excel_edit', { + type: 'insert_rows', sourcePath: path.join(runtime.workspaceRoot, 'row-insert.xlsx'), - actions: [ - { type: 'insert_rows', sheetName: '阶段涨幅', startRow: 3, count: 1 }, - { - type: 'set_range', - sheetName: '阶段涨幅', - startCell: 'A3', - values: [['新增行', '1%', '2%', '3%', '4%']] - } - ] + sheetName: '阶段涨幅', + startRow: 3, + count: 1 }) - const deletedFirst = await harness.invoke<{ - record: { previewPath: string; insertCount: number; deleteCount: number } - }>('excel_edit', { + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: path.join(runtime.workspaceRoot, 'row-insert.xlsx'), + sheetName: '阶段涨幅', + range: 'A3', + values: [['新增行', '1%', '2%', '3%', '4%']] + }) + const inserted = await recordOf(path.join(runtime.workspaceRoot, 'row-insert.xlsx')) + await harness.invoke('excel_edit', { + type: 'delete_rows', sourcePath: path.join(runtime.workspaceRoot, 'row-delete-first.xlsx'), - actions: [{ type: 'delete_rows', sheetName: '阶段涨幅', startRow: 2, count: 1 }] + sheetName: '阶段涨幅', + startRow: 2, + count: 1 }) - const deletedLast = await harness.invoke<{ - record: { previewPath: string; insertCount: number; deleteCount: number } - }>('excel_edit', { + const deletedFirst = await recordOf( + path.join(runtime.workspaceRoot, 'row-delete-first.xlsx') + ) + await harness.invoke('excel_edit', { + type: 'delete_rows', sourcePath: path.join(runtime.workspaceRoot, 'row-delete-last.xlsx'), - actions: [{ type: 'delete_rows', sheetName: '阶段涨幅', startRow: 9, count: 1 }] + sheetName: '阶段涨幅', + startRow: 9, + count: 1 }) - const deletedContiguous = await harness.invoke<{ - record: { previewPath: string; insertCount: number; deleteCount: number } - }>('excel_edit', { + const deletedLast = await recordOf(path.join(runtime.workspaceRoot, 'row-delete-last.xlsx')) + await harness.invoke('excel_edit', { + type: 'delete_rows', sourcePath: path.join(runtime.workspaceRoot, 'row-delete-contiguous.xlsx'), - actions: [{ type: 'delete_rows', sheetName: '阶段涨幅', startRow: 3, count: 2 }] + sheetName: '阶段涨幅', + startRow: 3, + count: 2 }) + const deletedContiguous = await recordOf( + path.join(runtime.workspaceRoot, 'row-delete-contiguous.xlsx') + ) const actual = new ExcelJS.Workbook() await actual.xlsx.load((await fs.readFile(sourcePath)) as never) const diff = new ExcelJS.Workbook() - await diff.xlsx.load((await fs.readFile(edited.record.previewPath)) as never) + await diff.xlsx.load((await fs.readFile(edited.previewPath)) as never) const actualSheet = actual.getWorksheet('阶段涨幅')! const diffSheet = diff.getWorksheet('阶段涨幅')! const inspectDiff = async (previewPath: string) => { @@ -118,12 +138,12 @@ describe('ai tool excel row diff backend', () => { redAddresses: collectBorderAddresses(diffSheet, RED), greenAddresses: collectBorderAddresses(diffSheet, GREEN), deletedRowHeight: diffSheet.getRow(3).height, - insertCount: edited.record.insertCount, - deleteCount: edited.record.deleteCount, - inserted: await inspectDiff(inserted.record.previewPath), - deletedFirst: await inspectDiff(deletedFirst.record.previewPath), - deletedLast: await inspectDiff(deletedLast.record.previewPath), - deletedContiguous: await inspectDiff(deletedContiguous.record.previewPath) + insertCount: edited.insertCount, + deleteCount: edited.deleteCount, + inserted: await inspectDiff(inserted.previewPath), + deletedFirst: await inspectDiff(deletedFirst.previewPath), + deletedLast: await inspectDiff(deletedLast.previewPath), + deletedContiguous: await inspectDiff(deletedContiguous.previewPath) } }, assert: async (result, runtime) => { @@ -203,75 +223,89 @@ describe('ai tool excel row diff backend', () => { ) const harness = createExcelToolHarness(runtime) + const recordOf = async (filePath: string) => { + const record = (await harness.ctx.listFileChangeRecords({ runId: runtime.runId })).find( + (item) => item.path === filePath + ) + if (!record?.previewPath) throw new Error(`missing preview for ${filePath}`) + return { + previewPath: record.previewPath, + changeCount: record.changeCount, + insertCount: record.insertCount, + deleteCount: record.deleteCount + } + } await harness.invoke('excel_edit', { + type: 'set_range', sourcePath: path.join(runtime.workspaceRoot, 'cell-restored.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'B2', value: 'B' }] + sheetName: 'Data', + range: 'B2', + values: [['B']] }) - const cellRestored = await harness.invoke<{ - record: { - previewPath: string - changeCount: number - insertCount: number - deleteCount: number - } - }>('excel_edit', { + await harness.invoke('excel_edit', { + type: 'set_range', sourcePath: path.join(runtime.workspaceRoot, 'cell-restored.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'B2', value: 'A' }] + sheetName: 'Data', + range: 'B2', + values: [['A']] }) + const cellRestored = await recordOf(path.join(runtime.workspaceRoot, 'cell-restored.xlsx')) await harness.invoke('excel_edit', { + type: 'insert_rows', sourcePath: path.join(runtime.workspaceRoot, 'row-restored.xlsx'), - actions: [ - { type: 'insert_rows', sheetName: 'Data', startRow: 2, count: 1 }, - { type: 'set_cell', sheetName: 'Data', address: 'A2', value: 'Temporary' } - ] + sheetName: 'Data', + startRow: 2, + count: 1 }) - const rowRestored = await harness.invoke<{ - record: { - previewPath: string - changeCount: number - insertCount: number - deleteCount: number - } - }>('excel_edit', { + await harness.invoke('excel_edit', { + type: 'set_range', sourcePath: path.join(runtime.workspaceRoot, 'row-restored.xlsx'), - actions: [{ type: 'delete_rows', sheetName: 'Data', startRow: 2, count: 1 }] + sheetName: 'Data', + range: 'A2', + values: [['Temporary']] }) + await harness.invoke('excel_edit', { + type: 'delete_rows', + sourcePath: path.join(runtime.workspaceRoot, 'row-restored.xlsx'), + sheetName: 'Data', + startRow: 2, + count: 1 + }) + const rowRestored = await recordOf(path.join(runtime.workspaceRoot, 'row-restored.xlsx')) await harness.invoke('excel_edit', { + type: 'add_sheet', sourcePath: path.join(runtime.workspaceRoot, 'sheet-restored.xlsx'), - actions: [ - { type: 'add_sheet', sheetName: 'Temporary' }, - { type: 'set_cell', sheetName: 'Temporary', address: 'A1', value: 'Temporary' } - ] + sheetName: 'Temporary' }) - const sheetRestored = await harness.invoke<{ - record: { - previewPath: string - changeCount: number - insertCount: number - deleteCount: number - } - }>('excel_edit', { + await harness.invoke('excel_edit', { + type: 'set_range', sourcePath: path.join(runtime.workspaceRoot, 'sheet-restored.xlsx'), - actions: [{ type: 'delete_sheet', sheetName: 'Temporary' }] + sheetName: 'Temporary', + range: 'A1', + values: [['Temporary']] }) + await harness.invoke('excel_edit', { + type: 'delete_sheet', + sourcePath: path.join(runtime.workspaceRoot, 'sheet-restored.xlsx'), + sheetName: 'Temporary' + }) + const sheetRestored = await recordOf(path.join(runtime.workspaceRoot, 'sheet-restored.xlsx')) await harness.invoke('excel_edit', { + type: 'rename_sheet', sourcePath: path.join(runtime.workspaceRoot, 'sheet-renamed.xlsx'), - actions: [{ type: 'rename_sheet', sheetName: 'Data', newName: 'Intermediate' }] + sheetName: 'Data', + newName: 'Intermediate' }) - const sheetRenamed = await harness.invoke<{ - record: { - previewPath: string - changeCount: number - insertCount: number - deleteCount: number - } - }>('excel_edit', { + await harness.invoke('excel_edit', { + type: 'rename_sheet', sourcePath: path.join(runtime.workspaceRoot, 'sheet-renamed.xlsx'), - actions: [{ type: 'rename_sheet', sheetName: 'Intermediate', newName: 'Final' }] + sheetName: 'Intermediate', + newName: 'Final' }) + const sheetRenamed = await recordOf(path.join(runtime.workspaceRoot, 'sheet-renamed.xlsx')) const inspect = async (record: { previewPath: string @@ -293,10 +327,10 @@ describe('ai tool excel row diff backend', () => { } return { - cell: await inspect(cellRestored.record), - row: await inspect(rowRestored.record), - sheet: await inspect(sheetRestored.record), - renamed: await inspect(sheetRenamed.record) + cell: await inspect(cellRestored), + row: await inspect(rowRestored), + sheet: await inspect(sheetRestored), + renamed: await inspect(sheetRenamed) } }, assert: async (result, runtime) => { diff --git a/tests/case/ai/tool/excel/excel-edit-operations-and-atomicity.test.ts b/tests/case/ai/tool/excel/excel-edit-operations-and-atomicity.test.ts index 72b77244..82706b54 100644 --- a/tests/case/ai/tool/excel/excel-edit-operations-and-atomicity.test.ts +++ b/tests/case/ai/tool/excel/excel-edit-operations-and-atomicity.test.ts @@ -66,75 +66,186 @@ describe('ai tool excel edit backend', () => { }) const harness = createExcelToolHarness(runtime) - const firstEdit = await harness.invoke>('excel_edit', { - sourcePath: path.join(runtime.workspaceRoot, 'operations.xlsx'), - actions: [ - { - type: 'set_range', - sheetName: 'Data', - startCell: 'A1', - values: [ - ['Item', 'Amount', 'Double'], - ['A', 12, { formula: 'B2*2' }], - ['B', 8, { formula: 'B3*2' }] - ], - style: { - bold: true, - italic: true, - fontColor: '#FFFFFF', - fillColor: '#2266AA', - numberFormat: '0.00', - horizontalAlignment: 'center', - verticalAlignment: 'middle' - } - }, - { type: 'clear_range', sheetName: 'Data', range: 'D2' }, - { type: 'add_sheet', sheetName: 'Summary' }, - { type: 'set_cell', sheetName: 'Summary', address: 'A1', value: 'Overview' } - ] + const operationsPath = path.join(runtime.workspaceRoot, 'operations.xlsx') + const recordOf = async (filePath: string) => + (await harness.ctx.listFileChangeRecords({ runId: runtime.runId })).find( + (record) => record.path === filePath + ) + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: operationsPath, + sheetName: 'Data', + range: 'A1', + values: [ + ['Item', 'Amount', 'Double'], + ['A', 12, { formula: 'B2*2' }], + ['B', 8, { formula: 'B3*2' }] + ], + style: { + bold: true, + italic: true, + fontColor: '#FFFFFF', + fillColor: '#2266AA', + numberFormat: '0.00', + horizontalAlignment: 'center', + verticalAlignment: 'middle' + } }) - const secondEdit = await harness.invoke>('excel_edit', { - sourcePath: path.join(runtime.workspaceRoot, 'operations.xlsx'), - actions: [ - { type: 'rename_sheet', sheetName: 'Summary', newName: 'Overview' }, - { type: 'merge_cells', sheetName: 'Overview', range: 'A1:B1' }, - { type: 'set_column_width', sheetName: 'Data', column: 'B', width: 24 }, - { type: 'insert_rows', sheetName: 'Data', startRow: 2, count: 1 }, - { type: 'set_cell', sheetName: 'Data', address: 'A2', value: 'Inserted' }, - { type: 'delete_rows', sheetName: 'Data', startRow: 4, count: 1 }, - { type: 'delete_sheet', sheetName: 'DeleteMe' } - ] + await harness.invoke('excel_edit', { + type: 'clear_range', + sourcePath: operationsPath, + sheetName: 'Data', + range: 'D2' }) + await harness.invoke('excel_edit', { + type: 'add_sheet', + sourcePath: operationsPath, + sheetName: 'Summary' + }) + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: operationsPath, + sheetName: 'Summary', + range: 'A1', + values: [['Overview']] + }) + await harness.invoke('excel_edit', { + type: 'rename_sheet', + sourcePath: operationsPath, + sheetName: 'Summary', + newName: 'Overview' + }) + await harness.invoke('excel_edit', { + type: 'merge_cells', + sourcePath: operationsPath, + sheetName: 'Overview', + range: 'A1:B1' + }) + await harness.invoke('excel_edit', { + type: 'set_column_width', + sourcePath: operationsPath, + sheetName: 'Data', + column: 'B', + width: 24 + }) + await harness.invoke('excel_edit', { + type: 'insert_rows', + sourcePath: operationsPath, + sheetName: 'Data', + startRow: 2, + count: 1 + }) + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: operationsPath, + sheetName: 'Data', + range: 'A2', + values: [['Inserted']] + }) + await harness.invoke('excel_edit', { + type: 'delete_rows', + sourcePath: operationsPath, + sheetName: 'Data', + startRow: 4, + count: 1 + }) + await harness.invoke('excel_edit', { + type: 'delete_sheet', + sourcePath: operationsPath, + sheetName: 'DeleteMe' + }) + await harness.invoke('excel_edit', { + type: 'set_style', + sourcePath: operationsPath, + sheetName: 'Overview', + range: 'A1', + style: { italic: true } + }) + await harness.invoke('excel_edit', { + type: 'set_row_height', + sourcePath: operationsPath, + sheetName: 'Overview', + row: 1, + height: 22 + }) + await harness.invoke('excel_edit', { + type: 'auto_fit', + sourcePath: operationsPath, + sheetName: 'Overview', + range: 'A1:B1' + }) + await harness.invoke('excel_edit', { + type: 'insert_columns', + sourcePath: operationsPath, + sheetName: 'Overview', + startColumn: 'C', + count: 1 + }) + await harness.invoke('excel_edit', { + type: 'delete_columns', + sourcePath: operationsPath, + sheetName: 'Overview', + startColumn: 'C', + count: 1 + }) + await harness.invoke('excel_edit', { + type: 'merge_cells', + sourcePath: operationsPath, + sheetName: 'Overview', + range: 'D1:E1' + }) + await harness.invoke('excel_edit', { + type: 'unmerge_cells', + sourcePath: operationsPath, + sheetName: 'Overview', + range: 'D1:E1' + }) + const secondEditRecord = await recordOf(operationsPath) const noOp = await harness.invoke>('excel_edit', { + type: 'set_range', sourcePath: path.join(runtime.workspaceRoot, 'noop.xlsx'), - actions: [ - { type: 'set_cell', sheetName: 'Data', address: 'A1', value: 'same' }, - { type: 'delete_cell', sheetName: 'Data', address: 'C3' }, - { type: 'clear_range', sheetName: 'Data', range: 'D4:E5' } - ] + sheetName: 'Data', + range: 'A1', + values: [['same']] }) const deleteLastSheet = await harness.invoke>('excel_edit', { + type: 'delete_sheet', sourcePath: path.join(runtime.workspaceRoot, 'noop.xlsx'), - actions: [{ type: 'delete_sheet', sheetName: 'Data' }] + sheetName: 'Data' }) - const baselineFirst = await harness.invoke>('excel_edit', { - sourcePath: path.join(runtime.workspaceRoot, 'baseline.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'A1', value: 2 }] + const baselinePath = path.join(runtime.workspaceRoot, 'baseline.xlsx') + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: baselinePath, + sheetName: 'Data', + range: 'A1', + values: [[2]] }) - const baselineSecond = await harness.invoke>('excel_edit', { - sourcePath: path.join(runtime.workspaceRoot, 'baseline.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'B1', value: 20 }] + const baselineFirstId = (await recordOf(baselinePath))?.id + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: baselinePath, + sheetName: 'Data', + range: 'B1', + values: [[20]] }) - await harness.invoke>('excel_edit', { - sourcePath: path.join(runtime.workspaceRoot, 'conflict.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'A1', value: 2 }] + const conflictPath = path.join(runtime.workspaceRoot, 'conflict.xlsx') + await harness.invoke('excel_edit', { + type: 'set_range', + sourcePath: conflictPath, + sheetName: 'Data', + range: 'A1', + values: [[2]] }) const external = new ExcelJS.Workbook() external.addWorksheet('Data').getCell('A1').value = 99 await fs.writeFile(conflictFile.filePath, Buffer.from(await external.xlsx.writeBuffer())) const conflict = await harness.invoke>('excel_edit', { - sourcePath: path.join(runtime.workspaceRoot, 'conflict.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'A1', value: 3 }] + type: 'set_range', + sourcePath: conflictPath, + sheetName: 'Data', + range: 'A1', + values: [[3]] }) const rollbackRoot = path.join(runtime.workspaceRoot, 'rollback-scope') @@ -162,8 +273,11 @@ describe('ai tool excel edit backend', () => { let rollbackError = '' try { await rollbackRuntime.edit({ + type: 'set_range', sourcePath: rollbackPath, - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'A1', value: 'after' }] + sheetName: 'Data', + range: 'A1', + values: [['after']] }) } catch (error) { rollbackError = error instanceof Error ? error.message : String(error) @@ -186,22 +300,19 @@ describe('ai tool excel edit backend', () => { } const operations = await loadWorkbook(operationsFile.filePath) - const operationsDiff = await loadWorkbook( - String((secondEdit.record as { previewPath?: string } | undefined)?.previewPath) - ) + const operationsDiff = await loadWorkbook(String(secondEditRecord?.previewPath)) const operationsXml = await JSZip.loadAsync( await fs.readFile(operationsFile.filePath) ).then((zip) => zip.file('xl/workbook.xml')!.async('text')) - const baselineRecord = baselineSecond.record as - | { id?: string; previewPath?: string; beforeSnapshotPath?: string; changeCount?: number } - | undefined + const baselineRecord = await recordOf(baselinePath) const baselineDiff = await loadWorkbook(String(baselineRecord?.previewPath)) const baselineSource = await loadWorkbook(baselineFile.filePath) return { - firstEdit, - secondEdit, + firstEdit: {}, + secondEdit: {}, noOp, + noOpRecord: await recordOf(path.join(runtime.workspaceRoot, 'noop.xlsx')), deleteLastSheet, conflict, rollback, @@ -258,8 +369,7 @@ describe('ai tool excel edit backend', () => { fullCalcOnLoad: operationsXml.includes('fullCalcOnLoad="1"') }, baseline: { - sameRecordId: - (baselineFirst.record as { id?: string } | undefined)?.id === baselineRecord?.id, + sameRecordId: baselineFirstId === baselineRecord?.id, changeCount: baselineRecord?.changeCount, originalSnapshot: Boolean(baselineRecord?.beforeSnapshotPath) && @@ -309,17 +419,11 @@ describe('ai tool excel edit backend', () => { data: result.operations } ) - runtime.assertThat( - result.noOp.changed === false && - Array.isArray(result.noOp.changes) && - result.noOp.changes.length === 0 && - !result.noOp.record, - { + runtime.assertThat(Object.keys(result.noOp).length === 0 && !result.noOpRecord, { name: 'excel-no-op-has-no-record', message: '无变化的 Excel 编辑不能生成修改记录。', data: result.noOp - } - ) + }) runtime.assertThat( result.deleteLastSheet.ok === false && String(result.deleteLastSheet.error).includes('至少保留一个工作表'), diff --git a/tests/case/ai/tool/excel/excel-read-search-and-format-boundaries.test.ts b/tests/case/ai/tool/excel/excel-read-search-and-format-boundaries.test.ts index 63e41ef0..3405352e 100644 --- a/tests/case/ai/tool/excel/excel-read-search-and-format-boundaries.test.ts +++ b/tests/case/ai/tool/excel/excel-read-search-and-format-boundaries.test.ts @@ -13,7 +13,7 @@ describe('ai tool excel read backend', () => { domain: 'ai', suite: 'tool', groups: ['excel'], - description: '验证 Excel 多范围读取、搜索 scope/regex/截断、格式兼容和错误边界。', + description: '验证 Excel 网格读取、搜索截断、格式兼容和错误边界。', fixtures: [], runMode: 'single', execute: async (runtime) => { @@ -30,6 +30,12 @@ describe('ai tool excel read backend', () => { ] }) data.getCell('C2').value = { formula: 'B2*2' } + data.getCell('A1').font = { bold: true } + data.getCell('A1').fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFDDEBF7' } + } workbook.addWorksheet('Other').getCell('A1').value = 'Q3' const bytes = Buffer.from(await workbook.xlsx.writeBuffer()) await Promise.all( @@ -52,85 +58,133 @@ describe('ai tool excel read backend', () => { await fs.writeFile(path.join(runtime.workspaceRoot, 'broken.xlsx'), 'not-a-workbook') const harness = createExcelToolHarness(runtime) + const searchPath = path.join(runtime.workspaceRoot, 'search.xlsx') const outlines = await Promise.all( ['xlsx', 'xlsm', 'xltx', 'xltm'].map( async (extension) => - await harness.invoke>('excel_read_outline', { + await harness.invoke>('excel_read', { + type: 'workbook', sourcePath: path.join(runtime.workspaceRoot, `search.${extension}`) }) ) ) - const table = await harness.invoke>('excel_search', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), + const table = await harness.invoke>('excel_read', { + type: 'search', + sourcePath: searchPath, query: 'RevenueTable' }) - const column = await harness.invoke>('excel_search', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), + const column = await harness.invoke>('excel_read', { + type: 'search', + sourcePath: searchPath, query: 'Amount' }) - const sheet = await harness.invoke>('excel_search', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), + const sheet = await harness.invoke>('excel_read', { + type: 'search', + sourcePath: searchPath, query: 'Data' }) - const scopedRegex = await harness.invoke>('excel_search', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), + const scopedRegex = await harness.invoke>('excel_read', { + type: 'search', + sourcePath: searchPath, query: '^Q[12]$', mode: 'regex', - scope: { sheetNames: ['Data'] } + sheetName: 'Data' }) - const read = await harness.invoke>('excel_read_cells', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), - ranges: [ - { sheetName: 'Data', range: '$A$2:B2' }, - { sheetName: 'Other', range: 'A1' } - ] + const read = await harness.invoke>('excel_read', { + type: 'range', + sourcePath: searchPath, + sheetName: 'Data', + range: '$A$2:B2' }) - const capped = await harness.invoke>('excel_search', { + const other = await harness.invoke>('excel_read', { + type: 'range', + sourcePath: searchPath, + sheetName: 'Other', + range: 'A1' + }) + const style = await harness.invoke>('excel_read', { + type: 'style', + sourcePath: searchPath, + sheetName: 'Data', + range: 'A1' + }) + const capped = await harness.invoke>('excel_read', { + type: 'search', sourcePath: path.join(runtime.workspaceRoot, 'large.xlsx'), query: 'hit-' }) + const overflow = await harness.invoke>('excel_read', { + type: 'range', + sourcePath: searchPath, + sheetName: 'Data', + range: 'A1:BZ80' + }) const failures = await Promise.all([ - harness.invoke>('excel_read_outline', { + harness.invoke>('excel_read', { + type: 'workbook', sourcePath: path.join(path.dirname(runtime.workspaceRoot), 'outside.xlsx') }), - harness.invoke>('excel_read_outline', { + harness.invoke>('excel_read', { + type: 'workbook', sourcePath: path.join(runtime.workspaceRoot, 'data.csv') }), - harness.invoke>('excel_read_outline', { + harness.invoke>('excel_read', { + type: 'workbook', sourcePath: path.join(runtime.workspaceRoot, 'broken.xlsx') }), - harness.invoke>('excel_read_cells', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), - ranges: [{ sheetName: 'Missing', range: 'A1' }] + harness.invoke>('excel_read', { + type: 'range', + sourcePath: searchPath, + sheetName: 'Missing', + range: 'A1' }), - harness.invoke>('excel_read_cells', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), - ranges: [{ sheetName: 'Data', range: 'B2:A1' }] + harness.invoke>('excel_read', { + type: 'range', + sourcePath: searchPath, + sheetName: 'Data', + range: 'B2:A1' }), - harness.invoke>('excel_search', { - sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'), + harness.invoke>('excel_read', { + type: 'search', + sourcePath: searchPath, query: '[', mode: 'regex' }) ]) - return { outlines, table, column, sheet, scopedRegex, read, capped, failures } + return { + outlines, + table, + column, + sheet, + scopedRegex, + read, + other, + style, + capped, + overflow, + failures + } }, assert: async (result, runtime) => { - const serialized = JSON.stringify(result) runtime.assertThat( - result.outlines.every((outline) => outline.sheetCount === 2) && - serialized.includes('table_name') && - serialized.includes('table_column') && - serialized.includes('sheet_name') && + result.outlines.every((outline) => Array.isArray(outline.sheets) && outline.sheets.length === 2) && + Array.isArray(result.table.matches) && + result.table.matches.length === 0 && + JSON.stringify(result.column).includes('Amount') && + Array.isArray(result.sheet.matches) && + result.sheet.matches.length === 0 && JSON.stringify(result.scopedRegex).includes('Q1') && JSON.stringify(result.scopedRegex).includes('Q2') && !JSON.stringify(result.scopedRegex).includes('Q3') && - Array.isArray(result.read.cells) && - result.read.cells.length === 3, + Array.isArray(result.read.rows) && + result.read.rows.length === 1 && + (result.read.rows[0] as unknown[]).length === 2 && + JSON.stringify(result.other.rows).includes('Q3') && + JSON.stringify(result.style).includes('bold'), { name: 'excel-read-search-contracts', - message: '四种 Excel 格式、多范围读取及 metadata/regex/scope 搜索应稳定工作。', + message: '四种 Excel 格式、网格读取及单元格搜索应稳定工作,且不按表名命中。', data: result } ) @@ -140,16 +194,18 @@ describe('ai tool excel read backend', () => { result.capped.matches.length === 500, { name: 'excel-search-result-cap', - message: 'Excel 搜索结果应在 500 条截断并显式报告。', + message: 'Excel 搜索结果应在 500 条截断并显式报告 truncated。', data: result.capped } ) runtime.assertThat( - result.failures.every((failure) => typeof failure.error === 'string'), + typeof result.overflow.error === 'string' && + String(result.overflow.error).includes('缩小 range') && + result.failures.every((failure) => typeof failure.error === 'string'), { name: 'excel-read-failure-boundaries', - message: '路径、扩展名、损坏文件、Sheet、range 和 regex 错误都应返回结构化失败。', - data: result.failures + message: '超格子预算、路径、扩展名、损坏文件、Sheet、range 和 regex 错误都应返回结构化失败。', + data: { overflow: result.overflow, failures: result.failures } } ) } diff --git a/tests/case/ai/tool/hand/hand-excel-read-write-read.test.ts b/tests/case/ai/tool/hand/hand-excel-read-write-read.test.ts index 671d82bb..a447e2a7 100644 --- a/tests/case/ai/tool/hand/hand-excel-read-write-read.test.ts +++ b/tests/case/ai/tool/hand/hand-excel-read-write-read.test.ts @@ -2,7 +2,6 @@ import fs from 'node:fs/promises' import path from 'node:path' import ExcelJS from 'exceljs' import { describe, it } from 'vitest' -import path from 'node:path' import { createHandNode } from '../../../../../src/main/services/ai/human/node/handNode' import { ensureSkillTools } from '../../../../../src/main/services/ai/human/node/learningNode/skillLoader' import { createHumanRuntimeCache } from '../../../../../src/main/services/ai/human/node/utils/runtimeCache' @@ -63,8 +62,9 @@ describe('ai tool hand excel backend', () => { calls: [ { toolCallId: 'call-0', - toolName: 'excel_read_outline', + toolName: 'excel_read', args: { + type: 'workbook', sourcePath: path.join(runtime.workspaceRoot, 'hand-report.xlsx') } }, @@ -72,16 +72,21 @@ describe('ai tool hand excel backend', () => { toolCallId: 'call-1', toolName: 'excel_edit', args: { + type: 'set_range', sourcePath: path.join(runtime.workspaceRoot, 'hand-report.xlsx'), - actions: [{ type: 'set_cell', sheetName: 'Data', address: 'B2', value: 20 }] + sheetName: 'Data', + range: 'B2', + values: [[20]] } }, { toolCallId: 'call-2', - toolName: 'excel_read_cells', + toolName: 'excel_read', args: { + type: 'range', sourcePath: path.join(runtime.workspaceRoot, 'hand-report.xlsx'), - ranges: [{ sheetName: 'Data', range: 'A1:B2' }] + sheetName: 'Data', + range: 'A1:B2' } } ] @@ -95,7 +100,7 @@ describe('ai tool hand excel backend', () => { const results = result.state.execution.lastToolResults || [] runtime.assertThat( results.map((item) => item.toolName).join(',') === - 'excel_read_outline,excel_edit,excel_read_cells' && + 'excel_read,excel_edit,excel_read' && results.every((item) => item.ok) && JSON.stringify(results[2]?.data).includes('20'), { diff --git a/tests/case/unit/ai/excel-contracts.test.ts b/tests/case/unit/ai/excel-contracts.test.ts index cbf12b50..a06db9ad 100644 --- a/tests/case/unit/ai/excel-contracts.test.ts +++ b/tests/case/unit/ai/excel-contracts.test.ts @@ -12,16 +12,12 @@ describe('Excel static contracts', () => { renderToolOutputSchemaForPrompt(spec.outputSchema) ]) ) - expect(rendered.excel_read_outline).toContain('sheetCount') - expect(rendered.excel_search).toContain('matches') - expect(rendered.excel_read_cells).toContain('cells') - expect(rendered.excel_edit).toContain('changes') - expect(Object.values(rendered)).not.toContain(' {}') - expect(getSkillDefinition('excel-read').toolNames).toEqual([ - 'excel_read_outline', - 'excel_search', - 'excel_read_cells' - ]) + expect(rendered.excel_read).toContain('sheets') + expect(rendered.excel_read).toContain('rows') + expect(rendered.excel_read).toContain('truncated') + expect(rendered.excel_read).toContain('matches') + expect(rendered.excel_edit.replace(/\s/g, '')).toBe('{}') + expect(getSkillDefinition('excel-read').toolNames).toEqual(['excel_read']) expect(getSkillDefinition('excel-write').toolNames).toEqual(['excel_edit']) }) diff --git a/tests/case/unit/workbench/assistant-process-icons.test.ts b/tests/case/unit/workbench/assistant-process-icons.test.ts index e4584259..a0266b84 100644 --- a/tests/case/unit/workbench/assistant-process-icons.test.ts +++ b/tests/case/unit/workbench/assistant-process-icons.test.ts @@ -32,7 +32,7 @@ describe('workbench assistant process icons', () => { it('uses action icons for file read, search, and edit steps', () => { expect(resolveAssistantExecutionIcon(createToolGroup('docx_read_outline'))).toBe('document') expect(resolveAssistantExecutionIcon(createToolGroup('docx_content_edit'))).toBe('edit') - expect(resolveAssistantExecutionIcon(createToolGroup('excel_read_outline'))).toBe('document') + expect(resolveAssistantExecutionIcon(createToolGroup('excel_read'))).toBe('document') expect(resolveAssistantExecutionIcon(createToolGroup('excel_edit'))).toBe('edit') expect(resolveAssistantExecutionIcon(createToolGroup('ppt_read_outline'))).toBe('document') expect(resolveAssistantExecutionIcon(createToolGroup('ppt_search'))).toBe('search') @@ -40,7 +40,7 @@ describe('workbench assistant process icons', () => { }) it('uses workflow icon and a concise sentence for mixed tool summaries', () => { - const groups = createToolGroups(['excel_read_outline', 'excel_edit']) + const groups = createToolGroups(['excel_read', 'excel_edit']) expect(resolveAssistantExecutionIcon(groups)).toBe('workflow') expect(toPlainText(buildAssistantExecutionSummaryText(groups))).toBe( @@ -50,7 +50,7 @@ describe('workbench assistant process icons', () => { it('summarizes more than three tool kinds with action labels', () => { const groups = createToolGroups([ - 'excel_read_outline', + 'excel_read', 'web_search', 'excel_edit', 'terminal_run' diff --git a/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts b/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts index d26d7b91..9229271b 100644 --- a/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts +++ b/tests/case/workbench/result/apply/excel-update-diff-and-revert.e2e.test.ts @@ -232,7 +232,7 @@ describe('workbench result e2e', () => { result.fileModeNotesVisible && result.pptBeforeAfterVisible === false && result.revertedToBaseline && - result.toolNames.includes('excel_read_outline') && + result.toolNames.includes('excel_read') && result.toolNames.filter((name) => name === 'excel_edit').length >= 2, { name: 'workbench-excel-update-diff-and-revert-real', diff --git a/tests/fixtures/workspace/operating-review.xlsx b/tests/fixtures/workspace/operating-review.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..aa892e347529ea49936211fc5033d4f1ed405428 GIT binary patch literal 9582 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 b?*8>Yh=MfqkLd9f{2o;@7~k!r-^u?EGRZF% literal 0 HcmV?d00001