回查循环原先吞掉全部 queryErr,鉴权失败、MCP 错误与 JSONML 解析失败都 退化成 soft success 返回 whiteboardId: null,Agent 会把硬失败误判成最终 一致性并带着空 partId 继续调用 whiteboard query/update。 - 引入 errWhiteboardBlockPending sentinel,只有「块暂不可见」允许重试; 其余错误立即返回,并把已插入的 blockId 带进错误消息供复原 - queryWhiteboardCardNode 严格校验 blocks 字段(缺失 / 非数组均为协议 错误),避免畸形响应伪装成「块暂不可见」 - --ref-block 与 --parent-block、--where 与 --parent-block 显式互斥, 锚点组装改用 else if 让单一定位分支在代码上自证 - 补全 mono / multi 两份 recipes.md 被截断的开篇句 - doc 根命令的命令结构清单补上 whiteboard insert 与 media upload/download - 新增 3 个回归测试覆盖 fail-closed、soft success 与锚点互斥
285 lines
10 KiB
Go
285 lines
10 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
||
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
)
|
||
|
||
const (
|
||
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
|
||
whiteboardDefaultHeight = 600
|
||
)
|
||
|
||
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
|
||
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
|
||
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
|
||
// 继续调用 whiteboard query/update。
|
||
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
|
||
|
||
var (
|
||
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
|
||
whiteboardSleep = time.Sleep
|
||
whiteboardJSONMarshal = json.Marshal
|
||
prepareWhiteboardCard = prepareJsonMLNode
|
||
)
|
||
|
||
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
|
||
node := []any{
|
||
"card",
|
||
map[string]any{
|
||
"uuid": blockUUID,
|
||
"cardType": "hetu",
|
||
"height": whiteboardDefaultHeight,
|
||
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
|
||
},
|
||
[]any{"span", map[string]any{"data-type": "text"},
|
||
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
|
||
}
|
||
out, err := whiteboardJSONMarshal(node)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
func extractWhiteboardID(attrs map[string]any) string {
|
||
meta, _ := attrs["metadata"].(map[string]any)
|
||
if meta == nil {
|
||
return ""
|
||
}
|
||
id, _ := meta["id"].(string)
|
||
return id
|
||
}
|
||
|
||
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
|
||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||
"nodeId": nodeID,
|
||
"blockId": blockID,
|
||
"format": "jsonml",
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var data map[string]any
|
||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
|
||
}
|
||
if result, ok := data["result"].(map[string]any); ok {
|
||
data = result
|
||
}
|
||
blocksField, ok := data["blocks"]
|
||
if !ok {
|
||
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
|
||
}
|
||
blocks, ok := blocksField.([]any)
|
||
if !ok {
|
||
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
|
||
}
|
||
var raw string
|
||
for _, block := range blocks {
|
||
entry, _ := block.(map[string]any)
|
||
if entry == nil || entry["blockId"] != blockID {
|
||
continue
|
||
}
|
||
raw, _ = entry["jsonml"].(string)
|
||
break
|
||
}
|
||
if raw == "" {
|
||
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
|
||
}
|
||
var node []any
|
||
if err := json.Unmarshal([]byte(raw), &node); err != nil {
|
||
return nil, fmt.Errorf("parse block jsonml: %w", err)
|
||
}
|
||
return node, nil
|
||
}
|
||
|
||
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
|
||
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(node) < 2 {
|
||
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
|
||
}
|
||
attrs, _ := node[1].(map[string]any)
|
||
if attrs == nil {
|
||
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
|
||
}
|
||
return attrs, nil
|
||
}
|
||
|
||
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
blockUUID := uuid.New().String()
|
||
whiteboardID := uuid.New().String()
|
||
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
|
||
normalized, err := prepareWhiteboardCard(cmd, element)
|
||
if err != nil {
|
||
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
|
||
}
|
||
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"jsonml": normalized,
|
||
"format": "jsonml",
|
||
}
|
||
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
|
||
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
|
||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||
toolArgs["referenceBlockId"] = v
|
||
where, _ := cmd.Flags().GetString("where")
|
||
if where == "" {
|
||
where = "after"
|
||
}
|
||
toolArgs["where"] = where
|
||
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
|
||
toolArgs["referenceBlockId"] = v
|
||
}
|
||
if cmd.Flags().Changed("index") {
|
||
index, _ := cmd.Flags().GetInt("index")
|
||
toolArgs["index"] = index
|
||
}
|
||
|
||
if deps.Caller.DryRun() {
|
||
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
|
||
}
|
||
|
||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
|
||
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||
ctx := cmd.Context()
|
||
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
|
||
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
|
||
return err
|
||
}
|
||
|
||
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
|
||
persistedID := ""
|
||
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
|
||
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
|
||
switch {
|
||
case queryErr == nil:
|
||
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
|
||
persistedID = extractWhiteboardID(attrs)
|
||
case errors.Is(queryErr, errWhiteboardBlockPending):
|
||
// 块暂不可见,属于最终一致性,继续重试。
|
||
default:
|
||
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
|
||
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
|
||
return fmt.Errorf(
|
||
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
|
||
blockUUID, queryErr)
|
||
}
|
||
if persistedID != "" {
|
||
break
|
||
}
|
||
if attempt < len(whiteboardRetryDelays) {
|
||
whiteboardSleep(whiteboardRetryDelays[attempt])
|
||
}
|
||
}
|
||
|
||
result := map[string]any{"blockId": blockUUID}
|
||
if persistedID == "" {
|
||
result["whiteboardId"] = nil
|
||
deps.Out.PrintWarning(fmt.Sprintf(
|
||
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
|
||
nodeID, blockUUID))
|
||
} else {
|
||
result["whiteboardId"] = persistedID
|
||
}
|
||
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
|
||
}
|
||
|
||
func newDocWhiteboardCommand() *cobra.Command {
|
||
root := &cobra.Command{
|
||
Use: "whiteboard",
|
||
Short: "白板卡片管理",
|
||
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
insertCmd := &cobra.Command{
|
||
Use: "insert",
|
||
Short: "插入白板卡片",
|
||
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
|
||
|
||
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
|
||
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
|
||
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
|
||
|
||
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
|
||
不能同时使用。`,
|
||
Example: ` dws doc whiteboard insert --node DOC_ID
|
||
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
|
||
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
|
||
RunE: runWhiteboardInsert,
|
||
}
|
||
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
|
||
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
|
||
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
|
||
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
|
||
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
|
||
|
||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
|
||
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
|
||
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
|
||
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
|
||
|
||
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||
insertCmd.Flags().String(name, "", "--node 的兼容别名")
|
||
_ = insertCmd.Flags().MarkHidden(name)
|
||
}
|
||
|
||
DeclareLeafMetadata(insertCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "whiteboard_insert",
|
||
CanonicalPath: "doc.whiteboard_insert",
|
||
CLIPath: "doc whiteboard insert",
|
||
PrimaryCLIPath: "doc whiteboard insert",
|
||
},
|
||
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
|
||
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
|
||
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||
},
|
||
},
|
||
})
|
||
|
||
root.AddCommand(insertCmd)
|
||
return root
|
||
}
|