Files
homeagent-sdk/example/editdoc/plugin.go
JianFeeeee 81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00

534 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// editDocPy 内嵌 Python 后端脚本。
//
// 为何必须内嵌:hmapdev 的打包器只把 plugin.json + plugin.bin 放进 .hmap
// (加上可选的 README/LICENSE),**不打包任何资源文件**。
// 而本插件的全部业务逻辑就在 edit_doc.py 里,若让它单独存在于
// plugin_data/editdoc/,就会出现「Go 侧升级了、脚本忘了换」的版本漂移
// (mc 插件就踩过这个坑:工具注册了但子脚本还是旧的,报未知命令)。
// 内嵌 + 内容指纹校验可以让两侧永远同版本。
//
//go:embed embed/edit_doc.py
var embeddedScript []byte
// scriptStamp 返回内嵌脚本的内容指纹。
func scriptStamp() string {
h := sha256.Sum256(embeddedScript)
return hex.EncodeToString(h[:])[:16]
}
// scriptProtocolMarker 是 edit_doc.py 里的 v2 协议标记(见 embed/edit_doc.py 顶部)。
// resolveScript 靠它识别「script_path 指向的是不是 v2 脚本」,防止旧脚本被误用。
const scriptProtocolMarker = `EDITDOC_PROTOCOL = "v2"`
// Plugin 是 editdoc v2.0.0(全能办公)插件。
//
// 设计要点(与 v1.0.0 的关系):
// - edit_document 工具名与 file/operation/target/replacement/sheet/row/col/value
// 参数语义**完全保留**,老调用不会破。
// - 全部实现委托给同目录下的 edit_doc.py,Go 侧只做参数编排 + 结构化返回。
// - 所有子命令统一走 runScript,错误一律结构化(ok:false + error + hint),不 panic。
type Plugin struct {
name string
sdk *sdk.PluginSDK
scriptPath string
venvPython string
timeoutSec int
// ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。
// 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。
ignoredScriptPath string
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
// ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)----
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "script_path", Default: "", Type: "string",
DisplayName: "办公脚本路径",
Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" +
"仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "venv_python", Default: "", Type: "string",
DisplayName: "venv Python 解释器",
Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout_sec", Default: 60, Type: "int",
DisplayName: "脚本超时(秒)",
Description: "单次调用 edit_doc.py 的超时上限;大文件或格式转换(soffice 首次启动慢)建议调到 180",
Category: p.name, Min: 5, Max: 900,
})
p.reloadSettings()
// ---- 工具注册 ----
// 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。
s.RegisterTool("read_document", sdk.ToolDef{
Name: "read_document",
Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" +
".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" +
"大文件用 offset/limit 分段读。改文件前先用它看内容。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "文档绝对路径"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx:只读某张表(可选)"},
"offset": map[string]interface{}{"type": "integer", "description": "分段读的起始偏移(docx=块序号, xlsx/pptx=页/表序号),默认 0"},
"limit": map[string]interface{}{"type": "integer", "description": "本次最多返回多少段,默认全部"},
"formulas": map[string]interface{}{"type": "boolean", "description": "xlsx 是否附带公式的计算值,默认 true"},
},
"required": []string{"file"},
},
}, p.handleRead)
s.RegisterTool("create_document", sdk.ToolDef{
Name: "create_document",
Description: "从零新建办公文档。支持 .docx(title+content,content 支持 Markdown:# 标题、**粗体**、*斜体*、- 列表)、" +
".xlsx(sheets 多表:表名+表头+行数据)、.pptx(title+content,## 分页)、.md、.csv、.txt。" +
"目录不存在会自动创建;文件已存在时需传 overwrite=true 才会覆盖(覆盖前自动 .bak 留档)。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "要创建的文件绝对路径(扩展名决定格式)"},
"title": map[string]interface{}{"type": "string", "description": "标题(docx/pptx/md)"},
"subtitle": map[string]interface{}{"type": "string", "description": "副标题(pptx 首页)"},
"content": map[string]interface{}{"type": "string", "description": "正文内容(docx/md/txt 支持 Markdown;pptx 用 ## 分页)"},
"sheets": map[string]interface{}{"type": "array", "description": "xlsx 多表:[{name, headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"headers": map[string]interface{}{"type": "array", "description": "单表模式的表头(xlsx/csv)", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "单表模式的数据行(xlsx/csv)", "items": map[string]interface{}{"type": "array"}},
"tables": map[string]interface{}{"type": "array", "description": "docx 追加表格:[{headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"overwrite": map[string]interface{}{"type": "boolean", "description": "文件已存在时是否覆盖,默认 false(报错)"},
},
"required": []string{"file"},
},
}, p.handleCreate)
s.RegisterTool("create_from_template", sdk.ToolDef{
Name: "create_from_template",
Description: "基于已有文件复制一份再套用替换(模板场景)。先 shutil.copy2 复制,再对 docx/xlsx/文本做文本替换。" +
"replacements 是 {\"旧文本\":\"新文本\"} 映射;也可用 target/replacement 只替换一处。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"template": map[string]interface{}{"type": "string", "description": "模板文件绝对路径"},
"file": map[string]interface{}{"type": "string", "description": "新文件绝对路径"},
"replacements": map[string]interface{}{"type": "object", "description": "替换映射 {\"旧\":\"新\"}"},
"target": map[string]interface{}{"type": "string", "description": "单处替换的原文本(可选)"},
"replacement": map[string]interface{}{"type": "string", "description": "单处替换的新文本(可选)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "目标已存在时是否覆盖"},
},
"required": []string{"template", "file"},
},
}, p.handleCreateFromTemplate)
// ---- 兼容性红线:工具名 + 参数与 v1.0.0 完全一致 ----
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档(会覆盖原文件,编辑前自动 .bak 留档)。支持 .docx / .xlsx / .xlsm / .pptx。" +
"operation 可选:replace_text(查找替换,支持 regex/case_sensitive/nth)、set_cell、insert_row、" +
"delete_row、insert_paragraph、append_paragraph、set_style、" +
"insert_image、insert_table、merge_cells、add_sheet、delete_sheet、rename_sheet。" +
"注意:pptx 无插入行 API,insert_row 在 pptx 上语义为「表格末尾追加一行」;merge_cells 不支持 pptx。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
// v1 原有 8 个参数,语义不变
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
"operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"},
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"},
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "写入值(set_cell/add_sheet/rename_sheet/段落文本)"},
// v2 扩展参数
"regex": map[string]interface{}{"type": "boolean", "description": "target 按正则解释,默认 false"},
"case_sensitive": map[string]interface{}{"type": "boolean", "description": "是否区分大小写,默认 true"},
"nth": map[string]interface{}{"type": "integer", "description": "只替换第 N 处(1 基),默认全部替换"},
"count": map[string]interface{}{"type": "integer", "description": "delete_row 删除几行,默认 1"},
"index": map[string]interface{}{"type": "integer", "description": "insert_paragraph 的插入位置(段落序号)"},
"row2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束行"},
"col2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束列"},
"table": map[string]interface{}{"type": "integer", "description": "docx 表格序号(merge_cells/insert_table),从 0 开始"},
"headers": map[string]interface{}{"type": "array", "description": "insert_table 的表头", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "insert_table 的数据行", "items": map[string]interface{}{"type": "array"}},
"image": map[string]interface{}{"type": "string", "description": "insert_image 的图片路径"},
"width_inch": map[string]interface{}{"type": "number", "description": "insert_image 的宽度(英寸)"},
"slide": map[string]interface{}{"type": "integer", "description": "pptx 页号(从 1 开始)"},
"size": map[string]interface{}{"type": "number", "description": "set_style 字号(磅)"},
"bold": map[string]interface{}{"type": "boolean", "description": "set_style 加粗"},
"italic": map[string]interface{}{"type": "boolean", "description": "set_style 斜体"},
"color": map[string]interface{}{"type": "string", "description": "set_style 字体颜色(如 FF0000)"},
"bg_color": map[string]interface{}{"type": "string", "description": "xlsx set_style 背景色"},
"align": map[string]interface{}{"type": "string", "description": "set_style 对齐:left/center/right/justify"},
"dry_run": map[string]interface{}{"type": "boolean", "description": "只校验不改动"},
},
"required": []string{"file", "operation"},
},
}, p.handleEdit)
s.RegisterTool("convert_document", sdk.ToolDef{
Name: "convert_document",
Description: "办公文档格式转换。md→docx/html/pdf(pandoc)、xlsx→csv(openpyxl,多表自动分文件)、" +
"pptx→txt/md(结构化抽取)、office→pdf(soffice)。缺外部依赖时会在返回里点名缺哪个,不静默失败。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "源文件绝对路径"},
"to": map[string]interface{}{"type": "string", "description": "目标格式:docx / pdf / html / csv / txt / md"},
"output": map[string]interface{}{"type": "string", "description": "输出路径(可选,默认同名换扩展名)"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx→csv 时指定工作表(可选,默认全部)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "输出已存在时覆盖"},
"timeout": map[string]interface{}{"type": "integer", "description": "转换超时(秒),默认 120"},
},
"required": []string{"file", "to"},
},
}, p.handleConvert)
s.RegisterTool("office_check", sdk.ToolDef{
Name: "office_check",
Description: "办公能力自检(独立可调用,不依赖真实文件)。返回:Python 解释器版本、每个依赖库" +
"(python-docx/openpyxl/python-pptx/xlrd/markdown)的可用性与版本、外部工具(soffice/pandoc)、" +
"以及格式×能力矩阵。换机器或升级插件后可用它一条命令验收。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{},
},
}, p.handleCheck)
// 首次启动就把脚本释放好,让 office_check 立刻可用(也便于运维确认落盘位置)
if sp, err := p.resolveScript(); err == nil {
fmt.Fprintf(os.Stderr, "[%s] 已就绪(脚本: %s, 指纹: %s)\n", p.name, sp, scriptStamp())
if p.ignoredScriptPath != "" {
fmt.Fprintf(os.Stderr, "[%s] 注意:script_path=%s 已被忽略(非 v2 脚本),当前使用内嵌脚本\n",
p.name, p.ignoredScriptPath)
}
} else {
fmt.Fprintf(os.Stderr, "[%s] 脚本释放失败: %v\n", p.name, err)
}
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) reloadSettings() {
s := p.sdk.Settings()
if v, err := s.Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = strings.TrimSpace(str)
}
}
if v, err := s.Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = strings.TrimSpace(str)
}
}
p.timeoutSec = 60
if v, err := s.Get("timeout_sec"); err == nil {
switch n := v.(type) {
case float64:
if n > 0 {
p.timeoutSec = int(n)
}
case int64:
if n > 0 {
p.timeoutSec = int(n)
}
case int:
if n > 0 {
p.timeoutSec = n
}
}
}
}
// resolveScript 返回 edit_doc.py 的可用路径。
//
// 优先级:
// 1. 配置 script_path 指向的文件,且**含 v2 协议标记**(便于运维手动覆盖调试)
// 2. DataDir()/runtime/edit_doc.py(内嵌脚本释放位置,指纹驱动的自动更新)
//
// 关键防呆:script_path 指向的文件若存在但**不含** v2 协议标记,说明它是旧版脚本
// (v1 的 argv 约定是 <file> <op> <json>,与 v2 的 <cmd> <json> 完全不同)。
// 若照样采用,6 个工具会全部报「文件不存在: create/read/edit/...」,等于把线上打挂。
// 因此这里主动忽略它、回退内嵌脚本,并置 p.ignoredScriptPath 让调用方能看到。
func (p *Plugin) resolveScript() (string, error) {
p.ignoredScriptPath = ""
if p.scriptPath != "" {
if data, err := os.ReadFile(p.scriptPath); err == nil {
if bytes.Contains(data, []byte(scriptProtocolMarker)) {
return p.scriptPath, nil
}
// 存在但不是 v2 脚本:忽略,回退内嵌(绝不能让它把工具打挂)
p.ignoredScriptPath = p.scriptPath
fmt.Fprintf(os.Stderr,
"[%s] 忽略 script_path=%s:缺少 v2 协议标记 %q,判定为旧版脚本;改用内嵌脚本\n",
p.name, p.scriptPath, scriptProtocolMarker)
}
}
// 释放内嵌脚本
dir := ""
if p.sdk != nil {
dir = p.sdk.Settings().DataDir()
}
if dir == "" {
return "", fmt.Errorf("拿不到插件数据目录(Settings().DataDir() 为空),且 script_path 未指向有效文件")
}
rt := filepath.Join(dir, "runtime")
if err := os.MkdirAll(rt, 0755); err != nil {
return "", fmt.Errorf("创建运行时目录失败: %w", err)
}
jsPath := filepath.Join(rt, "edit_doc.py")
stampPath := filepath.Join(rt, ".stamp")
want := scriptStamp()
if got, err := os.ReadFile(stampPath); err == nil && string(got) == want {
if _, e := os.Stat(jsPath); e == nil {
return jsPath, nil
}
}
if err := os.WriteFile(jsPath, embeddedScript, 0644); err != nil {
return "", fmt.Errorf("释放内嵌脚本失败: %w", err)
}
_ = os.WriteFile(stampPath, []byte(want), 0644)
return jsPath, nil
}
// resolvePython 返回可用的 python 解释器(配置为空时回退到 PATH 里的 python3)。
func (p *Plugin) resolvePython() (string, error) {
py := p.venvPython
if py == "" {
if cand, err := exec.LookPath("python3"); err == nil {
py = cand
}
}
if py == "" {
return "", fmt.Errorf("venv_python 未配置且 PATH 中找不到 python3")
}
if _, err := os.Stat(py); err != nil {
return "", fmt.Errorf("Python 解释器不存在: %s", py)
}
return py, nil
}
// scriptError 是结构化错误体,直接透传给上层 LLM。
type scriptError struct {
Error string `json:"error"`
Hint string `json:"hint"`
}
// runScript 执行 edit_doc.py <cmd> <argsJSON>,返回解析后的 map。
//
// 统一错误处理:脚本缺失/解释器缺失/超时/非 JSON 输出,都转成带 hint 的结构化返回,
// 而不是抛裸 error(v1 是直接 return nil, err,上层只能看到一句无上下文的失败)。
func (p *Plugin) runScript(cmd string, args map[string]interface{}) (map[string]interface{}, error) {
p.reloadSettings() // 支持改配置后无需重载插件
scriptPath, err := p.resolveScript()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 script_path 指定 edit_doc.py 的绝对路径", err)
}
py, err := p.resolvePython()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 venv_python 指定 python 解释器", err)
}
argsJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("参数序列化失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(p.timeoutSec)*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
c := exec.CommandContext(ctx, py, scriptPath, cmd, string(argsJSON))
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("脚本执行超时(%d 秒);大文件或格式转换可调高 timeout_sec 配置",
p.timeoutSec)
}
out := strings.TrimSpace(stdout.String())
if out == "" {
msg := strings.TrimSpace(stderr.String())
if runErr != nil {
return nil, fmt.Errorf("脚本无输出且退出异常: %v; stderr: %s", runErr, truncate(msg, 300))
}
return nil, fmt.Errorf("脚本无输出(stderr: %s)", truncate(msg, 300))
}
// 脚本只保证最后一行是 JSON(soffice/pandoc 可能往 stdout 混东西)
line := out
if i := strings.LastIndex(out, "\n"); i >= 0 {
line = strings.TrimSpace(out[i+1:])
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
return nil, fmt.Errorf("脚本输出不是合法 JSON: %s; 原文: %s",
err, truncate(out, 300))
}
if se := strings.TrimSpace(stderr.String()); se != "" && runErr != nil {
m["stderr"] = truncate(se, 300)
}
// script_path 因非 v2 脚本被忽略时,把这次降级明确告知上层
if p.ignoredScriptPath != "" {
m["script_path_ignored"] = p.ignoredScriptPath
m["script_path_ignored_reason"] = "该文件缺少 v2 协议标记,判定为旧版脚本;本次已改用内嵌脚本"
}
return m, nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// respond 把脚本返回整形成工具返回值:ok:true 时给 content/summary,
// ok:false 时给 isError + error + hint。
func respondOffice(m map[string]interface{}) (interface{}, error) {
if ok, _ := m["ok"].(bool); !ok {
out := map[string]interface{}{
"isError": true,
"content": "操作失败: " + str(m["error"]),
"error": m["error"],
"hint": m["hint"],
}
for _, k := range []string{"path", "exists", "missing", "format", "cmd",
"script_path_ignored", "script_path_ignored_reason"} {
if v, has := m[k]; has {
out[k] = v
}
}
return out, nil
}
// 成功:把脚本的结构化字段原样上抛,便于 LLM 二次利用
out := map[string]interface{}{}
for k, v := range m {
out[k] = v
}
if s, has := m["summary"]; has {
out["content"] = s
}
return out, nil
}
func str(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// ---------------------------------------------------------------- handlers
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("read", args)
if err != nil {
return toolErr("read_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create", args)
if err != nil {
return toolErr("create_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreateFromTemplate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create_from_template", args)
if err != nil {
return toolErr("create_from_template", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
// v1 兼容:file / operation 必填
if str(args["file"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 file 参数"}, nil
}
if str(args["operation"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 operation 参数"}, nil
}
m, err := p.runScript("edit", args)
if err != nil {
return toolErr("edit_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleConvert(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("convert", args)
if err != nil {
return toolErr("convert_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCheck(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("check", map[string]interface{}{})
if err != nil {
return toolErr("office_check", err), nil
}
return respondOffice(m)
}
func toolErr(tool string, err error) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": fmt.Sprintf("%s 失败:%s", tool, err.Error()),
"error": err.Error(),
}
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}