Generate real docx artifacts and guard against pseudo-binary writes #27
@@ -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))
|
||||
|
||||
@@ -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}})
|
||||
}
|
||||
|
||||
@@ -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(`<w:pStyle w:val="Heading%d"/>`, lvl)))
|
||||
case core.BlockPara, core.BlockCode:
|
||||
body.WriteString(ooxmlPara(b.Text, ""))
|
||||
case core.BlockList:
|
||||
for _, item := range b.Items {
|
||||
body.WriteString(ooxmlPara("• "+item, ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
document := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
|
||||
body.String() +
|
||||
`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:body></w:document>`
|
||||
|
||||
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 = `<?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"/>` +
|
||||
`</Types>`
|
||||
|
||||
const rootRelsXML = `<?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"/>` +
|
||||
`</Relationships>`
|
||||
|
||||
// ooxmlPara 输出一个段落:props 为 pPr 内容(可为空),文本按行拆 run(行间 w:br),
|
||||
// 行内 **加粗** 转 <w:b/> run,其余字符经 XML 转义。
|
||||
func ooxmlPara(text, props string) string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<w:p>")
|
||||
if props != "" {
|
||||
sb.WriteString("<w:pPr>" + props + "</w:pPr>")
|
||||
}
|
||||
for i, line := range strings.Split(text, "\n") {
|
||||
if i > 0 {
|
||||
sb.WriteString("<w:r><w:br/></w:r>")
|
||||
}
|
||||
sb.WriteString(ooxmlRuns(line))
|
||||
}
|
||||
sb.WriteString("</w:p>")
|
||||
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("<w:r>")
|
||||
if i%2 == 1 {
|
||||
sb.WriteString("<w:rPr><w:b/></w:rPr>")
|
||||
}
|
||||
sb.WriteString(`<w:t xml:space="preserve">`)
|
||||
xml.EscapeText(&sb, []byte(s))
|
||||
sb.WriteString("</w:t></w:r>")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 表格与代码样式(等宽 rFonts)暂不输出:ingest.parseMarkdown 尚不解析 md 表格
|
||||
// (不产 BlockTable),序列化端先不做走不到的分支;随 ingest 表格解析一并补。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 上限")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user