From d8cb216f9247f8a2e41273fe55b872579546d805 Mon Sep 17 00:00:00 2001 From: "sunpeiyang.996" Date: Tue, 7 Jul 2026 19:44:13 +0800 Subject: [PATCH] feat: support whiteboard file inputs in docs XML --- shortcuts/doc/html5_block_resources.go | 319 +++++++++++++++++- shortcuts/doc/html5_block_resources_test.go | 84 +++++ .../references/lark-doc-whiteboard.md | 4 + skills/lark-doc/references/lark-doc-xml.md | 2 +- 4 files changed, 407 insertions(+), 2 deletions(-) diff --git a/shortcuts/doc/html5_block_resources.go b/shortcuts/doc/html5_block_resources.go index 80955ba58..2683fe7af 100644 --- a/shortcuts/doc/html5_block_resources.go +++ b/shortcuts/doc/html5_block_resources.go @@ -27,12 +27,17 @@ const ( html5BlockDataAttr = "data" html5BlockReferenceRoot = "doc-fetch-resources" html5BlockReferenceMaxRaw = 1024 + + whiteboardTag = "whiteboard" + whiteboardTypeAttr = "type" + whiteboardPathAttr = "path" ) var ( html5BlockStartTagPattern = regexp.MustCompile(`(?is)]*>`) html5BlockElementPattern = regexp.MustCompile(`(?is)]*>(.*?)`) html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + whiteboardElementPattern = regexp.MustCompile(`(?is)]*(?:/>|>.*?)`) ) type html5BlockReferenceEntry struct { @@ -58,6 +63,11 @@ type html5BlockStartTag struct { SelfClosing bool } +type whiteboardStartTag struct { + Attrs []html5BlockAttr + SelfClosing bool +} + func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { body := buildCreateBody(runtime) if runtime.Str("content") == "" && !runtime.Changed("reference-map") { @@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn return docsV2WriteInput{}, err } - content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap) + content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content) + if err != nil { + return docsV2WriteInput{}, err + } + content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap) if err != nil { return docsV2WriteInput{}, err } @@ -232,6 +246,213 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string return out, compactReferenceMap(refMap), nil } +func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) { + if !strings.Contains(content, " 0 { + return "", aggregateWhiteboardRewriteErrors(rewriteErrs) + } + return out, nil +} + +func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) { + startRaw, body, _, ok := splitWhiteboardElement(raw) + if !ok { + return raw, nil + } + tag, err := parseWhiteboardStartTag(startRaw) + if err != nil { + return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard") + } + + pathValue, hasPath := tag.attr(whiteboardPathAttr) + bodyPath, hasBodyPath := whiteboardBodyPathRef(body) + if !hasPath && !hasBodyPath { + return raw, nil + } + if hasPath && strings.TrimSpace(body) != "" { + return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard") + } + if hasPath && hasBodyPath { + return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard") + } + + typRaw, ok := tag.attr(whiteboardTypeAttr) + if !ok || strings.TrimSpace(typRaw) == "" { + return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type") + } + typ, ok := canonicalWhiteboardFileType(typRaw) + if !ok { + return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type") + } + + if hasBodyPath { + pathValue = bodyPath + } + data, err := readWhiteboardPath(runtime, pathValue, typ) + if err != nil { + return "", err + } + + tag.setAttr(whiteboardTypeAttr, typ) + tag.removeAttrs(whiteboardPathAttr) + return tag.render(false) + whiteboardContentForType(typ, data) + "", nil +} + +func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) { + trimmed := strings.TrimSpace(raw) + selfClosing = strings.HasSuffix(trimmed, "/>") + if selfClosing { + return raw, "", true, true + } + startEnd := strings.Index(raw, ">") + if startEnd < 0 { + return "", "", false, false + } + endStart := strings.LastIndex(strings.ToLower(raw), "") + if endStart < 0 || endStart < startEnd { + return "", "", false, false + } + return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true +} + +func whiteboardBodyPathRef(body string) (string, bool) { + trimmed := strings.TrimSpace(body) + if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") { + return "", false + } + if strings.ContainsAny(trimmed, "\r\n") { + return "", false + } + return trimmed, true +} + +func canonicalWhiteboardFileType(raw string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "svg": + return "svg", true + case "mermaid": + return "mermaid", true + case "plantuml": + return "plantuml", true + default: + return "", false + } +} + +func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) { + pathRaw := strings.TrimSpace(pathValue) + if !strings.HasPrefix(pathRaw, "@") { + return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path") + } + relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@")) + if relPath == "" { + return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path") + } + clean := filepath.Clean(relPath) + if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path") + } + if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) { + return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path") + } + data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean) + if err != nil { + return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).WithParam("path").WithCause(err) + } + return string(data), nil +} + +func whiteboardExtAllowed(typ string, ext string) bool { + for _, allowed := range whiteboardAllowedExts(typ) { + if ext == allowed { + return true + } + } + return false +} + +func whiteboardAllowedExts(typ string) []string { + switch typ { + case "svg": + return []string{".svg"} + case "mermaid": + return []string{".mermaid", ".mmd"} + case "plantuml": + return []string{".plantuml", ".puml", ".pu", ".uml"} + default: + return nil + } +} + +func whiteboardExtList(typ string) string { + return strings.Join(whiteboardAllowedExts(typ), ", ") +} + +func exampleWhiteboardExt(typ string) string { + exts := whiteboardAllowedExts(typ) + if len(exts) == 0 { + return "txt" + } + return strings.TrimPrefix(exts[0], ".") +} + +func whiteboardContentForType(typ string, data string) string { + if typ == "svg" { + return data + } + return escapeXMLText(data) +} + +func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error { + messages := make([]string, 0, len(rewriteErrs)) + for _, err := range rewriteErrs { + messages = append(messages, err.Error()) + } + return common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).WithParam("whiteboard").WithCause(errors.Join(rewriteErrs...)) +} + func validateHTML5BlockWriteElementBodies(format string, content string) error { validateSegment := func(segment string) error { matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1) @@ -621,6 +842,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) { return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. } +func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) { + trimmed := strings.TrimSpace(raw) + selfClosing := strings.HasSuffix(trimmed, "/>") + decoder := xml.NewDecoder(strings.NewReader(raw)) + for { + tok, err := decoder.Token() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return whiteboardStartTag{}, err + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local != whiteboardTag { + return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. + } + attrs := make([]html5BlockAttr, 0, len(start.Attr)) + for _, attr := range start.Attr { + attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value}) + } + return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil + } + return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. +} + func (t html5BlockStartTag) attr(name string) (string, bool) { for _, attr := range t.Attrs { if attr.Name == name { @@ -630,6 +879,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) { return "", false } +func (t whiteboardStartTag) attr(name string) (string, bool) { + for _, attr := range t.Attrs { + if attr.Name == name { + return attr.Value, true + } + } + return "", false +} + func (t html5BlockStartTag) hasAttr(name string) bool { _, ok := t.attr(name) return ok @@ -650,6 +908,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) { t.Attrs = attrs } +func (t *whiteboardStartTag) removeAttrs(names ...string) { + remove := make(map[string]struct{}, len(names)) + for _, name := range names { + remove[name] = struct{}{} + } + attrs := t.Attrs[:0] + for _, attr := range t.Attrs { + if _, ok := remove[attr.Name]; ok { + continue + } + attrs = append(attrs, attr) + } + t.Attrs = attrs +} + +func (t *whiteboardStartTag) setAttr(name string, value string) { + for i, attr := range t.Attrs { + if attr.Name == name { + t.Attrs[i].Value = value + return + } + } + t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value}) +} + func (t html5BlockStartTag) render(selfClosing bool) string { var b strings.Builder b.WriteByte('<') @@ -674,6 +957,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string { return b.String() } +func (t whiteboardStartTag) render(selfClosing bool) string { + var b strings.Builder + b.WriteByte('<') + b.WriteString(whiteboardTag) + for _, attr := range t.Attrs { + b.WriteByte(' ') + b.WriteString(attr.Name) + b.WriteString(`="`) + b.WriteString(escapeXMLAttr(attr.Value)) + b.WriteByte('"') + } + if selfClosing { + b.WriteString("/>") + } else { + b.WriteByte('>') + } + return b.String() +} + func escapeXMLAttr(value string) string { var b strings.Builder for _, r := range value { @@ -694,3 +996,18 @@ func escapeXMLAttr(value string) string { } return b.String() } + +func escapeXMLText(value string) string { + var b strings.Builder + for _, r := range value { + switch r { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/shortcuts/doc/html5_block_resources_test.go b/shortcuts/doc/html5_block_resources_test.go index 1da3f10d4..f976da013 100644 --- a/shortcuts/doc/html5_block_resources_test.go +++ b/shortcuts/doc/html5_block_resources_test.go @@ -116,6 +116,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) { } } +func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + files := map[string]string{ + "diagram.svg": `A`, + "flow.mmd": "flowchart TD\nA --> B", + "sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml", + } + for name, content := range files { + if err := os.WriteFile(name, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(%s) error: %v", name, err) + } + } + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", strings.Join([]string{ + ``, + `@flow.mmd`, + ``, + }, "\n"), + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeRequestBody(t, stub.CapturedBody) + got := body["content"].(string) + for _, want := range []string{ + `A`, + "flowchart TD\nA --> B", + "@startuml\nAlice -> Bob: hi\n@enduml", + } { + if !strings.Contains(got, want) { + t.Fatalf("content missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `path="@`) { + t.Fatalf("content still contains whiteboard path attr: %s", got) + } + if _, ok := body["reference_map"]; ok { + t.Fatalf("whiteboard file input must not create reference_map: %#v", body) + } +} + func findDocsTestFlag(flags []common.Flag, name string) common.Flag { for _, flag := range flags { if flag.Name == name { @@ -407,6 +462,35 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) { } } +func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", strings.Join([]string{ + ``, + `@missing.mmd`, + ``, + }, "\n"), + "--as", "user", + }) + if err == nil { + t.Fatal("expected aggregated whiteboard path error") + } + for _, want := range []string{ + `whiteboard svg path "missing.svg" cannot be read`, + `whiteboard mermaid path "missing.mmd" cannot be read`, + `whiteboard plantuml path "missing.puml" cannot be read`, + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q:\n%v", want, err) + } + } +} + func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md index 6d16b74ac..b09d9e79a 100644 --- a/skills/lark-doc/references/lark-doc-whiteboard.md +++ b/skills/lark-doc/references/lark-doc-whiteboard.md @@ -44,6 +44,8 @@ SubAgent 插入 SVG。 ``` +如果 Mermaid 已在本地文件中,可写成 `@diagram.mmd` 或 ``;CLI 会在写入前读取文件并展开为内联内容。 + ### 步骤 2B: SubAgent 使用 SVG 插入图表 主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入: @@ -56,6 +58,8 @@ SubAgent 插入 SVG。 ``` +如果 SVG 已在本地文件中,可写成 ``;PlantUML 文件同理使用 `` 或 `@sequence.plantuml`。 + Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南: - doc token、插入位置(标题 / block_id / command) diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md index 7484f428d..e7f70d210 100644 --- a/skills/lark-doc/references/lark-doc-xml.md +++ b/skills/lark-doc/references/lark-doc-xml.md @@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr 文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建: - `` — `` 上传网络图片 -- `` — 简单图由 SubAgent 直接插入 `完整自包含 SVG`;复杂图使用 `` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入; +- `` — 简单图由 SubAgent 直接插入 `完整自包含 SVG`;也可用本地文件简写 ``、`@flow.mmd`、``,CLI 会写入前展开为内联内容;复杂图使用 `` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入; - `` — `` 空白;`` 复制已有 - `` — ``,必传 task-id(任务 guid) - `` — ``,必传 chat-id