feat(sheets): support multi-step undo

This commit is contained in:
zhengzhijie
2026-07-02 20:56:46 +08:00
parent 00927f4ed6
commit 6f7340627f
7 changed files with 127 additions and 20 deletions

View File

@@ -80,6 +80,32 @@
}
]
},
"+revision-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": {
"risk": "write",
"flags": [
@@ -4941,6 +4967,14 @@
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "count",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.",
"default": "1"
},
{
"name": "dry-run",
"kind": "system",
@@ -4949,5 +4983,39 @@
"desc": ""
}
]
},
"+changeset-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "start-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
},
{
"name": "end-revision",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
"default": "-1"
}
]
}
}

View File

@@ -1000,6 +1000,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.", Default: "1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},

View File

@@ -75,14 +75,14 @@ func changesetRevisions(runtime flagView) (start int, end int, err error) {
start = runtime.Int("start-revision")
end = runtime.Int("end-revision")
if start < 1 {
return 0, 0, common.FlagErrorf("--start-revision must be >= 1")
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
}
if end > 0 {
if end < start {
return 0, 0, common.FlagErrorf("--end-revision (%d) must be >= --start-revision (%d)", end, start)
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
}
if end-start+1 > changesetMaxRevGap {
return 0, 0, common.FlagErrorf("version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
}
}
return start, end, nil

View File

@@ -17,21 +17,30 @@ var Undo = common.Shortcut{
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: historyLocatorFlags(),
// History shortcuts keep locator flags hand-written because they share
// revision/revert helpers; keep --count here in sync with sheet-skill-spec.
Flags: append(historyLocatorFlags(),
common.Flag{Name: "count", Type: "int", Default: "1", Desc: "Number of user undo stack entries to undo sequentially (1-20)."},
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if runtime.Int("count") < 1 || runtime.Int("count") > 20 {
return sheetsValidationForFlag("count", "--count must be between 1 and 20")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(token))
return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(token))
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(runtime, token))
if err != nil {
return err
}
@@ -40,8 +49,11 @@ var Undo = common.Shortcut{
},
}
func undoInput(token string) map[string]interface{} {
func undoInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
// Always send count, including the default 1, so dry-run mirrors the exact
// request body sent by Execute.
return map[string]interface{}{
"excel_id": token,
"count": runtime.Int("count"),
}
}

View File

@@ -3,7 +3,10 @@
package sheets
import "testing"
import (
"strings"
"testing"
)
func TestUndo_DryRun(t *testing.T) {
t.Parallel()
@@ -18,14 +21,15 @@ func TestUndo_DryRun(t *testing.T) {
got := decodeToolInput(t, body, "undo_last")
assertInputEquals(t, got, map[string]interface{}{
"excel_id": testToken,
"count": float64(1),
})
}
func TestExecute_Undo(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"undone":1,"op_id":"op-1","top_doc_revision":2,"new_revision":3}`)
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--as", "user"}, stub)
stub := toolOutputStub(testToken, "write", `{"undone":3,"op_ids":["op-3","op-2","op-1"],"results":[{"op_id":"op-3"},{"op_id":"op-2"},{"op_id":"op-1"}]}`)
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "3", "--as", "user"}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
@@ -34,10 +38,23 @@ func TestExecute_Undo(t *testing.T) {
input := decodeToolInput(t, body, "undo_last")
assertInputEquals(t, input, map[string]interface{}{
"excel_id": testToken,
"count": float64(3),
})
data := decodeEnvelopeData(t, out)
if data["undone"].(float64) != 1 || data["op_id"] != "op-1" {
if data["undone"].(float64) != 3 {
t.Fatalf("unexpected output data: %#v", data)
}
}
func TestUndo_ValidateCount(t *testing.T) {
t.Parallel()
_, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "21", "--as", "user"})
if err == nil {
t.Fatal("expected count validation error")
}
if !strings.Contains(err.Error(), "--count must be between 1 and 20") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -65,6 +65,8 @@ metadata:
| 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find``+find``--query` |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `lark-sheets-sheet-structure` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | `lark-sheets-workbook` | `+sheet-list``+workbook-get``+workbook-list` |
| 复核某次AI编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20 | — |
| 取当前文档 revision版本号 | `+revision-get` | `lark-sheets-workbook` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import` |
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all | `lark-sheets-range-operations` | `--type` |
@@ -157,6 +159,7 @@ metadata:
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本、回滚到指定版本,或撤销当前用户最近一次 AI 工具写入。当用户需要查看编辑历史、回滚历史版本、查询回滚状态,或说“撤销我刚才的修改 / undo 上一步 AI 写入”时使用。历史回滚为异步全表恢复;用户维度 undo 只撤当前用户自己的 undo 栈顶。仅针对飞书表格。 |
| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本CS revision之间的 changeset原始变更操作清单用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 |
## 公共 flag 速查

View File

@@ -6,9 +6,9 @@
回滚revert把电子表格的当前内容覆盖回某个历史版本——这是一个**写入 / 不可逆**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。
`+undo` 是另一类撤销:撤销当前 CLI 用户在该电子表格里最近一次由 AI 工具产生、且尚未撤销的写入。每个用户有独立 undo 栈;多人编辑同一篇文档时,只撤当前用户的栈顶,不撤其他用户的操作。它和历史版本回滚不同,不需要选择 `history_version_id`,也不是把整表恢复到某个历史快照。
`+undo` 是另一类撤销:撤销当前 CLI 用户在该电子表格里最近一次或最近多次由 AI 工具产生、且尚未撤销的写入。每个用户有独立 undo 栈;多人编辑同一篇文档时,只撤当前用户自己的 undo 栈,不撤其他用户的操作。它和历史版本回滚不同,不需要选择 `history_version_id`,也不是把整表恢复到某个历史快照。
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果;`+undo` 撤销当前用户最近一次 AI 工具写入。
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果;`+undo` 撤销当前用户最近 AI 工具写入。若只是想拿**当前文档版本号revision**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。
## 使用场景
@@ -19,11 +19,11 @@
| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version` |
| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 |
| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 |
| 撤销自己最近一次 AI 写入 | `+undo` | 只需 spreadsheet 定位;撤当前用户 undo 栈顶;支持普通单元格/结构写入,以及图表、透视表 update 的反向操作 |
| 撤销自己最近 AI 写入 | `+undo` | 只需 spreadsheet 定位;默认撤当前用户 undo 栈顶;可用 `--count` 连续撤销多步;支持普通单元格/结构写入,以及图表、透视表 update 的反向操作 |
典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id``+history-revert-status --transaction-id <transaction_id>` 轮询直到成功或失败。
`+undo` 的典型工作流更短:执行某个 AI 写入工具后,如果用户要求撤销刚才自己的修改,直接运行 `+undo`。返回 `undone=1` 表示已撤销一条当前用户栈顶;返回 `undone=0``reason=undo_stack_empty` 表示当前用户没有可撤销项。
`+undo` 的典型工作流更短:执行某个 AI 写入工具后,如果用户要求撤销刚才自己的修改,直接运行 `+undo`。返回 `undone=1` 表示已撤销一条当前用户栈顶;`--count N` 时会按当前用户栈顶从新到旧连续撤销,返回的 `undone` 是实际撤销数量。返回 `undone=0``reason=undo_stack_empty` 表示当前用户没有可撤销项。
**注意事项(必须了解)**
- **回滚是写入 / 不可逆操作**:会用历史版本内容覆盖当前表格,发起前请确认目标 `history_version_id` 正确。
@@ -32,6 +32,7 @@
- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`XOR不需要子表选择器。
- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version``has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。
- **`+undo` 是用户维度**:只撤当前登录用户的 undo 栈顶;同一文档里其他用户的写入不会被撤销。
- **`+undo --count` 是顺序多步撤销**:从当前用户最新栈项开始逐条撤销,最多 20 步;如果可撤销项不足或中途遇到不可撤销项,会停止并返回实际 `undone` 数量与 `reason`
- **`+undo` 不区分 session / agent**:同一用户的不同 CLI 会话或 agent 共用同一个用户 undo 栈。
- **`+undo` 不进入 `+batch-update`**:撤销本身依赖用户栈状态,不能作为批量子操作嵌套执行。
@@ -74,11 +75,13 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--count` | int | optional | 连续撤销的用户 undo 栈项数量,默认 1最大 20。按当前用户栈顶从新到旧顺序撤销实际撤销数量以返回的 undone 为准。 |
## Examples
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`XOR二选一`+history-revert``--history-version-id`(取自 `+history-list``+history-revert-status``--transaction-id`(取自 `+history-revert` 的异步受理标识)。`+undo` 不需要 sheet-id也不需要 history version。
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`XOR二选一`+history-revert``--history-version-id`(取自 `+history-list``+history-revert-status``--transaction-id`(取自 `+history-revert` 的异步受理标识)。`+undo` 不需要 sheet-id也不需要 history version;连续撤销多步时传 `--count`
### `+history-list`
@@ -113,6 +116,9 @@ lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SH
# 撤销当前用户最近一次 AI 工具写入
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx"
# 连续撤销当前用户最近 3 次 AI 工具写入;实际撤销数量以返回的 undone 为准
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3
# 先预览将调用的底层 undo_last 请求
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --dry-run
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3 --dry-run
```