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) + "" + whiteboardTag + ">", 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": ``,
+ "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{
+ ``,
+ "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