mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
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>
This commit is contained in:
@ -2,21 +2,60 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"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 }
|
||||
@ -25,138 +64,469 @@ 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 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
|
||||
Category: p.name,
|
||||
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 解释器(建议用 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,
|
||||
})
|
||||
|
||||
if v, err := s.Settings().Get("script_path"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.scriptPath = str
|
||||
}
|
||||
}
|
||||
if v, err := s.Settings().Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = str
|
||||
}
|
||||
}
|
||||
s.RegisterTool("edit_document", sdk.ToolDef{
|
||||
Name: "edit_document",
|
||||
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
|
||||
NoMemory: true,
|
||||
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": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
|
||||
"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)"},
|
||||
"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)"},
|
||||
"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.handleEditDocument)
|
||||
}, 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) handleEditDocument(args map[string]interface{}) (interface{}, error) {
|
||||
file, _ := args["file"].(string)
|
||||
if file == "" {
|
||||
return nil, fmt.Errorf("file is required")
|
||||
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)
|
||||
}
|
||||
}
|
||||
operation, _ := args["operation"].(string)
|
||||
if operation == "" {
|
||||
return nil, fmt.Errorf("operation is required")
|
||||
if v, err := s.Get("venv_python"); err == nil {
|
||||
if str, ok := v.(string); ok {
|
||||
p.venvPython = strings.TrimSpace(str)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("文件不存在: %s", file),
|
||||
}, nil
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pyArgs := map[string]interface{}{}
|
||||
if v, ok := args["target"]; ok {
|
||||
pyArgs["target"] = v
|
||||
}
|
||||
if v, ok := args["replacement"]; ok {
|
||||
pyArgs["replacement"] = v
|
||||
}
|
||||
if v, ok := args["sheet"]; ok {
|
||||
pyArgs["sheet"] = v
|
||||
}
|
||||
if v, ok := args["row"]; ok {
|
||||
pyArgs["row"] = v
|
||||
}
|
||||
if v, ok := args["col"]; ok {
|
||||
pyArgs["col"] = v
|
||||
}
|
||||
if v, ok := args["value"]; ok {
|
||||
pyArgs["value"] = v
|
||||
}
|
||||
pyArgsJSON, _ := json.Marshal(pyArgs)
|
||||
|
||||
scriptPath := p.scriptPath
|
||||
if scriptPath == "" {
|
||||
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
|
||||
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
|
||||
}
|
||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
|
||||
}
|
||||
|
||||
if p.venvPython == "" {
|
||||
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
|
||||
}
|
||||
if _, err := os.Stat(p.venvPython); err != nil {
|
||||
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("edit document: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
|
||||
"file": file,
|
||||
}, nil
|
||||
}
|
||||
if !result.Ok {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("编辑失败: %s", result.Error),
|
||||
}, nil
|
||||
}
|
||||
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
|
||||
if result.Count > 0 {
|
||||
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"content": msg,
|
||||
"file": file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user