feat: support whiteboard file inputs in docs XML

This commit is contained in:
sunpeiyang.996
2026-07-07 19:44:13 +08:00
parent f98dbfe247
commit d8cb216f92
4 changed files with 407 additions and 2 deletions

View File

@@ -27,12 +27,17 @@ const (
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
whiteboardTag = "whiteboard"
whiteboardTypeAttr = "type"
whiteboardPathAttr = "path"
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
)
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, "<whiteboard") {
return content, nil
}
rewrite := func(segment string) (string, error) {
return rewriteWhiteboardFileRefs(runtime, segment)
}
if strings.TrimSpace(format) != "markdown" {
return rewrite(content)
}
var (
out strings.Builder
err error
)
out.WriteString(applyOutsideCodeFences(content, func(segment string) string {
if err != nil {
return segment
}
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
err = rewriteErr
return segment
}
return outSegment
}))
if err != nil {
return "", err
}
return out.String(), nil
}
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
var rewriteErrs []error
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
if err != nil {
rewriteErrs = append(rewriteErrs, err)
return raw
}
return rewritten
})
if len(rewriteErrs) > 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), "</whiteboard>")
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("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -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": `<svg viewBox="0 0 10 10"><text>A</text></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{
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
}, "\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{
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
} {
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{
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
}, "\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)

View File

@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid">@diagram.mmd</whiteboard>``<whiteboard type="mermaid" path="@diagram.mermaid"></whiteboard>`CLI 会在写入前读取文件并展开为内联内容。
### 步骤 2B: SubAgent 使用 SVG 插入图表
主 Agent 启动 SubAgent让它用 `docs +create` / `docs +update` 插入:
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>``<whiteboard type="plantuml">@sequence.plantuml</whiteboard>`
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
- doc token、插入位置标题 / block_id / command

View File

@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>``<whiteboard type="mermaid">@flow.mmd</whiteboard>``<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id