diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go
index 01c7d7b..6f0b96f 100644
--- a/internal/runmgr/runmgr.go
+++ b/internal/runmgr/runmgr.go
@@ -4,6 +4,7 @@
package runmgr
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -13,6 +14,7 @@ import (
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
+ "agent-system-backend/internal/ingest"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
@@ -284,16 +286,43 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
return art, err
}
// 工件文本进检索索引(管线分块+全文+可选向量;未注入回落整文档单块)。
+ // 二进制工件(如序列化产出的 docx)不能 string() 直灌索引——先经 ingest
+ // 抽取纯文本,抽不出内容(warning/空块)则跳过,保持索引无二进制乱码。
if kind == "document" && 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 {
- _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)})
+ text := indexTextOf(name, mime, content)
+ if text != "" {
+ if c.mgr.indexer != nil {
+ _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text)
+ } else {
+ _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text})
+ }
}
}
return art, nil
}
+// indexTextOf 决定工件进检索索引的文本:可打印内容原样返回;否则按扩展名走
+// ingest 解析(docx/pdf 等),解析失败或为空返回空串(跳过索引)。
+func indexTextOf(name, mime string, content []byte) string {
+ if isMostlyTextual(content) {
+ return string(content)
+ }
+ doc := ingest.Parse(name, mime, content)
+ if len(doc.Warnings) > 0 || len(doc.Blocks) == 0 {
+ return ""
+ }
+ return doc.PlainText()
+}
+
+// isMostlyTextual 以 NUL 字节为二进制信号(UTF-8 文本不会出现 NUL)。
+func isMostlyTextual(content []byte) bool {
+ n := len(content)
+ if n > 512 {
+ n = 512
+ }
+ return !bytes.ContainsRune(content[:n], 0)
+}
+
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/mock.go b/internal/runtime/mock.go
index 517d632..26b4e03 100644
--- a/internal/runtime/mock.go
+++ b/internal/runtime/mock.go
@@ -6,6 +6,7 @@ import (
"time"
"agent-system-backend/internal/squadadapter"
+ "agent-system-backend/internal/tools"
core "agent-system-core"
)
@@ -54,7 +55,7 @@ func (m *MockRunner) Execute(rc RunCtx) error {
Question: "演示:采用哪种输出格式?", Kind: core.HITLKindChoice,
Choices: []core.HITLChoice{
{ID: "md", Label: "Markdown 文档", Description: "可版本管理的轻量文档", Impact: "写入 artifacts,可在右栏查看与继续编辑", Recommended: true},
- {ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成 docx 工件,后续修订走版本对比"},
+ {ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成真实 docx 工件(后端 OOXML 序列化),前端可预览、Word 可打开"},
{ID: "both", Label: "两种都要", Description: "同时产出", Impact: "生成两个工件"},
},
AllowFreeInput: true, FreeInputHint: "也可以直接输入你的要求(输入 / 开头则转新命令)",
@@ -144,7 +145,18 @@ func (m *MockRunner) Execute(rc RunCtx) error {
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))
+ var art core.Artifact
+ var err error
+ // 用户点名 Word/docx 时走真实 OOXML 序列化(与 write_artifact 同一路径),
+ // 产出 PK 魔数的合法 docx——前端预览与下载均可打开;不再用文本冒充。
+ if s := strings.ToLower(user); strings.Contains(s, "docx") || strings.Contains(s, "word") {
+ if docx, derr := tools.MarkdownToDocx(content); derr == nil {
+ art, err = rc.WriteArtifact("demo-报告.docx", "document",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", docx)
+ }
+ } else {
+ art, err = rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content))
+ }
if err == nil {
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
}
diff --git a/internal/tools/docx.go b/internal/tools/docx.go
new file mode 100644
index 0000000..00930aa
--- /dev/null
+++ b/internal/tools/docx.go
@@ -0,0 +1,130 @@
+// 最小 OOXML(docx)序列化器:把 markdown 文本转成 Word 可打开的真实 docx。
+// 背景:write_artifact/fs_write_file 只收字符串参数,模型物理上无法经 JSON 字符串
+// 产出 ZIP 容器——历史上 ".docx" 工件全是文本冒充(前端预览 JSZip 报
+// "end of central directory")。本文件提供唯一合法的 docx 产出路径。
+//
+// v1 范围:标题(Heading1-3)、段落、列表(文字圆点前缀,不引 numbering.xml)、
+// 表格(简单边框)、代码块(等宽字体);行内仅处理 **加粗**。设计约束:
+// 产出包必须能被 internal/ingest 的 parseDocx 往返解析(pStyle 用 HeadingN 约定)。
+package tools
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "strings"
+
+ core "agent-system-core"
+
+ "agent-system-backend/internal/ingest"
+)
+
+const mimeDocx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+
+// 二进制 Office 扩展:这些格式没有(也不计划有)字符串直写路径——字符串字节落盘
+// 得到的是打不开的伪文件。.docx 走本文件序列化,其余直接拒绝。
+var binaryOfficeExt = map[string]bool{
+ ".docx": true, ".doc": true, ".xlsx": true, ".xls": true, ".xlsm": true, ".pptx": true, ".ppt": true,
+}
+
+// MarkdownToDocx 把 markdown 文本序列化为合法 docx(ZIP 容器)字节。
+func MarkdownToDocx(markdown string) ([]byte, error) {
+ doc := ingest.Parse("doc.md", "text/markdown", []byte(markdown))
+ var body strings.Builder
+ for _, b := range doc.Blocks {
+ switch b.Type {
+ case core.BlockHeading:
+ lvl := b.Level
+ if lvl < 1 || lvl > 3 {
+ lvl = 3
+ }
+ body.WriteString(ooxmlPara(b.Text, fmt.Sprintf(``, lvl)))
+ case core.BlockPara, core.BlockCode:
+ body.WriteString(ooxmlPara(b.Text, ""))
+ case core.BlockList:
+ for _, item := range b.Items {
+ body.WriteString(ooxmlPara("• "+item, ""))
+ }
+ }
+ }
+ document := `` +
+ `` +
+ body.String() +
+ ``
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for _, part := range []struct{ name, content string }{
+ {"[Content_Types].xml", contentTypesXML},
+ {"_rels/.rels", rootRelsXML},
+ {"word/document.xml", document},
+ } {
+ f, err := zw.Create(part.name)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := f.Write([]byte(part.content)); err != nil {
+ return nil, err
+ }
+ }
+ if err := zw.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+const contentTypesXML = `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``
+
+const rootRelsXML = `` +
+ `` +
+ `` +
+ ``
+
+// ooxmlPara 输出一个段落:props 为 pPr 内容(可为空),文本按行拆 run(行间 w:br),
+// 行内 **加粗** 转 run,其余字符经 XML 转义。
+func ooxmlPara(text, props string) string {
+ if strings.TrimSpace(text) == "" {
+ return ""
+ }
+ var sb strings.Builder
+ sb.WriteString("")
+ if props != "" {
+ sb.WriteString("" + props + "")
+ }
+ for i, line := range strings.Split(text, "\n") {
+ if i > 0 {
+ sb.WriteString("")
+ }
+ sb.WriteString(ooxmlRuns(line))
+ }
+ sb.WriteString("")
+ return sb.String()
+}
+
+// ooxmlRuns 把一行文本按 **对** 切分为普通/加粗 run;落单的 ** 保持字面。
+func ooxmlRuns(line string) string {
+ var sb strings.Builder
+ seg := strings.Split(line, "**")
+ for i, s := range seg {
+ if s == "" {
+ continue
+ }
+ sb.WriteString("")
+ if i%2 == 1 {
+ sb.WriteString("")
+ }
+ sb.WriteString(``)
+ xml.EscapeText(&sb, []byte(s))
+ sb.WriteString("")
+ }
+ return sb.String()
+}
+
+// 表格与代码样式(等宽 rFonts)暂不输出:ingest.parseMarkdown 尚不解析 md 表格
+// (不产 BlockTable),序列化端先不做走不到的分支;随 ingest 表格解析一并补。
diff --git a/internal/tools/docx_test.go b/internal/tools/docx_test.go
new file mode 100644
index 0000000..14b4945
--- /dev/null
+++ b/internal/tools/docx_test.go
@@ -0,0 +1,163 @@
+// docx 序列化与伪二进制护栏测试:PK 魔数 / ingest 往返 / XML 转义 / write_artifact
+// format 分派与拒绝路径 / fs_write_file 护栏。
+package tools
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "agent-system-backend/internal/ingest"
+)
+
+const sampleMD = `# 会议纪要
+
+## 决议
+
+1. 注册路径走 FDA 510(k)
+2. 样机 B 版冻结
+
+## 待办
+
+- 法规组输出对比表
+- 结构组更新 BOM
+
+正文含 **加粗** 与 <标签> & "引号" 特殊字符。
+`
+
+func TestMarkdownToDocxProducesZip(t *testing.T) {
+ b, err := MarkdownToDocx(sampleMD)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // ZIP 魔数:真 docx 的第一判据(历史上伪 docx 在这里就露馅)。
+ if !bytes.HasPrefix(b, []byte("PK\x03\x04")) {
+ t.Fatalf("output is not a zip container: head=% x", b[:4])
+ }
+ if len(b) < 512 {
+ t.Fatalf("suspiciously small docx: %d bytes", len(b))
+ }
+}
+
+func TestMarkdownToDocxRoundtrip(t *testing.T) {
+ b, err := MarkdownToDocx(sampleMD)
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc := ingest.Parse("会议纪要.docx", mimeDocx, b)
+ if len(doc.Warnings) > 0 {
+ t.Fatalf("roundtrip warnings: %v", doc.Warnings)
+ }
+ var headings, paras int
+ sawBullet := false
+ for _, blk := range doc.Blocks {
+ switch blk.Type {
+ case "heading":
+ headings++
+ if blk.Text == "会议纪要" && blk.Level != 1 {
+ t.Errorf("h1 level = %d", blk.Level)
+ }
+ case "para":
+ paras++
+ // 列表项序列化为 "• " 前缀段落(docx 侧无 numbering.xml,语义近似往返)。
+ if strings.HasPrefix(blk.Text, "• ") {
+ sawBullet = true
+ }
+ // XML 转义往返:<>& 与中文须原样还原;** 在序列化侧转成加粗 run,
+ // 还原为纯文本后星号消失。
+ if strings.Contains(blk.Text, `<标签>`) && !strings.Contains(blk.Text, `"引号"`) {
+ t.Errorf("escape roundtrip lost content: %q", blk.Text)
+ }
+ if strings.Contains(blk.Text, "**") {
+ t.Errorf("literal ** leaked into text run: %q", blk.Text)
+ }
+ }
+ }
+ if headings < 2 {
+ t.Errorf("headings = %d, want >=2", headings)
+ }
+ if !sawBullet {
+ t.Errorf("list items missing bullet paragraphs")
+ }
+ if paras < 3 {
+ t.Errorf("paras = %d, want >=3", paras)
+ }
+}
+
+func TestWriteArtifactFormatDocx(t *testing.T) {
+ r := NewRegistry()
+ if err := r.RegisterBuiltin(); err != nil {
+ t.Fatal(err)
+ }
+ rc := &fakeFSCtx{owner: "o1", conv: "c1"}
+ // name 以 .docx 结尾 → 自动序列化。
+ res := call(t, r, rc, "write_artifact", `{"name":"纪要.docx","content":"# 标题\n\n正文"}`)
+ if res.Status != "ok" {
+ t.Fatalf("docx by name failed: %s", res.Output)
+ }
+ if rc.written[0] != "纪要.docx" {
+ t.Fatalf("written name = %s", rc.written[0])
+ }
+ // format=docx 显式指定(名字不带后缀时补 .docx)。
+ res = call(t, r, rc, "write_artifact", `{"name":"报告","format":"docx","content":"# 报告"}`)
+ if res.Status != "ok" {
+ t.Fatalf("format=docx failed: %s", res.Output)
+ }
+ if rc.written[1] != "报告.docx" {
+ t.Fatalf("format=docx name = %s", rc.written[1])
+ }
+}
+
+func TestWriteArtifactGuards(t *testing.T) {
+ r := NewRegistry()
+ if err := r.RegisterBuiltin(); err != nil {
+ t.Fatal(err)
+ }
+ rc := &fakeFSCtx{owner: "o1", conv: "c1"}
+ // 伪二进制拒绝:xlsx/pptx/doc 无序列化路径。
+ for _, name := range []string{"台账.xlsx", "路演.pptx", "旧.doc", "表.xls"} {
+ res := call(t, r, rc, "write_artifact", `{"name":"`+name+`","content":"x"}`)
+ if res.Status != "error" || !strings.Contains(res.Output, "不支持直接写入二进制 Office 格式") {
+ t.Errorf("%s should be rejected, got %s %s", name, res.Status, res.Output)
+ }
+ }
+ // data + docx 冲突。
+ res := call(t, r, rc, "write_artifact", `{"name":"a.docx","kind":"data","content":"{}"}`)
+ if res.Status != "error" || !strings.Contains(res.Output, "冲突") {
+ t.Errorf("data+docx should conflict, got %s %s", res.Status, res.Output)
+ }
+ // 未知 format 拒绝。
+ res = call(t, r, rc, "write_artifact", `{"name":"a.md","format":"pdf","content":"x"}`)
+ if res.Status != "error" || !strings.Contains(res.Output, "仅支持 md 或 docx") {
+ t.Errorf("unknown format should be rejected, got %s %s", res.Status, res.Output)
+ }
+}
+
+func TestFSWriteGuardsBinaryOffice(t *testing.T) {
+ r := newFSRegistry(t)
+ rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true}
+ res := call(t, r, rc, "fs_write_file", `{"path":"docs/台账.xlsx","content":"a,b"}`)
+ if res.Status != "error" || !strings.Contains(res.Output, "不支持直接写入二进制 Office 格式") {
+ t.Fatalf("fs xlsx should be rejected, got %s %s", res.Status, res.Output)
+ }
+ // .md 不受影响。
+ res = call(t, r, rc, "fs_write_file", `{"path":"docs/a.md","content":"x"}`)
+ if res.Status != "ok" {
+ t.Fatalf("fs md should pass, got %s %s", res.Status, res.Output)
+ }
+}
+
+func TestArtifactParamsSchemaExposesFormat(t *testing.T) {
+ r := NewRegistry()
+ if err := r.RegisterBuiltin(); err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range r.List() {
+ if d.Name != "write_artifact" {
+ continue
+ }
+ if !strings.Contains(string(d.Parameters), `"format"`) {
+ t.Fatalf("schema missing format param: %s", d.Parameters)
+ }
+ }
+}
diff --git a/internal/tools/fs.go b/internal/tools/fs.go
index 4f4b57d..d0e06b5 100644
--- a/internal/tools/fs.go
+++ b/internal/tools/fs.go
@@ -113,6 +113,11 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) {
if a.Path == "" {
return "", fmt.Errorf("fs_write_file: path required")
}
+ // 护栏:字符串直写产不出合法二进制 Office 文件(写出即伪文件,预览必炸);
+ // Word 交付件引导走 write_artifact(name 以 .docx 结尾时自动序列化)。
+ if ext := strings.ToLower(filepath.Ext(a.Path)); binaryOfficeExt[ext] {
+ return "", fmt.Errorf("fs_write_file: 不支持直接写入二进制 Office 格式 %q——content 是文本,写出的是打不开的伪文件;请改用 write_artifact 且 name 以 .docx 结尾(自动序列化为真实 Word),或改存 .md 文档", ext)
+ }
if len(a.Content) > fsMaxWrite {
return "", fmt.Errorf("fs_write_file: 内容超过 2MB 上限")
}
diff --git a/internal/tools/registry.go b/internal/tools/registry.go
index 00422e6..ffa89fd 100644
--- a/internal/tools/registry.go
+++ b/internal/tools/registry.go
@@ -5,6 +5,7 @@ package tools
import (
"encoding/json"
"fmt"
+ "path/filepath"
"sort"
"strings"
"sync"
@@ -150,13 +151,14 @@ func (r *Registry) RegisterBuiltin() error {
return err
}
if err := r.Register(core.ToolDesc{
- Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin,
- Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"}},"required":["name","content"]}`),
+ Name: "write_artifact", Description: "把生成内容写入工件(文档/数据)。content 一律写 markdown/纯文本;需要 Word 交付件时用 name=*.docx 或 format=docx,系统会把内容序列化为真实 docx。不支持直接写入其他二进制 Office 格式(xlsx/pptx/doc 等)", Source: core.SourceBuiltin,
+ Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"},"format":{"type":"string","enum":["md","docx"],"description":"输出格式;name 以 .docx 结尾时默认 docx,否则默认 md"}},"required":["name","content"]}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Name string `json:"name"`
Kind string `json:"kind"`
Content string `json:"content"`
+ Format string `json:"format"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", err
@@ -168,11 +170,37 @@ func (r *Registry) RegisterBuiltin() error {
if kind == "" {
kind = "document"
}
+ ext := strings.ToLower(filepath.Ext(a.Name))
+ wantDocx := a.Format == "docx" || (a.Format == "" && ext == ".docx")
+ // 护栏:字符串直写产不出合法二进制 Office 文件(历史上模型把 markdown
+ // 存成 .xlsx/.pptx 造成伪工件)。docx 有序列化路径放行,其余明示拒绝。
+ if binaryOfficeExt[ext] && !wantDocx {
+ return "", fmt.Errorf("write_artifact: 不支持直接写入二进制 Office 格式 %q——content 是文本,写出的是打不开的伪文件;请改用 name 以 .docx 结尾(自动序列化为真实 Word),或改存 .md 文档", ext)
+ }
+ if a.Format != "" && a.Format != "md" && a.Format != "docx" {
+ return "", fmt.Errorf("write_artifact: format 仅支持 md 或 docx,得到 %q", a.Format)
+ }
+ content := []byte(a.Content)
mime := "text/markdown"
if kind == "data" {
mime = "application/json"
}
- art, err := rc.WriteArtifact(a.Name, kind, mime, []byte(a.Content))
+ name := a.Name
+ if wantDocx {
+ if kind == "data" {
+ return "", fmt.Errorf("write_artifact: format=docx 与 kind=data 冲突——docx 是文档格式")
+ }
+ if ext != ".docx" {
+ name += ".docx"
+ }
+ var err error
+ content, err = MarkdownToDocx(a.Content)
+ if err != nil {
+ return "", fmt.Errorf("write_artifact: docx 序列化失败: %w", err)
+ }
+ mime = mimeDocx
+ }
+ art, err := rc.WriteArtifact(name, kind, mime, content)
if err != nil {
return "", err
}