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)
}