Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a5cc42ce | ||
|
|
fec750b09e | ||
|
|
ddd5f15b91 | ||
|
|
db50be868b | ||
|
|
a6220d7d8b | ||
|
|
81bf0d2a6b | ||
|
|
f3a95d34a3 | ||
|
|
a37e6e6847 | ||
|
|
0ceb96c745 | ||
|
|
114503d52f | ||
|
|
9de1c9c304 | ||
|
|
840e1d665f | ||
|
|
f362c8c2a4 | ||
|
|
e73a1556ce | ||
|
|
186f2fa474 | ||
|
|
d91a93c43b | ||
|
|
08254e2a36 | ||
|
|
fdd9e189d6 | ||
|
|
eebdf52da9 | ||
|
|
9eaee76a51 | ||
|
|
d7c28bcfef | ||
|
|
287b079c18 | ||
|
|
50f8ade1d7 | ||
|
|
b87cad1eb5 | ||
|
|
0f2eec145e | ||
|
|
64c2e8544c | ||
|
|
fc31fddd73 | ||
|
|
4298d0833b | ||
|
|
7e0957d9e8 |
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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`。
|
||||
+186
@@ -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 回写。
|
||||
+270
@@ -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、展示影响摘要并获得确认。
|
||||
@@ -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 知识库中
|
||||
|
||||
@@ -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`。
|
||||
+186
@@ -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。
|
||||
+313
@@ -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 回写。
|
||||
+270
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user