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:
Yoan Huang committed 2026-08-30 12:16:27 +08:00
1 parent 038e195513
commit ae4cd1c56a
30 files changed
+1584 -1111

No files matched your search

+2 -1
View File
@@ -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 | 普通文本读写搜索、命令执行、网页抓取与内置浏览器操作 |
+4 -4
View File
@@ -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
View File
@@ -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`
+1 -1
View File
@@ -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 }) => {
+1 -1
View File
@@ -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
+20 -37
View File
@@ -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()
+334 -251
View File
@@ -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 => ({
+187 -136
View File
@@ -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')
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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'),
{
+6 -10
View File
@@ -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',
Binary file not shown.