Compare commits

..
Author SHA1 Message Date
chichuan 95a5cc42ce Merge pull request #879 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.2
docs: seal v1.0.57-beta.2 changelog
2026-08-05 19:36:25 +08:00
chichuan fec750b09e docs: remove duplicate beta.2 changelog entry 2026-08-05 19:26:27 +08:00
chichuan ddd5f15b91 docs: seal v1.0.57-beta.2 changelog 2026-08-05 19:19:49 +08:00
github-actions[bot] db50be868b Merge pull request #876 from DingTalk-Real-AI/codex/restore-chat-im-compat
fix(chat): restore stable send and history compatibility
2026-08-05 19:13:50 +08:00
Dennis a6220d7d8b fix(chat): preserve migration hints with legacy flags 2026-08-05 18:19:16 +08:00
Dennis 81bf0d2a6b test(coverage): stabilize drive worker cancellation branch 2026-08-05 18:05:54 +08:00
Dennis f3a95d34a3 fix(chat): restore stable send and history compatibility 2026-08-05 17:32:12 +08:00
chichuan a37e6e6847 Merge pull request #875 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.1
docs: seal v1.0.57-beta.1 changelog
2026-08-05 16:51:42 +08:00
chichuan 0ceb96c745 docs: seal v1.0.57-beta.1 changelog 2026-08-05 16:47:10 +08:00
github-actions[bot] 114503d52f Merge pull request #872 from lifeihong/feat/addUpdateUserOwnessV2A84934011
feat(contact): add update-ownness command for user personal status
2026-08-05 08:36:50 +00:00
chichuan 9de1c9c304 Merge branch 'main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:25:02 +08:00
github-actions[bot] 840e1d665f Merge pull request #861 from DingTalk-Real-AI/codex/sync-wukong-whiteboard
feat: add document whiteboard workflows
2026-08-05 16:17:14 +08:00
昕卉 f362c8c2a4 Merge remote-tracking branch 'upstream/main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:06:52 +08:00
chichuan e73a1556ce Merge latest main into codex/sync-wukong-whiteboard
冲突仅在 skills/mono/SKILL.md 的意图路由表,两侧改动正交,均保留:
- 本分支新增的 whiteboard 路由行
- main 把 event 行拆成 `event +listen-im` / `event consume` 的新表述
(下文「优先由一个 dws event +listen-im 进程表达目标」已是 main 版本,
保留旧 event 行会自相矛盾)
2026-08-05 15:59:17 +08:00
昕卉 186f2fa474 test(contact): add update-ownness tests and align confirmation with framework gate 2026-08-05 15:39:22 +08:00
github-actions[bot] d91a93c43b Merge pull request #860 from DingTalk-Real-AI/codex/multi-im-optimization
feat(im): harden Multi IM and publish complete Chat Schema
2026-08-05 15:28:29 +08:00
chichuan 08254e2a36 fix(whiteboard): fail closed when insert verification query fails
回查循环原先吞掉全部 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 与锚点互斥
2026-08-05 15:14:27 +08:00
昕卉 fdd9e189d6 add update ownness 2026-08-05 13:32:23 +08:00
chichuan eebdf52da9 fix: classify local whiteboard example precondition 2026-08-05 12:28:46 +08:00
chichuan 9eaee76a51 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-05 11:51:35 +08:00
chichuan d7c28bcfef fix: complete whiteboard skill examples 2026-08-04 21:19:45 +08:00
chichuan 287b079c18 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 20:12:37 +08:00
chichuan 50f8ade1d7 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 17:36:59 +08:00
chichuan b87cad1eb5 docs: fix whiteboard protocol table 2026-08-04 17:14:11 +08:00
chichuan 0f2eec145e docs: add OpenNodes V1 whiteboard protocol 2026-08-04 17:10:15 +08:00
chichuan 64c2e8544c test: complete whiteboard branch coverage 2026-08-04 14:11:29 +08:00
chichuan fc31fddd73 test: cover whiteboard error paths 2026-08-04 13:51:37 +08:00
chichuan 4298d0833b test: refresh CLI interface baseline 2026-08-04 11:45:05 +08:00
chichuan 7e0957d9e8 feat: add document whiteboard workflows 2026-08-04 11:25:31 +08:00
53 changed files with 6592 additions and 107 deletions
+64
View File
@@ -6,6 +6,70 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
### Changed
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
+33
View File
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
+9 -18
View File
@@ -30,38 +30,29 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode string
type AgentExampleMode = contract.ExampleDispositionMode
const (
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode string
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
const (
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
+1
View File
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
return selection
}
})
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
}}
return selection
}
})
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
+4
View File
@@ -332,6 +332,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
reviewed := true
selection.Reviewed = &reviewed
}
// Example dispositions control only the policy gate's execution eligibility.
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
+48
View File
@@ -200,6 +200,10 @@ type SelectionSpec struct {
Tips []string
WorkflowRefs []string
Examples []string
// ExampleDispositions narrows an exact example with a reviewed local or
// stateful precondition from dry-run execution to contract validation.
// It does not change the command's declared DryRun capability.
ExampleDispositions []ExampleDisposition
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
// declaration path must not set it: declared selection is final by
// construction, and assembly rejects a declared payload carrying it.
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
out.Tips = stableUniqueStrings(s.Tips)
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
out.Examples = stableUniqueStrings(s.Examples)
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
return out
}
// ExampleDispositionMode controls how an already contract-validated example
// is exercised by the Agent example gate.
type ExampleDispositionMode string
const (
ExampleDispositionModeContract ExampleDispositionMode = "contract"
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
)
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
// contract-only exceptions to an explicit dry-run capability.
type ExampleDispositionReasonCode string
const (
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
)
// ExampleDisposition narrows one exact example to contract-only validation.
// Index is a pointer so a missing index cannot silently select example zero.
type ExampleDisposition struct {
Index *int `json:"index"`
Mode ExampleDispositionMode `json:"mode"`
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
if len(in) == 0 {
return nil
}
out := append([]ExampleDisposition(nil), in...)
for i := range out {
if out[i].Index != nil {
index := *out[i].Index
out[i].Index = &index
}
}
return out
}
// ParamDecl is one parameter-level Schema fact declared on a command. It is
// stored at DeclareLeafMetadata time and applied as annotations at assembly
// time, when all flags are guaranteed to exist on the fully-built command tree.
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
}
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
exampleIndex := 0
normalized := (SelectionSpec{
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
ExampleDispositions: []ExampleDisposition{{
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
}},
SourceRefs: []string{"b", "a", "b"},
}).Normalized()
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
}
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
}
exampleIndex = 1
if *normalized.ExampleDispositions[0].Index != 0 {
t.Fatal("ExampleDispositions index was not cloned")
}
if got := cloneExampleDispositions(nil); got != nil {
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
}
if got := stableUniqueStrings(nil); got != nil {
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
}
+14
View File
@@ -54,6 +54,14 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
}
}
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
command := hintSubCmd(use, hint)
// Legacy callers may still pass the old command's flags. Let the migration
// command consume them so Cobra reaches RunE and returns the replacement path.
command.DisableFlagParsing = true
return command
}
type nativeChatTargetReader struct{}
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
@@ -8166,5 +8174,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
// compatibility hints available through both chat and im.
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
+21 -12
View File
@@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
})
}
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
root := newChatCommand()
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
}
for _, tc := range []struct {
path string
flag string
args []string
hint string
}{
{path: "send", flag: "--group"},
{path: "history", flag: "--group"},
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
t.Fatalf("find chat %s: %v", tc.path, err)
}
if strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
if len(remaining) != 0 || command.Name() != tc.path {
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
}
if caller.calls != 0 {
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
if !command.Hidden || !command.Runnable() {
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
}
}
}
+80 -6
View File
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
return cmd
}
func newContactUserUpdateOwnnessCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-ownness",
Aliases: []string{"set-ownness"},
Short: "更新用户个人状态",
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
return err
}
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
if userID == "" {
return fmt.Errorf("--user-id 不能为空")
}
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
return err
}
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
if ownnessText == "" {
return fmt.Errorf("--ownness-text 不能为空")
}
return callMCPTool("user_ownness_update", map[string]any{
"userId": userID,
"ownnessText": ownnessText,
})
},
}
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
cmd.Flags().String("id", "", "--user-id 的别名")
cmd.Flags().String("userid", "", "--user-id 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("userid")
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
return cmd
}
func newContactAccountUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
通讯录功能:
- contact user get-self/search/search-mobile/get: 通讯录用户查询
- contact user invite/update/update-self: 邀请与更新员工
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
- contact relation list-my-followings: 特别关注人查询
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的部门、主管、管理员权限 → contact user get
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
- 邀请员工加入企业 → contact user invite
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
},
},
})
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "contact",
Name: "user_ownness_update",
CanonicalPath: "contact.user_ownness_update",
CLIPath: "contact user update-ownness",
PrimaryCLIPath: "contact user update-ownness",
},
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "userId", Required: boolPtr(false)},
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
{Name: "userid", Property: "userId", Required: boolPtr(false)},
},
},
})
// ── flags 注册 ───────────────────────────────────────────────
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
_ = contactUserGetCmd.Flags().MarkHidden("userid")
userCmd.AddCommand(
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserUpdateOwnnessCmd, // 更新用户个人状态
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
)
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
}
for _, tc := range cases {
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
toolName: "self_user_profile_update",
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
},
{
name: "update user ownness",
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
},
{
name: "update user ownness with aliases",
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
},
{
name: "update enterprise account",
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
{"dept", "update", "--dept", "7", "--name", "研发中心"},
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
{"user", "update-self", "--nick", "新昵称"},
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
}
for _, args := range tests {
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
+53 -3
View File
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
dws doc create 创建文档
dws doc update 更新文档内容
dws doc block [list|insert|update|delete] 块级编辑
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
mediaUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传可复用的文档媒体资源",
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
等后续写入使用;临时 uploadUrl 不会输出。`,
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
RunE: runDocMediaUpload,
}
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "media_upload",
CanonicalPath: "doc.media_upload",
CLIPath: "doc media upload",
PrimaryCLIPath: "doc media upload",
},
Description: "上传可复用的文档媒体资源",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "file", Required: boolPtr(true)},
},
},
})
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
mediaInsertCmd := &cobra.Command{
Use: "insert",
Short: "上传附件并插入文档",
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
// media 子命令的 --node 隐藏别名
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
for _, c := range mediaNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
_ = c.Flags().MarkHidden("file-id")
}
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
@@ -4227,7 +4277,7 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
return root
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName, _ := cmd.Flags().GetString("name")
if fileName == "" {
fileName = filepath.Base(filePath)
} else if filepath.Ext(fileName) == "" {
fileName += filepath.Ext(filePath)
}
mimeType, _ := cmd.Flags().GetString("mime-type")
if mimeType == "" {
mimeType = inferMimeType(fileName)
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
if err != nil {
return err
}
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
if err != nil {
return err
}
if resourceURL == "" {
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
}
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
return fmt.Errorf("document media upload failed: %s", message)
}
return deps.Out.PrintJSON(map[string]any{
"nodeId": nodeID,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"mimeType": mimeType,
"size": fileInfo.Size(),
})
}
+284
View File
@@ -0,0 +1,284 @@
// 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
}
+3 -1
View File
@@ -66,6 +66,8 @@ var (
driveFileStat = (*os.File).Stat
)
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
go func() {
defer wg.Done()
for part := range jobs {
if runCtx.Err() != nil {
if driveWorkerContextErr(runCtx) != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
+25 -39
View File
@@ -13,6 +13,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
totalSize := int64(200)
content := makeTestContent(int(totalSize))
origClient := driveRangeClient
t.Cleanup(func() { driveRangeClient = origClient })
driveRangeClient = &http.Client{
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
var checks atomic.Int32
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
checks.Add(1)
return context.Canceled
})
var requests atomic.Int32
testseam.Swap(t, &driveRangeClient, &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// 每次请求加小延迟,让总处理时间接近 deadline
time.Sleep(50 * time.Microsecond)
var start, end int64
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
}
if end >= int64(len(content)) {
end = int64(len(content)) - 1
}
resp := &http.Response{
StatusCode: http.StatusPartialContent,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
}
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
return resp, nil
requests.Add(1)
return nil, errors.New("worker context guard did not stop the request")
}),
})
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
t.Fatalf("downloadRangedParts context guard: %v", err)
}
// 多次尝试以确保覆盖(goroutine 调度非确定性)
for attempt := 0; attempt < 50; attempt++ {
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
cancel()
if checks.Load() != 1 {
t.Fatalf("worker context checks = %d, want 1", checks.Load())
}
if requests.Load() != 0 {
t.Fatalf("worker requests = %d, want 0", requests.Load())
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
})
}
+352
View File
@@ -0,0 +1,352 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardServerID = "whiteboard"
whiteboardQueryTool = "read_whiteboard_content"
whiteboardUpdateTool = "update_whiteboard"
)
type whiteboardUpdateFile struct {
Overwrite bool `json:"overwrite"`
Source *whiteboardOpenSource `json:"source"`
}
type whiteboardOpenSource struct {
SchemaVersion string `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Nodes json.RawMessage `json:"nodes"`
}
var compactWhiteboardJSON = json.Compact
func newWhiteboardCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "whiteboard",
Selection: contract.ProductSelectionDecl{
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
queryCmd := &cobra.Command{
Use: "query",
Short: "读取白板内容",
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
return err
}
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
})
},
}
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
DeclareLeafMetadata(queryCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "query",
CanonicalPath: "whiteboard.query",
CLIPath: "whiteboard query",
PrimaryCLIPath: "whiteboard query",
},
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
},
},
})
updateCmd := &cobra.Command{
Use: "update",
Short: "追加或整页重建白板内容",
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
return err
}
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
if err != nil {
return err
}
mode := "append"
if input.Overwrite {
mode = "overwrite"
}
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
"mode": mode,
"nodes": nodesJSON,
})
},
}
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
updateExampleIndex := 0
DeclareLeafMetadata(updateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "update",
CanonicalPath: "whiteboard.update",
CLIPath: "whiteboard update",
PrimaryCLIPath: "whiteboard update",
},
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
ExampleDispositions: []contract.ExampleDisposition{{
Index: &updateExampleIndex,
Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonLocalState,
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
Reviewed: true,
}},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
{Name: "source", Required: boolPtr(true)},
},
},
})
root.AddCommand(queryCmd, updateCmd)
return root
}
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
for _, name := range []string{"jq", "fields"} {
flag := cmd.Flags().Lookup(name)
if flag == nil {
flag = cmd.InheritedFlags().Lookup(name)
}
if flag != nil && flag.Changed {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
Suggestion: "直接读取命令返回的结构化 JSON",
}
}
}
return nil
}
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
data, err := os.ReadFile(path)
if err != nil {
code := CodeInvalidPath
if os.IsNotExist(err) {
code = CodeFileNotFound
}
return nil, "", &CLIError{
Code: code,
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
Cause: err,
}
}
var input whiteboardUpdateFile
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if input.Source == nil {
return nil, "", invalidWhiteboardSourceParam("source is required")
}
if input.Source.SchemaVersion != "1.0" {
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
}
if input.Source.CatalogVersion != "dml-v1" {
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
}
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
if err != nil {
return nil, "", err
}
if !input.Overwrite && nodeCount == 0 {
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
}
return &input, nodesJSON, nil
}
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
} else if !errors.Is(err, io.EOF) {
return err
}
return nil
}
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
}
var nodes []json.RawMessage
if err := json.Unmarshal(raw, &nodes); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
for i, node := range nodes {
var object map[string]any
if err := json.Unmarshal(node, &object); err != nil || object == nil {
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
}
}
var compact bytes.Buffer
if err := compactWhiteboardJSON(&compact, raw); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
return compact.String(), len(nodes), nil
}
func invalidWhiteboardSourceJSON(err error) error {
return &CLIError{
Code: CodeInvalidJSON,
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
Cause: err,
}
}
func invalidWhiteboardSourceParam(message string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: message,
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
}
}
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer(whiteboardServerID, toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
if err != nil {
return err
}
if text == "" {
return nil
}
var response map[string]any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&response); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if response == nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
}
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
var result any
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
resultDecoder.UseNumber()
if err := resultDecoder.Decode(&result); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
response["resultJson"] = result
}
return deps.Out.PrintJSON(response)
}
func invalidWhiteboardToolResult(toolName string, err error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: "白板服务返回了无法解析的 JSON",
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
Operation: whiteboardServerID + "/" + toolName,
Cause: err,
}
}
@@ -0,0 +1,310 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
previousMarshal := whiteboardJSONMarshal
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
t.Fatalf("got %q", got)
}
whiteboardJSONMarshal = previousMarshal
previousPrepare := prepareWhiteboardCard
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
caller := &whiteboardTestCaller{}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
t.Fatalf("err=%v", err)
}
previousCompact := compactWhiteboardJSON
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
t.Fatal("expected compact error")
}
}
func TestDocWhiteboardInsertDryRun(t *testing.T) {
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
}
func writeWhiteboardFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
tests := []struct {
name string
content string
}{
{name: "invalid json", content: `{`},
{name: "trailing value", content: `{}` + ` {}`},
{name: "missing source", content: `{}`},
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "unknown field", content: `{"unknown":true}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing-file error")
}
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
t.Fatal("expected directory read error")
}
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
t.Fatal("expected malformed nodes array error")
}
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
if err != nil || !input.Overwrite || nodes != "[]" {
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
}
}
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
for _, name := range []string{"jq", "fields"} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(name, "", "")
if err := cmd.Flags().Set(name, ".result"); err != nil {
t.Fatal(err)
}
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
t.Fatal("expected rejected output filter")
}
})
}
responses := []string{
`{`,
`{} {}`,
`null`,
`{"resultJson":"{"}`,
`{"resultJson":"{} {}"}`,
}
for _, response := range responses {
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
t.Fatalf("response %q should fail", response)
}
}
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
t.Fatal(err)
}
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
t.Fatal(err)
}
}
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
tests := []struct {
name string
response string
attrs bool
}{
{name: "invalid response", response: `{`},
{name: "missing block", response: `{"blocks":[]}`},
{name: "non object block", response: `{"blocks":[1]}`},
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
installWhiteboardTestCaller(t, caller)
var err error
if test.attrs {
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
} else {
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
}
if err == nil {
t.Fatal("expected query validation error")
}
})
}
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
installWhiteboardTestCaller(t, caller)
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected caller error")
}
}
func TestWhiteboardCommandValidationBranches(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
for _, args := range [][]string{
{"query", "--node", "n"},
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
{"update", "--node", "n", "--part-id", "p"},
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
} {
cmd := newWhiteboardCommand()
cmd.PersistentFlags().String("jq", "", "")
cmd.PersistentFlags().String("fields", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["mode"] != "overwrite" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
if err := cmd.Execute(); err == nil {
t.Fatal("expected source error")
}
}
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
}}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
file := writeWhiteboardFixture(t, "svg")
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
for _, args := range [][]string{
{"media", "upload", "--node", "n"},
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
{"media", "upload", "--node", "n", "--file", t.TempDir()},
} {
cmd = newDocCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestDocMediaUploadRemainingBranches(t *testing.T) {
file := writeWhiteboardFixture(t, "svg")
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *whiteboardTestCaller
response string
}{
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
}}},
} {
t.Run(test.name, func(t *testing.T) {
installWhiteboardTestCaller(t, test.caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected upload error")
}
})
}
}
func TestDocWhiteboardInsertCallerError(t *testing.T) {
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
if index == 0 {
return errors.New("insert")
}
return nil
}}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected insert error")
}
}
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
if got := extractWhiteboardID(nil); got != "" {
t.Fatalf("got %q", got)
}
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
t.Fatalf("got %q", got)
}
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
var value any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
t.Fatal("expected trailing token error")
}
}
+411
View File
@@ -0,0 +1,411 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type whiteboardTestCall struct {
server string
tool string
args map[string]any
}
type whiteboardTestCaller struct {
dry bool
format string
err func(whiteboardTestCall, int) error
response func(whiteboardTestCall, int) string
calls []whiteboardTestCall
}
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
call := whiteboardTestCall{server: server, tool: tool, args: args}
c.calls = append(c.calls, call)
if c.err != nil {
if err := c.err(call, len(c.calls)-1); err != nil {
return nil, err
}
}
text := `{}`
if c.response != nil {
text = c.response(call, len(c.calls)-1)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *whiteboardTestCaller) Format() string { return c.format }
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
func (*whiteboardTestCaller) Fields() string { return "" }
func (*whiteboardTestCaller) JQ() string { return "" }
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
output := &bytes.Buffer{}
deps.Out.w = output
deps.Out.errW = &bytes.Buffer{}
return output
}
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
},
}
output := installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
t.Fatalf("args = %#v", caller.calls[0].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if _, ok := payload["resultJson"].(map[string]any); !ok {
t.Fatalf("resultJson was not decoded: %#v", payload)
}
}
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
t.Fatalf("err = %v, want cancellation", err)
}
if len(caller.calls) != 0 {
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
t.Fatalf("args = %#v", caller.calls[0].args)
}
}
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
var blockID string
caller := &whiteboardTestCaller{
format: "json",
response: func(call whiteboardTestCall, index int) string {
if index == 0 {
var node []any
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
attrs := node[1].(map[string]any)
blockID = attrs["uuid"].(string)
return `{}`
}
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
encoded, _ := json.Marshal(jsonml)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
}
output := installWhiteboardTestCaller(t, caller)
previousDelays := whiteboardRetryDelays
whiteboardRetryDelays = nil
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
t.Fatalf("unexpected servers: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
if result["whiteboardId"] != "part-real" {
t.Fatalf("output = %#v", payload)
}
}
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
// 让回查桩可以用真实块 ID 组装响应。
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
t.Helper()
raw, _ := call.args["jsonml"].(string)
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
if len(node) < 2 {
t.Fatalf("jsonml node missing attrs: %q", raw)
}
attrs, _ := node[1].(map[string]any)
id, _ := attrs["uuid"].(string)
if id == "" {
t.Fatalf("jsonml node missing uuid: %q", raw)
}
return id
}
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
t.Helper()
previousDelays := whiteboardRetryDelays
previousSleep := whiteboardSleep
stub := make([]time.Duration, delays)
for i := range stub {
stub[i] = time.Millisecond
}
slept := 0
whiteboardRetryDelays = stub
whiteboardSleep = func(time.Duration) { slept++ }
t.Cleanup(func() {
whiteboardRetryDelays = previousDelays
whiteboardSleep = previousSleep
})
return func() int { return slept }
}
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
// 继续带着空 partId 调用 whiteboard query/update。
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
tests := []struct {
name string
queryErr error
queryBody func(blockID string) string
}{
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
{
name: "response missing blocks field",
queryBody: func(string) string { return `{"success":true}` },
},
{
name: "blocks field is not an array",
queryBody: func(string) string { return `{"blocks":{}}` },
},
{
name: "block jsonml unparsable",
queryBody: func(blockID string) string {
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
},
},
{
name: "card node without attrs",
queryBody: func(blockID string) string {
encoded, _ := json.Marshal(`[]`)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
blockID := ""
caller := &whiteboardTestCaller{format: "json"}
caller.response = func(call whiteboardTestCall, index int) string {
if index == 0 {
blockID = whiteboardCardBlockID(t, call)
return `{}`
}
if test.queryBody == nil {
return `{}`
}
return test.queryBody(blockID)
}
if test.queryErr != nil {
caller.err = func(_ whiteboardTestCall, index int) error {
if index == 0 {
return nil
}
return test.queryErr
}
}
installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
t.Fatalf("err = %v, want fail-closed verification error", err)
}
if !strings.Contains(err.Error(), blockID) {
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
}
if len(caller.calls) != 2 || slept() != 0 {
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
len(caller.calls), slept())
}
})
}
}
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
// whiteboardId 为 null。
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(_ whiteboardTestCall, index int) string {
if index == 0 {
return `{}`
}
return `{"blocks":[]}`
},
}
output := installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("block-not-visible must stay a soft success: %v", err)
}
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
if len(caller.calls) != 4 || slept() != 2 {
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
whiteboardID, present := result["whiteboardId"]
if payload["success"] != true || !present || whiteboardID != nil {
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
}
if result["blockId"] == "" || result["blockId"] == nil {
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
}
}
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{
name: "ref-block with parent-block",
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
},
{
name: "where with parent-block",
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(test.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
}
if len(caller.calls) != 0 {
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
}
})
}
}
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
},
}
output := installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
t.Fatalf("output = %#v", payload)
}
}
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
uploadURL := "https://upload.example.test/secret-token"
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
},
}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
return fmt.Errorf("PUT %s: connection reset", uploadURL)
}
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
err := cmd.Execute()
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
t.Fatalf("err = %v, want redacted temporary upload URL", err)
}
}
+5
View File
@@ -55,6 +55,11 @@ func openSupplementServers() []ServerInfo {
Name: "MCP 元服务",
Endpoint: "https://mcp-gw.dingtalk.com/server/89833ea5debf30c260a07ffcb5127ffa3bf0c830cd76babadb293d9861485d44",
},
{
ID: "whiteboard",
Name: "钉钉白板",
Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard",
},
}
}
+12 -2
View File
@@ -105,17 +105,27 @@ func TestOpenVisibleProductsExcludesCompatibilityOnlyCommands(t *testing.T) {
func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
servers := openSupplementServers()
foundMCPMeta := false
foundWhiteboard := false
for _, server := range servers {
if server.ID == "whiteboard" {
foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard"
}
if server.ID != "mcp-meta" {
continue
}
foundMCPMeta = true
if server.Endpoint == "" {
t.Fatal("mcp-meta has empty endpoint")
}
if len(server.Prefixes) != 0 {
t.Fatal("mcp-meta must remain helper-only without command prefixes")
}
return
}
t.Fatal("openSupplementServers() missing mcp-meta")
if !foundMCPMeta {
t.Fatal("openSupplementServers() missing mcp-meta")
}
if !foundWhiteboard {
t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint")
}
}
+2
View File
@@ -94,6 +94,7 @@ cli_version: ">=1.0.15"
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -120,6 +121,7 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume <event_key> [event_key...] --flatten`。
+13
View File
@@ -1071,6 +1071,19 @@ EOF
- `comment create` 是全文评论;`comment create-inline` 是划词评论,必须先 `block list` 拿到 `blockId` 并确定 `--start` / `--end` 偏移(按块内纯文本字符算,从 0 开始)
- 全文评论 `create` / `reply` / `update` 支持通过 `--mentioned-open-conversation-id` @群;划词评论 `create-inline` 不支持 @群
## 白板卡片与白板媒体资源
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
两个命令都是远端写入,必须先获得用户确认。insert 返回的 `whiteboardId` 是
`dws whiteboard query/update` 使用的 partId;`blockId` 只用于文档块定位/删除。
media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板
Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。
## 自动化脚本
| 脚本 | 场景 | 用法 |
@@ -0,0 +1,78 @@
# 钉钉文档内嵌白板
`dws whiteboard` 读取和更新已存在于在线文档中的单页白板。创建白板卡片使用
`dws doc whiteboard insert`;删除卡片使用已有的 `dws doc block delete`。
OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需读取
[协议索引](./whiteboard/open-nodes-v1.md);不要根据本页概要猜测节点字段或
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
## 标准流程
1. 从用户输入或真实文档 JSONML 取得 `nodeId` 和 card `metadata.id`(partId)。
2. `dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json` 保存当前内容。
3. 生成 OpenNodes V1 文件;不能把 query 响应直接回写。
4. 向用户展示写入范围并取得确认。
5. `dws whiteboard update --node <DOC_ID> --part-id <PART_ID> --source <FILE> --yes --format json`。
6. 再次 query 验证节点、层级和连接关系。
更新文件:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "n1",
"type": "text",
"x": 40,
"y": 40,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{"text": "方案"}]
}
]
}
}
]
}
}
```
- append (`overwrite=false`) 至少包含一个节点。
- overwrite (`true`) 整页重建并允许空数组;必须先备份当前 query 结果。
- 所有 update 都要求用户确认和 `--yes`。
- 当前只支持单页,不支持 `pageId`,也不支持使用真实节点 ID 做局部更新。
- `--jq` / `--fields` 不适用于白板命令。
## 创建白板
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
```
返回的 `whiteboardId` 是 partId,`blockId` 是文档块 ID。删除卡片走:
```bash
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes --format json
```
## Vector / SVG
先上传绑定到同一 nodeId 的媒体资源:
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
只使用稳定输出 `resourceId` / `resourceUrl`,不要使用临时 uploadUrl、本地路径或
跨 nodeId 资源。
@@ -0,0 +1,45 @@
# OpenNodes V1(DWS 白板协议索引)
本目录承载 `dws whiteboard query/update` 使用的 OpenNodes V1 协议。协议按
调用阶段拆分,Agent 只加载当前任务所需章节,避免一次性读取全部内容。
## DWS 使用规则
- 只通过 `dws whiteboard query/update` 读写白板。
- 每次调用必须提供承载白板的文档 `--node` 和目标白板 `--part-id`。
- DWS 当前只支持单页白板:命令没有 `--page-id`,update 文件禁止包含
`pageId`。
- `query` 不接收请求体;CLI 会把返回的 `resultJson` 解析成对象。
- 白板命令不支持使用全局 `--jq` 或 `--fields` 过滤输出;传入任一参数都会报错,
Agent 直接读取 CLI 返回的结构化 JSON。
- `update --source` 使用 `overwrite + source` 信封;append 和 overwrite 都是
远端写入,获得用户确认后必须通过 `--yes` 显式确认。
- CLI 只预检 JSON、信封、版本和 `nodes` 数组等外层结构;节点字段、枚举、层级、
引用和业务约束由白板服务完整校验。任一层失败都不会保留部分更新。
- DWS 返回以 `success`、`nodeId`、`partId`、`resultJson` 和可选的
`resultSummary` 为准。
## 按任务读取
| 当前任务 | 必读章节 |
|---|---|
| 理解版本、兼容和命令语义 | [01-overview](open-nodes-v1/01-overview.md) |
| 读取或解释 query 结果 | [02-query](open-nodes-v1/02-query.md) |
| 构造 append、overwrite 或清空请求 | [03-update](open-nodes-v1/03-update.md) |
| 写富文本、列表、链接、主题色、渐变或阴影 | [04-text-style](open-nodes-v1/04-text-style.md) |
| 写 shape、便签、frame、group 或 connector | [05-shape-frame-group-connector](open-nodes-v1/05-shape-frame-group-connector.md) |
| 写已上传 Vector、内置 Icon 或自由 Path | [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md) |
| 处理错误、query 转写或判断 writeSupport | [07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md) |
| 选择合法 geometry 或 icon catalogId | [08-catalogs](open-nodes-v1/08-catalogs.md) |
## 强制读取规则
- 使用 `shape.geometry` 前必须读取 [08-catalogs](open-nodes-v1/08-catalogs.md),
不得猜测 geometry。
- 使用 `icon.catalogId` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md)
和 [08-catalogs](open-nodes-v1/08-catalogs.md)。
- 使用 `path` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md),
不得把它当作通用 SVG Path。
- Query 结果不能直接作为 update source;转换前必须读取
[03-update](open-nodes-v1/03-update.md) 和
[07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md)。
@@ -0,0 +1,57 @@
# DWS OpenNodes V1 协议说明
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
> 协议版本:`schemaVersion = "1.0"`,`catalogVersion = "dml-v1"`。
## 1. 协议用途
OpenNodes 是 DWS 白板命令使用的语义节点协议,提供两类能力:
- `dws whiteboard query`:返回稳定、可理解的页面和节点数据。
- `dws whiteboard update`:接收受约束的节点描述,以 `append` 或
`overwrite` 模式修改白板。
调用方只应依赖本文声明的语义字段和行为:
- `query` 不修改白板。
- `update` 全部成功或全部回滚,不返回中间状态。
- 未声明的存储字段、类型名称和处理过程不属于协议承诺。
OpenNodes V1 支持的节点类型、字段和读写范围见第 7 节。
DWS 负责身份认证和权限校验。Vector 资源准备使用 `dws doc media upload`,
具体流程见白板命令参考。
## 2. 版本与兼容原则
| 字段 | 当前值 | 作用 |
| --- | --- | --- |
| `schemaVersion` | `1.0` | 控制文档结构、节点字段和字段语义。 |
| `catalogVersion` | `dml-v1` | 控制允许写入的 DML 几何、连接线标记和内置 icon 目录。 |
V1 采用严格校验:
- 必填字段缺失会失败。
- 未声明字段会失败,不会被静默忽略。
- query-only 字段出现在 update 中会以 `readOnlyField` 失败。
- 不支持的节点类型、目录值或引用范围会失败。
- `null` 不代表“使用默认值”;除非字段类型明确允许,否则会失败。
本文列出的请求枚举值都是协议字面量,调用方必须按文档中的大小写和拼写原样传入,
不能自行转换或猜测。响应中未来可能增加可选字段,调用方应忽略不认识的响应字段。
调用方必须原样携带当前版本值。新增不兼容结构时应升级
`schemaVersion`;修改 DML、marker 或 icon 目录时应评估并升级
`catalogVersion`。
## 3. DWS 命令一览
| 命令 | 所需权限 | 效果 |
| --- | --- | --- |
| `dws whiteboard query --node ... --part-id ...` | 可查看白板 | 读取单页白板,不修改内容。 |
| `dws whiteboard update --node ... --part-id ... --source ... --yes` | 可编辑白板 | 追加节点或整页重建;所有更新都需先取得用户确认。 |
DWS 当前只支持文字文档中已有的单页内嵌白板。命令不接收 `pageId`,也不提供
创建页面、切换页面或按既有节点 ID 局部修改的能力。
@@ -0,0 +1,288 @@
# OpenNodes V1 — Query 请求、返回结构和节点公共字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 4. Query 协议
### 4.1 请求
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
`query` 不接收请求体或 `pageId`。`--node` 和 `--part-id` 的发现规则见
[白板命令参考](../../whiteboard.md)。CLI 会把服务返回的 `resultJson` JSON
字符串解析成对象。
### 4.2 返回结构
```ts
interface OpenNodesDocument {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
pages: OpenPage[];
}
interface OpenPage {
id: string;
nodes: OpenNode[];
}
```
DWS 当前只支持单页白板,因此 `pages` 固定包含一个页面。调用方仍应从返回值读取
页面 `id`,但不能把它作为 `pageId` 传给 DWS 命令。
母版节点不会作为独立页面返回,而会合并到引用它的页面 `nodes` 中,并带有:
```json
{
"source": "master",
"writeSupport": "readOnly",
"unsupportedFeatures": ["node.source.master"]
}
```
### 4.3 节点公共字段
`type` 的完整公开枚举如下。前一组可由 update 创建,后一组仅供 query 返回:
```ts
type WritableOpenNodeType =
| "shape"
| "text"
| "connector"
| "stickyNote"
| "frame"
| "group"
| "vector"
| "icon"
| "path";
type ReadOnlyOpenNodeType =
| "image"
| "pdf"
| "media"
| "webLink"
| "table"
| "chart"
| "uml"
| "swimlane"
| "mind"
| "timer"
| "placeholder"
| "unknown";
type OpenNodeType = WritableOpenNodeType | ReadOnlyOpenNodeType;
```
每个 query 节点都包含以下公共字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 白板中真实、稳定的节点 ID。 |
| `type` | `OpenNodeType` | OpenNodes 公开语义类型,取值见上方枚举。 |
| `parentId` | `string?` | group/frame 父节点 ID。无父节点时省略。 |
| `children` | `string[]?` | group/frame 的直接子节点 ID,由服务端推导。 |
| `x`、`y` | `number` | 有父节点时相对父节点;否则相对页面。单位为 px。 |
| `width`、`height` | `number` | 节点包围盒尺寸,单位为 px。连接线允许其中一个为 `0`。 |
| `angle` | `number` | 归一化到 `[0, 360)` 的角度。 |
| `absoluteBounds` | `OpenBounds` | 页面坐标系中的绝对包围盒。 |
| `layer` | `background \| normal \| foreground` | 节点所在层。 |
| `zIndex` | `number` | 同一父节点、同一 layer 内的非负顺序,值越小越靠后。 |
| `hidden` | `boolean` | 节点是否隐藏。 |
| `locked` | `boolean` | 节点是否锁定。 |
| `source` | `page \| master` | 节点来自当前页面还是母版。 |
| `writeSupport` | `readWrite \| readOnly` | 当前节点能否由 V1 update 表达。 |
| `unsupportedFeatures` | `string[]?` | 只读原因;`readWrite` 节点省略。 |
公共结构和各节点分支定义如下;分支中的字段含义与写入限制见第 7 节:
```ts
interface OpenPoint {
x: number;
y: number;
}
interface OpenBounds {
x: number;
y: number;
width: number;
height: number;
angle: number;
}
interface OpenNodeBase {
id: string;
type: OpenNodeType;
parentId?: string;
children?: string[];
x: number;
y: number;
width: number;
height: number;
angle: number;
absoluteBounds: OpenBounds;
layer: "background" | "normal" | "foreground";
zIndex: number;
hidden: boolean;
locked: boolean;
source: "page" | "master";
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenShapeNode extends OpenNodeBase {
type: "shape";
geometry: `dml:${string}`;
adjustments?: Record<string, number>;
text?: OpenText;
style?: OpenNodeStyle;
}
interface OpenTextNode extends OpenNodeBase {
type: "text";
text: OpenText;
style?: OpenNodeStyle;
}
interface OpenConnectorNode extends OpenNodeBase {
type: "connector";
start: OpenConnectorEndpoint;
end: OpenConnectorEndpoint;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyle;
resolvedPath: OpenResolvedConnectorPath;
}
interface OpenStickyNoteNode extends OpenNodeBase {
type: "stickyNote";
text?: OpenText;
style?: OpenNodeStyle;
creator?: {
displayName?: string;
hasAvatar?: boolean;
};
tags?: Array<{
id: string;
text: string;
background: OpenPaint;
}>;
}
interface OpenFrameNode extends OpenNodeBase {
type: "frame";
title?: {
text: OpenText;
box: { width: number; height: number };
};
style?: OpenNodeStyle;
presentationOrder?: number;
resizeMode: "free" | "fixedAspectRatio";
}
interface OpenGroupNode extends OpenNodeBase {
type: "group";
children: string[];
}
interface OpenVectorNode extends OpenNodeBase {
type: "vector";
resource: OpenVectorResource;
}
interface OpenIconNode extends OpenNodeBase {
type: "icon";
catalogId: string;
}
interface OpenPathNode extends OpenNodeBase {
type: "path";
path: OpenPathData;
style?: OpenNodeStyle;
}
interface OpenReadOnlyNode extends OpenNodeBase {
type: ReadOnlyOpenNodeType;
writeSupport: "readOnly";
unsupportedFeatures: string[];
}
type OpenNode =
| OpenShapeNode
| OpenTextNode
| OpenConnectorNode
| OpenStickyNoteNode
| OpenFrameNode
| OpenGroupNode
| OpenVectorNode
| OpenIconNode
| OpenPathNode
| OpenReadOnlyNode;
```
query 中 `icon.catalogId` 使用 `string`,是为了让无法映射到当前目录的既有图标仍可
被诊断;update 只能传入 7.10 节 `OpenIconCatalogId` 中列出的值。
### 4.4 Query 示例
```json
{
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"pages": [
{
"id": "page",
"nodes": [
{
"id": "real-node-id",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0,
"absoluteBounds": {
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0
},
"layer": "normal",
"zIndex": 0,
"hidden": false,
"locked": false,
"source": "page",
"writeSupport": "readWrite",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4],
"plainText": "Hello OpenNodes",
"writeSupport": "readWrite"
}
}
]
}
]
}
```
@@ -0,0 +1,249 @@
# OpenNodes V1 — Update 信封、Append/Overwrite 和公共写入字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 5. Update 协议
### 5.1 请求信封
```ts
interface OpenNodesUpdateRequest {
overwrite?: boolean;
source: {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
nodes: OpenNodeWrite[];
};
}
```
字段含义:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `overwrite` | 否 | `false` 或省略为 append;`true` 为 overwrite。 |
| `source.schemaVersion` | 是 | 必须为 `1.0`。 |
| `source.catalogVersion` | 是 | 必须为 `dml-v1`。 |
| `source.nodes` | 是 | 本次创建的节点数组。append 至少一个;overwrite 允许空数组。 |
`source` 不能直接使用 query 返回的 `OpenNodesDocument`,也不接受 `pages`。
DWS 不接受 `pageId`;传入会在 CLI 本地校验阶段失败。
V1 没有“按真实节点 ID patch 既有节点”的语义。`source.nodes` 中的每一项都会
创建一个新节点,`id` 仅是本次请求内建立父子关系和连接线引用的临时 ID:
append 是新增节点,overwrite 是整页删除后重新创建。
### 5.2 Append 与 Overwrite
> **注意:** `overwrite: true` 是破坏性操作,会删除当前白板页面的全部自有
> 节点。调用前应先 query 并确认影响范围;`nodes: []` 会清空页面。不希望删除
> 既有内容时,应使用 append。
| 行为 | append | overwrite |
| --- | --- | --- |
| `overwrite` | `false` 或省略 | `true` |
| 空 `nodes` | 禁止 | 允许,用于清空当前页面 |
| 当前页面旧节点 | 全部保留 | 删除页面自有节点后创建新节点 |
| 母版节点 | 保留 | 保留 |
| 页面级设置 | 保留 | 保留 |
| 原子性 | 全部成功或全部回滚 | 全部成功或全部回滚 |
overwrite 会替换当前页面的全部自有节点,不要求旧节点本身可由 OpenNodes V1
写入。因此页面中存在 image、PDF、复杂文本等只读节点,不会单独阻止清空页面。
overwrite 会在删除前执行安全预检;以下情况会拒绝执行:
- 节点被锁定:`lockedNode`。
- 节点不允许被删除:`deleteForbidden`。
- 页面包含无法安全保留或清理的关联数据:`unknownMetadataReference`。
- 目标节点未能完整删除:`deleteFailed`。
与被删除节点绑定且有明确清理规则的关联数据会随节点清理;无法安全保留或清理的
关联数据会使 overwrite 失败。
overwrite 只替换当前页面节点,不会重建页面,也不会修改主题等页面级设置。
白板已有的主题会被保留;白板没有有效主题时也不会自动添加。调用方应使用
已配置有效主题的白板,或者显式提供不依赖主题的颜色。
### 5.3 成功结果
```ts
interface DWSWhiteboardUpdateResponse {
success: true;
nodeId: string;
partId: string;
resultJson: {
mode: "append" | "overwrite";
createdNodeIds: string[];
idMap: Record<string, string>;
deletedNodeCount: number;
message: string;
};
}
```
| 字段 | 说明 |
| --- | --- |
| `success` | `true` 表示本次 DWS 调用成功。 |
| `nodeId` | 输入的文档节点 ID。 |
| `partId` | 输入的白板标识。 |
| `resultJson.mode` | 实际执行的模式。 |
| `resultJson.createdNodeIds` | 按请求节点顺序返回真实节点 ID。 |
| `resultJson.idMap` | 请求中显式临时 ID 到真实节点 ID 的映射。 |
| `resultJson.deletedNodeCount` | append 恒为 `0`;overwrite 为删除的页面自有节点数。 |
| `resultJson.message` | 供人阅读的结果摘要,不应作为机器判断依据。 |
响应可能增加其他可选字段;Agent 不应依赖本节未声明的字段。
示例:
```json
{
"success": true,
"nodeId": "DOC_NODE_ID",
"partId": "WHITEBOARD_PART_ID",
"resultJson": {
"mode": "append",
"createdNodeIds": ["generated-title-id", "generated-body-id"],
"idMap": {
"title": "generated-title-id",
"body": "generated-body-id"
},
"deletedNodeCount": 0,
"message": "Created 2 Whiteboard nodes"
}
}
```
## 6. Update 公共节点字段
V1 可写节点公共字段如下:
```ts
interface OpenNodeWriteBase {
id?: string;
layer?: "background" | "normal" | "foreground";
zIndex?: number;
hidden?: boolean;
}
interface OpenChildNodeWriteBase extends OpenNodeWriteBase {
parentId?: string;
}
interface OpenSizedNodeWriteBase extends OpenChildNodeWriteBase {
x: number;
y: number;
width: number;
height: number;
angle?: number;
}
interface OpenShapeNodeWrite extends OpenSizedNodeWriteBase {
type: "shape";
geometry: `dml:${string}`;
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenTextNodeWrite extends OpenSizedNodeWriteBase {
type: "text";
text: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenConnectorNodeWrite extends OpenNodeWriteBase {
type: "connector";
start: OpenConnectorEndpointWrite;
end: OpenConnectorEndpointWrite;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyleWrite;
}
interface OpenStickyNoteNodeWrite extends OpenSizedNodeWriteBase {
type: "stickyNote";
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenFrameNodeWrite extends OpenNodeWriteBase {
type: "frame";
x: number;
y: number;
width: number;
height: number;
angle?: 0;
title?: {
text: OpenTextWrite;
box?: { width: number; height: number };
};
style?: OpenNodeStyleWrite;
presentationOrder?: number;
resizeMode?: "free" | "fixedAspectRatio";
}
interface OpenGroupNodeWrite extends OpenChildNodeWriteBase {
id: string;
type: "group";
x: number;
y: number;
}
interface OpenVectorNodeWrite extends OpenSizedNodeWriteBase {
type: "vector";
resource: OpenManagedVectorResourceWrite;
}
interface OpenIconNodeWrite extends OpenSizedNodeWriteBase {
type: "icon";
catalogId: OpenIconCatalogId;
}
interface OpenPathNodeWrite extends OpenSizedNodeWriteBase {
type: "path";
path: OpenPathDataWrite;
style?: OpenNodeStyleWrite;
}
type OpenNodeWrite =
| OpenShapeNodeWrite
| OpenTextNodeWrite
| OpenConnectorNodeWrite
| OpenStickyNoteNodeWrite
| OpenFrameNodeWrite
| OpenGroupNodeWrite
| OpenVectorNodeWrite
| OpenIconNodeWrite
| OpenPathNodeWrite;
```
上面的联合类型是 update 的字段白名单。各辅助结构和完整枚举值在第 7 节定义;
未出现在对应分支中的字段不能发送。
| 字段 | 规则 |
| --- | --- |
| `id` | 可选的请求级临时 ID;非空、区分大小写、在请求内唯一。被引用时必须提供。 |
| `type` | 必填,必须是 V1 可写类型。 |
| `parentId` | 可选,引用同一请求中 group/frame 的临时 ID。 |
| `x`、`y` | 除 connector 外必填;有父节点时为父节点相对坐标。 |
| `width`、`height` | shape/text/stickyNote/frame/vector/icon/path 必填且大于 `0`;group/connector 只读。 |
| `angle` | 可选,默认 `0`;frame 只允许 `0`;group/connector 只读。 |
| `layer` | 可选;frame 默认 `background`,其他节点默认 `normal`。 |
| `zIndex` | 可选的非负整数;相同值时按请求顺序稳定排序。 |
| `hidden` | 可选布尔值,默认 `false`。 |
以下 query 字段禁止写回:
`children`、`absoluteBounds`、`locked`、`source`、`writeSupport`、
`unsupportedFeatures`。
关系规则:
- `parentId` 只能引用同一请求中的 group 或 frame。
- frame 和 connector 必须是页面直属节点,不能带 `parentId`。
- group 可以嵌套,也可以放在 frame 中。
- `children` 始终由各子节点的 `parentId` 推导。
- 父子关系不能成环。
- group 必须有临时 `id`、至少两个直接子节点,且不能全部隐藏。
@@ -0,0 +1,395 @@
# OpenNodes V1 — 支持矩阵、富文本和样式
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 7. 节点类型
### 7.1 支持矩阵
| `type` | query | update | 主要字段 |
| --- | --- | --- | --- |
| `shape` | 支持 | 支持 | `geometry`、`text?`、`style?` |
| `text` | 支持 | 支持 | `text`、`style?` |
| `connector` | 支持 | 支持 | `start`、`end`、`routing`、`waypoints?`、`style?` |
| `stickyNote` | 支持 | 支持 | `text?`、`style?` |
| `frame` | 支持 | 支持 | `title?`、`style?`、`presentationOrder?`、`resizeMode?` |
| `group` | 支持 | 支持 | 子关系通过 `parentId` 表达 |
| `image` | 支持 | 只读 | 仅公共字段 |
| `vector` | 支持 | 支持 | `resource` |
| `icon` | 支持 | 支持 | `catalogId` |
| `path` | 支持 | 支持 | `path`、`style?` |
| `pdf` | 支持 | 只读 | 仅公共字段 |
| `media` | 支持 | 只读 | 仅公共字段 |
| `webLink` | 支持 | 只读 | 仅公共字段 |
| `table` | 支持 | 只读 | 仅公共字段 |
| `chart` | 支持 | 只读 | 仅公共字段 |
| `uml` | 支持 | 只读 | 仅公共字段 |
| `swimlane` | 支持 | 只读 | 仅公共字段 |
| `mind` | 支持 | 只读 | 仅公共字段 |
| `timer` | 支持 | 只读 | 仅公共字段 |
| `placeholder` | 支持 | 只读 | 仅公共字段 |
| `unknown` | 支持 | 只读 | 未识别或尚未定义独立语义的节点统一映射到此类型 |
表中标记为只读的类型仍会完整返回公共几何、层级和顺序信息,但 update 提交会以
`nodeTypeUnsupported` 失败。
`timer`、`table`、`webLink` 不支持 V1 update。query 仅返回这些节点的公共几何、
层级、顺序和诊断字段,不承诺完整业务字段。文字 run 中的 `link` 是富文本能力,
不属于 `webLink` 节点,V1 支持读写。
`webLink` 只保留只读查询;OpenNodes V1 不支持创建或重建该节点。
### 7.2 Text
query 和 update 均支持普通段落、无序列表、有序列表、多 block、多 run 和文字
链接:
```ts
interface OpenTextRun {
text: string;
marks?: {
fontFamily?: string;
fontSize?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string;
highlight?: string;
};
link?: { url: string };
}
interface OpenTextBlock {
type: "paragraph" | "bulletList" | "orderedList";
horizontalAlign?: "left" | "center" | "right";
runs: OpenTextRun[];
}
interface OpenTextWrite {
blocks: OpenTextBlock[];
verticalAlign?: "top" | "center" | "bottom";
padding?: number | [number, number];
}
interface OpenText extends OpenTextWrite {
plainText: string;
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
```
约束:
- `blocks.length >= 1`,每个 block 的 `type` 必须是 `paragraph`、
`bulletList` 或 `orderedList`。
- 每个 block 都必须满足 `runs.length >= 1`。
- run 的 `text` 不能包含 `\r`、`\n`、U+2028 或 U+2029。换行和列表项使用
独立 block 表达;每一段或每个列表项应写成一个 block,而不是把原始换行符
放进单个 run。
- 每个 `bulletList` / `orderedList` block 表示一个列表项;服务端会把连续且同类的
block 解释为同一个列表中的多个列表项。
- `link.url` 长度必须为 `1..2048`,不能包含控制字符、`<`、`>`。支持无 scheme
的相对/裸链接,以及 `http`、`https`、`mailto`、`tel`、`dingtalk` scheme;
`javascript:`、`data:` 等可执行或未知 scheme 会被拒绝。
- 相邻且 URL 相同的 linked run 会呈现为同一个链接,同时保留各 run 自己的 marks。
- `fontSize > 0`,padding 各项必须大于等于 `0`。
- `plainText`、文本级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- text 节点必须提供 `text`;shape 和 stickyNote 的 `text` 可省略。
- 受支持的 paragraph、列表、链接、多 run 都保持
`writeSupport = "readWrite"`;未知 list style、非法链接、未支持的 block 或
run marks 会令文本和所属节点变为 `readOnly`。
下面的文本会显示为两个段落,第一段由两个不同样式的 run 组成:
```json
{
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "OpenNodes ",
"marks": { "fontSize": 18, "bold": true, "color": "#2563EB" }
},
{
"text": "rich text",
"marks": { "fontSize": 18, "italic": true, "color": "#0F172A" }
}
]
},
{
"type": "paragraph",
"horizontalAlign": "right",
"runs": [
{
"text": "第二段",
"marks": { "fontSize": 16, "underline": true, "color": "#047857" }
}
]
}
],
"verticalAlign": "center",
"padding": [4, 8]
}
```
同一 `OpenTextWrite` 结构适用于独立 text 节点、shape 文本、stickyNote 文本和
frame title。
下面三个 block 会生成两个无序列表项,其中第二项包含文字链接:
```json
{
"blocks": [
{
"type": "bulletList",
"runs": [{ "text": "准备输入数据" }]
},
{
"type": "bulletList",
"runs": [
{
"text": "查看钉钉文档",
"marks": { "underline": true },
"link": { "url": "https://alidocs.dingtalk.com" }
}
]
},
{
"type": "orderedList",
"runs": [{ "text": "执行生成" }]
}
]
}
```
颜色接受以下 CSS 形式:3/4/6/8 位十六进制、颜色名,以及
`rgb()`、`rgba()`、`hsl()`、`hsla()`、`oklch()`、`lab()`、`lch()`、
`color()`。字符串最长 128 字符,不能包含控制字符、`<`、`>` 或 `;`。
这里只接受可独立解析的字面量;依赖外部样式上下文的 `var()`、`calc()`、
`color-mix()` 等动态表达式不属于 V1。颜色名必须是标准 CSS named color,
任意字母串不会被当成颜色。
### 7.3 Style
query 可表达:
- `none`、`solid`、`theme`、线性渐变、径向渐变和图片 paint。
- shadow、blur 和 unknown effect。
V1 update 支持 `none`、`solid`、`theme`、线性渐变、九方向径向渐变,以及
单个自定义 shadow。主题明暗参数和渐变色标位置使用 `0~100` 的百分比,
不会归一化成 `0~1`:
```ts
type OpenRadialGradientPosition =
| "topLeft"
| "topCenter"
| "topRight"
| "centerLeft"
| "center"
| "centerRight"
| "bottomLeft"
| "bottomCenter"
| "bottomRight";
interface OpenColorStop {
offset: number;
color: string;
opacity?: number;
}
interface OpenImagePaint {
type: "image";
resource: {
kind: "managed" | "external" | "embedded" | "unresolved";
resourceId?: string;
};
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPaint =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number;
lumOff?: number;
resolvedColor?: string;
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStop[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition | "custom";
stops: OpenColorStop[];
}
| OpenImagePaint;
type OpenEffect =
| {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
| { type: "blur"; blur: number }
| { type: "unknown" };
interface OpenNodeStyle {
opacity?: number;
fill?: OpenPaint;
stroke?: {
paint: OpenPaint;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenEffect[];
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenColorStopWrite {
offset: number; // [0, 100],百分比
color: string;
opacity?: number; // [0, 1]
}
type OpenPaintWrite =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number; // [0, 100],默认 100
lumOff?: number; // [0, 100],默认 0
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStopWrite[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition;
stops: OpenColorStopWrite[];
};
interface OpenShadowEffectWrite {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
interface OpenNodeStyleWrite {
opacity?: number;
fill?: OpenPaintWrite;
stroke?: {
paint: OpenPaintWrite;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenShadowEffectWrite[];
}
```
约束:
- opacity 范围为 `[0, 1]`。
- solid paint 的 `color` 与 `opacity` 在 query 后仍保持独立字段,不会合并为
动态 CSS 表达式。
- theme 的 `token` 必须能在当前白板主题中解析;
`lumMod`、`lumOff` 范围均为 `[0, 100]`。token 不存在时在
Request graph 阶段返回 `themeTokenNotFound`,不会写出部分节点。
token 长度为 `1~64`,首尾不能有空白,也不能包含空白、控制字符、
`<`、`>` 或 `;`。
- query 的 theme paint 还会返回按当前白板主题计算出的 query-only
`resolvedColor`。调用方写入时只传 `token/lumMod/lumOff`,不传
`resolvedColor`。
- 渐变必须至少有一个 stop;`offset` 范围为 `[0, 100]`,单位是百分比。
- 线性渐变 `angle` 范围为 `[0, 360]`。
- 径向渐变只接受上述九宫格位置。query 遇到九宫格以外的位置时返回
`position: "custom"` 并将该节点标为只读。
- `effects` 最多包含一个 `shadow`;`offsetX/offsetY` 必须是有限数,
`blur >= 0`,shadow opacity 范围为 `[0, 1]`。
- stroke width 和 dash 各项必须大于等于 `0`。
- 一旦提供 stroke,`stroke.paint` 必填。
- 样式级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- 能在当前白板主题中解析且明暗参数合法的 theme paint 可写。无法解析的主题
token、越界的主题明暗参数、image paint、blur、unknown effect、叠加 effect
或自定义径向位置会令节点只读;受支持的 theme、渐变和单个 shadow 本身不会
令节点只读。
主题色示例:
```json
{
"fill": {
"type": "theme",
"token": "ac3",
"lumMod": 20,
"lumOff": 80
},
"stroke": {
"paint": {
"type": "theme",
"token": "sk1",
"lumMod": 80,
"lumOff": 20
},
"width": 2
}
}
```
示例中的 `ac3`、`sk1` 只是主题 token 示例,不是所有白板都可用的全局枚举。
调用方只能使用已确认可由当前白板主题解析的 token;无法确认时应改用
`solid` 颜色。
DWS 不提供新增、修改或切换主题的命令。当前白板没有有效主题时,应改用
`solid`。
示例:
```json
{
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#1677ff", "opacity": 0.4 },
{ "offset": 100, "color": "#69b1ff" }
]
},
"effects": [
{
"type": "shadow",
"offsetX": 8,
"offsetY": 8,
"blur": 19,
"color": "rgba(93,190,172,1)",
"opacity": 0.5
}
]
}
```
未传 `style` 时使用节点类型的默认样式。调用方如需稳定的视觉结果,应显式传入
`fill` 和 `stroke`。
@@ -0,0 +1,186 @@
# OpenNodes V1 — Shape、Text、Sticky note、Frame、Group 和 Connector
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.4 Shape
shape 必须提供 `geometry`,格式为 `dml:<name>`:
```json
{
"id": "shape-1",
"type": "shape",
"x": 100,
"y": 80,
"width": 160,
"height": 100,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "solid",
"color": "#DCEEFF"
},
"stroke": {
"paint": {
"type": "solid",
"color": "#225588"
},
"width": 2
}
}
}
```
query 可能返回 `adjustments`,但 V1 update 不支持写入;带 adjustments 的
shape 会标为只读。`dml-v1` 的完整 geometry 目录见附录 A。
### 7.5 Text node 与 Sticky note
text node 使用公共几何、必填 `text` 和可选 `style`。
stickyNote 使用公共几何、可选 `text` 和可选 `style`。省略 `text` 时创建空
便签。query 还可能返回:
- `creator`:创建者展示信息。
- `tags`:标签 ID、文本和背景 paint。
这两个字段是 query-only;存在 creator 或 tags 的 stickyNote 会被标为只读。
### 7.6 Frame
frame 的 update 结构见第 6 节 `OpenFrameNodeWrite`。
约束:
- frame 必须是页面直属节点,不能带 `parentId`。
- angle 只允许 `0`。
- `presentationOrder` 是非负整数,并且不能和已有或本次创建的 frame 冲突。
- frame 的子节点通过子节点 `parentId` 引用 frame 临时 ID。
- frame 不能包含 frame 或 connector。
- frame 默认 layer 为 `background`。
### 7.7 Group
group 的写入字段只有公共字段中的 `id`、`parentId?`、`x`、`y`、`layer?`、
`zIndex?` 和 `hidden?`。其 width、height、angle 和 children 都由服务端根据
子节点推导。
group 必须:
- 提供临时 `id`。
- 至少包含两个直接子节点。
- 至少有一个直接子节点可见。
group 的任一子节点为只读时,query 会把 group 一并标为只读。
### 7.8 Connector
connector 的几何由端点和路由推导,因此 update 不能提供 `x`、`y`、
`width`、`height` 或 `angle`。
query 的连接线结构如下:
```ts
type OpenConnectorRouting =
| "straight"
| "polyline"
| "curve"
| "orthogonal";
interface OpenConnectorMarker {
catalogId: string;
}
type OpenConnectorAnchor =
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
position: OpenPoint;
}
| {
mode: "fixed";
side: "custom";
position: OpenPoint;
};
type OpenConnectorEndpoint =
| {
type: "point";
point: OpenPoint;
marker: OpenConnectorMarker;
}
| {
type: "node";
nodeRef: { scope: "document"; id: string };
anchor: OpenConnectorAnchor;
resolvedPoint: OpenPoint;
marker: OpenConnectorMarker;
};
interface OpenBezierSegment {
start: OpenPoint;
control1: OpenPoint;
control2: OpenPoint;
end: OpenPoint;
}
type OpenResolvedConnectorPath =
| { type: "polyline"; points: OpenPoint[] }
| { type: "bezier"; segments: OpenBezierSegment[] };
```
update 端点有两种形式:
```ts
type OpenConnectorEndpointWrite =
| {
type: "point";
point: { x: number; y: number };
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
}
| {
type: "node";
nodeRef: { scope: "request"; id: string };
anchor?:
| { mode: "auto" }
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
};
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
};
```
query 的 marker `catalogId` 使用 `string`,以便返回无法映射的既有 marker;
update 只接受上面列出的 `"none"`、`"arrow.open"`、`"arrow.filled"`。
路由规则:
| `routing` | `waypoints` |
| --- | --- |
| `straight` | 禁止提供,包括空数组。 |
| `polyline` | 必须至少提供一个。 |
| `curve` | 可选。 |
| `orthogonal` | 可选;显式点路径的相邻线段必须水平或垂直。 |
其他约束:
- 所有 point 和 waypoint 都使用页面绝对坐标。
- node 端点只能引用同一请求中的 shape、text、stickyNote、frame、group 或 path。
- node 端点不能引用隐藏节点、connector 或 query 中既有节点。
- update 引用范围固定为 `scope: "request"`;query 返回的节点引用范围为
`scope: "document"`,不能直接回写。
- 同一连接线的两端不能引用同一个节点。
- 零长度或无效路径会被拒绝。
- marker 省略时默认为 `none`。
- anchor 省略时按 `auto` 处理。
query 额外返回服务端解析后的:
- node 端点 `resolvedPoint`。
- fixed anchor 的归一化 `position`。
- `resolvedPath`,类型为 polyline points 或 cubic bezier segments。
- 由真实路径推导的 `absoluteBounds`。
这些解析字段都是 query-only。
@@ -0,0 +1,313 @@
# OpenNodes V1 — Vector、Icon 和 Path
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.9 Vector(已上传 SVG/矢量资源)
`vector` 表示已经通过 `dws doc media upload` 获得稳定引用的 SVG/矢量图片。
OpenNodes 只接收上传结果中的资源引用,不接收本地路径或原始 SVG/XML 内容。
上传时必须使用与后续白板更新相同的文档 `nodeId`:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--format json
```
将上传结果的 `resourceId` 和 `resourceUrl` 分别写入
`resource.resourceId` 和 `resource.url`。
Update 必须提供完整的托管资源信息:
```ts
interface OpenManagedVectorResourceWrite {
kind: "managed";
resourceId: string;
url: string;
}
```
完整节点结构见第 6 节 `OpenVectorNodeWrite`。
`resource` 字段规则:
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `kind` | 是 | 当前只允许固定值 `managed`,表示资源已经通过 DWS 上传。 |
| `resourceId` | 是 | 资源稳定 ID,长度 1~256,只允许字母、数字、`.`、`_`、`:`、`-`。 |
| `url` | 是 | 已上传资源地址,最长 4096;必须包含且只能包含一个同值的 `resourceId` 查询参数。 |
`url` 必须直接使用 `dws doc media upload` 返回的 `resourceUrl`,不得自行拼装
或修改。
以下内容会被拒绝:
- 原始 SVG/XML、`data:`、`blob:`、`http:` URL。
- `//host/path` 协议相对地址,以及自行构造或修改的其他相对地址。
- 含空白、控制字符、反斜杠、fragment 或用户凭证的 URL。
- URL 缺少 `resourceId`、重复出现 `resourceId`,或者 URL 中 ID 与显式
`resource.resourceId` 不一致。
- `resource` 缺失、字段不完整、`kind` 不是 `managed`,或包含未知字段。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-svg-1",
"type": "vector",
"x": 100,
"y": 80,
"width": 240,
"height": 180,
"angle": 0,
"resource": {
"kind": "managed",
"resourceId": "0c1c94e1-f9af-4228-b32f-42bbd1555253",
"url": "https://resources.example.com/assets/opaque-path?resourceId=0c1c94e1-f9af-4228-b32f-42bbd1555253"
}
}
]
},
"overwrite": false
}
```
示例中的 `resources.example.com` 是占位域名,实际调用必须使用
`dws doc media upload` 返回的 `resourceUrl`。
`resourceId` 同时显式出现并包含在 URL 中,用于校验资源身份与地址是否一致。
两者必须来自同一次 `dws doc media upload` 结果。
Query 的 `resource` 可能是:
```ts
type OpenVectorResource =
| { kind: "managed"; resourceId: string; url: string }
| { kind: "external" | "embedded" | "unresolved" };
```
- 能识别为托管资源的引用返回完整 `managed` 信息。
- HTTP(S) 外链但不满足托管资源契约时返回 `external`。
- `data:`/`blob:` 返回 `embedded`。
- 其他缺失或无法识别的地址返回 `unresolved`。
- 非 `managed` 资源会令节点只读,并分别产生
`vector.resource.external`、`vector.resource.embedded` 或
`vector.resource.unresolved`。
V1 vector update 不接受 `style`。既有节点包含 V1 无法表达的 fill、stroke、
opacity、effect 或 adjustments 时,query 仍可读取资源和几何,但节点会标为只读。
不影响资源内容的兼容性装饰不会单独令节点变为只读。
DWS 会校验资源引用。上传与 `whiteboard update` 必须使用同一个文档
`nodeId`;不要跨文档复用资源,也不要使用临时 `uploadUrl`。
### 7.10 Icon(内置图标)
`icon` 表示内置图标。OpenNodes 使用版本化的 `catalogId` 作为稳定标识,
允许值见下列类型定义和附录 B。
Update 结构:
```ts
type OpenIconCatalogId =
| `emoji/${
| "happy"
| "smile"
| "laugh"
| "fighting"
| "like"
| "ok"
| "please"
| "face-plam"
| "tears-of-joy"
| "cry"
| "question"
| "face-with-sweat"
| "bloody-nose"
| "doggy"}`
| `tools/${
| "pad"
| "blue-note"
| "yellow-notes"
| "chart"
| "chart-2"
| "pencil"
| "pen"
| "bag"
| "rocket"
| "fire"
| "gold"
| "light"
| "pin"
| "red-flag"
| "tea"
| "island"
| "ball"
| "lucky-fish"
| "coffee"
| "milky-tea"
| "pan"}`
| `priority/priority-${1 | 2 | 3 | 4 | 5 | 6 | 7}`
| `task/${
| "task-start"
| "task-oct"
| "task-3oct"
| "task-half"
| "task-5oct"
| "task-7oct"
| "task-done"}`;
```
完整节点结构见第 6 节 `OpenIconNodeWrite`。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pencil-icon",
"type": "icon",
"x": 100,
"y": 80,
"width": 48,
"height": 48,
"catalogId": "tools/pencil"
}
]
},
"overwrite": false
}
```
规则:
- `catalogId` 必填,格式为 `<group>/<name>`,并且必须精确命中附录 B 的
`dml-v1` allowlist;大小写、连字符和历史拼写都不能自行修正。
- 当前目录有 `emoji`、`tools`、`priority`、`task` 四组,共 49 项。
- update 只接受 `catalogId`,不接受 `group`、`name`、`style` 或 `resource`。
- 内置 icon 不需要调用方上传资源,也不需要 `resourceId`。
- 任意已上传 SVG 或自定义图标应使用 `vector`,并按 7.9 节提供完整
`resource` 信息,不能伪造一个 icon `catalogId`。
- icon 可以作为 group/frame 子节点;V1 connector 的 node 端点当前仍不接受
icon 作为目标。
Query 返回相同的 `catalogId`。既有 icon 无法映射到当前目录时,节点仍会以
`type: "icon"` 返回以便诊断,但 `writeSupport` 为 `readOnly`,原因包含
`icon.catalogId`。非默认 opacity、filter/effect、text 或 adjustments 同样会令节点
只读。
### 7.11 Path(自由画笔)
`path` 表示自由画笔轨迹。OpenNodes 保留两组互相独立的尺寸:
- 节点公共 `width` / `height` 是画布上的实际渲染尺寸,缩放节点时会变化。
- `path.intrinsicWidth` / `path.intrinsicHeight` 是 SVG path 自身的坐标空间尺寸,
表示 `path.data` 使用的内部坐标空间。
```ts
interface OpenPathData {
data: string;
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPathDataWrite = OpenPathData;
```
完整 update 节点结构见第 6 节 `OpenPathNodeWrite`。query 和 update 的 `path`
字段结构相同,但 update 仍必须满足下方命令子集和大小限制。
```json
{
"id": "freehand-stroke",
"type": "path",
"x": 120,
"y": 100,
"width": 500,
"height": 150,
"path": {
"data": "M0,75 Q50,0 100,75 Q150,150 200,75 Q250,0 300,75 Q350,150 400,75 Q450,0 500,75",
"intrinsicWidth": 500,
"intrinsicHeight": 150
},
"style": {
"fill": { "type": "none" },
"stroke": {
"paint": { "type": "solid", "color": "#7C3AED" },
"width": 10,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
下面是一个仍然只使用 V1 命令子集、但包含 12 段二次贝塞尔曲线的蝴蝶轮廓。
它显式回到起点,因此不需要使用尚未支持的 `Z`:
```json
{
"id": "complex-butterfly-path",
"type": "path",
"x": 1500,
"y": 1215,
"width": 500,
"height": 420,
"path": {
"data": "M250,180 Q210,105 145,70 Q55,25 35,110 Q10,195 125,220 Q35,275 80,355 Q125,415 210,315 Q235,285 250,250 Q265,285 290,315 Q375,415 420,355 Q465,275 375,220 Q490,195 465,110 Q445,25 355,70 Q290,105 250,180",
"intrinsicWidth": 500,
"intrinsicHeight": 420
},
"style": {
"opacity": 0.96,
"fill": {
"type": "solid",
"color": "#EDE9FE",
"opacity": 0.72
},
"stroke": {
"paint": {
"type": "solid",
"color": "#6D28D9"
},
"width": 8,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
V1 path 写入约束如下:
- `data` 必须是一个绝对 `M`,后跟至少一个显式写出的绝对 `Q`;不接受相对命令,
也不接受 `L`、`C`、`A`、`Z` 等通用 SVG 命令。
- 所有命令参数必须完整且为有限数值;单节点 `data` 最长 1 MiB,最多 50,000 个
命令。
- `intrinsicWidth` 和 `intrinsicHeight` 必须为有限正数;它们不要求等于节点的
`width` 和 `height`。
- 未传 `style` 时,默认使用透明填充、`#222222` 描边、宽度 5,以及 round
line cap/join。需要稳定视觉结果时仍应显式传入 `style`。
- path 可以作为 group/frame 的子节点,也可以作为同一 update 请求中 connector
的 node 端点。
query 会把其他能够解析的 SVG path 保留为 `type: "path"`,但标记为
`readOnly`,原因包含 `path.commands`;超出上述 V1 限制时使用
`path.data.size` 或 `path.commands.limit`。既有 path 带 text、adjustments、非
`nonzero` fillRule 或 V1 无法表达的样式时也会只读。仅包含不影响画笔几何的
兼容性信息时,不会因此变为只读。theme stroke 遵循通用 style 契约:query 会
保留 token 和明暗参数;只要 token 能在当前白板主题中解析,就可以按 theme
paint 回写。
@@ -0,0 +1,270 @@
# OpenNodes V1 — Update 示例、回写规则、错误模型和 writeSupport
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 8. Update 示例
### 8.1 Append 一个文本节点
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "title",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4]
}
}
]
}
}
```
### 8.2 Append 两个形状和一条引用连接线
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "left",
"type": "shape",
"x": 80,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "right",
"type": "shape",
"x": 360,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "line",
"type": "connector",
"start": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "left"
},
"anchor": {
"mode": "fixed",
"side": "right"
}
},
"end": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "right"
},
"anchor": {
"mode": "fixed",
"side": "left"
},
"marker": {
"catalogId": "arrow.filled"
}
},
"routing": "straight"
}
]
}
}
```
### 8.3 Overwrite 整页
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "replacement",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [
{
"text": "Replacement content"
}
]
}
]
}
}
]
}
}
```
### 8.4 清空当前页面
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
## 9. Query 数据不能直接回写
query 是完整可读投影,update 是受约束的创建协议,两者不是对称 JSON:
| Query 字段/能力 | Update 处理方式 |
| --- | --- |
| 真实 `id` | 只能作为请求级临时 ID;不能引用既有 document 节点。 |
| `children` | 删除,通过子节点 `parentId` 重建。 |
| `absoluteBounds` | 删除;普通节点使用 `x/y/width/height`,connector 使用端点和路由字段。 |
| `locked`、`source`、`writeSupport`、`unsupportedFeatures` | 删除,均为 query-only。 |
| 文本 `plainText` | 删除,由服务端根据 paragraph 和 run 重新计算。 |
| 多 paragraph、列表、多 run、文字链接 | 可以保留;每个 block 必须是受支持类型,且 run 内不能包含原始换行符。 |
| 未知 list style、非法链接或未支持的 block/marks | 需要移除或降级为受支持的 block/run。 |
| theme paint | 保留 `token/lumMod/lumOff`,删除 query-only `resolvedColor`;token 必须能在当前白板主题中解析。 |
| image paint | 需要降级成受支持的 paint,或不更新该节点。 |
| 受支持的 linear/radial gradient、单个 shadow | 可以保留;gradient offset 使用 `0~100`,radial `custom` 不能回写。 |
| blur、unknown 或叠加 effects | 需要删除、降级成单个 shadow,或不更新该节点。 |
| connector `scope: "document"` | 不能回写;改为引用同一请求节点的 `scope: "request"`。 |
| connector `resolvedPoint`、`position`、`resolvedPath` | 删除,均由服务端重新计算。 |
| shape `adjustments` | V1 不支持写入。 |
| stickyNote `creator`、`tags` | V1 不支持写入。 |
即使 query 节点显示 `writeSupport = "readWrite"`,update 仍会对版本、目录、
字段和请求关系做完整校验。调用方不应跳过 update 错误处理。
## 10. 错误模型
### 10.1 顶层错误码
| 错误码 | 含义 |
| --- | --- |
| `invalidRequest.whiteboard.schemaInvalid` | JSON、字段或节点 schema 不合法。 |
| `invalidRequest.whiteboard.catalogVersionUnsupported` | catalogVersion 不受支持。 |
| `invalidRequest.whiteboard.validationFailed` | 节点间引用、父子关系或路径关系不合法。 |
| `invalidRequest.whiteboard.emptySource` | append 的 nodes 为空。 |
| `invalidRequest.whiteboard.overwriteUnsafe` | overwrite 安全预检失败。 |
### 10.2 DWS 错误输出
远端校验失败时,DWS 以统一 CLI 错误结构返回:
```json
{
"error": {
"category": "api",
"reason": "business_error",
"server_key": "whiteboard",
"server_error_code": "invalidRequest.whiteboard.validationFailed",
"message": "Whiteboard request graph is invalid",
"trace_id": "TRACE_ID"
}
}
```
部分服务错误会使用更宽泛的 `invalidRequest.inputArgs.invalid`。Agent 应结合
`server_error_code` 和 `message` 修正输入;需要排障时保留 `trace_id`。JSON 或
信封级错误可能由 CLI 本地返回,不一定包含 `server_key` 和 `trace_id`。
校验类错误不可通过原样重试恢复。常见原因包括:
- Schema:缺少字段、未知字段、类型或枚举值错误、提交 query-only 字段、
节点类型不支持。
- 请求关系:临时 ID 重复、引用不存在、父子关系非法、连接线目标不支持、
路径退化或主题 token 不存在。
- Overwrite 预检:锁定节点、禁止删除、关联数据无法安全处理或删除失败。
任一阶段失败都不会保留部分更新。
## 11. writeSupport 的含义
`writeSupport` 表示当前 query 节点是否能由 V1 update 无损表达,不代表用户
权限,也不代表 overwrite 是否允许移除该既有节点。
以下 `unsupportedFeatures` 均为对外返回的诊断枚举值:
- `node.source.master`、`node.locked`、`node.role`、`node.placeholder`、
`node.extras`、`node.ability`。
- `node.type.image`、`node.type.pdf`、`node.type.media`、
`node.type.webLink`、`node.type.table`、`node.type.chart`、
`node.type.uml`、`node.type.swimlane`、`node.type.mind`、
`node.type.timer`、`node.type.placeholder`、`node.type.unknown`。
- `text.list.unsupported`、`text.link.unsupported`、`text.block.unsupported`、
`text.lineBreak.unsupported`、`text.marks.unsupported`、
`text.color.unsupported`、`text.highlight.unsupported`。
- `style.fill.color`、`style.fill.opacity`、`style.fill.theme.token`、
`style.fill.theme.unresolved`、`style.fill.theme.modifier`、
`style.fill.theme.opacity`、
`style.fill.gradient.angle`、`style.fill.gradient.offset`、
`style.fill.gradient.color`、`style.fill.gradient.opacity`、
`style.fill.gradient.position`、`style.fill.image`。
- `style.stroke.color`、`style.stroke.opacity`、`style.stroke.theme.token`、
`style.stroke.theme.unresolved`、
`style.stroke.theme.modifier`、`style.stroke.theme.opacity`、
`style.stroke.gradient.angle`、`style.stroke.gradient.offset`、
`style.stroke.gradient.color`、`style.stroke.gradient.opacity`、
`style.stroke.gradient.position`、`style.stroke.image`。
- `style.effects`。
- `shape.adjustments`、`stickyNote.creator`、`stickyNote.tags`。
- `vector.resource.external`、`vector.resource.embedded`、
`vector.resource.unresolved`、`vector.fill`、`vector.stroke`、
`vector.opacity`、`vector.effect`、`vector.adjustments`。
- `icon.catalogId`、`icon.opacity`、`icon.effect`、`icon.text`、
`icon.adjustments`。
- `path.commands`、`path.data.size`、`path.commands.limit`、`path.text`、
`path.fillRule`、`path.adjustments`。
- `connector.parent`、`connector.marker.unsupported`、
`connector.target.unexposed`、`connector.target.unsupported`、
`connector.anchor.unresolved`、`connector.anchor.custom`、
`connector.selfLoop`。
- `group.angle`、`group.children.minimum`、`group.children.hidden`、
`group.child.readOnly`。
- `frame.angle`、`frame.child.frame`、`frame.child.readOnly`。
调用方应把 `unsupportedFeatures` 当作诊断信息,不应把当前枚举穷举写死为
业务逻辑。真正可写与否以 `writeSupport` 和 update 校验结果为准。
@@ -0,0 +1,61 @@
# OpenNodes V1 — dml-v1 Geometry 和 Icon 完整目录
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 附录 A:dml-v1 geometry 目录
写入时在以下名称前加 `dml:`,例如 `rect` 写成 `dml:rect`。当前目录共
183 项,目录版本由 `catalogVersion = "dml-v1"` 标识。
```text
accentBorderCallout1 accentBorderCallout2 accentBorderCallout3
accentCallout1 accentCallout2 accentCallout3 actionButtonBackPrevious
actionButtonBeginning actionButtonBlank actionButtonDocument actionButtonEnd
actionButtonForwardNext actionButtonHelp actionButtonHome
actionButtonInformation actionButtonMovie actionButtonReturn actionButtonSound
active allGeneralization arc attribute bentArrow bentUpArrow bevel bind blockArc
borderCallout1 borderCallout2 borderCallout3 bracePair bracketPair callout1
callout2 callout3 can chevron chord circularArrow cloud cloudCallout comment
component control convert corner cube curvedDownArrow curvedLeftArrow
curvedRightArrow curvedUpArrow dataStorage decagon delete diagStripe diamond
dodecagon donut doubleWave downArrow downArrowCallout ellipse ellipseRibbon
ellipseRibbon2 entity entitySet flowChartAlternateProcess flowChartCollate
flowChartConnector flowChartDecision flowChartDelay flowChartDisplay
flowChartDocument flowChartExtract flowChartInputOutput flowChartInternalStorage
flowChartMagneticDisk flowChartMagneticDrum flowChartMagneticTape
flowChartManualInput flowChartManualOperation flowChartMerge
flowChartMultidocument flowChartOffpageConnector flowChartOnlineStorage
flowChartOr flowChartPredefinedProcess flowChartPreparation
flowChartPunchedCard flowChartPunchedTape flowChartSort
flowChartSummingJunction flowChartTerminator foldedCorner frame generalization
halfFrame heart heptagon hexagon history homePlate horizontalDivCircle
horizontalScroll irregularSeal1 irregularSeal2 leftArrow leftArrowCallout
leftBrace leftBracket leftRightArrow leftRightArrowCallout leftRightUpArrow
leftUpArrow lightningBolt mathDivide mathEqual mathMinus mathMultiply
mathNotEqual mathPlus moon multiClass multiValuedAttribute noSmoking node
nonIsoscelesTrapezoid notchedRightArrow octagon parallelogram pentagon person
pie plaque plus quadArrow quadArrowCallout receiveSignal rect relationship
ribbon ribbon2 rightArrow rightArrowCallout rightBrace rightBracket round1Rect
round2DiagRect round2SameRect roundRect rtTriangle smileyFace snip1Rect
snip2DiagRect snip2SameRect snipRoundRect star10 star12 star16 star24 star32
star4 star5 star6 star7 star8 stripedRightArrow sun teardrop triangle upArrow
upArrowCallout upDownArrow user uturnArrow verticalDivCircle verticalScroll
wave weakEntitySet weakRelationship wedgeEllipseCallout wedgeRectCallout
wedgeRoundRectCallout
```
## 附录 B:dml-v1 icon 目录
写入时必须使用完整的 `<group>/<name>`。当前共 4 组 49 项,目录版本由
`catalogVersion = "dml-v1"` 标识。
| group | 数量 | name(组成 `group/name`) |
| --- | ---: | --- |
| `emoji` | 14 | `happy`、`smile`、`laugh`、`fighting`、`like`、`ok`、`please`、`face-plam`、`tears-of-joy`、`cry`、`question`、`face-with-sweat`、`bloody-nose`、`doggy` |
| `tools` | 21 | `pad`、`blue-note`、`yellow-notes`、`chart`、`chart-2`、`pencil`、`pen`、`bag`、`rocket`、`fire`、`gold`、`light`、`pin`、`red-flag`、`tea`、`island`、`ball`、`lucky-fish`、`coffee`、`milky-tea`、`pan` |
| `priority` | 7 | `priority-1`、`priority-2`、`priority-3`、`priority-4`、`priority-5`、`priority-6`、`priority-7` |
| `task` | 7 | `task-start`、`task-oct`、`task-3oct`、`task-half`、`task-5oct`、`task-7oct`、`task-done` |
注意:`emoji/face-plam` 是 V1 保留的历史兼容枚举值,拼写虽然异常但属于协议值;
传入 `emoji/face-palm` 会被拒绝。
@@ -0,0 +1,308 @@
# 钉钉白板常用 Recipes
以下各 Recipe 的 JSON 都写入本地文件,再通过 `dws whiteboard update --source <FILE.json>`
传给白板。执行任何远端写入前,必须先向用户展示影响并取得明确确认;确认后
才可添加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source <FILE.json> \
--yes \
--format json
```
每次更新后都要再次执行 `dws whiteboard query ... --format json` 回读验证。
## 1. 追加两个流程节点和一条箭头
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "start",
"type": "shape",
"x": 80,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "读取需求", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"id": "finish",
"type": "shape",
"x": 360,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "输出结果", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"type": "connector",
"start": {
"type": "node",
"nodeRef": { "scope": "request", "id": "start" },
"anchor": { "mode": "fixed", "side": "right" }
},
"end": {
"type": "node",
"nodeRef": { "scope": "request", "id": "finish" },
"anchor": { "mode": "fixed", "side": "left" },
"marker": { "catalogId": "arrow.filled" }
},
"routing": "straight"
}
]
}
}
```
## 2. 追加带渐变和阴影的卡片
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "styled-card",
"type": "shape",
"x": 80,
"y": 260,
"width": 260,
"height": 120,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#DBEAFE" },
{ "offset": 100, "color": "#A7F3D0" }
]
},
"stroke": {
"paint": { "type": "solid", "color": "#2563EB" },
"width": 2
},
"effects": [
{
"type": "shadow",
"offsetX": 5,
"offsetY": 7,
"blur": 18,
"color": "#0F172A",
"opacity": 0.22
}
]
},
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [
{
"text": "复杂样式",
"marks": { "fontSize": 20, "bold": true, "color": "#0F172A" }
}
]
}
],
"verticalAlign": "center"
}
}
]
}
}
```
## 3. Frame 中放置分支流程
先创建 frame,再让子节点通过 `parentId` 引用 frame 的临时 ID。子节点坐标相对
frame 左上角:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pipeline",
"type": "frame",
"x": 60,
"y": 440,
"width": 720,
"height": 300,
"title": {
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{ "text": "生成流水线", "marks": { "bold": true } }]
}
]
}
}
},
{
"id": "branch-a",
"type": "shape",
"parentId": "pipeline",
"x": 60,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
},
{
"id": "branch-b",
"type": "shape",
"parentId": "pipeline",
"x": 420,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
}
]
}
}
```
## 4. 上传 SVG 并追加 Vector
Vector 固定使用“上传 → 字段映射 → update → query”流程,并且所有命令使用同一个
`DOC_NODE_ID`。
先上传 SVG。该命令只准备资源,不会插入文档正文:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--yes \
--format json
```
从成功输出取 `resourceId` 和 `resourceUrl`:
```json
{
"nodeId": "<DOC_NODE_ID>",
"resourceId": "resource-stable-id",
"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id",
"fileName": "icon.svg",
"mimeType": "image/svg+xml",
"size": 1024
}
```
写入 `whiteboard-vector.json`,其中 `resourceId` 原样映射到
`resource.resourceId`,`resourceUrl` 映射到 `resource.url`:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-vector",
"type": "vector",
"x": 80,
"y": 80,
"width": 160,
"height": 160,
"resource": {
"kind": "managed",
"resourceId": "resource-stable-id",
"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"
}
}
]
}
}
```
执行更新:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./whiteboard-vector.json \
--yes \
--format json
```
最后独立回读,不以 update 的成功响应替代验证:
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
禁止跨 nodeId 复用资源,也不要把本地 SVG 路径、独立文件节点 URL 或临时
`uploadUrl` 写入 `resource.url`。
## 5. 整页替换或清空
把文件设为 `overwrite: true` 后,命令必须加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./overwrite.json \
--yes \
--format json
```
清空整页:
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
仅当用户明确要求清空时使用。执行前必须 query、展示影响摘要并获得确认。
+4 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-doc
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、白板卡片、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -25,6 +25,7 @@ metadata:
- 文档内容只用 `--content` / `--content-file`,不要写 `--markdown`。
- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。
- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。
- `doc whiteboard insert`、`doc media upload` 属于远端写入;必须先获得用户确认,再加 `--yes`。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
@@ -66,6 +67,8 @@ metadata:
| "导入本地文件为在线文档" | `dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`(详见 `references/doc/doc-import.md`) |
| "查模板 / 套用模板创建文档" | `dws doc template list|search|apply`(详见 `references/doc.md` 模板管理) |
| "保存 / 查看 / 回滚在线文字文档(adoc)版本" | `dws doc version save/list/revert` |
| "在文档里创建空白板" | `dws doc whiteboard insert --node <nodeId> --yes --format json` |
| "为白板上传 SVG/Vector 资源" | `dws doc media upload --node <nodeId> --file <path> --yes --format json` |
## 标准 SOP(必遵流程)
@@ -482,6 +482,26 @@ dws doc version revert --node <DOC_ID> --version <N> --yes --format json # 3.
| `import` | `documentUrl` / `documentName` / `documentType` | 导入完成后的在线文档地址和名称 |
| `import`(中断后) | `taskId` | `import get` 的 `--task-id`(查询后取结果获取文档地址) |
## 白板卡片与白板媒体资源
创建文档内空白板前先确认目标文档,获得用户确认后执行:
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
```
返回的 `blockId` 用于 `doc block delete`,`whiteboardId` 是白板 partId,用于
`dws whiteboard query/update`。两者不可混用。为白板 Vector/SVG 准备资源时:
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
上传与后续白板更新必须使用同一 nodeId;仅使用稳定返回的 `resourceId` 和
`resourceUrl`,禁止使用临时 uploadUrl 或跨文档复用资源。白板内容协议与更新
流程见 `dingtalk-misc` 的 `references/whiteboard.md`。
## 相关产品
- [wiki](../../dingtalk-wiki/references/wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中
+2 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-misc
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -30,6 +30,7 @@ metadata:
| 日报 / 周报 / 月报 / 写日志 / 收件箱日志 / 发件箱日志 | 日志(日报/周报/月报)查询与按模版提交 | `dws report`(别名 `dws log`) | [report.md](references/report.md) |
| 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) |
| 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) |
| 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) |
## 说明
@@ -0,0 +1,97 @@
# 钉钉文档内嵌白板
`dws whiteboard` 只操作已经存在于钉钉在线文档中的单页内嵌白板。创建白板卡片使用
`dws doc whiteboard insert`;普通文档块仍使用 `dws doc block`。
OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需读取
[协议索引](./whiteboard/open-nodes-v1.md);不要根据本页概要猜测节点字段或
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
## 定位白板
每次操作都需要真实的文档 `nodeId` 和白板 `partId`。缺少 `partId` 时先读取文档
JSONML,查找 `cardType=hetu` 且 `metadata.id` 非空的 card;`uuid` 是 blockId,
不能当作 partId。多个候选时必须让用户选择,不能取第一个。
```bash
dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags card --format json
```
## 读取
```bash
dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json
```
CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令不支持全局
`--jq` 或 `--fields`。
## 更新
更新文件使用 OpenNodes V1 信封:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "title",
"type": "text",
"x": 40,
"y": 40,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{"text": "方案"}]
}
]
}
}
]
}
}
```
- `overwrite=false`:追加,`nodes` 至少一个对象。
- `overwrite=true`:整页重建,允许空数组;执行前必须先 query 保存当前内容。
- 所有更新都是远端写入,必须先获得用户确认,再加 `--yes`。
- Query 返回不能直接作为 update 输入;真实节点 ID 不能用于局部修改。
```bash
dws whiteboard update --node <DOC_ID> --part-id <PART_ID> \
--source ./whiteboard.json --yes --format json
```
常用节点类型包括 `text`、`shape`、`frame`、`group`、`connector`、`vector`。
节点可用请求内临时 `id` 建立 `parentId` 或 connector 引用;服务端负责完整字段、
层级和枚举校验,未知字段会使整次更新失败。
## Vector / SVG 资源
本地 SVG 不能直接写入 OpenNodes。先上传为绑定到同一文档 nodeId 的资源:
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
将返回的 `resourceId` 和 `resourceUrl` 分别映射为 Vector resource 的
`resourceId` 与 `url`。禁止使用临时 uploadUrl、跨 nodeId 复用或传本地路径。
## 创建和删除白板卡片
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes --format json
```
insert 返回 `blockId` 和 `whiteboardId`。前者用于块删除,后者就是后续 whiteboard
命令的 partId;两者不可混用。插入成功但回查暂未取到 partId 时,命令会返回
`whiteboardId: null` 并提示稍后按 blockId 回查。
@@ -0,0 +1,45 @@
# OpenNodes V1(DWS 白板协议索引)
本目录承载 `dws whiteboard query/update` 使用的 OpenNodes V1 协议。协议按
调用阶段拆分,Agent 只加载当前任务所需章节,避免一次性读取全部内容。
## DWS 使用规则
- 只通过 `dws whiteboard query/update` 读写白板。
- 每次调用必须提供承载白板的文档 `--node` 和目标白板 `--part-id`。
- DWS 当前只支持单页白板:命令没有 `--page-id`,update 文件禁止包含
`pageId`。
- `query` 不接收请求体;CLI 会把返回的 `resultJson` 解析成对象。
- 白板命令不支持使用全局 `--jq` 或 `--fields` 过滤输出;传入任一参数都会报错,
Agent 直接读取 CLI 返回的结构化 JSON。
- `update --source` 使用 `overwrite + source` 信封;append 和 overwrite 都是
远端写入,获得用户确认后必须通过 `--yes` 显式确认。
- CLI 只预检 JSON、信封、版本和 `nodes` 数组等外层结构;节点字段、枚举、层级、
引用和业务约束由白板服务完整校验。任一层失败都不会保留部分更新。
- DWS 返回以 `success`、`nodeId`、`partId`、`resultJson` 和可选的
`resultSummary` 为准。
## 按任务读取
| 当前任务 | 必读章节 |
|---|---|
| 理解版本、兼容和命令语义 | [01-overview](open-nodes-v1/01-overview.md) |
| 读取或解释 query 结果 | [02-query](open-nodes-v1/02-query.md) |
| 构造 append、overwrite 或清空请求 | [03-update](open-nodes-v1/03-update.md) |
| 写富文本、列表、链接、主题色、渐变或阴影 | [04-text-style](open-nodes-v1/04-text-style.md) |
| 写 shape、便签、frame、group 或 connector | [05-shape-frame-group-connector](open-nodes-v1/05-shape-frame-group-connector.md) |
| 写已上传 Vector、内置 Icon 或自由 Path | [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md) |
| 处理错误、query 转写或判断 writeSupport | [07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md) |
| 选择合法 geometry 或 icon catalogId | [08-catalogs](open-nodes-v1/08-catalogs.md) |
## 强制读取规则
- 使用 `shape.geometry` 前必须读取 [08-catalogs](open-nodes-v1/08-catalogs.md),
不得猜测 geometry。
- 使用 `icon.catalogId` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md)
和 [08-catalogs](open-nodes-v1/08-catalogs.md)。
- 使用 `path` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md),
不得把它当作通用 SVG Path。
- Query 结果不能直接作为 update source;转换前必须读取
[03-update](open-nodes-v1/03-update.md) 和
[07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md)。
@@ -0,0 +1,57 @@
# DWS OpenNodes V1 协议说明
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
> 协议版本:`schemaVersion = "1.0"`,`catalogVersion = "dml-v1"`。
## 1. 协议用途
OpenNodes 是 DWS 白板命令使用的语义节点协议,提供两类能力:
- `dws whiteboard query`:返回稳定、可理解的页面和节点数据。
- `dws whiteboard update`:接收受约束的节点描述,以 `append` 或
`overwrite` 模式修改白板。
调用方只应依赖本文声明的语义字段和行为:
- `query` 不修改白板。
- `update` 全部成功或全部回滚,不返回中间状态。
- 未声明的存储字段、类型名称和处理过程不属于协议承诺。
OpenNodes V1 支持的节点类型、字段和读写范围见第 7 节。
DWS 负责身份认证和权限校验。Vector 资源准备使用 `dws doc media upload`,
具体流程见白板命令参考。
## 2. 版本与兼容原则
| 字段 | 当前值 | 作用 |
| --- | --- | --- |
| `schemaVersion` | `1.0` | 控制文档结构、节点字段和字段语义。 |
| `catalogVersion` | `dml-v1` | 控制允许写入的 DML 几何、连接线标记和内置 icon 目录。 |
V1 采用严格校验:
- 必填字段缺失会失败。
- 未声明字段会失败,不会被静默忽略。
- query-only 字段出现在 update 中会以 `readOnlyField` 失败。
- 不支持的节点类型、目录值或引用范围会失败。
- `null` 不代表“使用默认值”;除非字段类型明确允许,否则会失败。
本文列出的请求枚举值都是协议字面量,调用方必须按文档中的大小写和拼写原样传入,
不能自行转换或猜测。响应中未来可能增加可选字段,调用方应忽略不认识的响应字段。
调用方必须原样携带当前版本值。新增不兼容结构时应升级
`schemaVersion`;修改 DML、marker 或 icon 目录时应评估并升级
`catalogVersion`。
## 3. DWS 命令一览
| 命令 | 所需权限 | 效果 |
| --- | --- | --- |
| `dws whiteboard query --node ... --part-id ...` | 可查看白板 | 读取单页白板,不修改内容。 |
| `dws whiteboard update --node ... --part-id ... --source ... --yes` | 可编辑白板 | 追加节点或整页重建;所有更新都需先取得用户确认。 |
DWS 当前只支持文字文档中已有的单页内嵌白板。命令不接收 `pageId`,也不提供
创建页面、切换页面或按既有节点 ID 局部修改的能力。
@@ -0,0 +1,288 @@
# OpenNodes V1 — Query 请求、返回结构和节点公共字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 4. Query 协议
### 4.1 请求
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
`query` 不接收请求体或 `pageId`。`--node` 和 `--part-id` 的发现规则见
[白板命令参考](../../whiteboard.md)。CLI 会把服务返回的 `resultJson` JSON
字符串解析成对象。
### 4.2 返回结构
```ts
interface OpenNodesDocument {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
pages: OpenPage[];
}
interface OpenPage {
id: string;
nodes: OpenNode[];
}
```
DWS 当前只支持单页白板,因此 `pages` 固定包含一个页面。调用方仍应从返回值读取
页面 `id`,但不能把它作为 `pageId` 传给 DWS 命令。
母版节点不会作为独立页面返回,而会合并到引用它的页面 `nodes` 中,并带有:
```json
{
"source": "master",
"writeSupport": "readOnly",
"unsupportedFeatures": ["node.source.master"]
}
```
### 4.3 节点公共字段
`type` 的完整公开枚举如下。前一组可由 update 创建,后一组仅供 query 返回:
```ts
type WritableOpenNodeType =
| "shape"
| "text"
| "connector"
| "stickyNote"
| "frame"
| "group"
| "vector"
| "icon"
| "path";
type ReadOnlyOpenNodeType =
| "image"
| "pdf"
| "media"
| "webLink"
| "table"
| "chart"
| "uml"
| "swimlane"
| "mind"
| "timer"
| "placeholder"
| "unknown";
type OpenNodeType = WritableOpenNodeType | ReadOnlyOpenNodeType;
```
每个 query 节点都包含以下公共字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 白板中真实、稳定的节点 ID。 |
| `type` | `OpenNodeType` | OpenNodes 公开语义类型,取值见上方枚举。 |
| `parentId` | `string?` | group/frame 父节点 ID。无父节点时省略。 |
| `children` | `string[]?` | group/frame 的直接子节点 ID,由服务端推导。 |
| `x`、`y` | `number` | 有父节点时相对父节点;否则相对页面。单位为 px。 |
| `width`、`height` | `number` | 节点包围盒尺寸,单位为 px。连接线允许其中一个为 `0`。 |
| `angle` | `number` | 归一化到 `[0, 360)` 的角度。 |
| `absoluteBounds` | `OpenBounds` | 页面坐标系中的绝对包围盒。 |
| `layer` | `background \| normal \| foreground` | 节点所在层。 |
| `zIndex` | `number` | 同一父节点、同一 layer 内的非负顺序,值越小越靠后。 |
| `hidden` | `boolean` | 节点是否隐藏。 |
| `locked` | `boolean` | 节点是否锁定。 |
| `source` | `page \| master` | 节点来自当前页面还是母版。 |
| `writeSupport` | `readWrite \| readOnly` | 当前节点能否由 V1 update 表达。 |
| `unsupportedFeatures` | `string[]?` | 只读原因;`readWrite` 节点省略。 |
公共结构和各节点分支定义如下;分支中的字段含义与写入限制见第 7 节:
```ts
interface OpenPoint {
x: number;
y: number;
}
interface OpenBounds {
x: number;
y: number;
width: number;
height: number;
angle: number;
}
interface OpenNodeBase {
id: string;
type: OpenNodeType;
parentId?: string;
children?: string[];
x: number;
y: number;
width: number;
height: number;
angle: number;
absoluteBounds: OpenBounds;
layer: "background" | "normal" | "foreground";
zIndex: number;
hidden: boolean;
locked: boolean;
source: "page" | "master";
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenShapeNode extends OpenNodeBase {
type: "shape";
geometry: `dml:${string}`;
adjustments?: Record<string, number>;
text?: OpenText;
style?: OpenNodeStyle;
}
interface OpenTextNode extends OpenNodeBase {
type: "text";
text: OpenText;
style?: OpenNodeStyle;
}
interface OpenConnectorNode extends OpenNodeBase {
type: "connector";
start: OpenConnectorEndpoint;
end: OpenConnectorEndpoint;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyle;
resolvedPath: OpenResolvedConnectorPath;
}
interface OpenStickyNoteNode extends OpenNodeBase {
type: "stickyNote";
text?: OpenText;
style?: OpenNodeStyle;
creator?: {
displayName?: string;
hasAvatar?: boolean;
};
tags?: Array<{
id: string;
text: string;
background: OpenPaint;
}>;
}
interface OpenFrameNode extends OpenNodeBase {
type: "frame";
title?: {
text: OpenText;
box: { width: number; height: number };
};
style?: OpenNodeStyle;
presentationOrder?: number;
resizeMode: "free" | "fixedAspectRatio";
}
interface OpenGroupNode extends OpenNodeBase {
type: "group";
children: string[];
}
interface OpenVectorNode extends OpenNodeBase {
type: "vector";
resource: OpenVectorResource;
}
interface OpenIconNode extends OpenNodeBase {
type: "icon";
catalogId: string;
}
interface OpenPathNode extends OpenNodeBase {
type: "path";
path: OpenPathData;
style?: OpenNodeStyle;
}
interface OpenReadOnlyNode extends OpenNodeBase {
type: ReadOnlyOpenNodeType;
writeSupport: "readOnly";
unsupportedFeatures: string[];
}
type OpenNode =
| OpenShapeNode
| OpenTextNode
| OpenConnectorNode
| OpenStickyNoteNode
| OpenFrameNode
| OpenGroupNode
| OpenVectorNode
| OpenIconNode
| OpenPathNode
| OpenReadOnlyNode;
```
query 中 `icon.catalogId` 使用 `string`,是为了让无法映射到当前目录的既有图标仍可
被诊断;update 只能传入 7.10 节 `OpenIconCatalogId` 中列出的值。
### 4.4 Query 示例
```json
{
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"pages": [
{
"id": "page",
"nodes": [
{
"id": "real-node-id",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0,
"absoluteBounds": {
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0
},
"layer": "normal",
"zIndex": 0,
"hidden": false,
"locked": false,
"source": "page",
"writeSupport": "readWrite",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4],
"plainText": "Hello OpenNodes",
"writeSupport": "readWrite"
}
}
]
}
]
}
```
@@ -0,0 +1,249 @@
# OpenNodes V1 — Update 信封、Append/Overwrite 和公共写入字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 5. Update 协议
### 5.1 请求信封
```ts
interface OpenNodesUpdateRequest {
overwrite?: boolean;
source: {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
nodes: OpenNodeWrite[];
};
}
```
字段含义:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `overwrite` | 否 | `false` 或省略为 append;`true` 为 overwrite。 |
| `source.schemaVersion` | 是 | 必须为 `1.0`。 |
| `source.catalogVersion` | 是 | 必须为 `dml-v1`。 |
| `source.nodes` | 是 | 本次创建的节点数组。append 至少一个;overwrite 允许空数组。 |
`source` 不能直接使用 query 返回的 `OpenNodesDocument`,也不接受 `pages`。
DWS 不接受 `pageId`;传入会在 CLI 本地校验阶段失败。
V1 没有“按真实节点 ID patch 既有节点”的语义。`source.nodes` 中的每一项都会
创建一个新节点,`id` 仅是本次请求内建立父子关系和连接线引用的临时 ID:
append 是新增节点,overwrite 是整页删除后重新创建。
### 5.2 Append 与 Overwrite
> **注意:** `overwrite: true` 是破坏性操作,会删除当前白板页面的全部自有
> 节点。调用前应先 query 并确认影响范围;`nodes: []` 会清空页面。不希望删除
> 既有内容时,应使用 append。
| 行为 | append | overwrite |
| --- | --- | --- |
| `overwrite` | `false` 或省略 | `true` |
| 空 `nodes` | 禁止 | 允许,用于清空当前页面 |
| 当前页面旧节点 | 全部保留 | 删除页面自有节点后创建新节点 |
| 母版节点 | 保留 | 保留 |
| 页面级设置 | 保留 | 保留 |
| 原子性 | 全部成功或全部回滚 | 全部成功或全部回滚 |
overwrite 会替换当前页面的全部自有节点,不要求旧节点本身可由 OpenNodes V1
写入。因此页面中存在 image、PDF、复杂文本等只读节点,不会单独阻止清空页面。
overwrite 会在删除前执行安全预检;以下情况会拒绝执行:
- 节点被锁定:`lockedNode`。
- 节点不允许被删除:`deleteForbidden`。
- 页面包含无法安全保留或清理的关联数据:`unknownMetadataReference`。
- 目标节点未能完整删除:`deleteFailed`。
与被删除节点绑定且有明确清理规则的关联数据会随节点清理;无法安全保留或清理的
关联数据会使 overwrite 失败。
overwrite 只替换当前页面节点,不会重建页面,也不会修改主题等页面级设置。
白板已有的主题会被保留;白板没有有效主题时也不会自动添加。调用方应使用
已配置有效主题的白板,或者显式提供不依赖主题的颜色。
### 5.3 成功结果
```ts
interface DWSWhiteboardUpdateResponse {
success: true;
nodeId: string;
partId: string;
resultJson: {
mode: "append" | "overwrite";
createdNodeIds: string[];
idMap: Record<string, string>;
deletedNodeCount: number;
message: string;
};
}
```
| 字段 | 说明 |
| --- | --- |
| `success` | `true` 表示本次 DWS 调用成功。 |
| `nodeId` | 输入的文档节点 ID。 |
| `partId` | 输入的白板标识。 |
| `resultJson.mode` | 实际执行的模式。 |
| `resultJson.createdNodeIds` | 按请求节点顺序返回真实节点 ID。 |
| `resultJson.idMap` | 请求中显式临时 ID 到真实节点 ID 的映射。 |
| `resultJson.deletedNodeCount` | append 恒为 `0`;overwrite 为删除的页面自有节点数。 |
| `resultJson.message` | 供人阅读的结果摘要,不应作为机器判断依据。 |
响应可能增加其他可选字段;Agent 不应依赖本节未声明的字段。
示例:
```json
{
"success": true,
"nodeId": "DOC_NODE_ID",
"partId": "WHITEBOARD_PART_ID",
"resultJson": {
"mode": "append",
"createdNodeIds": ["generated-title-id", "generated-body-id"],
"idMap": {
"title": "generated-title-id",
"body": "generated-body-id"
},
"deletedNodeCount": 0,
"message": "Created 2 Whiteboard nodes"
}
}
```
## 6. Update 公共节点字段
V1 可写节点公共字段如下:
```ts
interface OpenNodeWriteBase {
id?: string;
layer?: "background" | "normal" | "foreground";
zIndex?: number;
hidden?: boolean;
}
interface OpenChildNodeWriteBase extends OpenNodeWriteBase {
parentId?: string;
}
interface OpenSizedNodeWriteBase extends OpenChildNodeWriteBase {
x: number;
y: number;
width: number;
height: number;
angle?: number;
}
interface OpenShapeNodeWrite extends OpenSizedNodeWriteBase {
type: "shape";
geometry: `dml:${string}`;
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenTextNodeWrite extends OpenSizedNodeWriteBase {
type: "text";
text: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenConnectorNodeWrite extends OpenNodeWriteBase {
type: "connector";
start: OpenConnectorEndpointWrite;
end: OpenConnectorEndpointWrite;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyleWrite;
}
interface OpenStickyNoteNodeWrite extends OpenSizedNodeWriteBase {
type: "stickyNote";
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenFrameNodeWrite extends OpenNodeWriteBase {
type: "frame";
x: number;
y: number;
width: number;
height: number;
angle?: 0;
title?: {
text: OpenTextWrite;
box?: { width: number; height: number };
};
style?: OpenNodeStyleWrite;
presentationOrder?: number;
resizeMode?: "free" | "fixedAspectRatio";
}
interface OpenGroupNodeWrite extends OpenChildNodeWriteBase {
id: string;
type: "group";
x: number;
y: number;
}
interface OpenVectorNodeWrite extends OpenSizedNodeWriteBase {
type: "vector";
resource: OpenManagedVectorResourceWrite;
}
interface OpenIconNodeWrite extends OpenSizedNodeWriteBase {
type: "icon";
catalogId: OpenIconCatalogId;
}
interface OpenPathNodeWrite extends OpenSizedNodeWriteBase {
type: "path";
path: OpenPathDataWrite;
style?: OpenNodeStyleWrite;
}
type OpenNodeWrite =
| OpenShapeNodeWrite
| OpenTextNodeWrite
| OpenConnectorNodeWrite
| OpenStickyNoteNodeWrite
| OpenFrameNodeWrite
| OpenGroupNodeWrite
| OpenVectorNodeWrite
| OpenIconNodeWrite
| OpenPathNodeWrite;
```
上面的联合类型是 update 的字段白名单。各辅助结构和完整枚举值在第 7 节定义;
未出现在对应分支中的字段不能发送。
| 字段 | 规则 |
| --- | --- |
| `id` | 可选的请求级临时 ID;非空、区分大小写、在请求内唯一。被引用时必须提供。 |
| `type` | 必填,必须是 V1 可写类型。 |
| `parentId` | 可选,引用同一请求中 group/frame 的临时 ID。 |
| `x`、`y` | 除 connector 外必填;有父节点时为父节点相对坐标。 |
| `width`、`height` | shape/text/stickyNote/frame/vector/icon/path 必填且大于 `0`;group/connector 只读。 |
| `angle` | 可选,默认 `0`;frame 只允许 `0`;group/connector 只读。 |
| `layer` | 可选;frame 默认 `background`,其他节点默认 `normal`。 |
| `zIndex` | 可选的非负整数;相同值时按请求顺序稳定排序。 |
| `hidden` | 可选布尔值,默认 `false`。 |
以下 query 字段禁止写回:
`children`、`absoluteBounds`、`locked`、`source`、`writeSupport`、
`unsupportedFeatures`。
关系规则:
- `parentId` 只能引用同一请求中的 group 或 frame。
- frame 和 connector 必须是页面直属节点,不能带 `parentId`。
- group 可以嵌套,也可以放在 frame 中。
- `children` 始终由各子节点的 `parentId` 推导。
- 父子关系不能成环。
- group 必须有临时 `id`、至少两个直接子节点,且不能全部隐藏。
@@ -0,0 +1,395 @@
# OpenNodes V1 — 支持矩阵、富文本和样式
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 7. 节点类型
### 7.1 支持矩阵
| `type` | query | update | 主要字段 |
| --- | --- | --- | --- |
| `shape` | 支持 | 支持 | `geometry`、`text?`、`style?` |
| `text` | 支持 | 支持 | `text`、`style?` |
| `connector` | 支持 | 支持 | `start`、`end`、`routing`、`waypoints?`、`style?` |
| `stickyNote` | 支持 | 支持 | `text?`、`style?` |
| `frame` | 支持 | 支持 | `title?`、`style?`、`presentationOrder?`、`resizeMode?` |
| `group` | 支持 | 支持 | 子关系通过 `parentId` 表达 |
| `image` | 支持 | 只读 | 仅公共字段 |
| `vector` | 支持 | 支持 | `resource` |
| `icon` | 支持 | 支持 | `catalogId` |
| `path` | 支持 | 支持 | `path`、`style?` |
| `pdf` | 支持 | 只读 | 仅公共字段 |
| `media` | 支持 | 只读 | 仅公共字段 |
| `webLink` | 支持 | 只读 | 仅公共字段 |
| `table` | 支持 | 只读 | 仅公共字段 |
| `chart` | 支持 | 只读 | 仅公共字段 |
| `uml` | 支持 | 只读 | 仅公共字段 |
| `swimlane` | 支持 | 只读 | 仅公共字段 |
| `mind` | 支持 | 只读 | 仅公共字段 |
| `timer` | 支持 | 只读 | 仅公共字段 |
| `placeholder` | 支持 | 只读 | 仅公共字段 |
| `unknown` | 支持 | 只读 | 未识别或尚未定义独立语义的节点统一映射到此类型 |
表中标记为只读的类型仍会完整返回公共几何、层级和顺序信息,但 update 提交会以
`nodeTypeUnsupported` 失败。
`timer`、`table`、`webLink` 不支持 V1 update。query 仅返回这些节点的公共几何、
层级、顺序和诊断字段,不承诺完整业务字段。文字 run 中的 `link` 是富文本能力,
不属于 `webLink` 节点,V1 支持读写。
`webLink` 只保留只读查询;OpenNodes V1 不支持创建或重建该节点。
### 7.2 Text
query 和 update 均支持普通段落、无序列表、有序列表、多 block、多 run 和文字
链接:
```ts
interface OpenTextRun {
text: string;
marks?: {
fontFamily?: string;
fontSize?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string;
highlight?: string;
};
link?: { url: string };
}
interface OpenTextBlock {
type: "paragraph" | "bulletList" | "orderedList";
horizontalAlign?: "left" | "center" | "right";
runs: OpenTextRun[];
}
interface OpenTextWrite {
blocks: OpenTextBlock[];
verticalAlign?: "top" | "center" | "bottom";
padding?: number | [number, number];
}
interface OpenText extends OpenTextWrite {
plainText: string;
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
```
约束:
- `blocks.length >= 1`,每个 block 的 `type` 必须是 `paragraph`、
`bulletList` 或 `orderedList`。
- 每个 block 都必须满足 `runs.length >= 1`。
- run 的 `text` 不能包含 `\r`、`\n`、U+2028 或 U+2029。换行和列表项使用
独立 block 表达;每一段或每个列表项应写成一个 block,而不是把原始换行符
放进单个 run。
- 每个 `bulletList` / `orderedList` block 表示一个列表项;服务端会把连续且同类的
block 解释为同一个列表中的多个列表项。
- `link.url` 长度必须为 `1..2048`,不能包含控制字符、`<`、`>`。支持无 scheme
的相对/裸链接,以及 `http`、`https`、`mailto`、`tel`、`dingtalk` scheme;
`javascript:`、`data:` 等可执行或未知 scheme 会被拒绝。
- 相邻且 URL 相同的 linked run 会呈现为同一个链接,同时保留各 run 自己的 marks。
- `fontSize > 0`,padding 各项必须大于等于 `0`。
- `plainText`、文本级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- text 节点必须提供 `text`;shape 和 stickyNote 的 `text` 可省略。
- 受支持的 paragraph、列表、链接、多 run 都保持
`writeSupport = "readWrite"`;未知 list style、非法链接、未支持的 block 或
run marks 会令文本和所属节点变为 `readOnly`。
下面的文本会显示为两个段落,第一段由两个不同样式的 run 组成:
```json
{
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "OpenNodes ",
"marks": { "fontSize": 18, "bold": true, "color": "#2563EB" }
},
{
"text": "rich text",
"marks": { "fontSize": 18, "italic": true, "color": "#0F172A" }
}
]
},
{
"type": "paragraph",
"horizontalAlign": "right",
"runs": [
{
"text": "第二段",
"marks": { "fontSize": 16, "underline": true, "color": "#047857" }
}
]
}
],
"verticalAlign": "center",
"padding": [4, 8]
}
```
同一 `OpenTextWrite` 结构适用于独立 text 节点、shape 文本、stickyNote 文本和
frame title。
下面三个 block 会生成两个无序列表项,其中第二项包含文字链接:
```json
{
"blocks": [
{
"type": "bulletList",
"runs": [{ "text": "准备输入数据" }]
},
{
"type": "bulletList",
"runs": [
{
"text": "查看钉钉文档",
"marks": { "underline": true },
"link": { "url": "https://alidocs.dingtalk.com" }
}
]
},
{
"type": "orderedList",
"runs": [{ "text": "执行生成" }]
}
]
}
```
颜色接受以下 CSS 形式:3/4/6/8 位十六进制、颜色名,以及
`rgb()`、`rgba()`、`hsl()`、`hsla()`、`oklch()`、`lab()`、`lch()`、
`color()`。字符串最长 128 字符,不能包含控制字符、`<`、`>` 或 `;`。
这里只接受可独立解析的字面量;依赖外部样式上下文的 `var()`、`calc()`、
`color-mix()` 等动态表达式不属于 V1。颜色名必须是标准 CSS named color,
任意字母串不会被当成颜色。
### 7.3 Style
query 可表达:
- `none`、`solid`、`theme`、线性渐变、径向渐变和图片 paint。
- shadow、blur 和 unknown effect。
V1 update 支持 `none`、`solid`、`theme`、线性渐变、九方向径向渐变,以及
单个自定义 shadow。主题明暗参数和渐变色标位置使用 `0~100` 的百分比,
不会归一化成 `0~1`:
```ts
type OpenRadialGradientPosition =
| "topLeft"
| "topCenter"
| "topRight"
| "centerLeft"
| "center"
| "centerRight"
| "bottomLeft"
| "bottomCenter"
| "bottomRight";
interface OpenColorStop {
offset: number;
color: string;
opacity?: number;
}
interface OpenImagePaint {
type: "image";
resource: {
kind: "managed" | "external" | "embedded" | "unresolved";
resourceId?: string;
};
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPaint =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number;
lumOff?: number;
resolvedColor?: string;
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStop[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition | "custom";
stops: OpenColorStop[];
}
| OpenImagePaint;
type OpenEffect =
| {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
| { type: "blur"; blur: number }
| { type: "unknown" };
interface OpenNodeStyle {
opacity?: number;
fill?: OpenPaint;
stroke?: {
paint: OpenPaint;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenEffect[];
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenColorStopWrite {
offset: number; // [0, 100],百分比
color: string;
opacity?: number; // [0, 1]
}
type OpenPaintWrite =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number; // [0, 100],默认 100
lumOff?: number; // [0, 100],默认 0
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStopWrite[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition;
stops: OpenColorStopWrite[];
};
interface OpenShadowEffectWrite {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
interface OpenNodeStyleWrite {
opacity?: number;
fill?: OpenPaintWrite;
stroke?: {
paint: OpenPaintWrite;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenShadowEffectWrite[];
}
```
约束:
- opacity 范围为 `[0, 1]`。
- solid paint 的 `color` 与 `opacity` 在 query 后仍保持独立字段,不会合并为
动态 CSS 表达式。
- theme 的 `token` 必须能在当前白板主题中解析;
`lumMod`、`lumOff` 范围均为 `[0, 100]`。token 不存在时在
Request graph 阶段返回 `themeTokenNotFound`,不会写出部分节点。
token 长度为 `1~64`,首尾不能有空白,也不能包含空白、控制字符、
`<`、`>` 或 `;`。
- query 的 theme paint 还会返回按当前白板主题计算出的 query-only
`resolvedColor`。调用方写入时只传 `token/lumMod/lumOff`,不传
`resolvedColor`。
- 渐变必须至少有一个 stop;`offset` 范围为 `[0, 100]`,单位是百分比。
- 线性渐变 `angle` 范围为 `[0, 360]`。
- 径向渐变只接受上述九宫格位置。query 遇到九宫格以外的位置时返回
`position: "custom"` 并将该节点标为只读。
- `effects` 最多包含一个 `shadow`;`offsetX/offsetY` 必须是有限数,
`blur >= 0`,shadow opacity 范围为 `[0, 1]`。
- stroke width 和 dash 各项必须大于等于 `0`。
- 一旦提供 stroke,`stroke.paint` 必填。
- 样式级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- 能在当前白板主题中解析且明暗参数合法的 theme paint 可写。无法解析的主题
token、越界的主题明暗参数、image paint、blur、unknown effect、叠加 effect
或自定义径向位置会令节点只读;受支持的 theme、渐变和单个 shadow 本身不会
令节点只读。
主题色示例:
```json
{
"fill": {
"type": "theme",
"token": "ac3",
"lumMod": 20,
"lumOff": 80
},
"stroke": {
"paint": {
"type": "theme",
"token": "sk1",
"lumMod": 80,
"lumOff": 20
},
"width": 2
}
}
```
示例中的 `ac3`、`sk1` 只是主题 token 示例,不是所有白板都可用的全局枚举。
调用方只能使用已确认可由当前白板主题解析的 token;无法确认时应改用
`solid` 颜色。
DWS 不提供新增、修改或切换主题的命令。当前白板没有有效主题时,应改用
`solid`。
示例:
```json
{
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#1677ff", "opacity": 0.4 },
{ "offset": 100, "color": "#69b1ff" }
]
},
"effects": [
{
"type": "shadow",
"offsetX": 8,
"offsetY": 8,
"blur": 19,
"color": "rgba(93,190,172,1)",
"opacity": 0.5
}
]
}
```
未传 `style` 时使用节点类型的默认样式。调用方如需稳定的视觉结果,应显式传入
`fill` 和 `stroke`。
@@ -0,0 +1,186 @@
# OpenNodes V1 — Shape、Text、Sticky note、Frame、Group 和 Connector
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.4 Shape
shape 必须提供 `geometry`,格式为 `dml:<name>`:
```json
{
"id": "shape-1",
"type": "shape",
"x": 100,
"y": 80,
"width": 160,
"height": 100,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "solid",
"color": "#DCEEFF"
},
"stroke": {
"paint": {
"type": "solid",
"color": "#225588"
},
"width": 2
}
}
}
```
query 可能返回 `adjustments`,但 V1 update 不支持写入;带 adjustments 的
shape 会标为只读。`dml-v1` 的完整 geometry 目录见附录 A。
### 7.5 Text node 与 Sticky note
text node 使用公共几何、必填 `text` 和可选 `style`。
stickyNote 使用公共几何、可选 `text` 和可选 `style`。省略 `text` 时创建空
便签。query 还可能返回:
- `creator`:创建者展示信息。
- `tags`:标签 ID、文本和背景 paint。
这两个字段是 query-only;存在 creator 或 tags 的 stickyNote 会被标为只读。
### 7.6 Frame
frame 的 update 结构见第 6 节 `OpenFrameNodeWrite`。
约束:
- frame 必须是页面直属节点,不能带 `parentId`。
- angle 只允许 `0`。
- `presentationOrder` 是非负整数,并且不能和已有或本次创建的 frame 冲突。
- frame 的子节点通过子节点 `parentId` 引用 frame 临时 ID。
- frame 不能包含 frame 或 connector。
- frame 默认 layer 为 `background`。
### 7.7 Group
group 的写入字段只有公共字段中的 `id`、`parentId?`、`x`、`y`、`layer?`、
`zIndex?` 和 `hidden?`。其 width、height、angle 和 children 都由服务端根据
子节点推导。
group 必须:
- 提供临时 `id`。
- 至少包含两个直接子节点。
- 至少有一个直接子节点可见。
group 的任一子节点为只读时,query 会把 group 一并标为只读。
### 7.8 Connector
connector 的几何由端点和路由推导,因此 update 不能提供 `x`、`y`、
`width`、`height` 或 `angle`。
query 的连接线结构如下:
```ts
type OpenConnectorRouting =
| "straight"
| "polyline"
| "curve"
| "orthogonal";
interface OpenConnectorMarker {
catalogId: string;
}
type OpenConnectorAnchor =
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
position: OpenPoint;
}
| {
mode: "fixed";
side: "custom";
position: OpenPoint;
};
type OpenConnectorEndpoint =
| {
type: "point";
point: OpenPoint;
marker: OpenConnectorMarker;
}
| {
type: "node";
nodeRef: { scope: "document"; id: string };
anchor: OpenConnectorAnchor;
resolvedPoint: OpenPoint;
marker: OpenConnectorMarker;
};
interface OpenBezierSegment {
start: OpenPoint;
control1: OpenPoint;
control2: OpenPoint;
end: OpenPoint;
}
type OpenResolvedConnectorPath =
| { type: "polyline"; points: OpenPoint[] }
| { type: "bezier"; segments: OpenBezierSegment[] };
```
update 端点有两种形式:
```ts
type OpenConnectorEndpointWrite =
| {
type: "point";
point: { x: number; y: number };
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
}
| {
type: "node";
nodeRef: { scope: "request"; id: string };
anchor?:
| { mode: "auto" }
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
};
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
};
```
query 的 marker `catalogId` 使用 `string`,以便返回无法映射的既有 marker;
update 只接受上面列出的 `"none"`、`"arrow.open"`、`"arrow.filled"`。
路由规则:
| `routing` | `waypoints` |
| --- | --- |
| `straight` | 禁止提供,包括空数组。 |
| `polyline` | 必须至少提供一个。 |
| `curve` | 可选。 |
| `orthogonal` | 可选;显式点路径的相邻线段必须水平或垂直。 |
其他约束:
- 所有 point 和 waypoint 都使用页面绝对坐标。
- node 端点只能引用同一请求中的 shape、text、stickyNote、frame、group 或 path。
- node 端点不能引用隐藏节点、connector 或 query 中既有节点。
- update 引用范围固定为 `scope: "request"`;query 返回的节点引用范围为
`scope: "document"`,不能直接回写。
- 同一连接线的两端不能引用同一个节点。
- 零长度或无效路径会被拒绝。
- marker 省略时默认为 `none`。
- anchor 省略时按 `auto` 处理。
query 额外返回服务端解析后的:
- node 端点 `resolvedPoint`。
- fixed anchor 的归一化 `position`。
- `resolvedPath`,类型为 polyline points 或 cubic bezier segments。
- 由真实路径推导的 `absoluteBounds`。
这些解析字段都是 query-only。
@@ -0,0 +1,313 @@
# OpenNodes V1 — Vector、Icon 和 Path
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.9 Vector(已上传 SVG/矢量资源)
`vector` 表示已经通过 `dws doc media upload` 获得稳定引用的 SVG/矢量图片。
OpenNodes 只接收上传结果中的资源引用,不接收本地路径或原始 SVG/XML 内容。
上传时必须使用与后续白板更新相同的文档 `nodeId`:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--format json
```
将上传结果的 `resourceId` 和 `resourceUrl` 分别写入
`resource.resourceId` 和 `resource.url`。
Update 必须提供完整的托管资源信息:
```ts
interface OpenManagedVectorResourceWrite {
kind: "managed";
resourceId: string;
url: string;
}
```
完整节点结构见第 6 节 `OpenVectorNodeWrite`。
`resource` 字段规则:
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `kind` | 是 | 当前只允许固定值 `managed`,表示资源已经通过 DWS 上传。 |
| `resourceId` | 是 | 资源稳定 ID,长度 1~256,只允许字母、数字、`.`、`_`、`:`、`-`。 |
| `url` | 是 | 已上传资源地址,最长 4096;必须包含且只能包含一个同值的 `resourceId` 查询参数。 |
`url` 必须直接使用 `dws doc media upload` 返回的 `resourceUrl`,不得自行拼装
或修改。
以下内容会被拒绝:
- 原始 SVG/XML、`data:`、`blob:`、`http:` URL。
- `//host/path` 协议相对地址,以及自行构造或修改的其他相对地址。
- 含空白、控制字符、反斜杠、fragment 或用户凭证的 URL。
- URL 缺少 `resourceId`、重复出现 `resourceId`,或者 URL 中 ID 与显式
`resource.resourceId` 不一致。
- `resource` 缺失、字段不完整、`kind` 不是 `managed`,或包含未知字段。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-svg-1",
"type": "vector",
"x": 100,
"y": 80,
"width": 240,
"height": 180,
"angle": 0,
"resource": {
"kind": "managed",
"resourceId": "0c1c94e1-f9af-4228-b32f-42bbd1555253",
"url": "https://resources.example.com/assets/opaque-path?resourceId=0c1c94e1-f9af-4228-b32f-42bbd1555253"
}
}
]
},
"overwrite": false
}
```
示例中的 `resources.example.com` 是占位域名,实际调用必须使用
`dws doc media upload` 返回的 `resourceUrl`。
`resourceId` 同时显式出现并包含在 URL 中,用于校验资源身份与地址是否一致。
两者必须来自同一次 `dws doc media upload` 结果。
Query 的 `resource` 可能是:
```ts
type OpenVectorResource =
| { kind: "managed"; resourceId: string; url: string }
| { kind: "external" | "embedded" | "unresolved" };
```
- 能识别为托管资源的引用返回完整 `managed` 信息。
- HTTP(S) 外链但不满足托管资源契约时返回 `external`。
- `data:`/`blob:` 返回 `embedded`。
- 其他缺失或无法识别的地址返回 `unresolved`。
- 非 `managed` 资源会令节点只读,并分别产生
`vector.resource.external`、`vector.resource.embedded` 或
`vector.resource.unresolved`。
V1 vector update 不接受 `style`。既有节点包含 V1 无法表达的 fill、stroke、
opacity、effect 或 adjustments 时,query 仍可读取资源和几何,但节点会标为只读。
不影响资源内容的兼容性装饰不会单独令节点变为只读。
DWS 会校验资源引用。上传与 `whiteboard update` 必须使用同一个文档
`nodeId`;不要跨文档复用资源,也不要使用临时 `uploadUrl`。
### 7.10 Icon(内置图标)
`icon` 表示内置图标。OpenNodes 使用版本化的 `catalogId` 作为稳定标识,
允许值见下列类型定义和附录 B。
Update 结构:
```ts
type OpenIconCatalogId =
| `emoji/${
| "happy"
| "smile"
| "laugh"
| "fighting"
| "like"
| "ok"
| "please"
| "face-plam"
| "tears-of-joy"
| "cry"
| "question"
| "face-with-sweat"
| "bloody-nose"
| "doggy"}`
| `tools/${
| "pad"
| "blue-note"
| "yellow-notes"
| "chart"
| "chart-2"
| "pencil"
| "pen"
| "bag"
| "rocket"
| "fire"
| "gold"
| "light"
| "pin"
| "red-flag"
| "tea"
| "island"
| "ball"
| "lucky-fish"
| "coffee"
| "milky-tea"
| "pan"}`
| `priority/priority-${1 | 2 | 3 | 4 | 5 | 6 | 7}`
| `task/${
| "task-start"
| "task-oct"
| "task-3oct"
| "task-half"
| "task-5oct"
| "task-7oct"
| "task-done"}`;
```
完整节点结构见第 6 节 `OpenIconNodeWrite`。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pencil-icon",
"type": "icon",
"x": 100,
"y": 80,
"width": 48,
"height": 48,
"catalogId": "tools/pencil"
}
]
},
"overwrite": false
}
```
规则:
- `catalogId` 必填,格式为 `<group>/<name>`,并且必须精确命中附录 B 的
`dml-v1` allowlist;大小写、连字符和历史拼写都不能自行修正。
- 当前目录有 `emoji`、`tools`、`priority`、`task` 四组,共 49 项。
- update 只接受 `catalogId`,不接受 `group`、`name`、`style` 或 `resource`。
- 内置 icon 不需要调用方上传资源,也不需要 `resourceId`。
- 任意已上传 SVG 或自定义图标应使用 `vector`,并按 7.9 节提供完整
`resource` 信息,不能伪造一个 icon `catalogId`。
- icon 可以作为 group/frame 子节点;V1 connector 的 node 端点当前仍不接受
icon 作为目标。
Query 返回相同的 `catalogId`。既有 icon 无法映射到当前目录时,节点仍会以
`type: "icon"` 返回以便诊断,但 `writeSupport` 为 `readOnly`,原因包含
`icon.catalogId`。非默认 opacity、filter/effect、text 或 adjustments 同样会令节点
只读。
### 7.11 Path(自由画笔)
`path` 表示自由画笔轨迹。OpenNodes 保留两组互相独立的尺寸:
- 节点公共 `width` / `height` 是画布上的实际渲染尺寸,缩放节点时会变化。
- `path.intrinsicWidth` / `path.intrinsicHeight` 是 SVG path 自身的坐标空间尺寸,
表示 `path.data` 使用的内部坐标空间。
```ts
interface OpenPathData {
data: string;
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPathDataWrite = OpenPathData;
```
完整 update 节点结构见第 6 节 `OpenPathNodeWrite`。query 和 update 的 `path`
字段结构相同,但 update 仍必须满足下方命令子集和大小限制。
```json
{
"id": "freehand-stroke",
"type": "path",
"x": 120,
"y": 100,
"width": 500,
"height": 150,
"path": {
"data": "M0,75 Q50,0 100,75 Q150,150 200,75 Q250,0 300,75 Q350,150 400,75 Q450,0 500,75",
"intrinsicWidth": 500,
"intrinsicHeight": 150
},
"style": {
"fill": { "type": "none" },
"stroke": {
"paint": { "type": "solid", "color": "#7C3AED" },
"width": 10,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
下面是一个仍然只使用 V1 命令子集、但包含 12 段二次贝塞尔曲线的蝴蝶轮廓。
它显式回到起点,因此不需要使用尚未支持的 `Z`:
```json
{
"id": "complex-butterfly-path",
"type": "path",
"x": 1500,
"y": 1215,
"width": 500,
"height": 420,
"path": {
"data": "M250,180 Q210,105 145,70 Q55,25 35,110 Q10,195 125,220 Q35,275 80,355 Q125,415 210,315 Q235,285 250,250 Q265,285 290,315 Q375,415 420,355 Q465,275 375,220 Q490,195 465,110 Q445,25 355,70 Q290,105 250,180",
"intrinsicWidth": 500,
"intrinsicHeight": 420
},
"style": {
"opacity": 0.96,
"fill": {
"type": "solid",
"color": "#EDE9FE",
"opacity": 0.72
},
"stroke": {
"paint": {
"type": "solid",
"color": "#6D28D9"
},
"width": 8,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
V1 path 写入约束如下:
- `data` 必须是一个绝对 `M`,后跟至少一个显式写出的绝对 `Q`;不接受相对命令,
也不接受 `L`、`C`、`A`、`Z` 等通用 SVG 命令。
- 所有命令参数必须完整且为有限数值;单节点 `data` 最长 1 MiB,最多 50,000 个
命令。
- `intrinsicWidth` 和 `intrinsicHeight` 必须为有限正数;它们不要求等于节点的
`width` 和 `height`。
- 未传 `style` 时,默认使用透明填充、`#222222` 描边、宽度 5,以及 round
line cap/join。需要稳定视觉结果时仍应显式传入 `style`。
- path 可以作为 group/frame 的子节点,也可以作为同一 update 请求中 connector
的 node 端点。
query 会把其他能够解析的 SVG path 保留为 `type: "path"`,但标记为
`readOnly`,原因包含 `path.commands`;超出上述 V1 限制时使用
`path.data.size` 或 `path.commands.limit`。既有 path 带 text、adjustments、非
`nonzero` fillRule 或 V1 无法表达的样式时也会只读。仅包含不影响画笔几何的
兼容性信息时,不会因此变为只读。theme stroke 遵循通用 style 契约:query 会
保留 token 和明暗参数;只要 token 能在当前白板主题中解析,就可以按 theme
paint 回写。
@@ -0,0 +1,270 @@
# OpenNodes V1 — Update 示例、回写规则、错误模型和 writeSupport
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 8. Update 示例
### 8.1 Append 一个文本节点
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "title",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4]
}
}
]
}
}
```
### 8.2 Append 两个形状和一条引用连接线
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "left",
"type": "shape",
"x": 80,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "right",
"type": "shape",
"x": 360,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "line",
"type": "connector",
"start": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "left"
},
"anchor": {
"mode": "fixed",
"side": "right"
}
},
"end": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "right"
},
"anchor": {
"mode": "fixed",
"side": "left"
},
"marker": {
"catalogId": "arrow.filled"
}
},
"routing": "straight"
}
]
}
}
```
### 8.3 Overwrite 整页
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "replacement",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [
{
"text": "Replacement content"
}
]
}
]
}
}
]
}
}
```
### 8.4 清空当前页面
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
## 9. Query 数据不能直接回写
query 是完整可读投影,update 是受约束的创建协议,两者不是对称 JSON:
| Query 字段/能力 | Update 处理方式 |
| --- | --- |
| 真实 `id` | 只能作为请求级临时 ID;不能引用既有 document 节点。 |
| `children` | 删除,通过子节点 `parentId` 重建。 |
| `absoluteBounds` | 删除;普通节点使用 `x/y/width/height`,connector 使用端点和路由字段。 |
| `locked`、`source`、`writeSupport`、`unsupportedFeatures` | 删除,均为 query-only。 |
| 文本 `plainText` | 删除,由服务端根据 paragraph 和 run 重新计算。 |
| 多 paragraph、列表、多 run、文字链接 | 可以保留;每个 block 必须是受支持类型,且 run 内不能包含原始换行符。 |
| 未知 list style、非法链接或未支持的 block/marks | 需要移除或降级为受支持的 block/run。 |
| theme paint | 保留 `token/lumMod/lumOff`,删除 query-only `resolvedColor`;token 必须能在当前白板主题中解析。 |
| image paint | 需要降级成受支持的 paint,或不更新该节点。 |
| 受支持的 linear/radial gradient、单个 shadow | 可以保留;gradient offset 使用 `0~100`,radial `custom` 不能回写。 |
| blur、unknown 或叠加 effects | 需要删除、降级成单个 shadow,或不更新该节点。 |
| connector `scope: "document"` | 不能回写;改为引用同一请求节点的 `scope: "request"`。 |
| connector `resolvedPoint`、`position`、`resolvedPath` | 删除,均由服务端重新计算。 |
| shape `adjustments` | V1 不支持写入。 |
| stickyNote `creator`、`tags` | V1 不支持写入。 |
即使 query 节点显示 `writeSupport = "readWrite"`,update 仍会对版本、目录、
字段和请求关系做完整校验。调用方不应跳过 update 错误处理。
## 10. 错误模型
### 10.1 顶层错误码
| 错误码 | 含义 |
| --- | --- |
| `invalidRequest.whiteboard.schemaInvalid` | JSON、字段或节点 schema 不合法。 |
| `invalidRequest.whiteboard.catalogVersionUnsupported` | catalogVersion 不受支持。 |
| `invalidRequest.whiteboard.validationFailed` | 节点间引用、父子关系或路径关系不合法。 |
| `invalidRequest.whiteboard.emptySource` | append 的 nodes 为空。 |
| `invalidRequest.whiteboard.overwriteUnsafe` | overwrite 安全预检失败。 |
### 10.2 DWS 错误输出
远端校验失败时,DWS 以统一 CLI 错误结构返回:
```json
{
"error": {
"category": "api",
"reason": "business_error",
"server_key": "whiteboard",
"server_error_code": "invalidRequest.whiteboard.validationFailed",
"message": "Whiteboard request graph is invalid",
"trace_id": "TRACE_ID"
}
}
```
部分服务错误会使用更宽泛的 `invalidRequest.inputArgs.invalid`。Agent 应结合
`server_error_code` 和 `message` 修正输入;需要排障时保留 `trace_id`。JSON 或
信封级错误可能由 CLI 本地返回,不一定包含 `server_key` 和 `trace_id`。
校验类错误不可通过原样重试恢复。常见原因包括:
- Schema:缺少字段、未知字段、类型或枚举值错误、提交 query-only 字段、
节点类型不支持。
- 请求关系:临时 ID 重复、引用不存在、父子关系非法、连接线目标不支持、
路径退化或主题 token 不存在。
- Overwrite 预检:锁定节点、禁止删除、关联数据无法安全处理或删除失败。
任一阶段失败都不会保留部分更新。
## 11. writeSupport 的含义
`writeSupport` 表示当前 query 节点是否能由 V1 update 无损表达,不代表用户
权限,也不代表 overwrite 是否允许移除该既有节点。
以下 `unsupportedFeatures` 均为对外返回的诊断枚举值:
- `node.source.master`、`node.locked`、`node.role`、`node.placeholder`、
`node.extras`、`node.ability`。
- `node.type.image`、`node.type.pdf`、`node.type.media`、
`node.type.webLink`、`node.type.table`、`node.type.chart`、
`node.type.uml`、`node.type.swimlane`、`node.type.mind`、
`node.type.timer`、`node.type.placeholder`、`node.type.unknown`。
- `text.list.unsupported`、`text.link.unsupported`、`text.block.unsupported`、
`text.lineBreak.unsupported`、`text.marks.unsupported`、
`text.color.unsupported`、`text.highlight.unsupported`。
- `style.fill.color`、`style.fill.opacity`、`style.fill.theme.token`、
`style.fill.theme.unresolved`、`style.fill.theme.modifier`、
`style.fill.theme.opacity`、
`style.fill.gradient.angle`、`style.fill.gradient.offset`、
`style.fill.gradient.color`、`style.fill.gradient.opacity`、
`style.fill.gradient.position`、`style.fill.image`。
- `style.stroke.color`、`style.stroke.opacity`、`style.stroke.theme.token`、
`style.stroke.theme.unresolved`、
`style.stroke.theme.modifier`、`style.stroke.theme.opacity`、
`style.stroke.gradient.angle`、`style.stroke.gradient.offset`、
`style.stroke.gradient.color`、`style.stroke.gradient.opacity`、
`style.stroke.gradient.position`、`style.stroke.image`。
- `style.effects`。
- `shape.adjustments`、`stickyNote.creator`、`stickyNote.tags`。
- `vector.resource.external`、`vector.resource.embedded`、
`vector.resource.unresolved`、`vector.fill`、`vector.stroke`、
`vector.opacity`、`vector.effect`、`vector.adjustments`。
- `icon.catalogId`、`icon.opacity`、`icon.effect`、`icon.text`、
`icon.adjustments`。
- `path.commands`、`path.data.size`、`path.commands.limit`、`path.text`、
`path.fillRule`、`path.adjustments`。
- `connector.parent`、`connector.marker.unsupported`、
`connector.target.unexposed`、`connector.target.unsupported`、
`connector.anchor.unresolved`、`connector.anchor.custom`、
`connector.selfLoop`。
- `group.angle`、`group.children.minimum`、`group.children.hidden`、
`group.child.readOnly`。
- `frame.angle`、`frame.child.frame`、`frame.child.readOnly`。
调用方应把 `unsupportedFeatures` 当作诊断信息,不应把当前枚举穷举写死为
业务逻辑。真正可写与否以 `writeSupport` 和 update 校验结果为准。
@@ -0,0 +1,61 @@
# OpenNodes V1 — dml-v1 Geometry 和 Icon 完整目录
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 附录 A:dml-v1 geometry 目录
写入时在以下名称前加 `dml:`,例如 `rect` 写成 `dml:rect`。当前目录共
183 项,目录版本由 `catalogVersion = "dml-v1"` 标识。
```text
accentBorderCallout1 accentBorderCallout2 accentBorderCallout3
accentCallout1 accentCallout2 accentCallout3 actionButtonBackPrevious
actionButtonBeginning actionButtonBlank actionButtonDocument actionButtonEnd
actionButtonForwardNext actionButtonHelp actionButtonHome
actionButtonInformation actionButtonMovie actionButtonReturn actionButtonSound
active allGeneralization arc attribute bentArrow bentUpArrow bevel bind blockArc
borderCallout1 borderCallout2 borderCallout3 bracePair bracketPair callout1
callout2 callout3 can chevron chord circularArrow cloud cloudCallout comment
component control convert corner cube curvedDownArrow curvedLeftArrow
curvedRightArrow curvedUpArrow dataStorage decagon delete diagStripe diamond
dodecagon donut doubleWave downArrow downArrowCallout ellipse ellipseRibbon
ellipseRibbon2 entity entitySet flowChartAlternateProcess flowChartCollate
flowChartConnector flowChartDecision flowChartDelay flowChartDisplay
flowChartDocument flowChartExtract flowChartInputOutput flowChartInternalStorage
flowChartMagneticDisk flowChartMagneticDrum flowChartMagneticTape
flowChartManualInput flowChartManualOperation flowChartMerge
flowChartMultidocument flowChartOffpageConnector flowChartOnlineStorage
flowChartOr flowChartPredefinedProcess flowChartPreparation
flowChartPunchedCard flowChartPunchedTape flowChartSort
flowChartSummingJunction flowChartTerminator foldedCorner frame generalization
halfFrame heart heptagon hexagon history homePlate horizontalDivCircle
horizontalScroll irregularSeal1 irregularSeal2 leftArrow leftArrowCallout
leftBrace leftBracket leftRightArrow leftRightArrowCallout leftRightUpArrow
leftUpArrow lightningBolt mathDivide mathEqual mathMinus mathMultiply
mathNotEqual mathPlus moon multiClass multiValuedAttribute noSmoking node
nonIsoscelesTrapezoid notchedRightArrow octagon parallelogram pentagon person
pie plaque plus quadArrow quadArrowCallout receiveSignal rect relationship
ribbon ribbon2 rightArrow rightArrowCallout rightBrace rightBracket round1Rect
round2DiagRect round2SameRect roundRect rtTriangle smileyFace snip1Rect
snip2DiagRect snip2SameRect snipRoundRect star10 star12 star16 star24 star32
star4 star5 star6 star7 star8 stripedRightArrow sun teardrop triangle upArrow
upArrowCallout upDownArrow user uturnArrow verticalDivCircle verticalScroll
wave weakEntitySet weakRelationship wedgeEllipseCallout wedgeRectCallout
wedgeRoundRectCallout
```
## 附录 B:dml-v1 icon 目录
写入时必须使用完整的 `<group>/<name>`。当前共 4 组 49 项,目录版本由
`catalogVersion = "dml-v1"` 标识。
| group | 数量 | name(组成 `group/name`) |
| --- | ---: | --- |
| `emoji` | 14 | `happy`、`smile`、`laugh`、`fighting`、`like`、`ok`、`please`、`face-plam`、`tears-of-joy`、`cry`、`question`、`face-with-sweat`、`bloody-nose`、`doggy` |
| `tools` | 21 | `pad`、`blue-note`、`yellow-notes`、`chart`、`chart-2`、`pencil`、`pen`、`bag`、`rocket`、`fire`、`gold`、`light`、`pin`、`red-flag`、`tea`、`island`、`ball`、`lucky-fish`、`coffee`、`milky-tea`、`pan` |
| `priority` | 7 | `priority-1`、`priority-2`、`priority-3`、`priority-4`、`priority-5`、`priority-6`、`priority-7` |
| `task` | 7 | `task-start`、`task-oct`、`task-3oct`、`task-half`、`task-5oct`、`task-7oct`、`task-done` |
注意:`emoji/face-plam` 是 V1 保留的历史兼容枚举值,拼写虽然异常但属于协议值;
传入 `emoji/face-palm` 会被拒绝。
@@ -0,0 +1,308 @@
# 钉钉白板常用 Recipes
以下各 Recipe 的 JSON 都写入本地文件,再通过 `dws whiteboard update --source <FILE.json>`
传给白板。执行任何远端写入前,必须先向用户展示影响并取得明确确认;确认后
才可添加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source <FILE.json> \
--yes \
--format json
```
每次更新后都要再次执行 `dws whiteboard query ... --format json` 回读验证。
## 1. 追加两个流程节点和一条箭头
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "start",
"type": "shape",
"x": 80,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "读取需求", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"id": "finish",
"type": "shape",
"x": 360,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "输出结果", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"type": "connector",
"start": {
"type": "node",
"nodeRef": { "scope": "request", "id": "start" },
"anchor": { "mode": "fixed", "side": "right" }
},
"end": {
"type": "node",
"nodeRef": { "scope": "request", "id": "finish" },
"anchor": { "mode": "fixed", "side": "left" },
"marker": { "catalogId": "arrow.filled" }
},
"routing": "straight"
}
]
}
}
```
## 2. 追加带渐变和阴影的卡片
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "styled-card",
"type": "shape",
"x": 80,
"y": 260,
"width": 260,
"height": 120,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#DBEAFE" },
{ "offset": 100, "color": "#A7F3D0" }
]
},
"stroke": {
"paint": { "type": "solid", "color": "#2563EB" },
"width": 2
},
"effects": [
{
"type": "shadow",
"offsetX": 5,
"offsetY": 7,
"blur": 18,
"color": "#0F172A",
"opacity": 0.22
}
]
},
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [
{
"text": "复杂样式",
"marks": { "fontSize": 20, "bold": true, "color": "#0F172A" }
}
]
}
],
"verticalAlign": "center"
}
}
]
}
}
```
## 3. Frame 中放置分支流程
先创建 frame,再让子节点通过 `parentId` 引用 frame 的临时 ID。子节点坐标相对
frame 左上角:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pipeline",
"type": "frame",
"x": 60,
"y": 440,
"width": 720,
"height": 300,
"title": {
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{ "text": "生成流水线", "marks": { "bold": true } }]
}
]
}
}
},
{
"id": "branch-a",
"type": "shape",
"parentId": "pipeline",
"x": 60,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
},
{
"id": "branch-b",
"type": "shape",
"parentId": "pipeline",
"x": 420,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
}
]
}
}
```
## 4. 上传 SVG 并追加 Vector
Vector 固定使用“上传 → 字段映射 → update → query”流程,并且所有命令使用同一个
`DOC_NODE_ID`。
先上传 SVG。该命令只准备资源,不会插入文档正文:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--yes \
--format json
```
从成功输出取 `resourceId` 和 `resourceUrl`:
```json
{
"nodeId": "<DOC_NODE_ID>",
"resourceId": "resource-stable-id",
"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id",
"fileName": "icon.svg",
"mimeType": "image/svg+xml",
"size": 1024
}
```
写入 `whiteboard-vector.json`,其中 `resourceId` 原样映射到
`resource.resourceId`,`resourceUrl` 映射到 `resource.url`:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-vector",
"type": "vector",
"x": 80,
"y": 80,
"width": 160,
"height": 160,
"resource": {
"kind": "managed",
"resourceId": "resource-stable-id",
"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"
}
}
]
}
}
```
执行更新:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./whiteboard-vector.json \
--yes \
--format json
```
最后独立回读,不以 update 的成功响应替代验证:
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
禁止跨 nodeId 复用资源,也不要把本地 SVG 路径、独立文件节点 URL 或临时
`uploadUrl` 写入 `resource.url`。
## 5. 整页替换或清空
把文件设为 `overwrite: true` 后,命令必须加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./overwrite.json \
--yes \
--format json
```
清空整页:
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
仅当用户明确要求清空时使用。执行前必须 query、展示影响摘要并获得确认。
+35 -3
View File
@@ -1,7 +1,7 @@
[root]
runnable: true
hidden: false
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent
[agoal]
@@ -3247,7 +3247,7 @@
[doc]
runnable: true
hidden: false
commands: +comment-create, +comment-list, +comment-reply, +copy, +doc-append, +export-get, +export-submit, +find-doc, +list, +move, +search, +share-doc, +template-list, +template-search, +version-list, +version-revert, +version-save, block, comment, create, export, file, import, info, media, read, template, update, version
commands: +comment-create, +comment-list, +comment-reply, +copy, +doc-append, +export-get, +export-submit, +find-doc, +list, +move, +search, +share-doc, +template-list, +template-search, +version-list, +version-revert, +version-save, block, comment, create, export, file, import, info, media, read, template, update, version, whiteboard
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.+comment-create]
@@ -3443,7 +3443,7 @@
[doc.media]
runnable: true
hidden: false
commands: download, insert
commands: download, insert, upload
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.media.download]
@@ -3456,6 +3456,11 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file:string|required=false|hidden=false|no-opt=""|scope=local, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, --file-path:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --index:int|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mime-type:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --name:string|required=false|hidden=false|no-opt=""|scope=local, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --ref-block:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --where:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.media.upload]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file:string|required=false|hidden=false|no-opt=""|scope=local, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, --file-path:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mime-type:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --name:string|required=false|hidden=false|no-opt=""|scope=local, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
[doc.read]
runnable: true
hidden: false
@@ -3509,6 +3514,17 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.whiteboard]
runnable: true
hidden: false
commands: insert
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.whiteboard.insert]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --index:int|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --parent-block:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --ref-block:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --where:string|required=false|hidden=false|no-opt=""|scope=local, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
[doctor]
runnable: true
hidden: false
@@ -5678,6 +5694,22 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[whiteboard]
runnable: true
hidden: false
commands: query, update
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[whiteboard.query]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --part-id:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[whiteboard.update]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --part-id:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --source:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
[wiki]
runnable: true
hidden: false
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package unit_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWhiteboardQuickExamplesUseWritableTextNodes(t *testing.T) {
paths := []string{
"../../skills/mono/references/products/whiteboard.md",
"../../skills/multi/dingtalk-misc/references/whiteboard.md",
}
for _, path := range paths {
path := path
t.Run(filepath.Base(filepath.Dir(path))+"/"+filepath.Base(path), func(t *testing.T) {
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
payload := firstJSONFence(t, string(data))
source, _ := payload["source"].(map[string]any)
nodes, _ := source["nodes"].([]any)
if len(nodes) == 0 {
t.Fatalf("quick example has no source.nodes: %#v", payload)
}
node, _ := nodes[0].(map[string]any)
if node["type"] != "text" {
t.Fatalf("quick example first node type = %#v, want text", node["type"])
}
for _, dimension := range []string{"width", "height"} {
value, ok := node[dimension].(float64)
if !ok || value <= 0 {
t.Errorf("quick example %s = %#v, want positive number", dimension, node[dimension])
}
}
text, ok := node["text"].(map[string]any)
if !ok {
t.Fatalf("quick example text = %#v, want OpenNodes text object", node["text"])
}
blocks, _ := text["blocks"].([]any)
if len(blocks) == 0 {
t.Fatalf("quick example text.blocks = %#v, want non-empty array", text["blocks"])
}
})
}
}
func TestWhiteboardRecipesAreDeliveredToBothSkillSurfaces(t *testing.T) {
monoPath := "../../skills/mono/references/products/whiteboard/recipes.md"
multiPath := "../../skills/multi/dingtalk-misc/references/whiteboard/recipes.md"
mono, err := os.ReadFile(monoPath)
if err != nil {
t.Fatal(err)
}
multi, err := os.ReadFile(multiPath)
if err != nil {
t.Fatal(err)
}
if string(mono) != string(multi) {
t.Fatal("mono and multi whiteboard recipes differ")
}
content := string(mono)
for _, required := range []string{
"## 1. 追加两个流程节点和一条箭头",
"## 2. 追加带渐变和阴影的卡片",
"## 3. Frame 中放置分支流程",
"## 4. 上传 SVG 并追加 Vector",
"## 5. 整页替换或清空",
"执行任何远端写入前,必须先向用户展示影响并取得明确确认",
`"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"`,
`"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"`,
} {
if !strings.Contains(content, required) {
t.Errorf("whiteboard recipes missing %q", required)
}
}
for _, fence := range bashFences(content) {
if strings.Contains(fence, "dws whiteboard update") || strings.Contains(fence, "dws doc media upload") {
if !strings.Contains(fence, "--yes") {
t.Errorf("whiteboard write example lacks --yes:\n%s", fence)
}
}
}
}
func bashFences(markdown string) []string {
const marker = "```bash\n"
var fences []string
for {
start := strings.Index(markdown, marker)
if start < 0 {
return fences
}
markdown = markdown[start+len(marker):]
end := strings.Index(markdown, "\n```")
if end < 0 {
return fences
}
fences = append(fences, markdown[:end])
markdown = markdown[end+len("\n```"):]
}
}
func firstJSONFence(t *testing.T, markdown string) map[string]any {
t.Helper()
const marker = "```json\n"
start := strings.Index(markdown, marker)
if start < 0 {
t.Fatal("markdown has no JSON fence")
}
start += len(marker)
end := strings.Index(markdown[start:], "\n```")
if end < 0 {
t.Fatal("JSON fence is not closed")
}
var payload map[string]any
if err := json.Unmarshal([]byte(markdown[start:start+end]), &payload); err != nil {
t.Fatalf("decode first JSON fence: %v", err)
}
return payload
}