Generate styled Word deliverables and pin artifact cards in chat

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 <w:tbl> 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
This commit is contained in:
2026-10-01 02:48:49 +00:00
parent d8497a1e60
commit 370ae1a280
15 changed files with 1448 additions and 31 deletions
+891
View File
@@ -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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
body.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>`)
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(`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>`)
body.WriteString("</w:body></w:document>")
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("<w:p><w:pPr>")
if style != "" {
sb.WriteString(`<w:pStyle w:val="` + style + `"/>`)
}
for _, e := range extra {
sb.WriteString(e)
}
sb.WriteString("</w:pPr>")
for _, r := range runs {
sb.WriteString(d.runXML(r))
}
sb.WriteString("</w:p>")
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, "<w:b/><w:bCs/>")
}
if r.italic {
props = append(props, "<w:i/><w:iCs/>")
}
if r.strike {
props = append(props, "<w:strike/>")
}
if r.code {
props = append(props, `<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>`)
}
if r.link != "" {
rid := d.hyperlinkRID(r.link)
return fmt.Sprintf(`<w:hyperlink r:id="%s"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/>%s</w:rPr>%s</w:r></w:hyperlink>`,
rid, strings.Join(props, ""), textRuns(r.text))
}
return "<w:r>" + rPr(props) + textRuns(r.text) + "</w:r>"
}
// 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("<w:br/>")
}
if p != "" {
sb.WriteString(`<w:t xml:space="preserve">` + esc(p) + `</w:t>`)
}
}
return sb.String()
}
func rPr(props []string) string {
if len(props) == 0 {
return ""
}
return "<w:rPr>" + strings.Join(props, "") + "</w:rPr>"
}
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(`<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="BFBFBF"/></w:pBdr></w:pPr></w:p>`)
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(`<w:numPr><w:ilvl w:val="%d"/><w:numId w:val="%d"/></w:numPr>`, 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(`<w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/><w:tblBorders>`)
for _, edge := range []string{"top", "left", "bottom", "right", "insideH", "insideV"} {
sb.WriteString(fmt.Sprintf(`<w:%s w:val="single" w:sz="4" w:space="0" w:color="BFBFBF"/>`, edge))
}
sb.WriteString(`</w:tblBorders><w:tblCellMar><w:top w:w="60" w:type="dxa"/><w:left w:w="108" w:type="dxa"/><w:bottom w:w="60" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar></w:tblPr>`)
for ri, row := range b.rows {
header := b.hasHeader && ri == 0
sb.WriteString("<w:tr>")
if header {
sb.WriteString(`<w:trPr><w:tblHeader/></w:trPr>`)
}
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 = `<w:shd w:val="clear" w:fill="EEF2F8"/>`
}
sb.WriteString(fmt.Sprintf(`<w:tc><w:tcPr>%s</w:tcPr><w:p><w:pPr><w:spacing w:before="20" w:after="20"/><w:jc w:val="%s"/></w:pPr>%s</w:p></w:tc>`,
shd, align[ci], d.runsXML(runs)))
}
sb.WriteString("</w:tr>")
}
sb.WriteString("</w:tbl>")
// 表后补一个近零高度空段:防相邻表格被 Word 合并。
sb.WriteString(`<w:p><w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr></w:p>`)
return sb.String()
}
// ---- 包部件文件 ----
func esc(s string) string {
var sb strings.Builder
for _, r := range s {
switch r {
case '&':
sb.WriteString("&amp;")
case '<':
sb.WriteString("&lt;")
case '>':
sb.WriteString("&gt;")
case '"':
sb.WriteString("&quot;")
case '\'':
sb.WriteString("&apos;")
default:
sb.WriteRune(r)
}
}
return sb.String()
}
func contentTypesXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`
}
func rootRelsXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`
}
func (d *document) relsXML() string {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
sb.WriteString(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`)
sb.WriteString(`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`)
sb.WriteString(`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>`)
for i, href := range d.hyperlinks {
sb.WriteString(fmt.Sprintf(`<Relationship Id="rIdH%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="%s" TargetMode="External"/>`, i+1, esc(href)))
}
sb.WriteString(`</Relationships>`)
return sb.String()
}
func stylesXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults><w:rPrDefault><w:rPr>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="等线" w:cs="Calibri"/>
<w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/>
</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:jc w:val="center"/><w:spacing w:before="120" w:after="360"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="44"/><w:szCs w:val="44"/><w:color w:val="1F3864"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="0"/><w:spacing w:before="320" w:after="160"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="32"/><w:szCs w:val="32"/><w:color w:val="1F4E79"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="1"/><w:spacing w:before="280" w:after="140"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/><w:color w:val="2E74B5"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="2"/><w:spacing w:before="240" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="24"/><w:szCs w:val="24"/><w:color w:val="404040"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="3"/><w:spacing w:before="200" w:after="100"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="22"/><w:szCs w:val="22"/><w:color w:val="595959"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:contextualSpacing/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:pBdr><w:left w:val="single" w:sz="18" w:space="4" w:color="4472C4"/></w:pBdr><w:ind w:left="360"/><w:spacing w:after="80"/></w:pPr>
<w:rPr><w:color w:val="595959"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="CodePara"><w:name w:val="Code Paragraph"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:shd w:val="clear" w:fill="F5F5F5"/><w:spacing w:after="0" w:line="264" w:lineRule="auto"/><w:ind w:left="240" w:right="240"/></w:pPr>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="19"/><w:szCs w:val="19"/></w:rPr></w:style>
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/>
<w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>
</w:styles>`
}
// numberingXML:abstractNum 0(无序)/1(有序)+ 预生成成对 num 实例,
// 与 newListNumID 的分配方案对齐(见其注释)。
func numberingXML() string {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
sb.WriteString(`<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
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(`</w:numbering>`)
return sb.String()
}
func abstractNumXML(aid int, ordered bool) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(`<w:abstractNum w:abstractNumId="%d"><w:multiLevelType w:val="hybridMultilevel"/>`, aid))
bullets := []string{"•", "◦", "▪", "·"}
for lvl := 0; lvl < 4; lvl++ {
if ordered {
sb.WriteString(fmt.Sprintf(`<w:lvl w:ilvl="%d"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%%%d."/><w:lvlJc w:val="left"/>`, lvl, lvl+1))
} else {
sb.WriteString(fmt.Sprintf(`<w:lvl w:ilvl="%d"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="%s"/><w:lvlJc w:val="left"/>`, lvl, bullets[lvl]))
}
sb.WriteString(fmt.Sprintf(`<w:pPr><w:ind w:left="%d" w:hanging="360"/></w:pPr></w:lvl>`, 720+lvl*420))
}
sb.WriteString(`</w:abstractNum>`)
return sb.String()
}
func numXML(numID, abstractID int, restart bool) string {
ov := ""
if restart {
ov = `<w:lvlOverride w:ilvl="0"><w:startOverride w:val="1"/></w:lvlOverride>`
}
return fmt.Sprintf(`<w:num w:numId="%d"><w:abstractNumId w:val="%d"/>%s</w:num>`, numID, abstractID, ov)
}
func coreXML(title string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>%s</dc:title><dc:creator>Agent-System</dc:creator><cp:lastModifiedBy>Agent-System</cp:lastModifiedBy>
</cp:coreProperties>`, esc(title))
}
func appXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>Agent-System docgen</Application>
</Properties>`
}
+242
View File
@@ -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{
`<w:pStyle w:val="Title"/>`,
`<w:pStyle w:val="Heading1"/>`,
`<w:pStyle w:val="Heading2"/>`,
`<w:tbl>`,
`<w:tblHeader/>`,
`<w:numPr>`,
`<w:hyperlink r:id="rIdH1">`,
`<w:strike/>`,
} {
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, "<w:numId w:val=\"")[1:] {
id := seg[:strings.IndexByte(seg, '"')]
refs[id] = true
}
if len(refs) == 0 {
t.Fatal("列表未引用任何 numId")
}
for id := range refs {
if !strings.Contains(numbering, `<w:num w:numId="`+id+`">`) {
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("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
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), "&lt;标签&gt;") || !strings.Contains(string(doc), "&amp;") {
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, "<w:br/>") {
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
}
+62
View File
@@ -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)
}
}
@@ -26,3 +26,4 @@ triggers:
- 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。
- 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。
- 待办事项若原文未指明负责人或期限,写"未指明",不要编造。
- 交付形态:用 write_docx 生成正式 Word 文档(Markdown 表达结构:一级标题为纪要标题、基本信息与待办用表格、决议用编号列表),name 用「<主题>会议纪要.docx」;对话内只简述结论,全文以文档交付,不要在回复里复述全文。
@@ -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 文件)。
@@ -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 排版"才用本工具。
+19 -1
View File
@@ -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))
+8 -2
View File
@@ -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 {
+23 -15
View File
@@ -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
+14 -4
View File
@@ -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 引擎编排……"})
+20 -3
View File
@@ -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), "<w:tbl>") {
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
}
}
+62
View File
@@ -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
})
}
+81
View File
@@ -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"`, `<w:tbl>`, `<w:numPr>`} {
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)
}
}
}
+9 -5
View File
@@ -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 }
+3
View File
@@ -180,5 +180,8 @@ func (r *Registry) RegisterBuiltin() error {
}); err != nil {
return err
}
if err := registerWriteDocx(r); err != nil {
return err
}
return registerPeerTools(r)
}