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.
This commit is contained in:
1 parent
038e195513
commit
ae4cd1c56a
30 files changed
+1584
-1111
No files matched your search
+2
-1
@@ -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 | 普通文本读写搜索、命令执行、网页抓取与内置浏览器操作 |
|
||||
|
||||
|
||||
@@ -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 必须失败。
|
||||
|
||||
+35
-25
@@ -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`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -89,7 +89,7 @@ export const RUNTIME_SKILL_CATALOG = {
|
||||
source: 'external',
|
||||
countsTowardToolBudget: true,
|
||||
title: 'excel-read',
|
||||
description: '读取 Excel 工作簿、工作表、单元格范围、公式和值。'
|
||||
description: '读取 Excel 工作簿地图、单元格范围的值与公式、样式,以及按格子搜索。'
|
||||
},
|
||||
'excel-write': {
|
||||
familyId: 'excel',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const EXCEL_SEARCH_MAX_MATCHES = 500
|
||||
export const EXCEL_READ_RANGE_MAX_CELLS = 2_000
|
||||
@@ -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<ExcelToolName, string> = {
|
||||
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<JsonObject>
|
||||
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'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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 = <T>(
|
||||
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<string, string | null>
|
||||
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<JsonObject> => {
|
||||
const read = async (args: ExcelReadInput): Promise<JsonObject> => {
|
||||
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<JsonObject> => {
|
||||
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<JsonObject> => {
|
||||
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<JsonObject> => {
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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 => ({
|
||||
|
||||
@@ -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<typeof excelReadSchema>
|
||||
export type ExcelEditInput = z.infer<typeof excelEditSchema>
|
||||
export type ExcelStyle = z.infer<typeof styleSchema>
|
||||
@@ -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<typeof excelReadOutlineSchema>,
|
||||
z.infer<typeof excelReadOutlineOutputSchema>,
|
||||
'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<typeof excelSearchSchema>,
|
||||
z.infer<typeof excelSearchOutputSchema>,
|
||||
'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<typeof excelReadCellsSchema>,
|
||||
z.infer<typeof excelReadCellsOutputSchema>,
|
||||
'excel_read_cells',
|
||||
z.infer<typeof excelReadSchema>,
|
||||
z.infer<typeof excelReadOutputSchema>,
|
||||
'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<typeof excelEditSchema>,
|
||||
z.infer<typeof excelEditOutputSchema>,
|
||||
@@ -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
|
||||
@@ -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<JsonObject>
|
||||
search: (args: {
|
||||
sourcePath: string
|
||||
query: string
|
||||
mode?: 'literal' | 'regex'
|
||||
scope?: { sheetNames?: string[] }
|
||||
}) => Promise<JsonObject>
|
||||
readCells: (args: {
|
||||
sourcePath: string
|
||||
ranges: Array<{ sheetName: string; range: string }>
|
||||
}) => Promise<JsonObject>
|
||||
read: (args: ExcelReadInput) => Promise<JsonObject>
|
||||
edit: (args: ExcelEditInput) => Promise<JsonObject>
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<string> =>
|
||||
createHash('sha256').update(await fs.readFile(filePath)).digest('hex')
|
||||
|
||||
const writeWorkbook = async (filePath: string, build: (workbook: ExcelJS.Workbook) => void): Promise<void> => {
|
||||
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<void> =>
|
||||
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<void> =>
|
||||
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<typeof filterAiRunEventsByType>,
|
||||
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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string> =>
|
||||
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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('excel_read_outline', {
|
||||
const outline = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'workbook',
|
||||
sourcePath
|
||||
})
|
||||
const search = await harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
const search = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'search',
|
||||
sourcePath,
|
||||
query: 'B2*2'
|
||||
})
|
||||
const read = await harness.invoke<Record<string, unknown>>('excel_read_cells', {
|
||||
const read = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'range',
|
||||
sourcePath,
|
||||
ranges: [{ sheetName: 'Data', range: 'A1:C3' }]
|
||||
sheetName: 'Data',
|
||||
range: 'A1:C3'
|
||||
})
|
||||
const createdUpdated = await harness.invoke<Record<string, unknown>>('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<Record<string, unknown>>('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 文件按规则显示红绿边框。',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -66,75 +66,186 @@ describe('ai tool excel edit backend', () => {
|
||||
})
|
||||
|
||||
const harness = createExcelToolHarness(runtime)
|
||||
const firstEdit = await harness.invoke<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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('至少保留一个工作表'),
|
||||
|
||||
@@ -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<Record<string, unknown>>('excel_read_outline', {
|
||||
await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'workbook',
|
||||
sourcePath: path.join(runtime.workspaceRoot, `search.${extension}`)
|
||||
})
|
||||
)
|
||||
)
|
||||
const table = await harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
const table = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'search',
|
||||
sourcePath: searchPath,
|
||||
query: 'RevenueTable'
|
||||
})
|
||||
const column = await harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
const column = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'search',
|
||||
sourcePath: searchPath,
|
||||
query: 'Amount'
|
||||
})
|
||||
const sheet = await harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
const sheet = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'search',
|
||||
sourcePath: searchPath,
|
||||
query: 'Data'
|
||||
})
|
||||
const scopedRegex = await harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
const scopedRegex = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'search',
|
||||
sourcePath: searchPath,
|
||||
query: '^Q[12]$',
|
||||
mode: 'regex',
|
||||
scope: { sheetNames: ['Data'] }
|
||||
sheetName: 'Data'
|
||||
})
|
||||
const read = await harness.invoke<Record<string, unknown>>('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<Record<string, unknown>>('excel_read', {
|
||||
type: 'range',
|
||||
sourcePath: searchPath,
|
||||
sheetName: 'Data',
|
||||
range: '$A$2:B2'
|
||||
})
|
||||
const capped = await harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
const other = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'range',
|
||||
sourcePath: searchPath,
|
||||
sheetName: 'Other',
|
||||
range: 'A1'
|
||||
})
|
||||
const style = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'style',
|
||||
sourcePath: searchPath,
|
||||
sheetName: 'Data',
|
||||
range: 'A1'
|
||||
})
|
||||
const capped = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'search',
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'large.xlsx'),
|
||||
query: 'hit-'
|
||||
})
|
||||
const overflow = await harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'range',
|
||||
sourcePath: searchPath,
|
||||
sheetName: 'Data',
|
||||
range: 'A1:BZ80'
|
||||
})
|
||||
const failures = await Promise.all([
|
||||
harness.invoke<Record<string, unknown>>('excel_read_outline', {
|
||||
harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'workbook',
|
||||
sourcePath: path.join(path.dirname(runtime.workspaceRoot), 'outside.xlsx')
|
||||
}),
|
||||
harness.invoke<Record<string, unknown>>('excel_read_outline', {
|
||||
harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'workbook',
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'data.csv')
|
||||
}),
|
||||
harness.invoke<Record<string, unknown>>('excel_read_outline', {
|
||||
harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'workbook',
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'broken.xlsx')
|
||||
}),
|
||||
harness.invoke<Record<string, unknown>>('excel_read_cells', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
ranges: [{ sheetName: 'Missing', range: 'A1' }]
|
||||
harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'range',
|
||||
sourcePath: searchPath,
|
||||
sheetName: 'Missing',
|
||||
range: 'A1'
|
||||
}),
|
||||
harness.invoke<Record<string, unknown>>('excel_read_cells', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
ranges: [{ sheetName: 'Data', range: 'B2:A1' }]
|
||||
harness.invoke<Record<string, unknown>>('excel_read', {
|
||||
type: 'range',
|
||||
sourcePath: searchPath,
|
||||
sheetName: 'Data',
|
||||
range: 'B2:A1'
|
||||
}),
|
||||
harness.invoke<Record<string, unknown>>('excel_search', {
|
||||
sourcePath: path.join(runtime.workspaceRoot, 'search.xlsx'),
|
||||
harness.invoke<Record<string, unknown>>('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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
{
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
BIN
Binary file not shown.
Reference in new issue
Block a user