From 370ae1a28020fb74ae8941923fa6911a89cc6576 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 02:48:49 +0000 Subject: [PATCH] Generate styled Word deliverables and pin artifact cards in chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: docx deliverables were plain text saved with a .docx name (no headings/tables/styles), and the artifact_ref part was marked Process so the deliverable vanished into the collapsed process section — the user saw only model-written text like "📄 xxx.docx 工件 ID …" instead of an openable, formatted document delivered in the conversation. Evidence: - new internal/docgen: Markdown -> styled OOXML (Title/Heading1-4, numbered+bullet lists with numbering.xml, GFM tables with shaded header, quotes, code blocks, bold/italic/strike, real hyperlinks); zero third-party deps; tests cover zip structure, XML well-formedness, numbering refs, hyperlink rels, escaping, plain-text fallback - ingest round-trip test proves generated docx parses back to blocks - new write_docx builtin tool (markdown in -> styled .docx artifact); write_artifact prompt now routes Word docs to it; task.doc-minutes output contract requires write_docx delivery - llm.go: artifact refs collected as deliverables and appended as content parts AFTER the final text (pinned tail, not process); mock #dogfood path generates a real styled docx; runmgr skips string indexing for binary office mimes - go test ./... 19 packages ok (incl. new e2e asserting and title text inside the artifact served by the API) Impact: model-written documents open in Word/WPS with real layout and preview with formatting (mammoth maps Heading1-4 to h1-h4); every tool artifact now lands as a visible in-chat card; no behavior change for text artifacts. Refs: task feature/deliverable-docx-r1 --- internal/docgen/docgen.go | 891 ++++++++++++++++++ internal/docgen/docgen_test.go | 242 +++++ internal/ingest/docgen_roundtrip_test.go | 62 ++ internal/prompts/library/task-doc-minutes.md | 1 + .../prompts/library/tool-write-artifact.md | 4 +- internal/prompts/library/tool-write-docx.md | 10 + internal/runmgr/runmgr.go | 20 +- internal/runtime/llm.go | 10 +- internal/runtime/llm_test.go | 38 +- internal/runtime/mock.go | 18 +- internal/server/e2e_test.go | 23 +- internal/tools/docx.go | 62 ++ internal/tools/docx_test.go | 81 ++ internal/tools/fs_test.go | 14 +- internal/tools/registry.go | 3 + 15 files changed, 1448 insertions(+), 31 deletions(-) create mode 100644 internal/docgen/docgen.go create mode 100644 internal/docgen/docgen_test.go create mode 100644 internal/ingest/docgen_roundtrip_test.go create mode 100644 internal/prompts/library/tool-write-docx.md create mode 100644 internal/tools/docx.go create mode 100644 internal/tools/docx_test.go diff --git a/internal/docgen/docgen.go b/internal/docgen/docgen.go new file mode 100644 index 0000000..47266ed --- /dev/null +++ b/internal/docgen/docgen.go @@ -0,0 +1,891 @@ +// Package docgen 把 Markdown 源文转换为带排版样式的 Word(.docx)文档。 +// +// 定位(正式交付物直接可交):模型只需产出 Markdown,本包负责把 +// "#/##/### 标题、有序/无序列表、任务项、GFM 表格、引用块、代码块、 +// 粗体/斜体/行内代码/删除线/超链接" 渲染成真正的 Word 样式—— +// Heading1-4 大纲层级、底纹表头表格、numbering.xml 真编号列表、 +// 引用左线、等宽底纹代码段。打开即是有排版的正式文档,不是纯文本。 +// +// 零第三方依赖:archive/zip + 手写 OOXML(WordprocessingML)。 +// 兼容性约束:产物必须能被本仓 ingest.parseDocx 回读(pStyle 含 +// Heading1-4、表格 tr/tc 结构),且前端 mammoth 预览可还原标题层级。 +package docgen + +import ( + "archive/zip" + "bytes" + "fmt" + "strings" +) + +// Build 生成 docx 字节。title 为文档标题;为空时取首个标题块,再无则不带 Title 段。 +func Build(title, markdown string) ([]byte, error) { + doc := parseMarkdown(markdown) + title = strings.TrimSpace(title) + if title == "" { + title = doc.firstHeading() + } + + var body strings.Builder + body.WriteString(`` + "\n") + body.WriteString(``) + if title != "" { + body.WriteString(doc.paragraph("Title", []run{{text: title}})) + } + for i := range doc.blocks { + doc.writeBlock(&body, i) + } + // sectPr:A4 + 中文文档常规页边距(上下 2.54cm、左右 3.18cm,twips)。 + body.WriteString(``) + body.WriteString("") + + files := []struct{ name, data string }{ + {"[Content_Types].xml", contentTypesXML()}, + {"_rels/.rels", rootRelsXML()}, + {"word/document.xml", body.String()}, + {"word/_rels/document.xml.rels", doc.relsXML()}, + {"word/styles.xml", stylesXML()}, + {"word/numbering.xml", numberingXML()}, + {"docProps/core.xml", coreXML(title)}, + {"docProps/app.xml", appXML()}, + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, f := range files { + w, err := zw.Create(f.name) + if err != nil { + return nil, fmt.Errorf("docgen: create %s: %w", f.name, err) + } + if _, err := w.Write([]byte(f.data)); err != nil { + return nil, fmt.Errorf("docgen: write %s: %w", f.name, err) + } + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("docgen: close zip: %w", err) + } + return buf.Bytes(), nil +} + +// SuggestName 由标题给一个人类可读的 .docx 文件名(调用方可不用)。 +func SuggestName(title string) string { + s := strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\n', '\r', '\t': + return -1 + } + return r + }, strings.TrimSpace(title)) + if r := []rune(s); len(r) > 60 { + s = string(r[:60]) + } + if s == "" { + s = "文档" + } + return s + ".docx" +} + +// ---- Markdown 块级解析 ---- + +type blockKind int + +const ( + bPara blockKind = iota + bHeading + bList + bTable + bCode + bQuote + bHR +) + +type block struct { + kind blockKind + level int // 标题级别 1-6 + lines []string // 段落/引用/代码的原始行 + items []listItem + rows [][]string + align []string + hasHeader bool +} + +type listItem struct { + text string + level int // 0 起 + ordered bool + checked *bool +} + +// document 承载解析结果与生成期状态(超链接收集、列表编号序列分配)。 +type document struct { + blocks []block + hyperlinks []string + listSeqs int +} + +func (d *document) firstHeading() string { + for _, b := range d.blocks { + if b.kind == bHeading { + return strings.Join(b.lines, " ") + } + } + return "" +} + +func parseMarkdown(src string) *document { + lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") + d := &document{} + i := 0 + for i < len(lines) { + line := lines[i] + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "": + i++ + case strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~"): + i = d.parseCode(lines, i) + case isHR(trimmed): + d.blocks = append(d.blocks, block{kind: bHR}) + i++ + case strings.HasPrefix(trimmed, "#"): + if lvl, text, ok := parseHeading(trimmed); ok { + d.blocks = append(d.blocks, block{kind: bHeading, level: lvl, lines: []string{text}}) + i++ + } else { + i = d.parsePara(lines, i) + } + case isTableStart(lines, i): + i = d.parseTable(lines, i) + case isListLine(trimmed): + i = d.parseList(lines, i) + case strings.HasPrefix(trimmed, ">"): + i = d.parseQuote(lines, i) + default: + i = d.parsePara(lines, i) + } + } + return d +} + +func isHR(s string) bool { + if len(s) < 3 { + return false + } + for _, ch := range []byte{'-', '*', '_'} { + if s[0] == ch { + return strings.TrimSpace(strings.ReplaceAll(s, string(ch), "")) == "" + } + } + return false +} + +func parseHeading(s string) (int, string, bool) { + n := 0 + for n < len(s) && s[n] == '#' { + n++ + } + if n == 0 || n > 6 || n >= len(s) || s[n] != ' ' { + return 0, "", false + } + return n, strings.TrimSpace(s[n+1:]), true +} + +func (d *document) parseCode(lines []string, i int) int { + mark := strings.TrimSpace(lines[i])[:3] + var body []string + j := i + 1 + for ; j < len(lines); j++ { + t := strings.TrimSpace(lines[j]) + if strings.HasPrefix(t, mark) && strings.TrimLeft(t, mark) == "" { + j++ + break + } + body = append(body, lines[j]) + } + d.blocks = append(d.blocks, block{kind: bCode, lines: body}) + return j +} + +func (d *document) parseQuote(lines []string, i int) int { + var body []string + for ; i < len(lines); i++ { + t := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(t, ">") { + break + } + body = append(body, strings.TrimPrefix(strings.TrimPrefix(t, ">"), " ")) + } + // 引用内按空行切分多段。 + var cur []string + for _, l := range body { + if strings.TrimSpace(l) == "" { + if len(cur) > 0 { + d.blocks = append(d.blocks, block{kind: bQuote, lines: cur}) + cur = nil + } + continue + } + cur = append(cur, l) + } + if len(cur) > 0 { + d.blocks = append(d.blocks, block{kind: bQuote, lines: cur}) + } + return i +} + +func (d *document) parsePara(lines []string, i int) int { + var body []string + for ; i < len(lines); i++ { + t := strings.TrimSpace(lines[i]) + if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, ">") || + strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") || + isListLine(t) || isTableStart(lines, i) || isHR(t) { + break + } + body = append(body, lines[i]) + } + if len(body) == 0 { // 防御:单行无法归块时按原文吃掉一行,避免死循环。 + body = []string{lines[i]} + i++ + } + d.blocks = append(d.blocks, block{kind: bPara, lines: body}) + return i +} + +func isListLine(s string) bool { + _, _, _, ok := splitListMarker(s) + return ok +} + +// splitListMarker 解析列表行:缩进层级(每 2 空格一级,封顶 3)、是否有序、 +// 去掉标记后的文本。 +func splitListMarker(s string) (level int, ordered bool, text string, ok bool) { + n := 0 + for n < len(s) && (s[n] == ' ' || s[n] == '\t') { + n++ + } + level = n / 2 + if level > 3 { + level = 3 + } + rest := s[n:] + for _, m := range []string{"- ", "* ", "+ "} { + if strings.HasPrefix(rest, m) { + return level, false, rest[2:], true + } + } + k := 0 + for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' { + k++ + } + if k > 0 && k <= 4 && k < len(rest) && (rest[k] == '.' || rest[k] == ')') && k+1 < len(rest) && rest[k+1] == ' ' { + return level, true, rest[k+2:], true + } + return 0, false, s, false +} + +func (d *document) parseList(lines []string, i int) int { + var items []listItem + for ; i < len(lines); i++ { + t := strings.TrimSpace(lines[i]) + if t == "" { + break + } + level, ordered, text, ok := splitListMarker(t) + if !ok { + break + } + it := listItem{level: level, ordered: ordered} + if !ordered { + switch { + case strings.HasPrefix(text, "[ ] "), text == "[ ]": + it.checked = boolPtr(false) + text = strings.TrimSpace(strings.TrimPrefix(text, "[ ]")) + case strings.HasPrefix(text, "[x] "), strings.HasPrefix(text, "[X] "), text == "[x]", text == "[X]": + it.checked = boolPtr(true) + text = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(text, "[x] "), "[X] ")) + } + } + it.text = strings.TrimSpace(text) + items = append(items, it) + } + d.blocks = append(d.blocks, block{kind: bList, items: items}) + return i +} + +// isTableStart:以 | 开头或结尾的行,或「含 | 且下一行是分隔行」——后者覆盖 +// 无边框管道写法,同时避免把正文里含 | 的普通句子误判成表格。 +func isTableStart(lines []string, i int) bool { + t := strings.TrimSpace(lines[i]) + if !strings.Contains(t, "|") { + return false + } + if strings.HasPrefix(t, "|") || strings.HasSuffix(t, "|") { + return true + } + return i+1 < len(lines) && isTableSeparator(strings.TrimSpace(lines[i+1])) +} + +func isTableSeparator(s string) bool { + core := strings.ReplaceAll(strings.ReplaceAll(s, "|", ""), " ", "") + if core == "" { + return false + } + for _, ch := range core { + if ch != '-' && ch != ':' { + return false + } + } + return true +} + +func (d *document) parseTable(lines []string, i int) int { + var rows [][]string + var align []string + hasHeader := false + for ; i < len(lines); i++ { + t := strings.TrimSpace(lines[i]) + if t == "" || !strings.Contains(t, "|") { + break + } + if isTableSeparator(t) { + align = parseTableAlign(t) + hasHeader = true + continue + } + rows = append(rows, splitTableRow(t)) + } + if len(rows) == 0 { + return i + 1 // 只有分隔行等异常输入:吃掉一行防死循环。 + } + d.blocks = append(d.blocks, block{kind: bTable, rows: rows, align: align, hasHeader: hasHeader}) + return i +} + +func parseTableAlign(sep string) []string { + cells := splitTableRow(sep) + out := make([]string, len(cells)) + for i, c := range cells { + c = strings.TrimSpace(c) + left := strings.HasPrefix(c, ":") + right := strings.HasSuffix(c, ":") + switch { + case left && right: + out[i] = "center" + case right: + out[i] = "right" + default: + out[i] = "left" + } + } + return out +} + +func splitTableRow(s string) []string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "|") + s = strings.TrimSuffix(s, "|") + parts := strings.Split(s, "|") + out := make([]string, len(parts)) + for i, p := range parts { + out[i] = strings.TrimSpace(p) + } + return out +} + +func boolPtr(b bool) *bool { return &b } + +// ---- 行内解析:**粗**、*斜*、`码`、~~删~~、[文](链) ---- + +type run struct { + text string + bold bool + italic bool + code bool + strike bool + link string +} + +func (d *document) parseInline(s string) []run { + var runs []run + i, start := 0, 0 + flush := func(end int) { + if end > start { + runs = append(runs, run{text: s[start:end]}) + } + } + for i < len(s) { + c := s[i] + switch { + case c == '\\': + flush(i) + if i+1 < len(s) { + runs = append(runs, run{text: string(s[i+1])}) + } + i += 2 + start = i + case c == '`': + if end := strings.IndexByte(s[i+1:], '`'); end >= 0 { + flush(i) + runs = append(runs, run{text: s[i+1 : i+1+end], code: true}) + i += end + 2 + start = i + } else { + i++ + } + case c == '*' && i+1 < len(s) && s[i+1] == '*': + if end, ok := findClose(s, i+2, "**"); ok { + flush(i) + runs = append(runs, mapAll(d.parseInline(s[i+2:end]), func(r run) run { r.bold = true; return r })...) + i = end + 2 + start = i + } else { + i++ + } + case c == '~' && i+1 < len(s) && s[i+1] == '~': + if end, ok := findClose(s, i+2, "~~"); ok { + flush(i) + runs = append(runs, mapAll(d.parseInline(s[i+2:end]), func(r run) run { r.strike = true; return r })...) + i = end + 2 + start = i + } else { + i++ + } + case c == '*' || c == '_': + if end, ok := findClose(s, i+1, string(c)); ok && strings.TrimSpace(s[i+1:end]) != "" { + flush(i) + runs = append(runs, mapAll(d.parseInline(s[i+1:end]), func(r run) run { r.italic = true; return r })...) + i = end + 1 + start = i + } else { + i++ + } + case c == '[': + if text, href, next, ok := parseLink(s, i); ok { + flush(i) + runs = append(runs, run{text: text, link: href}) + d.hyperlinks = appendLink(d.hyperlinks, href) + i = next + start = i + } else { + i++ + } + default: + i++ + } + } + flush(len(s)) + return runs +} + +// parseLink 解析 [text](href):href 需带协议/锚点/mailto,防把普通方括号误判。 +func parseLink(s string, i int) (text, href string, next int, ok bool) { + close := strings.IndexByte(s[i:], ']') + if close <= 0 { + return "", "", 0, false + } + after := i + close + 1 + if after >= len(s) || s[after] != '(' { + return "", "", 0, false + } + pclose := strings.IndexByte(s[after:], ')') + if pclose <= 0 { + return "", "", 0, false + } + h := s[after+1 : after+pclose] + if !strings.Contains(h, "://") && !strings.HasPrefix(h, "#") && !strings.HasPrefix(h, "mailto:") { + return "", "", 0, false + } + return s[i+1 : i+close], h, after + pclose + 1, true +} + +func mapAll(rs []run, f func(run) run) []run { + out := make([]run, len(rs)) + for i, r := range rs { + out[i] = f(r) + } + return out +} + +func findClose(s string, from int, mark string) (int, bool) { + idx := strings.Index(s[from:], mark) + if idx <= 0 { // 0 = 空内容,-1 = 未闭合。 + return 0, false + } + return from + idx, true +} + +func appendLink(list []string, href string) []string { + for _, h := range list { + if h == href { + return list + } + } + return append(list, href) +} + +// ---- OOXML 生成 ---- + +// paragraph 渲染一个段落:style 为样式 ID,extra 追加进 pPr(如 numPr)。 +func (d *document) paragraph(style string, runs []run, extra ...string) string { + var sb strings.Builder + sb.WriteString("") + if style != "" { + sb.WriteString(``) + } + for _, e := range extra { + sb.WriteString(e) + } + sb.WriteString("") + for _, r := range runs { + sb.WriteString(d.runXML(r)) + } + sb.WriteString("") + return sb.String() +} + +func (d *document) runsXML(runs []run) string { + var sb strings.Builder + for _, r := range runs { + sb.WriteString(d.runXML(r)) + } + return sb.String() +} + +func (d *document) runXML(r run) string { + if r.text == "" { + return "" + } + var props []string + if r.bold { + props = append(props, "") + } + if r.italic { + props = append(props, "") + } + if r.strike { + props = append(props, "") + } + if r.code { + props = append(props, ``) + } + if r.link != "" { + rid := d.hyperlinkRID(r.link) + return fmt.Sprintf(`%s%s`, + rid, strings.Join(props, ""), textRuns(r.text)) + } + return "" + rPr(props) + textRuns(r.text) + "" +} + +// textRuns 把文本渲染为 w:t;段内换行转 w:br(Word 段内不保留裸 \n)。 +func textRuns(text string) string { + var sb strings.Builder + parts := strings.Split(text, "\n") + for i, p := range parts { + if i > 0 { + sb.WriteString("") + } + if p != "" { + sb.WriteString(`` + esc(p) + ``) + } + } + return sb.String() +} + +func rPr(props []string) string { + if len(props) == 0 { + return "" + } + return "" + strings.Join(props, "") + "" +} + +func (d *document) hyperlinkRID(href string) string { + for i, h := range d.hyperlinks { + if h == href { + return fmt.Sprintf("rIdH%d", i+1) + } + } + return "" +} + +// 列表编号分配:numbering.xml 预生成成对 num 实例——无序 1、3..52;有序 +// 2(重启)、53..102(重启)。newListNumID 按序取用;超出预生成数回落 +// 常驻 1/2(Word 容忍多列表共用编号,代价是有序编号延续)。 +const ( + numPairBase = 3 + numOrderedShift = 50 + numPairsTotal = 50 + numFallbackBul = 1 + numFallbackOrd = 2 +) + +func (d *document) newListNumID(ordered bool) int { + d.listSeqs++ + base := numPairBase + d.listSeqs - 1 + if base >= numPairBase+numPairsTotal { + if ordered { + return numFallbackOrd + } + return numFallbackBul + } + if ordered { + return base + numOrderedShift + } + return base +} + +func (d *document) writeBlock(sb *strings.Builder, i int) { + b := &d.blocks[i] + switch b.kind { + case bHeading: + lvl := b.level + if lvl > 4 { + lvl = 4 + } + sb.WriteString(d.paragraph(fmt.Sprintf("Heading%d", lvl), d.parseInline(b.lines[0]))) + case bPara: + sb.WriteString(d.paragraph("", d.parseInline(strings.Join(b.lines, "\n")))) + case bQuote: + for _, line := range b.lines { + sb.WriteString(d.paragraph("Quote", d.parseInline(line))) + } + case bCode: + for _, line := range b.lines { + txt := line + if txt == "" { + txt = " " + } + sb.WriteString(d.paragraph("CodePara", []run{{text: txt}})) + } + case bHR: + sb.WriteString(``) + case bList: + numID := d.newListNumID(isOrdered(b.items)) + for _, it := range b.items { + var runs []run + if it.checked != nil { + glyph := "☐" + if *it.checked { + glyph = "☑" + } + runs = append(runs, run{text: glyph + " "}) + } + runs = append(runs, d.parseInline(it.text)...) + sb.WriteString(d.paragraph("ListParagraph", runs, + fmt.Sprintf(``, it.level, numID))) + } + case bTable: + sb.WriteString(d.tableXML(b)) + } +} + +func isOrdered(items []listItem) bool { + for _, it := range items { + if it.ordered { + return true + } + } + return false +} + +func (d *document) tableXML(b *block) string { + cols := 0 + for _, row := range b.rows { + if len(row) > cols { + cols = len(row) + } + } + align := make([]string, cols) + for i := range align { + align[i] = "left" + if i < len(b.align) { + align[i] = b.align[i] + } + } + + var sb strings.Builder + sb.WriteString(``) + for _, edge := range []string{"top", "left", "bottom", "right", "insideH", "insideV"} { + sb.WriteString(fmt.Sprintf(``, edge)) + } + sb.WriteString(``) + for ri, row := range b.rows { + header := b.hasHeader && ri == 0 + sb.WriteString("") + if header { + sb.WriteString(``) + } + for ci := 0; ci < cols; ci++ { + cell := "" + if ci < len(row) { + cell = row[ci] + } + var runs []run + if header { + runs = []run{{text: cell, bold: true}} + } else { + runs = d.parseInline(cell) + } + shd := "" + if header { + shd = `` + } + sb.WriteString(fmt.Sprintf(`%s%s`, + shd, align[ci], d.runsXML(runs))) + } + sb.WriteString("") + } + sb.WriteString("") + // 表后补一个近零高度空段:防相邻表格被 Word 合并。 + sb.WriteString(``) + return sb.String() +} + +// ---- 包部件文件 ---- + +func esc(s string) string { + var sb strings.Builder + for _, r := range s { + switch r { + case '&': + sb.WriteString("&") + case '<': + sb.WriteString("<") + case '>': + sb.WriteString(">") + case '"': + sb.WriteString(""") + case '\'': + sb.WriteString("'") + default: + sb.WriteRune(r) + } + } + return sb.String() +} + +func contentTypesXML() string { + return ` + + + + + + + + +` +} + +func rootRelsXML() string { + return ` + + + + +` +} + +func (d *document) relsXML() string { + var sb strings.Builder + sb.WriteString(`` + "\n") + sb.WriteString(``) + sb.WriteString(``) + sb.WriteString(``) + for i, href := range d.hyperlinks { + sb.WriteString(fmt.Sprintf(``, i+1, esc(href))) + } + sb.WriteString(``) + return sb.String() +} + +func stylesXML() string { + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` +} + +// numberingXML:abstractNum 0(无序)/1(有序)+ 预生成成对 num 实例, +// 与 newListNumID 的分配方案对齐(见其注释)。 +func numberingXML() string { + var sb strings.Builder + sb.WriteString(`` + "\n") + sb.WriteString(``) + sb.WriteString(abstractNumXML(0, false)) + sb.WriteString(abstractNumXML(1, true)) + sb.WriteString(numXML(1, 0, false)) + sb.WriteString(numXML(2, 1, true)) + for i := 0; i < numPairsTotal; i++ { + sb.WriteString(numXML(numPairBase+i, 0, false)) + sb.WriteString(numXML(numPairBase+i+numOrderedShift, 1, true)) + } + sb.WriteString(``) + return sb.String() +} + +func abstractNumXML(aid int, ordered bool) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf(``, aid)) + bullets := []string{"•", "◦", "▪", "·"} + for lvl := 0; lvl < 4; lvl++ { + if ordered { + sb.WriteString(fmt.Sprintf(``, lvl, lvl+1)) + } else { + sb.WriteString(fmt.Sprintf(``, lvl, bullets[lvl])) + } + sb.WriteString(fmt.Sprintf(``, 720+lvl*420)) + } + sb.WriteString(``) + return sb.String() +} + +func numXML(numID, abstractID int, restart bool) string { + ov := "" + if restart { + ov = `` + } + return fmt.Sprintf(`%s`, numID, abstractID, ov) +} + +func coreXML(title string) string { + return fmt.Sprintf(` + +%sAgent-SystemAgent-System +`, esc(title)) +} + +func appXML() string { + return ` + +Agent-System docgen +` +} diff --git a/internal/docgen/docgen_test.go b/internal/docgen/docgen_test.go new file mode 100644 index 0000000..af0736d --- /dev/null +++ b/internal/docgen/docgen_test.go @@ -0,0 +1,242 @@ +package docgen + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "io" + "strings" + "testing" +) + +// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。 +// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。 +const sampleMinutes = "# 研发周会纪要\n" + ` +## 一、基本信息 + +| 项目 | 内容 | +|---|---| +| 时间 | 2026-09-30 14:00 | +| 地点 | 3F 会议室 | +| 主持人 | 陈威 | + +## 二、讨论要点 + +- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan) +- 排版要求:标题层级、表格、列表 + - 二级要点:交付物卡片 +- [x] 已完成:预览装配 +- [ ] 待办:样式打磨 + +> 会议结论:按周五节点交付。 + +## 三、决议 + +1. 采用 write_docx 工具 +2. 对话内出交付物卡片 + +` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + ` + +--- + +后续:周报合并。 +` + +func buildSample(t *testing.T) []byte { + t.Helper() + b, err := Build("", sampleMinutes) + if err != nil { + t.Fatalf("Build: %v", err) + } + return b +} + +func TestBuildZipStructure(t *testing.T) { + b := buildSample(t) + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + t.Fatalf("zip 打开失败(产物不是合法 docx 容器): %v", err) + } + want := map[string]bool{ + "[Content_Types].xml": false, "word/document.xml": false, + "word/styles.xml": false, "word/numbering.xml": false, + "word/_rels/document.xml.rels": false, "_rels/.rels": false, + "docProps/core.xml": false, + } + for _, f := range zr.File { + if _, ok := want[f.Name]; ok { + want[f.Name] = true + } + } + for name, seen := range want { + if !seen { + t.Errorf("docx 缺少部件 %s", name) + } + } +} + +func TestBuildDocumentXMLWellFormed(t *testing.T) { + b := buildSample(t) + zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b))) + var doc string + for _, f := range zr.File { + if f.Name == "word/document.xml" { + rc, _ := f.Open() + doc = string(readAll(rc)) + rc.Close() + } + } + if doc == "" { + t.Fatal("word/document.xml 为空") + } + dec := xml.NewDecoder(strings.NewReader(doc)) + for { + _, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("document.xml 不是良构 XML: %v", err) + } + } + for _, marker := range []string{ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + } { + if !strings.Contains(doc, marker) { + t.Errorf("document.xml 缺少排版标记 %s", marker) + } + } + // 标题文本与转义:一级标题进 Title 段(title 取首个标题)。 + if !strings.Contains(doc, "研发周会纪要") { + t.Errorf("document.xml 缺少标题文本") + } +} + +func TestBuildNumberingRefsResolve(t *testing.T) { + b := buildSample(t) + zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b))) + read := func(name string) string { + for _, f := range zr.File { + if f.Name == name { + rc, _ := f.Open() + s, _ := io.ReadAll(rc) + rc.Close() + return string(s) + } + } + t.Fatalf("缺少 %s", name) + return "" + } + doc, numbering := read("word/document.xml"), read("word/numbering.xml") + // 收集 document 里引用的 numId,逐一确认 numbering.xml 有对应 w:num。 + refs := map[string]bool{} + for _, seg := range strings.Split(doc, "`) { + t.Errorf("numId %s 在 numbering.xml 无定义", id) + } + } + // 有序/无序列表分属不同编号实例(同文档两类列表都出现时 numId 应不同)。 + if len(refs) < 2 { + t.Errorf("样例含无序+有序两类列表,numId 应至少 2 个,得 %d", len(refs)) + } +} + +func TestBuildHyperlinkRels(t *testing.T) { + b := buildSample(t) + zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b))) + var rels string + for _, f := range zr.File { + if f.Name == "word/_rels/document.xml.rels" { + rc, _ := f.Open() + rels = string(readAll(rc)) + rc.Close() + } + } + if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") { + t.Errorf("超链接 relationship 缺失: %s", rels) + } +} + +func TestBuildEscapesXML(t *testing.T) { + b, err := Build("标题&'", "包含 <标签> & \"引号\" 的正文") + if err != nil { + t.Fatalf("Build: %v", err) + } + zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b))) + for _, f := range zr.File { + if f.Name != "word/document.xml" { + continue + } + rc, _ := f.Open() + doc := readAll(rc) + rc.Close() + if !strings.Contains(string(doc), "<标签>") || !strings.Contains(string(doc), "&") { + t.Errorf("正文 XML 转义缺失") + } + dec := xml.NewDecoder(bytes.NewReader(doc)) + for { + _, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("转义后 XML 不良构: %v", err) + } + } + } +} + +func TestBuildPlainTextFallback(t *testing.T) { + // 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。 + b, err := Build("", "第一行\n第二行\n\n第二段") + if err != nil { + t.Fatalf("Build: %v", err) + } + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + t.Fatalf("zip: %v", err) + } + var doc string + for _, f := range zr.File { + if f.Name == "word/document.xml" { + rc, _ := f.Open() + doc = string(readAll(rc)) + rc.Close() + } + } + if !strings.Contains(doc, "第二段") { + t.Errorf("纯文本段落丢失") + } + // 段内换行应转 w:br,且不残留裸换行进 w:t。 + if !strings.Contains(doc, "") { + t.Errorf("段内换行未转 w:br") + } +} + +func TestSuggestName(t *testing.T) { + if got := SuggestName("季度/报告:2026"); got != "季度报告2026.docx" { + t.Errorf("SuggestName = %q", got) + } + if got := SuggestName(" "); got != "文档.docx" { + t.Errorf("SuggestName 空 = %q", got) + } +} + +func readAll(r io.Reader) []byte { + b, _ := io.ReadAll(r) + return b +} diff --git a/internal/ingest/docgen_roundtrip_test.go b/internal/ingest/docgen_roundtrip_test.go new file mode 100644 index 0000000..05abe8a --- /dev/null +++ b/internal/ingest/docgen_roundtrip_test.go @@ -0,0 +1,62 @@ +package ingest + +import ( + "testing" + + core "agent-system-core" + + "agent-system-backend/internal/docgen" +) + +// TestParseDocxFromDocgen 回读契约:docgen 产出的 docx 必须能被 builtin:docx +// 解析器还原为块结构(标题层级、表格、列表项文本)——这是「生成→检索索引→ +// 预览」链路闭合的保证(生成侧改动须两头痛检查)。 +func TestParseDocxFromDocgen(t *testing.T) { + md := "# 周会纪要\n\n## 一、基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-09-30 |\n\n## 二、要点\n\n- 架构定案\n- 排版齐全\n\n结论段。" + b, err := docgen.Build("", md) + if err != nil { + t.Fatalf("docgen.Build: %v", err) + } + blocks, err := parseDocx(b) + if err != nil { + t.Fatalf("parseDocx 回读失败: %v", err) + } + + var h2, title int + var sawTable bool + var tableRows int + var sawArch bool + for _, blk := range blocks { + switch { + case blk.Type == core.BlockHeading && blk.Level == 3 && blk.Text == "周会纪要": + title++ // Title 样式在回读侧归 level 3(headingLevel 的 title 映射)。 + case blk.Type == core.BlockHeading && blk.Level == 2: + h2++ + if blk.Text != "一、基本信息" && blk.Text != "二、要点" { + t.Errorf("H2 文本不符: %q", blk.Text) + } + case blk.Type == core.BlockTable: + sawTable = true + tableRows = len(blk.Rows) + if len(blk.Rows) >= 2 { + if blk.Rows[0][0] != "项目" || blk.Rows[1][1] != "2026-09-30" { + t.Errorf("表格内容不符: %+v", blk.Rows) + } + } + case blk.Type == core.BlockPara && blk.Text == "架构定案": + sawArch = true // 列表项以 ListParagraph 落盘,回读侧按段落文本取回。 + } + } + if title != 1 { + t.Errorf("标题段(Title)回读数 = %d, want 1(blocks: %+v)", title, blocks) + } + if h2 != 2 { + t.Errorf("H2 数量 = %d, want 2(blocks: %+v)", h2, blocks) + } + if !sawTable || tableRows < 2 { + t.Errorf("表格未回读: sawTable=%v rows=%d", sawTable, tableRows) + } + if !sawArch { + t.Errorf("列表项文本未回读(blocks: %+v)", blocks) + } +} diff --git a/internal/prompts/library/task-doc-minutes.md b/internal/prompts/library/task-doc-minutes.md index 63d0bdd..e453950 100644 --- a/internal/prompts/library/task-doc-minutes.md +++ b/internal/prompts/library/task-doc-minutes.md @@ -26,3 +26,4 @@ triggers: - 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。 - 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。 - 待办事项若原文未指明负责人或期限,写"未指明",不要编造。 +- 交付形态:用 write_docx 生成正式 Word 文档(Markdown 表达结构:一级标题为纪要标题、基本信息与待办用表格、决议用编号列表),name 用「<主题>会议纪要.docx」;对话内只简述结论,全文以文档交付,不要在回复里复述全文。 diff --git a/internal/prompts/library/tool-write-artifact.md b/internal/prompts/library/tool-write-artifact.md index 09eb56d..6e41caa 100644 --- a/internal/prompts/library/tool-write-artifact.md +++ b/internal/prompts/library/tool-write-artifact.md @@ -5,4 +5,6 @@ version: 1 title: write_artifact 工具描述 description: 工件写入工具的模型侧说明文案 --- -把生成内容写入工件(文档/数据),返回工件 ID 与名称。用于正式交付物:报告、纪要、代码文件、数据集等需要保存、后续引用或继续编辑的内容。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。 +把生成内容写入工件(文档/数据),返回工件 ID 与名称。用于正式交付物:代码文件、数据集、Markdown/纯文本资料等需要保存、后续引用或继续编辑的内容。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。 + +分工:需要**带 Word 排版的正式文档**(报告、纪要、方案、说明书)时改用 write_docx——它把 Markdown 转成标题/表格/列表齐全的 .docx;本工具写的是原样字节,不要用它写 .docx(会产生无排版的伪 Word 文件)。 diff --git a/internal/prompts/library/tool-write-docx.md b/internal/prompts/library/tool-write-docx.md new file mode 100644 index 0000000..931942d --- /dev/null +++ b/internal/prompts/library/tool-write-docx.md @@ -0,0 +1,10 @@ +--- +id: tool.write_docx +layer: tool +version: 1 +title: write_docx 工具描述 +description: Word 文档生成工具的模型侧说明文案 +--- +生成带排版样式的 Word 文档(.docx 工件):content 用 Markdown 书写——`#/##/###` 表达章节层级、`|表格|`、`-` 列表、`1.` 编号、`- [x]` 任务项、`>` 引用、`**粗体**`、`[链接](url)`——系统自动转换为正式 Word 排版(标题样式、表头底纹表格、编号列表、引用线、代码段),用户打开即是成品文档。 + +用于正式文档交付物:报告、会议纪要、方案、说明书、总结等需要以 Word 交付或打印的内容。name 用人类可读的中文文件名(可不含扩展名,自动补 .docx);文档标题默认取正文第一个一级标题。对话里的临时说明、代码片段、纯文本/Markdown/JSON 数据仍用 write_artifact——需要"Word 排版"才用本工具。 diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 5580902..e783cc0 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -353,7 +353,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar return art, err } // 工件文本进检索索引(管线分块+全文+可选向量;未注入回落整文档单块)。 - if kind == "document" && len(content) < 200000 { + // 二进制 Office/PDF 不进字符串索引(zip 字节当文本是垃圾数据); + // docx 的可检索文本由 ingest 管线在上传路径解析,生成路径暂不索引。 + if kind == "document" && !binaryDocMime(mime) && len(content) < 200000 { if c.mgr.indexer != nil { _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, string(content)) } else { @@ -363,6 +365,22 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar return art, nil } +// binaryDocMime 判定二进制文档 mime:此类工件字节不能按 UTF-8 文本进索引。 +func binaryDocMime(mime string) bool { + switch mime { + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/msword", + "application/pdf", + "application/zip": + return true + } + return false +} + func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { if q.TopK <= 0 { q.TopK = int(c.eff.GetNumber("retrieval.topK", 5)) diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 6c52c9b..0e40fa7 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -245,6 +245,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } toolCallsMade := 0 thinkingIdx, textIdx := 0, 0 + // 交付物引用(对话内直接给出):工具轮产生的工件不进过程折叠区, + // 收集后作为内容部件钉在消息尾部(最终正文之后)——回复完给文件卡。 + var deliverables []*core.ArtifactRef for turn := 0; turn < maxTurns; turn++ { select { @@ -299,6 +302,10 @@ func (l *LLMRunner) Execute(rc RunCtx) error { if len(finalParts) == 0 { finalParts = append(finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"}) } + // 交付物卡片钉尾(内容部件,不折叠):live 投影(live.ts)与落库同构。 + for _, ref := range deliverables { + finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref}) + } rc.SetAssistantParts(finalParts) return nil } @@ -478,8 +485,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: statusOf(err), Summary: summary}}, ) if artifactRef != nil { - // 工件引用与工具调用同轮产生,同标过程(否则终态折叠时残留在回复区)。 - finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Process: true, Artifact: artifactRef}) + deliverables = append(deliverables, artifactRef) } // 回灌工具结果(截断保护)。 if len(out) > 16000 { diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 2fee87a..b996513 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -267,9 +267,9 @@ func TestLLMRunnerPhaseEventsPerTurn(t *testing.T) { } } -// TestLLMRunnerArtifactPartIsProcess 验证:工具产生的工件引用部件同样标记为过程, -// 终态随过程折叠(不残留在回复区)。 -func TestLLMRunnerArtifactPartIsProcess(t *testing.T) { +// TestLLMRunnerDeliverablePinnedAfterText 验证:工具产生的工件引用作为内容部件 +// (不进过程折叠区),且钉在最终正文之后——对话内直接给出交付物卡片。 +func TestLLMRunnerDeliverablePinnedAfterText(t *testing.T) { turns := [][]agentllm.Event{ { // 第 1 轮:解说文本 + 工具调用(产生工件)。 agentllm.Event{Kind: agentllm.KindTextDelta, Text: "我生成一份报表。"}, @@ -278,7 +278,7 @@ func TestLLMRunnerArtifactPartIsProcess(t *testing.T) { }}}, }, { // 第 2 轮(终轮):最终回复文本。 - agentllm.Event{Kind: agentllm.KindTextDelta, Text: "见上方工件。"}, + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "报表已生成。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } @@ -293,22 +293,30 @@ func TestLLMRunnerArtifactPartIsProcess(t *testing.T) { t.Fatalf("Execute: %v", err) } - found := false - for _, p := range rc.assistant { - if p.Type != core.PartArtifactRef { - continue + refIdx, lastTextIdx := -1, -1 + for i, p := range rc.assistant { + if p.Type == core.PartText { + lastTextIdx = i } - found = true - if !p.Process { - t.Errorf("artifact_ref 部件未标记 Process: %+v", p) - } - if p.Artifact == nil || p.Artifact.ArtifactID != "a1" { - t.Errorf("artifact 引用不符: %+v", p.Artifact) + if p.Type == core.PartArtifactRef { + if refIdx != -1 { + t.Errorf("artifact_ref 部件重复: %+v", rc.assistant) + } + refIdx = i + if p.Process { + t.Errorf("artifact_ref 不得标记 Process(须为内容部件): %+v", p) + } + if p.Artifact == nil || p.Artifact.ArtifactID != "a1" { + t.Errorf("artifact 引用不符: %+v", p.Artifact) + } } } - if !found { + if refIdx == -1 { t.Fatalf("未找到 artifact_ref 部件: %+v", rc.assistant) } + if lastTextIdx == -1 || refIdx < lastTextIdx { + t.Errorf("artifact_ref 应钉在最终正文之后: refIdx=%d lastTextIdx=%d parts=%+v", refIdx, lastTextIdx, rc.assistant) + } } // TestLLMRunnerSubmitCardGatedBySetting 验证 D39/D42:ui.cardflow 开启时 submit_card diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index 81b2053..e96a6f4 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "agent-system-backend/internal/docgen" "agent-system-backend/internal/squadadapter" core "agent-system-core" ) @@ -171,18 +172,27 @@ func (m *MockRunner) Execute(rc RunCtx) error { case strings.Contains(user, "#doc"): rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"}) parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true}) - content := "# 演示文档\n\n这是 mock runner 生成的示例工件。\n\n## 要点\n\n- 由 #doc 指令触发\n- 写入 artifacts 并可在右栏查看\n" - art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content)) + // 真 docx 演示(与 write_docx 工具同一生成器):mock 模式即可看到 + // 「带排版 Word + 交付物卡片」全链路——标题层级/表格/列表都在文档字节里。 + md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n" + buf, derr := docgen.Build("", md) + if derr != nil { + addText("演示文档生成失败:" + derr.Error()) + break + } + addText("Word 文档已生成——点击下方交付卡片右栏预览,或 ⌘1 工件面板查看。") + art, err := rc.WriteArtifact("demo-会议纪要.docx", "document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", buf) if err == nil { + // 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。 parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}) // 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现—— // 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard // 返回空 ID,跳过卡部件。 - if fc, ferr := rc.SubmitCard(artifactFacadeCard("write_artifact", art, "演示工件已生成(#doc)")); ferr == nil && fc.ID != "" { + if fc, ferr := rc.SubmitCard(artifactFacadeCard("write_docx", art, "演示 Word 工件已生成(#doc,带排版)")); ferr == nil && fc.ID != "" { parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}}) } } - addText("文档已生成,见右侧 Artifacts 标签(也可点击消息中的工件引用)。") case strings.Contains(user, "#squad"): rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "调用 Agent-Squad 引擎编排……"}) diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 3c9ffff..048d6d6 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -1,6 +1,7 @@ package server import ( + "archive/zip" "bufio" "bytes" "context" @@ -444,8 +445,24 @@ func TestArtifactFlow(t *testing.T) { req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) - if !strings.Contains(rec.Body.String(), "演示文档") { - t.Error("artifact content mismatch") + // mock #doc 走 docgen:内容是 zip 容器,标题/表格排版在 word/document.xml 里。 + body := rec.Body.Bytes() + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatalf("artifact 不是合法 docx 容器: %v", err) + } + var docXML []byte + for _, f := range zr.File { + if f.Name == "word/document.xml" { + rc, oerr := f.Open() + if oerr == nil { + docXML, _ = io.ReadAll(rc) + rc.Close() + } + } + } + if !strings.Contains(string(docXML), "演示会议纪要") || !strings.Contains(string(docXML), "") { + t.Error("docx 缺少标题/表格排版(仍是纯文本?)") } } @@ -467,7 +484,7 @@ func TestArtifactFacadeCard(t *testing.T) { var facade map[string]any for _, ci := range cards { c := ci.(map[string]any) - if c["kind"] == "result" && c["title"] == "工具大结果 · demo-报告.md" { + if c["kind"] == "result" && c["title"] == "工具大结果 · demo-会议纪要.docx" { facade = c } } diff --git a/internal/tools/docx.go b/internal/tools/docx.go new file mode 100644 index 0000000..c3685d3 --- /dev/null +++ b/internal/tools/docx.go @@ -0,0 +1,62 @@ +// write_docx 工具(提案:正式交付物直接可交):模型交 Markdown,后端用 +// docgen 转成带 Word 样式的 .docx 工件(标题层级/表格/编号列表/引用/代码段)。 +// 对话内临时说明与纯文本/数据交付物仍走 write_artifact——两者分工见 +// 提示词库 tool.write_docx / tool.write_artifact。 +package tools + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + core "agent-system-core" + + "agent-system-backend/internal/docgen" +) + +const docxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + +// registerWriteDocx 注册 docx 生成工具:name 建议含 .docx(缺省自动补), +// content 为 Markdown 源文(排版结构由 Markdown 表达,样式由生成器保证)。 +func registerWriteDocx(r *Registry) error { + return r.Register(core.ToolDesc{ + Name: "write_docx", Source: core.SourceBuiltin, + Description: "生成带排版样式的 Word 文档(.docx 工件):标题层级、表格、编号列表、引用、代码段、粗斜体与超链接。content 用 Markdown 书写,自动转换为正式 Word 排版。用于正式文档交付物(报告、纪要、方案、说明书)。", + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"name":{"type":"string","description":"文件名(建议 中文标题.docx;缺扩展名自动补)"},` + + `"title":{"type":"string","description":"文档标题(缺省取正文第一个标题)"},` + + `"content":{"type":"string","description":"Markdown 正文:#/##/### 表达章节,|表格|、- 列表、> 引用、**粗体**"}}` + + `,"required":["name","content"]}`), + }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { + var a struct { + Name string `json:"name"` + Title string `json:"title"` + Content string `json:"content"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("write_docx: bad args: %w", err) + } + } + if strings.TrimSpace(a.Name) == "" { + return "", fmt.Errorf("write_docx: name required") + } + if strings.TrimSpace(a.Content) == "" { + return "", fmt.Errorf("write_docx: content required") + } + name := strings.TrimSpace(a.Name) + if strings.ToLower(filepath.Ext(name)) != ".docx" { + name += ".docx" + } + buf, err := docgen.Build(a.Title, a.Content) + if err != nil { + return "", fmt.Errorf("write_docx: %w", err) + } + art, err := rc.WriteArtifact(name, "document", docxMIME, buf) + if err != nil { + return "", err + } + return fmt.Sprintf("已生成 Word 文档 %s(%s,%d 字节,含标题/表格/列表排版)", art.Name, art.ID, art.Size), nil + }) +} diff --git a/internal/tools/docx_test.go b/internal/tools/docx_test.go new file mode 100644 index 0000000..c9408cb --- /dev/null +++ b/internal/tools/docx_test.go @@ -0,0 +1,81 @@ +// write_docx 工具测试:Markdown 进、合法 OOXML docx 工件出(标题/表格排版 +// 落在文档字节里,而不是纯文本改扩展名);参数校验与扩展名补全。 +package tools + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "io" + "strings" + "testing" +) + +func TestWriteDocxProducesStyledOOXML(t *testing.T) { + r := NewRegistry() + if err := r.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + rc := &fakeFSCtx{} + res := call(t, r, rc, "write_docx", `{"name":"评审会议纪要","content":"# 纪要\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-10-01 |\n\n- 要点一\n"}`) + if res.Status != "ok" { + t.Fatalf("write_docx: %s", res.Output) + } + if len(rc.written) != 1 || rc.written[0] != "评审会议纪要.docx" { + t.Fatalf("工件名不符(应自动补 .docx): %+v", rc.written) + } + if !strings.Contains(rc.lastMime, "wordprocessingml") { + t.Errorf("docx 工件 mime 不符: %s", rc.lastMime) + } + + zr, err := zip.NewReader(bytes.NewReader(rc.lastContent), int64(len(rc.lastContent))) + if err != nil { + t.Fatalf("产物不是合法 zip/docx 容器: %v", err) + } + var doc string + for _, f := range zr.File { + if f.Name != "word/document.xml" { + continue + } + rc2, _ := f.Open() + b, _ := io.ReadAll(rc2) + rc2.Close() + doc = string(b) + } + if doc == "" { + t.Fatal("word/document.xml 缺失") + } + dec := xml.NewDecoder(strings.NewReader(doc)) + for { + _, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("document.xml 不良构: %v", err) + } + } + for _, marker := range []string{`w:val="Heading1"`, ``, ``} { + if !strings.Contains(doc, marker) { + t.Errorf("docx 缺少排版标记 %s(仍是纯文本?)", marker) + } + } +} + +func TestWriteDocxArgValidation(t *testing.T) { + r := NewRegistry() + if err := r.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + cases := []struct{ name, args string }{ + {"缺 name", `{"content":"x"}`}, + {"缺 content", `{"name":"a.docx"}`}, + {"坏 JSON", `{"name":`}, + } + for _, c := range cases { + res := call(t, r, &fakeFSCtx{}, "write_docx", c.args) + if res.Status != "error" { + t.Errorf("%s: 期望 error,得 %s(%s)", c.name, res.Status, res.Output) + } + } +} diff --git a/internal/tools/fs_test.go b/internal/tools/fs_test.go index 4827dab..fd91452 100644 --- a/internal/tools/fs_test.go +++ b/internal/tools/fs_test.go @@ -12,11 +12,13 @@ import ( ) type fakeFSCtx struct { - root string - owner string - conv string - fsToolsOn bool - written []string // 登记的工件名 + root string + owner string + conv string + fsToolsOn bool + written []string // 登记的工件名 + lastContent []byte // 最近一次写入的工件字节 + lastMime string } func (c *fakeFSCtx) ProjectID() string { return "p1" } @@ -32,6 +34,8 @@ func (c *fakeFSCtx) SettingBool(key string, def bool) bool { func (c *fakeFSCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil } func (c *fakeFSCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { c.written = append(c.written, name) + c.lastContent = content + c.lastMime = mime return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil } func (c *fakeFSCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 00422e6..be8356c 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -180,5 +180,8 @@ func (r *Registry) RegisterBuiltin() error { }); err != nil { return err } + if err := registerWriteDocx(r); err != nil { + return err + } return registerPeerTools(r) }