mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 00:48:12 +00:00
refactor(toolchain)!: 工具链 plugindev 更名为 hmapdev,module path 改回 gitcode
- 目录 tools/plugindev → tools/hmapdev,可执行文件名/平台产物名同步
(hmapdev_linux_amd64 等;包格式仍叫 .hmap)
- module path github.com/JianFeeeee/homeagent-sdk/tools/... → gitcode.com/...
(与仓库实际托管一致;核心仓不依赖该 path,改动无外部影响)
- SDK 存储目录 ~/.homeagent/plugindev/sdk → ~/.homeagent/hmapdev/sdk
新目录不存在而旧目录存在时沿用旧目录 → 已装 SDK 版本不会丢失
- 命令表/usage/--help/生成项目 README/示例 README/NSIS 安装器/
package/build.sh/build-examples.sh 全部同步;PLUGINDEV 环境变量保留兼容
- sdk/ 目录零改动(公开接口不变)
验证:
- go build ./... ok;go test ./tools/hmapdev/ ok(含模板接线守卫 TestProcTemplate_CoversAllCoreMethods)
- bash -n package/{build,build-examples}.sh ok
- 端到端:hmapdev init demo && hmapdev build → dist/demo_bundle.hmap(linux+darwin)
- 本机安装 /usr/local/bin/hmapdev,旧名以软链保留;sdk list/current 正常
This commit is contained in:
830
tools/hmapdev/cmd_build.go
Normal file
830
tools/hmapdev/cmd_build.go
Normal file
@ -0,0 +1,830 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BuildConfig struct {
|
||||
OutDir string
|
||||
Targets []string
|
||||
Bundle bool
|
||||
SDKPath string
|
||||
Replaces []string
|
||||
}
|
||||
|
||||
// buildFailed 记录本次构建是否有平台失败。
|
||||
//
|
||||
// 为什么要它:这两个构建函数遇到错误只是 Printf 后 return,而 cmdBuild 返回
|
||||
// void,于是**构建失败却以 0 退出**。调用方(批量重编脚本、CI、发版脚本)
|
||||
// 只能靠翻日志发现失败——实测中一个示例的 windows 目标编译失败,脚本却报
|
||||
// 「17/17 全绿」,并因此少产出 16 个 .hmap。
|
||||
// 判成功要看退出码,不能靠人读日志。
|
||||
var buildFailed bool
|
||||
|
||||
func cmdBuild(args []string) {
|
||||
// Read all config from plg.json first
|
||||
plg, err := readPlgJSON("plg.json")
|
||||
if err != nil {
|
||||
fmt.Printf("error: read plg.json: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Base config from plg.json
|
||||
outDir := plg.OutDirDefault()
|
||||
targets := plg.TargetList()
|
||||
bundle := plg.BundleDefault()
|
||||
sdkPath := plg.SDKPath
|
||||
var cliReplaces []string
|
||||
|
||||
// CLI flags override plg.json
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--outdir":
|
||||
if i+1 < len(args) {
|
||||
outDir = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--target":
|
||||
if i+1 < len(args) {
|
||||
targets = append(targets, args[i+1])
|
||||
i++
|
||||
}
|
||||
case "--bundle":
|
||||
bundle = true
|
||||
case "--no-bundle":
|
||||
bundle = false
|
||||
case "--sdk-path":
|
||||
if i+1 < len(args) {
|
||||
sdkPath = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--replace", "-R":
|
||||
if i+1 < len(args) {
|
||||
cliReplaces = append(cliReplaces, args[i+1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if plg.IsLua() {
|
||||
buildTarget(plg, "lua", outDir, "")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure go.mod exists with correct SDK path
|
||||
sdkModule := ensureGoMod(plg, sdkPath)
|
||||
|
||||
// 保证 SDK 模块可解析,否则编译必死在 "missing go.sum entry"。
|
||||
if sdkModule != "" {
|
||||
ensureSDKResolvable(plg, sdkModule, sdkPath)
|
||||
}
|
||||
|
||||
// Merge plg.json replaces + CLI overrides
|
||||
replaceSlice := plg.ReplacesToSlice()
|
||||
replaceSlice = append(replaceSlice, cliReplaces...)
|
||||
|
||||
// Apply go.mod replace directives for single-target go build
|
||||
gmPatcher := NewGoModPatcher(".", replaceSlice)
|
||||
gmRestore, err := gmPatcher.Apply()
|
||||
if err != nil {
|
||||
fmt.Printf("warn: apply go.mod replaces: %v\n", err)
|
||||
}
|
||||
defer gmRestore()
|
||||
|
||||
if bundle || len(targets) == 0 {
|
||||
buildBundle(plg, outDir, sdkPath)
|
||||
} else {
|
||||
for _, t := range targets {
|
||||
buildTarget(plg, t, outDir, sdkPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 以非零码退出:调用方(批量重编、CI、发版脚本)靠退出码判成败。
|
||||
// 以前这里直接 return,失败也退 0,于是「构建失败」只能靠人翻日志发现——
|
||||
// 实测中就因此把一次部分失败当成了全绿。
|
||||
if buildFailed {
|
||||
fmt.Println("error: 至少一个目标构建失败(详见上面日志)")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// allBundleTargets 是 --bundle 模式构建的全部平台。
|
||||
//
|
||||
// 子进程模式下各平台产物同名(plugin.bin)——进程边界即 ABI 边界,
|
||||
// 不存在平台特有扩展名,故 zip 内按平台加后缀区分;
|
||||
// 内核安装时按当前平台挑对应条目重命名为 plugin.bin。
|
||||
//
|
||||
// **不含 windows**:插件只能运行在 homed 能跑的平台上,而 homed 已明确放弃
|
||||
// Windows 原生支持(插件体系依赖 fd 继承 + 统一共享内存区的段内偏移,
|
||||
// Windows 句柄模型无法表达)。Windows 用户走 WSL2,而 WSL2 就是 linux/amd64。
|
||||
var allBundleTargets = []struct {
|
||||
target string
|
||||
entry string // 二进制在 zip 中的文件名
|
||||
}{
|
||||
{"linux/amd64", "plugin.bin.linux.amd64"},
|
||||
{"darwin/amd64", "plugin.bin.darwin.amd64"},
|
||||
}
|
||||
|
||||
// checkTargetSupported 在构建前拦下**已知不支持**的目标,给出可执行的报错。
|
||||
//
|
||||
// 为什么要有它:插件运行在 homed 的进程里,所以目标平台必须是 homed 能跑的。
|
||||
// homed 已放弃 Windows 原生(原因:插件依赖 fd 继承与统一共享内存区段内偏移,
|
||||
// Windows 句柄模型无法表达),却还去构建 windows 插件,结果是死在一句
|
||||
// 「undefined: attachUnifiedShm」——看起来像代码 bug,实际是平台策略。
|
||||
// 这里换成明确的结论,并且**不静默跳过**:静默跳过会让人以为产出的包里包含 windows。
|
||||
func checkTargetSupported(target string) error {
|
||||
if strings.HasPrefix(target, "windows/") {
|
||||
return fmt.Errorf("不支持 windows 插件目标:插件运行在 homed 内," +
|
||||
"而 homed 已放弃 Windows 原生支持(插件体系依赖 fd 继承与统一共享内存区" +
|
||||
"段内偏移解引用,Windows 句柄模型无法表达)。Windows 请用 WSL2——" +
|
||||
"它就是 linux/amd64,用 --target linux/amd64 即可")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBundle(plg *PlgConfig, outDir string, sdkPath string) {
|
||||
os.MkdirAll(outDir, 0755)
|
||||
buildDir := "build"
|
||||
os.MkdirAll(buildDir, 0755)
|
||||
|
||||
runtimeCleanup, err := generateProcRuntime()
|
||||
if err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
buildFailed = true
|
||||
return
|
||||
}
|
||||
defer runtimeCleanup()
|
||||
|
||||
thirdpartCleanup := linkThirdpart(plg, "linux/amd64")
|
||||
defer thirdpartCleanup()
|
||||
|
||||
var binaries []binEntry
|
||||
|
||||
for _, bt := range allBundleTargets {
|
||||
if err := checkTargetSupported(bt.target); err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
buildFailed = true
|
||||
return
|
||||
}
|
||||
cfg, errMsg := resolveBuild(bt.target)
|
||||
if cfg == nil {
|
||||
fmt.Printf(" error: %s\n", errMsg)
|
||||
buildFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
// 每平台产物落到独立路径,避免相互覆盖
|
||||
outName := fmt.Sprintf("%s_%s_%s", cfg.entryFile, cfg.goos, cfg.goarch)
|
||||
outPath := filepath.Join(buildDir, outName)
|
||||
|
||||
// 零 cgo:跨平台交叉编译不需目标平台 C 工具链
|
||||
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// 单平台失败即整包失败:bundle 少一个平台就是个坏包,
|
||||
// 却仍会生成 .hmap 让人以为打包成功。
|
||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||
buildFailed = true
|
||||
return
|
||||
}
|
||||
binaries = append(binaries, binEntry{src: outPath, zip: bt.entry})
|
||||
}
|
||||
|
||||
// Write plugin.json with all platforms declared
|
||||
platforms := map[string]bool{}
|
||||
for _, bt := range allBundleTargets {
|
||||
parts := strings.SplitN(bt.target, "/", 2)
|
||||
platforms[parts[0]] = true
|
||||
}
|
||||
plats := make([]string, 0, len(platforms))
|
||||
for p := range platforms {
|
||||
plats = append(plats, p)
|
||||
}
|
||||
writePluginJSON(plg, plats, procEntryFile)
|
||||
|
||||
// package single .hmap with correctly named entries
|
||||
hmapPath := filepath.Join(outDir, fmt.Sprintf("%s_bundle.hmap", toSnake(plg.NameEn)))
|
||||
createBundleHmap(hmapPath, "plugin.json", binaries)
|
||||
fmt.Printf(" packaged %s\n", filepath.Base(hmapPath))
|
||||
}
|
||||
|
||||
// IsLua 判断是否为 Lua 插件(走解释器,不经过 Go 编译)。
|
||||
//
|
||||
// 这是 entry 字段唯一仍在使用的用途:Go 插件不再看 entry 值,一律产出 plugin.bin。
|
||||
func (p *PlgConfig) IsLua() bool { return p.Entry == luaEntryFile }
|
||||
|
||||
func readPlgJSON(path string) (*PlgConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var plg PlgConfig
|
||||
if err := json.Unmarshal(data, &plg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plg, nil
|
||||
}
|
||||
|
||||
func parseTargets(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "native" {
|
||||
return nil
|
||||
}
|
||||
var t []string
|
||||
for _, s := range strings.Split(raw, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
t = append(t, s)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
|
||||
m := map[string]interface{}{
|
||||
"name": plg.Name,
|
||||
"name_zh": plg.NameZh,
|
||||
"name_en": plg.NameEn,
|
||||
"version": plg.Version,
|
||||
"description": plg.Description,
|
||||
"author": plg.Author,
|
||||
"entry": entry,
|
||||
}
|
||||
if len(platforms) > 0 {
|
||||
m["platforms"] = platforms
|
||||
}
|
||||
if len(plg.Tags) > 0 {
|
||||
m["tags"] = plg.Tags
|
||||
}
|
||||
data, _ := json.MarshalIndent(m, "", " ")
|
||||
os.WriteFile("plugin.json", data, 0644)
|
||||
}
|
||||
|
||||
type buildConfig struct {
|
||||
goos string
|
||||
goarch string
|
||||
entryFile string // 一律为 plugin.bin(进程边界即 ABI 边界,无平台特有扩展名)
|
||||
}
|
||||
|
||||
// resolveBuild 解析目标平台。
|
||||
//
|
||||
// 全平台统一产出 plugin.bin:子进程模式下不存在 .so/.dylib/.dll 的区分,
|
||||
// 因为进程边界本身就是 ABI 边界——这正是三套独立 ABI 实现收敛为
|
||||
// 单一 RPC 实现的直接后果(§9.2:Windows 不再是能力退化的第三套实现)。
|
||||
func resolveBuild(target string) (*buildConfig, string) {
|
||||
if target == "lua" || target == "" {
|
||||
return nil, "lua"
|
||||
}
|
||||
|
||||
goos, goarch, _ := strings.Cut(target, "/")
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
if goarch == "" {
|
||||
goarch = runtime.GOARCH
|
||||
}
|
||||
}
|
||||
|
||||
switch goos {
|
||||
case "linux", "darwin", "freebsd", "windows":
|
||||
return &buildConfig{goos: goos, goarch: goarch, entryFile: procEntryFile}, ""
|
||||
default:
|
||||
return nil, fmt.Sprintf("unsupported OS %q", goos)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureGoMod 确保插件项目的 go.mod 包含 SDK 的 replace 指令。
|
||||
// 如果 go.mod 不存在或已有正确 replace,则跳过。
|
||||
func ensureGoMod(plg *PlgConfig, sdkPath string) string {
|
||||
gomodPath := "go.mod"
|
||||
data, err := os.ReadFile(gomodPath)
|
||||
if err != nil {
|
||||
return "" // no go.mod, skip
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
var sdkModule string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
var mod string
|
||||
if strings.HasPrefix(line, "require ") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
mod = parts[1]
|
||||
}
|
||||
} else if !strings.HasPrefix(line, "require") &&
|
||||
!strings.HasPrefix(line, "module ") &&
|
||||
!strings.HasPrefix(line, "go ") &&
|
||||
!strings.HasPrefix(line, "replace ") {
|
||||
// require 块内行(无前缀)或 import 行
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 1 {
|
||||
mod = parts[0]
|
||||
}
|
||||
}
|
||||
if mod != "" && strings.Contains(mod, "homeagent-sdk") {
|
||||
sdkModule = mod
|
||||
break
|
||||
}
|
||||
}
|
||||
if sdkModule == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if sdkPath == "" {
|
||||
// 仅显式配置(plg.json sdk_path 或 --sdk-path)才写入 replace,
|
||||
// 避免 go.mod 中出现本地绝对路径。
|
||||
return sdkModule
|
||||
}
|
||||
|
||||
absSDK, _ := filepath.Abs(sdkPath)
|
||||
absSDK = strings.ReplaceAll(absSDK, "\\", "/")
|
||||
|
||||
// Remove any existing replace line for this module (even if path differs)
|
||||
var keep []string
|
||||
replaceLine := fmt.Sprintf("replace %s => %s", sdkModule, absSDK)
|
||||
alreadyExists := false
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "replace ") &&
|
||||
strings.Contains(line, sdkModule) {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 && strings.ReplaceAll(parts[2], "\\", "/") == absSDK {
|
||||
alreadyExists = true
|
||||
}
|
||||
continue // strip any existing replace for this module
|
||||
}
|
||||
keep = append(keep, line)
|
||||
}
|
||||
if alreadyExists {
|
||||
return sdkModule
|
||||
}
|
||||
keep = append(keep, replaceLine, "")
|
||||
if err := os.WriteFile(gomodPath, []byte(strings.Join(keep, "\n")), 0644); err != nil {
|
||||
fmt.Printf(" warn: update go.mod replace: %v\n", err)
|
||||
}
|
||||
return sdkModule
|
||||
}
|
||||
|
||||
// ensureSDKResolvable 保证 SDK 模块在编译前可解析。
|
||||
//
|
||||
// 为何需要这个函数:gitcode 的模块不在 proxy.golang.org 上。只要 go.mod
|
||||
// 里的 SDK 靠 require 版本号解析,而本地又没 go.sum 条目,go build 就报
|
||||
// "missing go.sum entry";而原来那句 `go mod download <mod>` 会去公共 proxy
|
||||
// 拉一个永远拉不到的条目,超时后只打一行 warn 就继继编译,紧接着死在
|
||||
// 同一个错误上——新用户拿到的是两段无关的报错。
|
||||
//
|
||||
// 三级策略,按代价递增:
|
||||
// 1. go.mod 已有指向本地目录的 replace —— 什么都不用做(replace 到目录时
|
||||
// go 不需要也不校验 go.sum)。
|
||||
// 2. 能定位到本机 SDK 源码 —— 写入 replace。这是存量项目(go.mod 旧、
|
||||
// 无 replace)的救场路径。
|
||||
// 3. 都不行 —— 跑 `go mod tidy`(带 -mod=mod)让它自己去试,失败则给
|
||||
// 可操作的提示而不是让用户去猜。
|
||||
func ensureSDKResolvable(plg *PlgConfig, sdkModule, sdkPath string) {
|
||||
data, err := os.ReadFile("go.mod")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 策略 1:已有指向本地目录的 replace。
|
||||
// replace 目标带 / 或 . 开头的才是路径;指向另一个模块的 replace 不算。
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "replace ") || !strings.Contains(line, sdkModule) {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
target := parts[3]
|
||||
if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") ||
|
||||
strings.Contains(target, ":/") || strings.Contains(target, ":\\") {
|
||||
return // 已指向本地目录,无需 go.sum
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 2:能定位到本机 SDK 就写 replace。
|
||||
// resolveSDKPath 失败会 os.Exit,所以只在能确定拿到路径时调用它背后的探测。
|
||||
if root := findLocalSDK(sdkPath); root != "" {
|
||||
if appendGoModReplace(sdkModule, root) {
|
||||
fmt.Printf(" SDK 指向本机源码(已写入 go.mod replace):%s\n", root)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 策略 3:交给 go mod tidy。
|
||||
if _, err := os.Stat("go.sum"); err == nil {
|
||||
return // 已有 go.sum,不插手
|
||||
}
|
||||
fmt.Println(" 解析 SDK 依赖(go mod tidy)...")
|
||||
tidy := exec.Command("go", "mod", "tidy")
|
||||
tidy.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
|
||||
if out, err := tidy.CombinedOutput(); err != nil {
|
||||
fmt.Printf(" warn: go mod tidy 失败:%v\n", err)
|
||||
if len(out) > 0 {
|
||||
fmt.Printf(" %s\n", strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Printf(" 提示:%s 不在公共 proxy 上。用以下任一方式指向本机 SDK:\n", sdkModule)
|
||||
fmt.Printf(" hmapdev sdk install latest # 装一份到 ~/.homeagent/hmapdev/sdk\n")
|
||||
fmt.Printf(" hmapdev build --sdk-path <路径> # 或直接指定源码目录\n")
|
||||
}
|
||||
}
|
||||
|
||||
// findLocalSDK 探测本机 SDK 源码根目录,找不到返回空串。
|
||||
//
|
||||
// 与 resolveSDKPath 的区别:后者找不到就 os.Exit,适合“必须有”的调用点;
|
||||
// 这里是“有则更好”的探测,不能把构建搞挂。
|
||||
func findLocalSDK(sdkPath string) string {
|
||||
candidates := []string{}
|
||||
if sdkPath != "" {
|
||||
if abs, err := filepath.Abs(sdkPath); err == nil {
|
||||
candidates = append(candidates, abs)
|
||||
}
|
||||
}
|
||||
// hmapdev 自身所在位置往上三级(tools/hmapdev/hmapdev → SDK 根)
|
||||
if self, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Dir(filepath.Dir(filepath.Dir(self))))
|
||||
}
|
||||
// hmapdev sdk use 选定的版本(复用 sdkStore(),含改名前的旧目录回退)
|
||||
store := sdkStore()
|
||||
if store != "" {
|
||||
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
||||
if ver := strings.TrimSpace(string(d)); ver != "" {
|
||||
candidates = append(candidates, filepath.Join(store, ver))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(c, "sdk", "plugin.go")); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// appendGoModReplace 向 go.mod 追加一条 replace,成功返回 true。
|
||||
func appendGoModReplace(module, localPath string) bool {
|
||||
data, err := os.ReadFile("go.mod")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
abs, err := filepath.Abs(localPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
abs = strings.ReplaceAll(abs, "\\", "/")
|
||||
s := strings.TrimRight(string(data), "\r\n")
|
||||
s += fmt.Sprintf("\n\nreplace %s => %s\n", module, abs)
|
||||
return os.WriteFile("go.mod", []byte(s), 0644) == nil
|
||||
}
|
||||
|
||||
func resolveSDKPath(sdkPath string) string {
|
||||
if sdkPath != "" {
|
||||
abs, _ := filepath.Abs(sdkPath)
|
||||
if _, err := os.Stat(filepath.Join(abs, "sdk", "plugin.go")); err == nil {
|
||||
return abs
|
||||
}
|
||||
fmt.Printf("error: --sdk-path %q not a valid SDK\n", sdkPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Detect from hmapdev's own location (internal dev)
|
||||
self, err := os.Executable()
|
||||
if err == nil {
|
||||
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
|
||||
if _, err := os.Stat(filepath.Join(cand, "sdk", "plugin.go")); err == nil {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
// Active SDK via hmapdev sdk use
|
||||
store := sdkStore()
|
||||
if store != "" {
|
||||
if d, err := os.ReadFile(filepath.Join(store, "current")); err == nil {
|
||||
ver := strings.TrimSpace(string(d))
|
||||
if ver != "" {
|
||||
root := filepath.Join(store, ver)
|
||||
if _, err := os.Stat(filepath.Join(root, "sdk", "plugin.go")); err == nil {
|
||||
return root
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("error: cannot locate SDK. Use --sdk-path or 'hmapdev sdk use'\n")
|
||||
os.Exit(1)
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
|
||||
os.MkdirAll(outDir, 0755)
|
||||
|
||||
// Lua: no compilation, package source directly
|
||||
if target == "lua" {
|
||||
writePluginJSON(plg, nil, "main.lua")
|
||||
pkgFiles := []string{"plugin.json", "main.lua"}
|
||||
for _, f := range []string{"README.md", "LICENSE"} {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
pkgFiles = append(pkgFiles, f)
|
||||
}
|
||||
}
|
||||
// Include thirdpart Lua files
|
||||
if entries, err := os.ReadDir("thirdpart"); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
|
||||
path := filepath.Join("thirdpart", e.Name())
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
pkgFiles = append(pkgFiles, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hmapName := fmt.Sprintf("%s_lua.hmap", toSnake(plg.NameEn))
|
||||
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
|
||||
fmt.Printf(" packaged %s\n", hmapName)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve build config(全平台统一产出 plugin.bin)
|
||||
cfg, errMsg := resolveBuild(target)
|
||||
if cfg == nil {
|
||||
fmt.Printf(" error: %s\n", errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
buildDir := "build"
|
||||
os.MkdirAll(buildDir, 0755)
|
||||
outPath := filepath.Join(buildDir, cfg.entryFile)
|
||||
|
||||
runtimeCleanup, err := generateProcRuntime()
|
||||
if err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer runtimeCleanup()
|
||||
|
||||
// 已知未实现的目标在编译前拦下,给可执行的报错(见 checkTargetSupported)。
|
||||
if err := checkTargetSupported(target); err != nil {
|
||||
fmt.Printf(" error: %v\n", err)
|
||||
buildFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-link thirdpart/ contents + source_dirs + replace targets
|
||||
thirdpartCleanup := linkThirdpart(plg, target)
|
||||
defer thirdpartCleanup()
|
||||
|
||||
// Write plugin.json with the correct entry for this target
|
||||
writePluginJSON(plg, nil, cfg.entryFile)
|
||||
|
||||
// 普通 go build + 零 cgo:交叉编译不再需要目标平台的 C 工具链
|
||||
// (旧路径靠 detectWindowsCC 找 MinGW,现在整个问题消失)。
|
||||
cmd := exec.Command("go", "build", "-trimpath", "-o", outPath)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, "GOOS="+cfg.goos, "GOARCH="+cfg.goarch, "CGO_ENABLED=0")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
fmt.Printf(" compiling %s/%s (子进程模式,CGO_ENABLED=0)...\n", cfg.goos, cfg.goarch)
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf(" error: build %s/%s: %v\n", cfg.goos, cfg.goarch, err)
|
||||
buildFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
// package
|
||||
pkgFiles := []string{"plugin.json", outPath}
|
||||
for _, f := range []string{"README.md", "LICENSE"} {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
pkgFiles = append(pkgFiles, f)
|
||||
}
|
||||
}
|
||||
hmapName := fmt.Sprintf("%s_%s_%s.hmap", toSnake(plg.NameEn), cfg.goos, cfg.goarch)
|
||||
createHmap(filepath.Join(outDir, hmapName), pkgFiles)
|
||||
fmt.Printf(" packaged %s\n", hmapName)
|
||||
}
|
||||
|
||||
type binEntry struct {
|
||||
src string // 磁盘路径,如 build/plugin.bin
|
||||
zip string // zip 中条目名,如 plugin.bin.linux.amd64
|
||||
}
|
||||
|
||||
// createBundleHmap 创建包含多平台二进制的 bundle .hmap 文件。
|
||||
// jsonName 是 plugin.json 在 zip 中的条目名;
|
||||
// binaries 的 src 为磁盘路径,zip 为 zip 中的条目名。
|
||||
func createBundleHmap(hmapPath, jsonName string, binaries []binEntry) {
|
||||
f, err := os.Create(hmapPath)
|
||||
if err != nil {
|
||||
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
defer w.Close()
|
||||
|
||||
// Add plugin.json
|
||||
writeZipEntry := func(zipName, diskPath string) {
|
||||
info, err := os.Stat(diskPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hdr, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hdr.Method = zip.Deflate
|
||||
hdr.Name = zipName
|
||||
writer, err := w.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
src, err := os.Open(diskPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
io.Copy(writer, src)
|
||||
src.Close()
|
||||
}
|
||||
|
||||
writeZipEntry(jsonName, jsonName)
|
||||
|
||||
// Add each platform binary with the correct zip entry name
|
||||
for _, b := range binaries {
|
||||
if _, err := os.Stat(b.src); err == nil {
|
||||
writeZipEntry(b.zip, b.src)
|
||||
}
|
||||
}
|
||||
|
||||
// Add optional metadata files
|
||||
for _, fname := range []string{"README.md", "LICENSE"} {
|
||||
if _, err := os.Stat(fname); err == nil {
|
||||
writeZipEntry(fname, fname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createHmap(hmapPath string, files []string) {
|
||||
f, err := os.Create(hmapPath)
|
||||
if err != nil {
|
||||
fmt.Printf("error: create hmap %s: %v\n", hmapPath, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
defer w.Close()
|
||||
|
||||
for _, path := range files {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hdr, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hdr.Method = zip.Deflate
|
||||
hdr.Name = filepath.Base(path)
|
||||
writer, err := w.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
io.Copy(writer, src)
|
||||
src.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func toSnake(s string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(s, " ", "_"))
|
||||
}
|
||||
|
||||
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
|
||||
// since these can confuse cgo's type resolution.
|
||||
// linkThirdpart scans thirdpart/, source_dirs from plg.json, and replace target dirs
|
||||
// for source files, generating auto-import stubs. Returns cleanup function.
|
||||
func linkThirdpart(plg *PlgConfig, target string) func() {
|
||||
const importFile = "z_thirdpart.go"
|
||||
os.Remove(importFile)
|
||||
|
||||
if target == "lua" {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
gomodPath := "go.mod"
|
||||
data, err := os.ReadFile(gomodPath)
|
||||
if err != nil {
|
||||
return func() {}
|
||||
}
|
||||
modulePath := ""
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "module ") {
|
||||
modulePath = strings.TrimSpace(line[7:])
|
||||
break
|
||||
}
|
||||
}
|
||||
if modulePath == "" {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// Collect directories to scan: thirdpart/ + source_dirs from plg.json + replace target dirs
|
||||
var dirs []string
|
||||
if info, err := os.Stat("thirdpart"); err == nil && info.IsDir() {
|
||||
dirs = append(dirs, "thirdpart")
|
||||
}
|
||||
dirs = append(dirs, plg.SourceDirs...)
|
||||
for _, r := range plg.ReplacesToSlice() {
|
||||
_, to, found := strings.Cut(r, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if abs, err := filepath.Abs(to); err == nil {
|
||||
if info, err := os.Stat(abs); err == nil && info.IsDir() {
|
||||
dirs = append(dirs, abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
seen := map[string]bool{}
|
||||
var unique []string
|
||||
for _, d := range dirs {
|
||||
abs, _ := filepath.Abs(d)
|
||||
if abs != "" && !seen[abs] {
|
||||
seen[abs] = true
|
||||
unique = append(unique, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate import stubs for each directory with .go files
|
||||
var stubs []string
|
||||
for _, d := range unique {
|
||||
entries, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hasGo := false
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
|
||||
hasGo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGo {
|
||||
continue
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(d) {
|
||||
importPath := modulePath + "/" + d
|
||||
stubs = append(stubs, importPath)
|
||||
} else {
|
||||
// External directory: must be in replaces to get a valid import path
|
||||
for _, r := range plg.ReplacesToSlice() {
|
||||
from, to, found := strings.Cut(r, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if absTo, _ := filepath.Abs(to); absTo == d {
|
||||
stubs = append(stubs, strings.TrimSpace(from))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(stubs) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("package main\n")
|
||||
for _, s := range stubs {
|
||||
sb.WriteString("import _ \"" + s + "\"\n")
|
||||
}
|
||||
os.WriteFile(importFile, []byte(sb.String()), 0644)
|
||||
}
|
||||
|
||||
return func() {
|
||||
os.Remove(importFile)
|
||||
}
|
||||
}
|
||||
33
tools/hmapdev/cmd_clean.go
Normal file
33
tools/hmapdev/cmd_clean.go
Normal file
@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func cmdClean(args []string) {
|
||||
outDir := "dist"
|
||||
if len(args) > 0 && args[0] == "--outdir" && len(args) > 1 {
|
||||
outDir = args[1]
|
||||
}
|
||||
|
||||
dirs := []string{"build", outDir}
|
||||
for _, d := range dirs {
|
||||
if _, err := os.Stat(d); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(d); err != nil {
|
||||
fmt.Printf("error: remove %s: %v\n", d, err)
|
||||
} else {
|
||||
fmt.Printf("removed %s/\n", d)
|
||||
}
|
||||
}
|
||||
|
||||
// also clean generated files
|
||||
for _, f := range []string{"plugin.json", "z_bridge_gen.go"} {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
os.Remove(f)
|
||||
fmt.Printf("removed %s\n", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
tools/hmapdev/cmd_debug.go
Normal file
175
tools/hmapdev/cmd_debug.go
Normal file
@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi"
|
||||
)
|
||||
|
||||
// tmplLuaDebug is the temporary Lua debug script template
|
||||
const tmplLuaDebug = `-- HomeAgent Lua Plugin Debug
|
||||
-- Generated by hmapdev debug --lua
|
||||
sdk = require("sdk")
|
||||
local ok, plugin = pcall(dofile, "main.lua")
|
||||
if not ok then
|
||||
print("[debug] ERROR loading main.lua: " .. tostring(plugin))
|
||||
os.exit(1)
|
||||
end
|
||||
if type(plugin) == "table" then
|
||||
print("[debug] Plugin: " .. tostring(plugin.name or "unnamed"))
|
||||
if plugin.start then
|
||||
print("[debug] Calling plugin.start(sdk) ...")
|
||||
local ok, err = pcall(plugin.start, sdk)
|
||||
if ok then
|
||||
print("[debug] plugin.start() OK")
|
||||
else
|
||||
print("[debug] plugin:start() ERROR: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
print("")
|
||||
print("=== Interactive REPL ===")
|
||||
print("sdk, plugin globals are available")
|
||||
local function repl()
|
||||
while true do
|
||||
io.write("> ")
|
||||
io.flush()
|
||||
local line = io.read()
|
||||
if line == nil or line == "exit" or line == "quit" then break end
|
||||
if line == "help" then
|
||||
print(" help - this help")
|
||||
print(" exit/quit - exit debug")
|
||||
print(" sdk - SDK API table")
|
||||
print(" plugin - loaded plugin table")
|
||||
else
|
||||
local fn, err = (loadstring or load)(line)
|
||||
if fn then
|
||||
local ok, result = pcall(fn)
|
||||
if ok and result ~= nil then print(tostring(result)) end
|
||||
if not ok then print("Error: " .. tostring(result)) end
|
||||
else
|
||||
print("Error: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
repl()
|
||||
`
|
||||
|
||||
type DebugConfig struct {
|
||||
Dir string
|
||||
Replaces []string
|
||||
}
|
||||
|
||||
func cmdDebug(args []string) {
|
||||
cfg := DebugConfig{Dir: "."}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--replace", "-R":
|
||||
if i+1 < len(args) {
|
||||
cfg.Replaces = append(cfg.Replaces, args[i+1]); i++
|
||||
}
|
||||
default:
|
||||
if !strings.HasPrefix(args[i], "-") {
|
||||
cfg.Dir = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dir := cfg.Dir
|
||||
luaPath := filepath.Join(dir, "main.lua")
|
||||
sdkPath := filepath.Join(dir, "sdk.lua")
|
||||
hasGo := false
|
||||
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") && !strings.HasSuffix(e.Name(), "_test.go") {
|
||||
hasGo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Merge plg.json replaces with CLI --replace overrides
|
||||
var replaces []string
|
||||
if plg, err := readPlgJSON(filepath.Join(dir, "plg.json")); err == nil {
|
||||
replaces = plg.ReplacesToSlice()
|
||||
}
|
||||
replaces = append(replaces, cfg.Replaces...)
|
||||
|
||||
if _, err := os.Stat(luaPath); err == nil {
|
||||
debugLua(dir, sdkPath, luaPath)
|
||||
} else if hasGo {
|
||||
debugGo(dir, replaces)
|
||||
} else {
|
||||
fmt.Println("error: no main.lua or .go files found in", dir)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func debugLua(dir, sdkPath, luaPath string) {
|
||||
// check lua interpreter
|
||||
luaBin, err := exec.LookPath("lua")
|
||||
if err != nil {
|
||||
fmt.Println("error: lua interpreter not found in PATH")
|
||||
fmt.Println(" install Lua 5.1+ or use hmapdev build to compile your plugin")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("[debug] Lua interpreter: %s\n", luaBin)
|
||||
fmt.Printf("[debug] Plugin dir: %s\n", dir)
|
||||
|
||||
// check if sdk.lua exists
|
||||
if _, err := os.Stat(sdkPath); os.IsNotExist(err) {
|
||||
fmt.Println("warning: sdk.lua not found, debug SDK mock will not be available")
|
||||
}
|
||||
|
||||
// write temporary debug script
|
||||
debugScript := filepath.Join(dir, "_debug.lua")
|
||||
if err := os.WriteFile(debugScript, []byte(tmplLuaDebug), 0644); err != nil {
|
||||
fmt.Printf("error: write debug script: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.Remove(debugScript)
|
||||
|
||||
cmd := exec.Command(luaBin, filepath.Base(debugScript))
|
||||
cmd.Dir = dir
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
fmt.Println("[debug] Starting Lua debug session...")
|
||||
fmt.Println()
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(exitErr.ExitCode())
|
||||
}
|
||||
fmt.Printf("[debug] error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func debugGo(dir string, replaces []string) {
|
||||
debug, err := yaegi.NewYaegiDebugger(dir, replaces)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("[debug] Plugin dir: %s\n", dir)
|
||||
|
||||
if err := debug.LoadPlugin(); err != nil {
|
||||
fmt.Printf("[debug] load plugin: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := debug.StartREPL(); err != nil {
|
||||
fmt.Printf("[debug] repl error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
295
tools/hmapdev/cmd_init.go
Normal file
295
tools/hmapdev/cmd_init.go
Normal file
@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
func (p *PlgConfig) ReplacesToSlice() []string {
|
||||
var s []string
|
||||
for from, to := range p.Replaces {
|
||||
s = append(s, from+"="+to)
|
||||
}
|
||||
sort.Strings(s) // deterministic order
|
||||
return s
|
||||
}
|
||||
|
||||
type PlgConfig struct {
|
||||
Name string `json:"name"`
|
||||
NameZh string `json:"name_zh"`
|
||||
NameEn string `json:"name_en"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Entry string `json:"entry"`
|
||||
Tags []string `json:"tags"`
|
||||
Targets string `json:"targets"`
|
||||
OutDir string `json:"outdir,omitempty"`
|
||||
Bundle *bool `json:"bundle,omitempty"`
|
||||
SDKPath string `json:"sdk_path,omitempty"`
|
||||
GoVersion string `json:"go_version,omitempty"`
|
||||
Replaces map[string]string `json:"replaces,omitempty"`
|
||||
SourceDirs []string `json:"source_dirs,omitempty"`
|
||||
}
|
||||
|
||||
// TargetList parses the Targets string into a slice.
|
||||
func (p *PlgConfig) TargetList() []string { return parseTargets(p.Targets) }
|
||||
|
||||
// BundleDefault returns true if bundle mode is not explicitly disabled.
|
||||
func (p *PlgConfig) BundleDefault() bool { return p.Bundle == nil || *p.Bundle }
|
||||
|
||||
// OutDirDefault returns the output directory, defaulting to "dist".
|
||||
func (p *PlgConfig) OutDirDefault() string {
|
||||
if p.OutDir != "" {
|
||||
return p.OutDir
|
||||
}
|
||||
return "dist"
|
||||
}
|
||||
|
||||
type TemplateData struct {
|
||||
Plg PlgConfig
|
||||
IsLua bool
|
||||
|
||||
// Go module info (for go.mod)
|
||||
ModulePath string
|
||||
GoVersion string
|
||||
SDKModule string
|
||||
SDKVersion string
|
||||
|
||||
// SDKLocalPath 是本机 SDK 源码绝对路径,写入生成的 go.mod 作为 replace 目标。
|
||||
//
|
||||
// 为何必须写:gitcode 的模块不在 proxy.golang.org 上,只 require 一个
|
||||
// 版本号的 go.mod 配上缺失的 go.sum,新用户第一次 `hmapdev build`
|
||||
// 必定死在 "missing go.sum entry",而 `go mod tidy` 又会去公共 proxy 拉
|
||||
// 一个不存在的条目。有了本地 replace,go 完全不需要 go.sum 条目。
|
||||
SDKLocalPath string
|
||||
}
|
||||
|
||||
func cmdInit(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Println("Usage: hmapdev init <name> [--lua] [--type remotedevice]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
isLua := false
|
||||
isRemoteDevice := false
|
||||
for _, a := range args[1:] {
|
||||
switch a {
|
||||
case "--lua":
|
||||
isLua = true
|
||||
case "--type", "-t":
|
||||
// handled in next iteration
|
||||
}
|
||||
}
|
||||
// also check --type remotedevice as a single arg
|
||||
for i, a := range args[1:] {
|
||||
if a == "--type" || a == "-t" {
|
||||
if i+1 < len(args[1:]) {
|
||||
if args[1:][i+1] == "remotedevice" {
|
||||
isRemoteDevice = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if a == "--type=remotedevice" || a == "-t=remotedevice" {
|
||||
isRemoteDevice = true
|
||||
}
|
||||
}
|
||||
|
||||
if isRemoteDevice && isLua {
|
||||
fmt.Println("error: --type remotedevice and --lua are mutually exclusive")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Remote device projects use different scaffold
|
||||
if isRemoteDevice {
|
||||
scaffoldRemoteDevice(name)
|
||||
return
|
||||
}
|
||||
|
||||
dir := name
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
fmt.Printf("error: directory %q already exists\n", dir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Go 插件统一产出 plugin.bin(v1.0.0 子进程模式)。
|
||||
//
|
||||
// 此前这里写 "plugin.so",scaffold 出来的 plg.json 就带着一个已退场的
|
||||
// entry 值,新手跟着模板走会误以为自己在做 C ABI 插件。
|
||||
// build 实际不看这个值(只用它区分 Lua),但模板不应误导。
|
||||
entry := "plugin.bin"
|
||||
var targets string
|
||||
if isLua {
|
||||
entry = "main.lua"
|
||||
targets = "lua"
|
||||
} else {
|
||||
targets = "linux/amd64,windows/amd64"
|
||||
}
|
||||
|
||||
nameEn := strings.ReplaceAll(name, "-", " ")
|
||||
nameEn = strings.Title(nameEn)
|
||||
|
||||
data := TemplateData{
|
||||
Plg: PlgConfig{
|
||||
Name: name,
|
||||
NameZh: "中文名",
|
||||
NameEn: nameEn,
|
||||
Version: "0.1.0",
|
||||
Description: name + " plugin",
|
||||
Author: "HomeAgent",
|
||||
Entry: entry,
|
||||
Tags: []string{name},
|
||||
Targets: targets,
|
||||
},
|
||||
IsLua: isLua,
|
||||
}
|
||||
|
||||
// Detect SDK info for Go plugin go.mod.
|
||||
// 生成的 go.mod 除 require 外还写一条指向本机 SDK 的 replace:
|
||||
// 否则 scaffold 出来的项目第一次 build 必定失败(详见 SDKLocalPath 注释)。
|
||||
if !isLua {
|
||||
sdkMod, goVer, sdkRoot, sdkVer := detectSDKInfo()
|
||||
data.ModulePath = name
|
||||
data.GoVersion = goVer
|
||||
data.SDKModule = sdkMod
|
||||
data.SDKVersion = "v" + sdkVer
|
||||
data.SDKLocalPath = strings.ReplaceAll(sdkRoot, "\\", "/")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("error: create dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// write plg.json
|
||||
writeTemplate(filepath.Join(dir, "plg.json"), tmplPlgJSON, data)
|
||||
|
||||
// Lua plugins get main.lua + sdk.lua; Go plugins get plugin.go only
|
||||
if isLua {
|
||||
writeTemplate(filepath.Join(dir, "main.lua"), tmplMainLua, data)
|
||||
writeTemplate(filepath.Join(dir, "sdk.lua"), tmplSDKLua, data)
|
||||
} else {
|
||||
writeTemplate(filepath.Join(dir, "plugin.go"), tmplPluginGo, data)
|
||||
}
|
||||
|
||||
// write README.md
|
||||
writeTemplate(filepath.Join(dir, "README.md"), tmplReadme, data)
|
||||
|
||||
// write go.mod for Go plugins
|
||||
if !isLua {
|
||||
writeTemplate(filepath.Join(dir, "go.mod"), tmplGoMod, data)
|
||||
}
|
||||
|
||||
// create thirdpart directory for external library sources
|
||||
os.MkdirAll(filepath.Join(dir, "thirdpart"), 0755)
|
||||
|
||||
fmt.Printf("Created plugin project %q (%s)\n", dir, entry)
|
||||
if isLua {
|
||||
fmt.Printf(" cd %s && lua main.lua (standalone test)\n", dir)
|
||||
}
|
||||
fmt.Printf(" cd %s && hmapdev build\n", dir)
|
||||
}
|
||||
|
||||
// detectSDKInfo reads the HomeAgent SDK's go.mod and meta to get module path, go version, and SDK version.
|
||||
func detectSDKInfo() (modulePath, goVersion, sdkPath, sdkVersion string) {
|
||||
root := activeSDKRoot()
|
||||
gomodPath := filepath.Join(root, "go.mod")
|
||||
data, err := os.ReadFile(gomodPath)
|
||||
if err != nil {
|
||||
fmt.Printf("error: cannot read SDK go.mod at %s: %v\n", gomodPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "module ") {
|
||||
modulePath = strings.TrimSpace(line[7:])
|
||||
}
|
||||
if strings.HasPrefix(line, "go ") {
|
||||
goVersion = strings.TrimSpace(line[3:])
|
||||
}
|
||||
}
|
||||
if modulePath == "" {
|
||||
fmt.Printf("error: no module directive in %s\n", gomodPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
if goVersion == "" {
|
||||
goVersion = "1.21"
|
||||
}
|
||||
sdkVersion = readMetaVersion(root)
|
||||
return modulePath, goVersion, root, sdkVersion
|
||||
}
|
||||
|
||||
// scaffoldRemoteDevice 创建远程设备适配器项目脚手架
|
||||
func scaffoldRemoteDevice(name string) {
|
||||
dir := name
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
fmt.Printf("error: directory %q already exists\n", dir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
nameEn := strings.Title(strings.ReplaceAll(name, "-", " "))
|
||||
|
||||
data := TemplateData{
|
||||
Plg: PlgConfig{
|
||||
Name: name,
|
||||
NameZh: "中文名",
|
||||
NameEn: nameEn,
|
||||
Version: "0.1.0",
|
||||
Description: name + " remote device adapter",
|
||||
Author: "HomeAgent",
|
||||
Entry: name,
|
||||
Tags: []string{name, "remotedevice"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("error: create dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 写入 main.c
|
||||
writeTemplate(filepath.Join(dir, "main.c"), tmplRemoteDeviceMain, data)
|
||||
|
||||
// 写入 CMakeLists.txt
|
||||
writeTemplate(filepath.Join(dir, "CMakeLists.txt"), tmplRemoteDeviceCMake, data)
|
||||
|
||||
// 创建 SDK 目录(symlink/copy)
|
||||
sdkSrc := filepath.Join("..", "remotedevice")
|
||||
sdkDst := filepath.Join(dir, "ha_remotedevice")
|
||||
if _, err := os.Stat(sdkDst); os.IsNotExist(err) {
|
||||
// 尝试创建符号链接,失败则提示
|
||||
if err := os.Symlink(sdkSrc, sdkDst); err != nil {
|
||||
fmt.Printf(" note: could not create symlink to SDK, copy manually:\n")
|
||||
fmt.Printf(" cp -r %s %s\n", sdkSrc, sdkDst)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Created remote device adapter project %q\n", dir)
|
||||
fmt.Printf(" cd %s && mkdir build && cd build && cmake .. && make\n", dir)
|
||||
fmt.Printf(" Or include as subdirectory in your project:\n")
|
||||
fmt.Printf(" add_subdirectory(%s)\n", dir)
|
||||
}
|
||||
|
||||
func writeTemplate(path, content string, data TemplateData) {
|
||||
tmpl, err := template.New("").Parse(content)
|
||||
if err != nil {
|
||||
fmt.Printf("error: parse template: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Printf("error: create %s: %v\n", path, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := tmpl.Execute(f, data); err != nil {
|
||||
fmt.Printf("error: execute template: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
524
tools/hmapdev/cmd_sdk.go
Normal file
524
tools/hmapdev/cmd_sdk.go
Normal file
@ -0,0 +1,524 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sdkDirName = "hmapdev/sdk"
|
||||
|
||||
// legacySDKDirName 是改名前的存储目录。工具链在 1.2.0 从 plugindev 改名 hmapdev;
|
||||
// 已装过旧版的机器上 SDK 仍在旧路径,直接换名会让它找不到已装 SDK
|
||||
// (表现为「没有活动版本」)。新目录不存在而旧目录存在时沿用旧目录。
|
||||
const legacySDKDirName = "plugindev/sdk"
|
||||
|
||||
// sdkStore returns the root directory for stored SDK versions.
|
||||
func sdkStore() string {
|
||||
if v := os.Getenv("HOMEAGENT_SDK_DIR"); v != "" {
|
||||
return v
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Printf("error: cannot determine home directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
dir := filepath.Join(home, ".homeagent", sdkDirName)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if legacy := filepath.Join(home, ".homeagent", legacySDKDirName); legacy != "" {
|
||||
if _, err := os.Stat(legacy); err == nil {
|
||||
return legacy
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func sdkCurrentDir() string {
|
||||
return filepath.Join(sdkStore(), "current")
|
||||
}
|
||||
|
||||
func sdkVersionDir(version string) string {
|
||||
return filepath.Join(sdkStore(), version)
|
||||
}
|
||||
|
||||
const sdkRepoURL = "https://gitcode.com/JianFeeeee/homeagent-sdk.git"
|
||||
const sdkDownloadURL = "https://gitcode.com/JianFeeeee/homeagent-sdk/-/archive/%s/homeagent-sdk-%s.tar.gz"
|
||||
|
||||
func cmdSDK(args []string) {
|
||||
if len(args) < 1 {
|
||||
sdkHelp()
|
||||
return
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
cmdSDKList()
|
||||
case "install":
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Usage: hmapdev sdk install <version>")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdSDKInstall(args[1])
|
||||
case "use":
|
||||
if len(args) < 2 {
|
||||
fmt.Println("Usage: hmapdev sdk use <version>")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdSDKUse(args[1])
|
||||
case "path":
|
||||
cmdSDKPath()
|
||||
case "current":
|
||||
cmdSDKCurrent()
|
||||
case "latest":
|
||||
cmdSDKLatest()
|
||||
default:
|
||||
sdkHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func sdkHelp() {
|
||||
fmt.Print(`Usage: hmapdev sdk <command>
|
||||
|
||||
Manage installed HomeAgent SDK versions.
|
||||
|
||||
Commands:
|
||||
list List installed SDK versions
|
||||
install <version> Download and install an SDK version (tag or branch)
|
||||
use <version> Switch to the specified SDK version for new projects
|
||||
path Show the current active SDK directory
|
||||
current Show the current active SDK version
|
||||
latest Show the latest available version from remote
|
||||
|
||||
Examples:
|
||||
hmapdev sdk install v0.7.1
|
||||
hmapdev sdk install latest
|
||||
hmapdev sdk use v0.7.1
|
||||
`)
|
||||
}
|
||||
|
||||
// cmdSDKList lists installed SDK versions.
|
||||
func cmdSDKList() {
|
||||
store := sdkStore()
|
||||
entries, err := os.ReadDir(store)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Println("No SDK versions installed.")
|
||||
return
|
||||
}
|
||||
fmt.Printf("error: read SDK store %s: %v\n", store, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
current := resolveCurrentVersion(store)
|
||||
|
||||
var versions []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && e.Name() != "current" && !strings.HasPrefix(e.Name(), ".") {
|
||||
versions = append(versions, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
|
||||
|
||||
if len(versions) == 0 {
|
||||
fmt.Println("No SDK versions installed.")
|
||||
return
|
||||
}
|
||||
fmt.Println("Installed SDK versions:")
|
||||
for _, v := range versions {
|
||||
mark := " "
|
||||
if v == current {
|
||||
mark = "*"
|
||||
}
|
||||
fmt.Printf(" %s %s\n", mark, v)
|
||||
}
|
||||
if current == "" {
|
||||
fmt.Println("\nNo version active. Use 'hmapdev sdk use <version>' to set one.")
|
||||
}
|
||||
}
|
||||
|
||||
// cmdSDKInstall downloads and installs an SDK version from Release archive.
|
||||
func cmdSDKInstall(version string) {
|
||||
store := sdkStore()
|
||||
if err := os.MkdirAll(store, 0755); err != nil {
|
||||
fmt.Printf("error: create SDK store %s: %v\n", store, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if version == "latest" {
|
||||
tag, err := fetchLatestTag()
|
||||
if err != nil {
|
||||
fmt.Printf("error: fetch latest tag: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
version = tag
|
||||
fmt.Printf("Latest version: %s\n", version)
|
||||
}
|
||||
|
||||
dest := sdkVersionDir(version)
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
fmt.Printf("SDK version %s already installed at %s\n", version, dest)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(sdkDownloadURL, version, version)
|
||||
fmt.Printf("Downloading SDK %s from Release archive...\n", version)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "homeagent-sdk-extract-*")
|
||||
if err != nil {
|
||||
fmt.Printf("error: create temp dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := installFromArchive(url, tmpDir); err != nil {
|
||||
fmt.Printf("warn: archive download failed (%v), falling back to git clone...\n", err)
|
||||
if err := installFromGit(version, tmpDir); err != nil {
|
||||
fmt.Printf("error: install SDK %s: %v\n", version, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpDir, dest); err != nil {
|
||||
// Cross-filesystem rename fallback
|
||||
if err := copyDir(tmpDir, dest); err != nil {
|
||||
fmt.Printf("error: move SDK to store: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
fmt.Printf("SDK version %s installed at %s\n", version, dest)
|
||||
|
||||
// Auto-switch to newly installed version if no version is currently active
|
||||
if resolveCurrentVersion(store) == "" {
|
||||
setCurrentVersion(store, version)
|
||||
}
|
||||
}
|
||||
|
||||
func openFile(path string) (*os.File, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// installFromArchive downloads the SDK release archive and extracts it to tmpDir.
|
||||
func installFromArchive(url, tmpDir string) error {
|
||||
tmpFile, err := os.CreateTemp("", "homeagent-sdk-*.tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download SDK %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
f, err := openFile(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gzr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
defer gzr.Close()
|
||||
defer f.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Strip top-level directory from archive path
|
||||
parts := strings.SplitN(header.Name, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
relPath := parts[1]
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(tmpDir, relPath)
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(target, os.FileMode(header.Mode))
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installFromGit clones the SDK repo at the given tag/branch into tmpDir.
|
||||
func installFromGit(version, tmpDir string) error {
|
||||
cmd := exec.Command("git", "clone", "--depth", "1", "--branch", version, sdkRepoURL, tmpDir)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git clone: %v", err)
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(tmpDir, ".git"))
|
||||
}
|
||||
|
||||
// cmdSDKUse switches the active SDK version.
|
||||
func cmdSDKUse(version string) {
|
||||
store := sdkStore()
|
||||
verDir := sdkVersionDir(version)
|
||||
if _, err := os.Stat(verDir); os.IsNotExist(err) {
|
||||
fmt.Printf("SDK version %s is not installed.\n", version)
|
||||
fmt.Printf("Install it first: hmapdev sdk install %s\n", version)
|
||||
os.Exit(1)
|
||||
}
|
||||
setCurrentVersion(store, version)
|
||||
fmt.Printf("Active SDK version set to %s\n", version)
|
||||
}
|
||||
|
||||
// cmdSDKPath prints the active SDK directory.
|
||||
func cmdSDKPath() {
|
||||
store := sdkStore()
|
||||
current := resolveCurrentVersion(store)
|
||||
if current == "" {
|
||||
fmt.Println("No active SDK version set.")
|
||||
fmt.Println("Use 'hmapdev sdk use <version>' to set one.")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(sdkVersionDir(current))
|
||||
}
|
||||
|
||||
// cmdSDKCurrent prints the active SDK version.
|
||||
func cmdSDKCurrent() {
|
||||
store := sdkStore()
|
||||
current := resolveCurrentVersion(store)
|
||||
if current == "" {
|
||||
fmt.Println("No active SDK version set.")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(current)
|
||||
}
|
||||
|
||||
// cmdSDKLatest fetches the latest tag from the remote SDK repo.
|
||||
func cmdSDKLatest() {
|
||||
tag, err := fetchLatestTag()
|
||||
if err != nil {
|
||||
fmt.Printf("error: fetch latest tag: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(tag)
|
||||
}
|
||||
|
||||
// resolveCurrentVersion reads the current active version.
|
||||
func resolveCurrentVersion(store string) string {
|
||||
currentFile := filepath.Join(store, "current")
|
||||
data, err := os.ReadFile(currentFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
v := strings.TrimSpace(string(data))
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
verDir := filepath.Join(store, v)
|
||||
if _, err := os.Stat(verDir); os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// setCurrentVersion writes the version name to the current file.
|
||||
func setCurrentVersion(store, version string) {
|
||||
currentFile := filepath.Join(store, "current")
|
||||
if err := os.WriteFile(currentFile, []byte(version+"\n"), 0644); err != nil {
|
||||
fmt.Printf("error: write current version: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLatestTag uses git ls-remote to find the latest semver tag.
|
||||
func fetchLatestTag() (string, error) {
|
||||
cmd := exec.Command("git", "ls-remote", "--tags", sdkRepoURL)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git ls-remote failed: %w", err)
|
||||
}
|
||||
|
||||
tags := parseTags(string(out))
|
||||
if len(tags) == 0 {
|
||||
return "", fmt.Errorf("no tags found in remote repository")
|
||||
}
|
||||
return tags[len(tags)-1], nil
|
||||
}
|
||||
|
||||
// parseTags extracts semver tags from git ls-remote output and sorts them.
|
||||
func parseTags(output string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var tags []string
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
ref := parts[1]
|
||||
// Only refs/tags/v*, skip refs/tags/v*-^{}
|
||||
if !strings.HasPrefix(ref, "refs/tags/v") || strings.HasSuffix(ref, "^{}") {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimPrefix(ref, "refs/tags/")
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
|
||||
sort.Slice(tags, func(i, j int) bool {
|
||||
return compareSemver(tags[i], tags[j]) < 0
|
||||
})
|
||||
return tags
|
||||
}
|
||||
|
||||
// compareSemver compares two semver tags (vX.Y.Z). Returns -1, 0, or 1.
|
||||
func compareSemver(a, b string) int {
|
||||
an := parseSemver(a)
|
||||
bn := parseSemver(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if an[i] != bn[i] {
|
||||
if an[i] < bn[i] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseSemver extracts [major, minor, patch] from a vX.Y.Z[-pre] string.
|
||||
// Prerelease tags parse to the same major.minor.patch as their release (ignoring prerelease).
|
||||
func parseSemver(tag string) [3]int {
|
||||
var v [3]int
|
||||
s := strings.TrimPrefix(tag, "v")
|
||||
// Strip prerelease suffix (-...)
|
||||
if idx := strings.IndexByte(s, '-'); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
parts := strings.SplitN(s, ".", 3)
|
||||
for i := 0; i < 3 && i < len(parts); i++ {
|
||||
n := 0
|
||||
fmt.Sscanf(parts[i], "%d", &n)
|
||||
v[i] = n
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// activeSDKRoot returns the path to the active SDK root.
|
||||
// It replaces the old runtime.Caller(0) approach so hmapdev can work
|
||||
// independently of its own build location.
|
||||
func activeSDKRoot() string {
|
||||
store := sdkStore()
|
||||
current := resolveCurrentVersion(store)
|
||||
if current == "" {
|
||||
fmt.Printf("error: no active SDK version set\n")
|
||||
fmt.Printf(" Install one: hmapdev sdk install latest\n")
|
||||
fmt.Printf(" Or set one: hmapdev sdk use <version>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
root := sdkVersionDir(current)
|
||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||
fmt.Printf("error: active SDK version %s not found at %s\n", current, root)
|
||||
fmt.Printf(" Reinstall: hmapdev sdk install %s\n", current)
|
||||
os.Exit(1)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// copyDir recursively copies src to dst (cross-filesystem rename fallback).
|
||||
func copyDir(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
srcPath := filepath.Join(src, e.Name())
|
||||
dstPath := filepath.Join(dst, e.Name())
|
||||
if e.IsDir() {
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dstPath, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readMetaVersion reads the Version string from the SDK's meta/meta.go.
|
||||
// If the file is missing or unreadable, returns "0.0.0".
|
||||
func readMetaVersion(sdkRoot string) string {
|
||||
metaPath := filepath.Join(sdkRoot, "meta", "meta.go")
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
return "0.0.0"
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Version = ") {
|
||||
v := strings.TrimPrefix(line, "Version = ")
|
||||
v = strings.Trim(v, `"`)
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "0.0.0"
|
||||
}
|
||||
|
||||
|
||||
9
tools/hmapdev/go.mod
Normal file
9
tools/hmapdev/go.mod
Normal file
@ -0,0 +1,9 @@
|
||||
module gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev
|
||||
|
||||
go 1.21.0
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
|
||||
|
||||
require github.com/traefik/yaegi v0.16.1
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
|
||||
2
tools/hmapdev/go.sum
Normal file
2
tools/hmapdev/go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
49
tools/hmapdev/main.go
Normal file
49
tools/hmapdev/main.go
Normal file
@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
help()
|
||||
return
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "init":
|
||||
cmdInit(os.Args[2:])
|
||||
case "build":
|
||||
cmdBuild(os.Args[2:])
|
||||
case "clean":
|
||||
cmdClean(os.Args[2:])
|
||||
case "debug":
|
||||
cmdDebug(os.Args[2:])
|
||||
case "sdk":
|
||||
cmdSDK(os.Args[2:])
|
||||
default:
|
||||
help()
|
||||
}
|
||||
}
|
||||
|
||||
func help() {
|
||||
fmt.Print(`HomeAgent Plugin Dev Tool
|
||||
|
||||
Usage:
|
||||
hmapdev init <name> Scaffold a new plugin project
|
||||
hmapdev init <name> --lua Create Lua plugin
|
||||
hmapdev init <name> --type remotedevice
|
||||
Create C remote device adapter
|
||||
hmapdev build [flags] Compile and package plugin
|
||||
hmapdev clean Clean build/dist artifacts
|
||||
hmapdev debug [dir] Interpret and debug plugin source
|
||||
hmapdev sdk <command> Manage SDK versions
|
||||
|
||||
Flags:
|
||||
--outdir Output directory (default: dist)
|
||||
--target Target OS/arch (e.g. linux/amd64), repeatable
|
||||
--lua Create Lua plugin (for init)
|
||||
--type Project type: "remotedevice" (for init)
|
||||
-t Alias for --type
|
||||
`)
|
||||
}
|
||||
91
tools/hmapdev/proc_runtime.go
Normal file
91
tools/hmapdev/proc_runtime.go
Normal file
@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// 子进程插件运行时(外部插件多进程化)。
|
||||
//
|
||||
// 模板为何是**真实 .go 源文件** + //go:embed,而不是 raw string:
|
||||
// 1100+ 行代码塞在字符串里写错只能等生成插件时才炸;作为源文件可被
|
||||
// gofmt / go vet / go/parser 直接检查(proc_runtime_test.go 的 16 项
|
||||
// 静态检查就以此为前提)。
|
||||
//
|
||||
// 构建从 `-buildmode=c-shared` + CGO_ENABLED=1 变成普通 `go build` +
|
||||
// CGO_ENABLED=0,交叉编译不再需要目标平台的 C 工具链(§3.1 连带消失项)。
|
||||
//
|
||||
// 设计依据:docs/zh/架构迁移评估.md §3、docs/zh/plugin-migration-plan.md Part 3/6
|
||||
|
||||
//go:embed templates/proc_main.go.tmpl
|
||||
//go:embed templates/proc_shm_unix.go.tmpl
|
||||
//go:embed templates/proc_shm_windows.go.tmpl
|
||||
var procTemplates embed.FS
|
||||
|
||||
// procRuntimeFiles 列出生成到插件目录的运行时文件。
|
||||
//
|
||||
// 共享段与事件通知的**传递机制**按平台不同(Unix 继承 fd,
|
||||
// Windows 命名内核对象),故拆成带 build tag 的两个文件;
|
||||
// 共享段**布局**与 RPC 逻辑完全平台无关,全在 proc_main 里。
|
||||
//
|
||||
// 这正是三套独立 ABI 实现收敛为单一 RPC 实现的效果:
|
||||
// 平台差异从「整套 stage 下发/写回逻辑各写一份」缩到「三个挂载函数」。
|
||||
var procRuntimeFiles = []struct {
|
||||
tmpl string // 内嵌模板路径
|
||||
out string // 生成到插件目录的文件名
|
||||
}{
|
||||
{"templates/proc_main.go.tmpl", "z_proc_gen.go"},
|
||||
{"templates/proc_shm_unix.go.tmpl", "z_proc_shm_unix.go"},
|
||||
{"templates/proc_shm_windows.go.tmpl", "z_proc_shm_windows.go"},
|
||||
}
|
||||
|
||||
// procEntryFile 是子进程插件的入口二进制名(与内核 internal/plugin/dynamic.go 的 binEntry 一致)。
|
||||
//
|
||||
// 全平台同名:进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名
|
||||
// (对比 C ABI 时代的 .so/.dylib/.dll 三套产物 + 三套 ABI 实现)。
|
||||
const procEntryFile = "plugin.bin"
|
||||
|
||||
// luaEntryFile 是 Lua 插件的入口。Lua 走解释器,不经过 Go 编译。
|
||||
const luaEntryFile = "main.lua"
|
||||
|
||||
// procGenFile 是生成的主运行时文件名(兼容旧注释引用)。
|
||||
// 前缀 z_ 使其在目录列表中排在业务代码之后。
|
||||
const procGenFile = "z_proc_gen.go"
|
||||
|
||||
// generateProcRuntime 把子进程运行时(平台无关主体 + 两个平台挂载实现)
|
||||
// 写入插件目录,返回清理函数。
|
||||
func generateProcRuntime() (func(), error) {
|
||||
// 清理历史 C ABI 产物:旧版 hmapdev(原名 plugindev)生成过这两个文件,残留下来会与
|
||||
// 本模板的 main 冲突。无需人工清理就能从旧版升级。
|
||||
for _, stale := range []string{"z_bridge_gen.go", "z_entry.c"} {
|
||||
os.Remove(stale)
|
||||
}
|
||||
|
||||
var written []string
|
||||
cleanup := func() {
|
||||
for _, f := range written {
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rf := range procRuntimeFiles {
|
||||
data, err := procTemplates.ReadFile(rf.tmpl)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("读取内嵌模板 %s: %w", rf.tmpl, err)
|
||||
}
|
||||
if err := os.WriteFile(rf.out, data, 0644); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("写入 %s: %w", rf.out, err)
|
||||
}
|
||||
written = append(written, rf.out)
|
||||
}
|
||||
return cleanup, nil
|
||||
}
|
||||
|
||||
// isProcEntry 已删除:Go 插件一律产出 plugin.bin,不再看 plg.json 的 entry 值。
|
||||
//
|
||||
// 为何忽略 entry:17 个存量插件的 plg.json 都写着 "plugin.so"。若把 entry 当作
|
||||
// 通道开关,迁移就得改 17 个文件——而「外部插件零改动」是本次迁移的硬约束。
|
||||
// entry 现在只用于区分 Lua(main.lua)与 Go 插件。
|
||||
462
tools/hmapdev/proc_runtime_test.go
Normal file
462
tools/hmapdev/proc_runtime_test.go
Normal file
@ -0,0 +1,462 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 子进程运行时模板的静态检查(Part 3)。
|
||||
//
|
||||
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
|
||||
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
|
||||
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
|
||||
//
|
||||
// 模板改为真实 .go 源文件(而非 raw string)的直接收益就是这类检查可行。
|
||||
|
||||
func loadProcTemplate(t *testing.T) string {
|
||||
t.Helper()
|
||||
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
|
||||
if err != nil {
|
||||
t.Fatalf("读取内嵌模板: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
|
||||
func stripComments(t *testing.T, src string) string {
|
||||
t.Helper()
|
||||
fs := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("解析模板: %v", err)
|
||||
}
|
||||
out := []byte(src)
|
||||
for _, cg := range f.Comments {
|
||||
s := fs.Position(cg.Pos()).Offset
|
||||
e := fs.Position(cg.End()).Offset
|
||||
for i := s; i < e && i < len(out); i++ {
|
||||
if out[i] != '\n' {
|
||||
out[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// 模板必须是合法 Go 源码。
|
||||
func TestProcTemplate_ParsesAsGo(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
fs := token.NewFileSet()
|
||||
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
|
||||
t.Fatalf("模板不是合法 Go 源码: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 模板必须提供 main(),且不得含 cgo 痕迹。
|
||||
//
|
||||
// 零 cgo 是迁移的核心收益之一(§3.7 锁仲裁回内核后整个架构无 cgo);
|
||||
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
|
||||
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
|
||||
if !strings.Contains(src, "func main()") {
|
||||
t.Error("子进程模板必须有 main() 入口")
|
||||
}
|
||||
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
|
||||
code := stripComments(t, src)
|
||||
for _, forbidden := range []string{
|
||||
`import "C"`,
|
||||
"//export ",
|
||||
"C.CString",
|
||||
"C.GoString",
|
||||
"C.free",
|
||||
} {
|
||||
if strings.Contains(code, forbidden) {
|
||||
t.Errorf("模板不应含 cgo 痕迹 %q(零 cgo 是迁移的核心收益)", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
|
||||
//
|
||||
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
|
||||
// 表现是插件调用返回「未知 method」,测试能提前拦住。
|
||||
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
|
||||
// 51 个 C ABI method id 平移后的名字(§3.2),加 stage 锁仲裁 2 个
|
||||
required := []string{
|
||||
// 注册面
|
||||
"tool.register", "stage.register", "output.register", "api.register", "input.register",
|
||||
// IO 注入(io.injectTextNoMem 见下方 deprecated:内核保留为兼容旧二进制,
|
||||
// 当前模板改走 io.injectText + NoMemory 标志位,不再发那个 id)
|
||||
"io.injectText", "io.injectInterrupt", "io.injectInputSync",
|
||||
"io.setToolBlocks",
|
||||
// 多模态注入(1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
|
||||
// 模板不发这个 RPC,内核也就永远收不到,而两边都不报错。
|
||||
"io.injectMedia", "io.injectMediaSync", "io.injectInterruptMedia",
|
||||
// 生命周期
|
||||
"lifecycle.autoRestart",
|
||||
// 图记忆
|
||||
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
|
||||
// 文档记忆
|
||||
"doc.query", "doc.insert", "doc.remove", "doc.stats",
|
||||
// 文档媒体(1.1.0 新增)
|
||||
"doc.insertWithMedia",
|
||||
// 知识库
|
||||
"knowledge.search", "knowledge.add", "knowledge.list",
|
||||
// 文本记忆
|
||||
"textmemory.append",
|
||||
// 设置
|
||||
"settings.get", "settings.set", "settings.registerDef",
|
||||
"settings.getCore", "settings.setCore", "settings.listCore",
|
||||
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
|
||||
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
|
||||
"settings.dataDir",
|
||||
// LLM
|
||||
"llm.listSources", "llm.setSource", "llm.currentSource",
|
||||
// 社交图
|
||||
"social.getPerson", "social.getNetwork", "social.getTrait",
|
||||
"social.getRelations", "social.listPersons",
|
||||
// 插件管理
|
||||
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
|
||||
// 共享段锁仲裁(新增,C ABI 下不存在此概念)
|
||||
"stage.lock", "stage.unlock",
|
||||
}
|
||||
for _, m := range required {
|
||||
if !strings.Contains(src, `"`+m+`"`) {
|
||||
t.Errorf("模板缺少 core method %q(内核已提供,插件侧未接线)", m)
|
||||
}
|
||||
}
|
||||
|
||||
// 内核保留、但**当前模板不再发送**的 method id。
|
||||
//
|
||||
// 它们不是「公开接口新增却忘记接线」,而是刻意的向后兼容面:
|
||||
// 内核必须继续接受用旧模板编出的插件二进制发来的 id,而当前模板没有理由再发。
|
||||
//
|
||||
// 可复查的判据:ba49dfd 之前的模板里 InjectTextNoMemory 发的就是
|
||||
// "io.injectTextNoMem";注入标志位落地后它改走 "io.injectText" + NoMemory。
|
||||
//
|
||||
// 为何要单独列而不是直接从 required 删掉:这条守卫的价值在于「新接口必须接线」,
|
||||
// 而把「内核有、模板就必须发」当不变量,会让它常驻误报——常驻误报的守卫迟早
|
||||
// 被人习惯性忽略,那时真漏接线也就没人看见了。
|
||||
deprecated := map[string]string{
|
||||
"io.injectTextNoMem": "旧模板经此表达「不进记忆」;现由 io.injectText + NoMemory 表达",
|
||||
}
|
||||
// 反向保护:allowlist 条目一旦又出现在模板里,说明它已过期,必须删掉,
|
||||
// 否则这里会悄悄变成「永久豁免」的垃圾抽屉。
|
||||
for m, why := range deprecated {
|
||||
if strings.Contains(src, `"`+m+`"`) {
|
||||
t.Errorf("deprecated 里的 %q 又出现在模板里(%s)——条目已过期,请从 deprecated 移除", m, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板必须处理内核发来的全部调用(含无法 JSON 序列化的 Cleaner 回调)。
|
||||
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
for _, m := range []string{
|
||||
"handshake",
|
||||
"plugin.init", "plugin.start", "plugin.stop",
|
||||
"tool.invoke", "cleaner.invoke", "stage.invoke", "output.invoke",
|
||||
} {
|
||||
if !strings.Contains(src, `case "`+m+`"`) {
|
||||
t.Errorf("模板未处理内核调用 %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用的 payload 必须走内核标定的**调用帧**(funccall 模型)。
|
||||
//
|
||||
// 共享内存是内核内部实现(插件作者只看到普通 map),但模板必须在传输层
|
||||
// 正确读写 frame / args_len / result_ref。漏接线的后果很隐蔽:参数被静默
|
||||
// 丢弃、结果只走内联,性能退化而不报错。
|
||||
func TestProcTemplate_ToolInvokeUsesSharedRef(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
for _, want := range []string{"frame", "args_len", "result_ref"} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("模板的 tool.invoke 必须处理 %q(payload 走内核标定的调用帧)", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板必须通过 arena.alloc / arena.free 向内核申请与归还共享内存。
|
||||
//
|
||||
// 共享内存是内核独占管理的**内部实现**:插件不能自己维护分配游标。
|
||||
// 历史上两版跨进程分配器(bump 游标 / 模板内位图 CAS)都因为把可变
|
||||
// 分配状态放在共享内存里而出竞态,所以这里做回归保护。
|
||||
func TestProcTemplate_UsesKernelArenaRPC(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
|
||||
for _, m := range []string{`"arena.alloc"`, `"arena.free"`} {
|
||||
if !strings.Contains(src, m) {
|
||||
t.Errorf("模板缺少内核共享内存 RPC %s(插件必须向内核申请/归还)", m)
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止插件侧再出现本地分配器符号。
|
||||
//
|
||||
// 只查代码不查注释:注释里会解释“为什么不再这么做”。
|
||||
code := stripComments(t, src)
|
||||
for _, forbidden := range []string{"arenaUsed", "arenaWrite"} {
|
||||
if strings.Contains(code, forbidden) {
|
||||
t.Errorf("模板不应再出现插件侧分配器 %q(共享内存由内核独占管理)", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
|
||||
//
|
||||
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
|
||||
// 而两边都不报错(同为 []byte)。
|
||||
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
|
||||
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
|
||||
layout := map[string]string{
|
||||
"shmOffMagic": "0",
|
||||
"shmOffVersion": "4",
|
||||
"shmOffArenaBase": "8",
|
||||
"shmOffArenaCap": "12",
|
||||
"shmOffArenaUsed": "16",
|
||||
"shmOffCtxBase": "20",
|
||||
"shmOffSeq": "24",
|
||||
// 与内核 stageFieldCount / sliceSize 对齐
|
||||
"shmStageFieldCount": "18",
|
||||
"shmSliceSize": "8",
|
||||
"shmVersion": "1",
|
||||
}
|
||||
constRe := func(name, want string) bool {
|
||||
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
|
||||
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
|
||||
return re.MatchString(src)
|
||||
}
|
||||
for name, want := range layout {
|
||||
if !constRe(name, want) {
|
||||
t.Errorf("共享段常量 %s 应为 %s(须与内核 internal/plugin/proc/shm.go 一致)", name, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 字段枚举顺序:内核 stageField 的前若干项
|
||||
fieldOrder := []string{
|
||||
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
|
||||
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
|
||||
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
|
||||
"fTokenUsage", "fErrors",
|
||||
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
|
||||
}
|
||||
idx := -1
|
||||
for _, f := range fieldOrder {
|
||||
at := strings.Index(src, f)
|
||||
if at < 0 {
|
||||
t.Fatalf("模板缺少字段常量 %s", f)
|
||||
}
|
||||
if at <= idx {
|
||||
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
|
||||
}
|
||||
idx = at
|
||||
}
|
||||
}
|
||||
|
||||
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
|
||||
//
|
||||
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
|
||||
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
|
||||
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
|
||||
for _, want := range []string{
|
||||
"func handleStageInvoke(",
|
||||
"stage.lock",
|
||||
"readStageContext()",
|
||||
"takeStageSnapshot(",
|
||||
"writeStageDirty(",
|
||||
"stage.unlock",
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("stage 处理链路缺少 %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
|
||||
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
|
||||
iRead := strings.Index(src, "readStageContext()")
|
||||
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
|
||||
if iLock < 0 || iRead < 0 || iWrite < 0 {
|
||||
t.Fatal("stage 链路关键调用缺失")
|
||||
}
|
||||
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
|
||||
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
|
||||
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
|
||||
iReadFn := strings.Index(stageFn, "readStageContext()")
|
||||
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
|
||||
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
|
||||
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
|
||||
}
|
||||
}
|
||||
|
||||
// 快照必须存序列化字符串而非 Go 值。
|
||||
//
|
||||
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑:StageContext 的切片字段与读出的值
|
||||
// 共享底层内容,handler 原地改元素(sc.ToolResults[0].Result = x)时,
|
||||
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
|
||||
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
|
||||
if !strings.Contains(src, "strs map[int]string") ||
|
||||
!strings.Contains(src, "jsons map[int]string") {
|
||||
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
|
||||
}
|
||||
if !strings.Contains(src, "json.Marshal(v)") {
|
||||
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
|
||||
}
|
||||
}
|
||||
|
||||
// arena 用尽必须显式报错,不得静默截断(§4.4 风险登记)。
|
||||
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
if !strings.Contains(src, "arena 空间不足") {
|
||||
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
|
||||
}
|
||||
}
|
||||
|
||||
// 日志必须走 stderr:stdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
|
||||
func TestProcTemplate_LogsToStderr(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
|
||||
t.Error("日志必须走 stderr,否则会破坏 stdout 的 RPC 帧")
|
||||
}
|
||||
}
|
||||
|
||||
// 请求必须在独立 goroutine 里处理。
|
||||
//
|
||||
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
|
||||
// 就没人读应答帧 → 死锁。
|
||||
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
if !strings.Contains(src, "go handleKernelRequest(&req)") {
|
||||
t.Error("请求须在独立 goroutine 处理(handler 内反向调用内核,同步处理会死锁)")
|
||||
}
|
||||
}
|
||||
|
||||
// 协议与共享内存区域版本/魔数不匹配必须拒绝,不得半兼容运行。
|
||||
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
|
||||
src := loadProcTemplate(t)
|
||||
// §13.1 起共享段合并为单一「统一区域」,魔数校验文案随之更新。
|
||||
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "统一区域魔数不匹配"} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("握手应校验并拒绝 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全平台统一产出 plugin.bin。
|
||||
//
|
||||
// 这是三套独立 ABI 实现(.so/.dylib/.dll)收敛为单一 RPC 实现的直接后果:
|
||||
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
|
||||
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
|
||||
// Windows 走的是与 Linux 完全相同的 RPC 实现。
|
||||
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
|
||||
for _, target := range []string{
|
||||
"linux/amd64", "linux/arm64",
|
||||
"darwin/amd64", "darwin/arm64",
|
||||
"windows/amd64",
|
||||
"freebsd/amd64",
|
||||
} {
|
||||
cfg, errMsg := resolveBuild(target)
|
||||
if cfg == nil {
|
||||
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
|
||||
}
|
||||
if cfg.entryFile != procEntryFile {
|
||||
t.Errorf("%s: 产物应为 %s,实际 %s", target, procEntryFile, cfg.entryFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lua 目标仍走解释器路径(entry 字段唯一仍在使用的用途)。
|
||||
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
|
||||
for _, target := range []string{"lua", ""} {
|
||||
cfg, kind := resolveBuild(target)
|
||||
if cfg != nil {
|
||||
t.Errorf("%q 应返回 nil cfg(Lua 不经 Go 编译)", target)
|
||||
}
|
||||
if kind != "lua" {
|
||||
t.Errorf("%q 应识别为 lua,实际 %q", target, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不支持的平台明确报错,不静默产出错误产物。
|
||||
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
|
||||
cfg, errMsg := resolveBuild("plan9/amd64")
|
||||
if cfg != nil {
|
||||
t.Error("不支持的平台应返回 nil cfg")
|
||||
}
|
||||
if !strings.Contains(errMsg, "unsupported") {
|
||||
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
|
||||
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, bt := range allBundleTargets {
|
||||
if seen[bt.entry] {
|
||||
t.Errorf("zip 条目名重复: %s(会相互覆盖)", bt.entry)
|
||||
}
|
||||
seen[bt.entry] = true
|
||||
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
|
||||
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
|
||||
}
|
||||
}
|
||||
if len(allBundleTargets) == 0 {
|
||||
t.Error("bundle 目标表不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
|
||||
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
|
||||
func TestToolchain_NoCABIResiduals(t *testing.T) {
|
||||
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", f, err)
|
||||
}
|
||||
src := stripComments(t, string(data))
|
||||
for _, forbidden := range []string{
|
||||
"c-shared",
|
||||
"CGO_ENABLED=1",
|
||||
"detectWindowsCC",
|
||||
"generateBridge",
|
||||
"tmplLinuxBridge",
|
||||
"tmplPluginInitC",
|
||||
} {
|
||||
if strings.Contains(src, forbidden) {
|
||||
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Go 插件的构建不再读 plg.json 的 entry 值。
|
||||
//
|
||||
// 这是「外部插件零改动」的关键:17 个存量插件的 plg.json 都写着 "plugin.so",
|
||||
// 若把 entry 当通道开关,迁移就得改 17 个文件。
|
||||
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
|
||||
data, err := os.ReadFile("cmd_build.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 cmd_build.go: %v", err)
|
||||
}
|
||||
src := stripComments(t, string(data))
|
||||
if strings.Contains(src, "isProcEntry") {
|
||||
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin,不看 entry 值")
|
||||
}
|
||||
// entry 仅剩 Lua 判定这一处用途
|
||||
if !strings.Contains(src, "luaEntryFile") {
|
||||
t.Error("IsLua 应改用 luaEntryFile 常量")
|
||||
}
|
||||
}
|
||||
77
tools/hmapdev/replace.go
Normal file
77
tools/hmapdev/replace.go
Normal file
@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GoModPatcher struct {
|
||||
dir string
|
||||
replaces []string
|
||||
backup string
|
||||
}
|
||||
|
||||
func NewGoModPatcher(dir string, replaces []string) *GoModPatcher {
|
||||
return &GoModPatcher{dir: dir, replaces: replaces}
|
||||
}
|
||||
|
||||
func (p *GoModPatcher) Apply() (func(), error) {
|
||||
if len(p.replaces) == 0 {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
gomodPath := filepath.Join(p.dir, "go.mod")
|
||||
data, err := os.ReadFile(gomodPath)
|
||||
if err != nil {
|
||||
return func() {}, fmt.Errorf("read go.mod: %w", err)
|
||||
}
|
||||
p.backup = string(data)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(strings.TrimRight(string(data), "\r\n"))
|
||||
sb.WriteString("\n")
|
||||
for _, r := range p.replaces {
|
||||
from, to, found := strings.Cut(r, "=")
|
||||
if !found {
|
||||
fmt.Printf(" warn: invalid replace %q, skipping\n", r)
|
||||
continue
|
||||
}
|
||||
from = strings.TrimSpace(from)
|
||||
to = strings.TrimSpace(to)
|
||||
absTo, err := filepath.Abs(to)
|
||||
if err != nil {
|
||||
fmt.Printf(" warn: resolve path %q: %v, skipping\n", to, err)
|
||||
continue
|
||||
}
|
||||
absTo = strings.ReplaceAll(absTo, "\\", "/")
|
||||
sb.WriteString(fmt.Sprintf("replace %s => %s\n", from, absTo))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(gomodPath, []byte(sb.String()), 0644); err != nil {
|
||||
return func() {}, fmt.Errorf("write go.mod: %w", err)
|
||||
}
|
||||
|
||||
return p.restore, nil
|
||||
}
|
||||
|
||||
func (p *GoModPatcher) restore() {
|
||||
if p.backup == "" {
|
||||
return
|
||||
}
|
||||
gomodPath := filepath.Join(p.dir, "go.mod")
|
||||
os.WriteFile(gomodPath, []byte(p.backup), 0644)
|
||||
p.backup = ""
|
||||
}
|
||||
|
||||
func (p *GoModPatcher) ReplaceDirs() []string {
|
||||
var dirs []string
|
||||
for _, r := range p.replaces {
|
||||
_, to, found := strings.Cut(r, "=")
|
||||
if found {
|
||||
dirs = append(dirs, strings.TrimSpace(to))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
247
tools/hmapdev/stagediff_test.go
Normal file
247
tools/hmapdev/stagediff_test.go
Normal file
@ -0,0 +1,247 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 本测试验证 tmplLinuxBridge 中 snapshotWritable + changedFieldsOnly 的语义(plan.md 11.3)。
|
||||
// 模板字符串本身无法直接单测,这里以同一份逻辑复刻,防止回归。
|
||||
// ❗ 模板与本文件须同步修改。
|
||||
//
|
||||
// 关键陷阱(第一版实现踩过):stageContextWritable 返回的切片字段与 sc 共享底层数组,
|
||||
// handler 原地改元素时"before 快照"会跟着变,diff 看不到变更 → 修复静默失效。
|
||||
// 故 before 必须是**序列化后的字符串快照**。
|
||||
|
||||
func writable(sc *sdk.StageContext) map[string]interface{} {
|
||||
m := map[string]interface{}{
|
||||
"raw_message": sc.RawMessage,
|
||||
"user_id": sc.UserID,
|
||||
"group_id": sc.GroupID,
|
||||
"phase": string(sc.Phase),
|
||||
"llm_text": sc.LLMText,
|
||||
"final_text": sc.FinalText,
|
||||
"no_memory": sc.NoMemory,
|
||||
}
|
||||
if sc.Response != nil {
|
||||
m["response"] = *sc.Response
|
||||
}
|
||||
if len(sc.ToolCalls) > 0 {
|
||||
m["tool_calls"] = sc.ToolCalls
|
||||
}
|
||||
if len(sc.ToolResults) > 0 {
|
||||
m["tool_results"] = sc.ToolResults
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// snapshot 对应模板里的 snapshotWritable:逐字段序列化为不可变快照。
|
||||
func snapshot(sc *sdk.StageContext) map[string]string {
|
||||
snap := map[string]string{}
|
||||
for k, v := range writable(sc) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
snap[k] = string(b)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// diffOnly 对应模板里的 changedFieldsOnly。
|
||||
func diffOnly(before map[string]string, after map[string]interface{}) map[string]interface{} {
|
||||
diff := map[string]interface{}{}
|
||||
keys := map[string]bool{}
|
||||
for k := range before {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range after {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range keys {
|
||||
bRaw, bHas := before[k]
|
||||
a, aHas := after[k]
|
||||
switch {
|
||||
case aHas && !bHas:
|
||||
diff[k] = a
|
||||
case aHas && bHas:
|
||||
ab, _ := json.Marshal(a)
|
||||
if bRaw != string(ab) {
|
||||
diff[k] = a
|
||||
}
|
||||
case bHas && !aHas:
|
||||
switch k {
|
||||
case "tool_calls":
|
||||
diff[k] = []sdk.ToolCall{}
|
||||
case "tool_results":
|
||||
diff[k] = []sdk.ToolResult{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return diff
|
||||
}
|
||||
|
||||
// 只读插件(如 weather 的 AfterToolcall)不改任何字段 → 零回传。
|
||||
// 这是修复 lost update 的关键:旧实现会回传它收到的旧快照,覆盖 sanitizer 的清洗结果。
|
||||
func TestChangedFieldsOnly_ReadOnlyPluginReturnsNothing(t *testing.T) {
|
||||
sc := &sdk.StageContext{
|
||||
RawMessage: "hello",
|
||||
LLMText: "world",
|
||||
ToolResults: []sdk.ToolResult{
|
||||
{CallID: "c1", Name: "weather_query", Success: true, Result: "已清洗结果"},
|
||||
},
|
||||
}
|
||||
before := snapshot(sc)
|
||||
// 只读 handler:读了但没改
|
||||
_ = sc.ToolResults[0].Result
|
||||
diff := diffOnly(before, writable(sc))
|
||||
|
||||
if len(diff) != 0 {
|
||||
t.Fatalf("只读插件应零回传,实际回传 %d 个字段: %v", len(diff), diff)
|
||||
}
|
||||
}
|
||||
|
||||
// 改写插件(如 sanitizer 改 ToolResults)→ 只回传被改的字段。
|
||||
// ⚠️ 这里是原地改切片元素,正是共享底层数组陷阱的触发场景。
|
||||
func TestChangedFieldsOnly_WriterReturnsOnlyChanged(t *testing.T) {
|
||||
sc := &sdk.StageContext{
|
||||
RawMessage: "hello",
|
||||
LLMText: "world",
|
||||
ToolResults: []sdk.ToolResult{
|
||||
{CallID: "c1", Name: "weather_query", Success: true, Result: "带\x1b[31mANSI\x1b[0m脏数据"},
|
||||
},
|
||||
}
|
||||
before := snapshot(sc)
|
||||
// sanitizer handler:原地清洗 ToolResults
|
||||
sc.ToolResults[0].Result = "带ANSI脏数据"
|
||||
diff := diffOnly(before, writable(sc))
|
||||
|
||||
if len(diff) != 1 {
|
||||
t.Fatalf("应只回传 tool_results 一个字段,实际 %d 个: %v", len(diff), diff)
|
||||
}
|
||||
if _, ok := diff["tool_results"]; !ok {
|
||||
t.Fatalf("回传字段应为 tool_results,实际 %v", diff)
|
||||
}
|
||||
// raw_message / llm_text 未改,不应出现(否则会覆盖其他插件的改写)
|
||||
if _, ok := diff["raw_message"]; ok {
|
||||
t.Error("raw_message 未改却被回传(会覆盖其他插件的改写)")
|
||||
}
|
||||
if _, ok := diff["llm_text"]; ok {
|
||||
t.Error("llm_text 未改却被回传")
|
||||
}
|
||||
}
|
||||
|
||||
// 改写标量字段(如 before_output 改 FinalText)→ 只回传该字段。
|
||||
func TestChangedFieldsOnly_ScalarChange(t *testing.T) {
|
||||
sc := &sdk.StageContext{
|
||||
RawMessage: "hi",
|
||||
FinalText: " 带空白的回复 ",
|
||||
LLMText: "原始",
|
||||
}
|
||||
before := snapshot(sc)
|
||||
sc.FinalText = "带空白的回复"
|
||||
diff := diffOnly(before, writable(sc))
|
||||
|
||||
if len(diff) != 1 || diff["final_text"] != "带空白的回复" {
|
||||
t.Fatalf("应只回传 final_text,实际 %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// 首次设置 response(短路)→ 回传。
|
||||
func TestChangedFieldsOnly_NewResponseIsReturned(t *testing.T) {
|
||||
sc := &sdk.StageContext{RawMessage: "hi"}
|
||||
before := snapshot(sc)
|
||||
resp := "被插件短路"
|
||||
sc.Response = &resp
|
||||
diff := diffOnly(before, writable(sc))
|
||||
|
||||
if v, ok := diff["response"]; !ok || v != "被插件短路" {
|
||||
t.Fatalf("新设置的 response 应回传,实际 %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空切片字段 → 显式回传空值让内核跟随。
|
||||
func TestChangedFieldsOnly_ClearedSliceIsReturnedAsEmpty(t *testing.T) {
|
||||
sc := &sdk.StageContext{
|
||||
ToolCalls: []sdk.ToolCall{{ID: "t1", Name: "cmd_run"}},
|
||||
}
|
||||
before := snapshot(sc)
|
||||
sc.ToolCalls = nil // 插件拒绝了全部工具调用
|
||||
diff := diffOnly(before, writable(sc))
|
||||
|
||||
v, ok := diff["tool_calls"]
|
||||
if !ok {
|
||||
t.Fatalf("清空 tool_calls 应显式回传空值,实际 %v", diff)
|
||||
}
|
||||
if arr, _ := v.([]sdk.ToolCall); len(arr) != 0 {
|
||||
t.Fatalf("应回传空切片,实际 %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 复刻现网场景(实验 13):sanitizer 清洗后 weather 只读回传,清洗结果不得被覆盖。
|
||||
// 旧实现下 weather 会回传自己收到的旧快照(含脏数据),覆盖 sanitizer 的清洗(丢失率 1.6~4.3%)。
|
||||
func TestChangedFieldsOnly_ProductionScenarioNoOverwrite(t *testing.T) {
|
||||
dirty := "天气:晴 \x1b[31m28°C\x1b[0m"
|
||||
clean := "天气:晴 28°C"
|
||||
|
||||
// 内核下发的原始快照(两插件各拿到一份副本)
|
||||
kernelSnapshot := map[string]interface{}{
|
||||
"raw_message": "查天气",
|
||||
"llm_text": "",
|
||||
"final_text": "",
|
||||
"user_id": "u1",
|
||||
"group_id": "",
|
||||
"phase": "after_toolcall",
|
||||
"no_memory": false,
|
||||
"tool_results": []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||
}
|
||||
|
||||
// sanitizer 副本:清洗
|
||||
scSan := &sdk.StageContext{
|
||||
RawMessage: "查天气",
|
||||
UserID: "u1",
|
||||
Phase: sdk.StageAfterToolcall,
|
||||
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||
}
|
||||
beforeSan := snapshot(scSan)
|
||||
scSan.ToolResults[0].Result = clean
|
||||
diffSan := diffOnly(beforeSan, writable(scSan))
|
||||
|
||||
// weather 副本:只读,不改
|
||||
scWea := &sdk.StageContext{
|
||||
RawMessage: "查天气",
|
||||
UserID: "u1",
|
||||
Phase: sdk.StageAfterToolcall,
|
||||
ToolResults: []sdk.ToolResult{{CallID: "c1", Name: "weather_query", Result: dirty}},
|
||||
}
|
||||
beforeWea := snapshot(scWea)
|
||||
diffWea := diffOnly(beforeWea, writable(scWea))
|
||||
|
||||
// weather 必须零回传,否则它的旧快照会覆盖 sanitizer 的清洗
|
||||
if len(diffWea) != 0 {
|
||||
t.Fatalf("weather 只读却回传 %v —— 会覆盖 sanitizer 清洗结果", diffWea)
|
||||
}
|
||||
// sanitizer 必须回传 tool_results
|
||||
if _, ok := diffSan["tool_results"]; !ok {
|
||||
t.Fatalf("sanitizer 改写了 tool_results 却未回传:%v", diffSan)
|
||||
}
|
||||
|
||||
// 内核按 sanitizer → weather 顺序应用 diff(weather 后到,是最坏情形)
|
||||
kernel := map[string]interface{}{}
|
||||
for k, v := range kernelSnapshot {
|
||||
kernel[k] = v
|
||||
}
|
||||
for k, v := range diffSan {
|
||||
kernel[k] = v
|
||||
}
|
||||
for k, v := range diffWea {
|
||||
kernel[k] = v
|
||||
}
|
||||
|
||||
res, _ := kernel["tool_results"].([]sdk.ToolResult)
|
||||
if len(res) == 0 || res[0].Result != clean {
|
||||
t.Fatalf("清洗结果被覆盖:期望 %q,实际 %v", clean, kernel["tool_results"])
|
||||
}
|
||||
}
|
||||
520
tools/hmapdev/templates.go
Normal file
520
tools/hmapdev/templates.go
Normal file
@ -0,0 +1,520 @@
|
||||
package main
|
||||
|
||||
// tmplPlgJSON is the plg.json template
|
||||
const tmplPlgJSON = `{
|
||||
"name": "{{.Plg.Name}}",
|
||||
"name_zh": "{{.Plg.NameZh}}",
|
||||
"name_en": "{{.Plg.NameEn}}",
|
||||
"version": "{{.Plg.Version}}",
|
||||
"description": "{{.Plg.Description}}",
|
||||
"author": "{{.Plg.Author}}",
|
||||
"entry": "{{.Plg.Entry}}",
|
||||
"tags": [{{range $i, $t := .Plg.Tags}}{{if $i}}, {{end}}"{{$t}}"{{end}}],
|
||||
"targets": "{{.Plg.Targets}}"
|
||||
}
|
||||
`
|
||||
|
||||
const tmplGoMod = `module {{.ModulePath}}
|
||||
|
||||
go {{.GoVersion}}
|
||||
|
||||
require {{.SDKModule}} {{.SDKVersion}}
|
||||
{{if .SDKLocalPath}}
|
||||
// SDK 指向本机源码。gitcode 的模块不在 proxy.golang.org 上,
|
||||
// 没有这条 replace 就需要 go.sum 条目,而那个条目无处可拉。
|
||||
// 若你已有可访问的私有 proxy,可删掉本行。
|
||||
replace {{.SDKModule}} => {{.SDKLocalPath}}
|
||||
{{end}}`
|
||||
|
||||
const tmplPluginGo = `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) })
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
|
||||
DisplayName: "示例配置", Description: "An example configuration key",
|
||||
Category: "{{.Plg.Name}}",
|
||||
})
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||
Name: tp + "hello",
|
||||
Description: "A hello world tool",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
NoMemory: false, // 工具输出对 LLM 注意力有信号价值时为 false,纯操作工具为 true
|
||||
// Cleaner: func(output string) string {
|
||||
// // 工具输出参与向量化/jieba/蒸馏前,在此过滤噪音
|
||||
// return output
|
||||
// },
|
||||
}, p.handleHello)
|
||||
fmt.Printf("[%s] started\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { fmt.Printf("[%s] stopped\n", p.name); return nil }
|
||||
|
||||
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||
return map[string]interface{}{"content": "Hello from {{.Plg.Name}} plugin!"}, nil
|
||||
}
|
||||
|
||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
`
|
||||
|
||||
const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock)
|
||||
sdk = {}
|
||||
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
|
||||
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
|
||||
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
|
||||
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
|
||||
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
|
||||
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
|
||||
function sdk.get_setting(key) return nil end
|
||||
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
|
||||
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
|
||||
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
|
||||
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
|
||||
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
|
||||
sdk.memory = {}
|
||||
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
|
||||
function sdk.memory.commit(triples) return nil end
|
||||
function sdk.memory.introspect() return {} end
|
||||
function sdk.memory.merge(source, target) return 0 end
|
||||
function sdk.memory.purge(criteria, hard) return 0 end
|
||||
sdk.doc = {}
|
||||
function sdk.doc.query(text, top_k) return {} end
|
||||
function sdk.doc.insert(doc) return nil end
|
||||
function sdk.doc.remove(id) return nil end
|
||||
function sdk.doc.stats() return {} end
|
||||
sdk.knowledge = {}
|
||||
function sdk.knowledge.search(query, limit) return {} end
|
||||
function sdk.knowledge.add(tag, content) return nil end
|
||||
function sdk.knowledge.list() return {} end
|
||||
sdk.text_memory = {}
|
||||
function sdk.text_memory.append(evt) return nil end
|
||||
sdk.llm = {}
|
||||
function sdk.llm.list_sources() return {} end
|
||||
function sdk.llm.set_source(name) return nil end
|
||||
function sdk.llm.current_source() return nil end
|
||||
sdk.social = {}
|
||||
function sdk.social.get_person(name) return {} end
|
||||
function sdk.social.get_network(name, depth) return {} end
|
||||
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
|
||||
function sdk.social.get_relations(name) return {} end
|
||||
function sdk.social.list_persons() return {} end
|
||||
sdk.settings = {}
|
||||
function sdk.settings.get_core(key) return nil end
|
||||
function sdk.settings.set_core(key, value) return nil end
|
||||
function sdk.settings.list_core(prefix) return {} end
|
||||
function sdk.settings.get_plugin(plugin, key) return nil end
|
||||
function sdk.settings.set_plugin(plugin, key, value) return nil end
|
||||
function sdk.settings.list_plugin(plugin, prefix) return {} end
|
||||
function sdk.settings.list(prefix) return {} end
|
||||
function sdk.settings.register_def(def) return nil end
|
||||
function sdk.settings.defs(prefix) return {} end
|
||||
function sdk.settings.dump() return {} end
|
||||
function sdk.settings.plugins() return {} end
|
||||
sdk.json = {}
|
||||
function sdk.json.encode(val)
|
||||
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
|
||||
elseif type(val) == "table" then local parts, i = {}, 1
|
||||
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
|
||||
return "{" .. table.concat(parts, ",") .. "}" end
|
||||
return "null"
|
||||
end
|
||||
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
|
||||
sdk.http = {}
|
||||
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
|
||||
return sdk
|
||||
`
|
||||
|
||||
const tmplMainLua = `-- {{.Plg.Name}} plugin
|
||||
local plugin = { name = "{{.Plg.Name}}" }
|
||||
function plugin.start(sdk)
|
||||
sdk.log("info", "{{.Plg.Name}} starting...")
|
||||
sdk.register_tool("{{.Plg.Name}}_hello", {
|
||||
description = "A hello world tool",
|
||||
parameters = { type = "object", properties = {} }
|
||||
}, function(args) return { content = "Hello from {{.Plg.Name}} plugin!" } end)
|
||||
sdk.log("info", "{{.Plg.Name}} started")
|
||||
end
|
||||
function plugin.stop() sdk.log("info", "{{.Plg.Name}} stopped") end
|
||||
return plugin
|
||||
`
|
||||
|
||||
// ============================================================
|
||||
// Remote Device Adapter Templates
|
||||
// ============================================================
|
||||
|
||||
const tmplRemoteDeviceMain = `#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "ha_remotedevice.h"
|
||||
|
||||
/* ============================================================
|
||||
* {{.Plg.Name}} — Remote Device Adapter
|
||||
*
|
||||
* 声明式远程设备接入示例。
|
||||
* 用户只需实现:
|
||||
* 1. ha_transport_t 的 4 个函数
|
||||
* 2. 声明 handlers 表(设备支持哪些命令 + 对应的处理函数)
|
||||
* 其余协议细节(WS 握手、hello/bind、心跳、重连、命令分发、结果回执)由 SDK 自动处理。
|
||||
* ============================================================ */
|
||||
|
||||
/* ====================== 传输层实现 ======================
|
||||
*
|
||||
* 请为你的平台实现以下 4 个函数:
|
||||
* connect(ctx, host, port) — 建立 TCP 连接
|
||||
* send(ctx, data, len) — 发送数据
|
||||
* recv(ctx, buf, len) — 接收数据(阻塞,返回实际接收字节数)
|
||||
* close(ctx) — 关闭连接
|
||||
*
|
||||
* 示例:POSIX socket 实现
|
||||
*/
|
||||
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
/* Windows 平台需包含 winsock2.h */
|
||||
#error "Please implement transport for your platform (see example below)"
|
||||
#else
|
||||
/* POSIX (Linux, macOS, ESP-IDF, Zephyr, etc.) */
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
|
||||
struct transport_ctx {
|
||||
int sock;
|
||||
};
|
||||
|
||||
static int transport_connect(void *ctx, const char *host, uint16_t port) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
struct hostent *he = gethostbyname(host);
|
||||
if (!he) return -1;
|
||||
tc->sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (tc->sock < 0) return -1;
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length);
|
||||
if (connect(tc->sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
close(tc->sock);
|
||||
tc->sock = -1;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int transport_send(void *ctx, const uint8_t *data, int len) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
int sent = 0;
|
||||
while (sent < len) {
|
||||
int n = (int)send(tc->sock, data + sent, len - sent, 0);
|
||||
if (n <= 0) return -1;
|
||||
sent += n;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
static int transport_recv(void *ctx, uint8_t *buf, int len) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
int n = (int)recv(tc->sock, buf, len, 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
static void transport_close(void *ctx) {
|
||||
struct transport_ctx *tc = (struct transport_ctx *)ctx;
|
||||
if (tc->sock >= 0) {
|
||||
close(tc->sock);
|
||||
tc->sock = -1;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ====================== 声明式命令处理 ======================
|
||||
*
|
||||
* 每个命令对应一个处理函数,通过填写 ha_cmd_result_t 返回数据。
|
||||
* SDK 自动回执结果,无需手动调用 send_result。
|
||||
*
|
||||
* 返回方式:
|
||||
* 1. 文本输出:填写 result->output
|
||||
* 2. 二进制数据:设置 result->has_binary=1 并填写 binary_data/len/mime
|
||||
* 3. 错误:设置 result->status=1 并填写 result->error
|
||||
* 4. 返回 HA_OK 表示处理成功,其他值表示处理失败
|
||||
*/
|
||||
|
||||
/* ESP32-CAM 摄像头处理 */
|
||||
static ha_status_t handle_camerasue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
int duration = 0;
|
||||
if (args && args[0]) duration = atoi(args);
|
||||
printf("[camera] %s (duration=%ds)\n", duration ? "record" : "snapshot", duration);
|
||||
|
||||
/* 返回文本结果(base64 图片) */
|
||||
result->status = 0;
|
||||
result->output = "data:image/jpeg;base64,/9j/4AAQ...";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 屏幕截图处理 */
|
||||
static ha_status_t handle_screensee(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)args; (void)userdata;
|
||||
printf("[screen] screenshot\n");
|
||||
result->status = 0;
|
||||
result->output = "data:image/png;base64,iVBORw0KGgo...";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 语音播报处理 */
|
||||
static ha_status_t handle_speakeruse(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[speaker] TTS: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "speakeruse done";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 远程操控处理(computeruse) */
|
||||
static ha_status_t handle_computeruse(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
const char *action = NULL;
|
||||
const char *json_str = NULL;
|
||||
ha_cmd_parse_json(args, &action, &json_str);
|
||||
printf("[computeruse] action=%s\n", action ? action : "unknown");
|
||||
result->status = 0;
|
||||
result->output = "computeruse done";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 剪贴板读取 */
|
||||
static ha_status_t handle_clipboardsee(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)args; (void)userdata;
|
||||
result->status = 0;
|
||||
result->output = "clipboard content";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 剪贴板写入 */
|
||||
static ha_status_t handle_clipboardsue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[clipboard] write: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "clipboard written";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 屏幕显示 */
|
||||
static ha_status_t handle_screensue(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[screensue] show: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "screensue shown";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* Shell 命令处理 */
|
||||
static ha_status_t handle_shell(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)userdata;
|
||||
printf("[shell] cmd: %s\n", args ? args : "");
|
||||
result->status = 0;
|
||||
result->output = "shell output";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* 设备信息查询 */
|
||||
static ha_status_t handle_deviceinfo(const char *req_id, const char *args,
|
||||
ha_cmd_result_t *result, void *userdata) {
|
||||
(void)req_id; (void)args; (void)userdata;
|
||||
result->status = 0;
|
||||
result->output = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}";
|
||||
return HA_OK;
|
||||
}
|
||||
|
||||
/* ====================== 连接状态回调 ====================== */
|
||||
|
||||
static void on_state(int connected, void *userdata) {
|
||||
(void)userdata;
|
||||
printf("[devicelink] state: %s\n", connected ? "connected" : "disconnected");
|
||||
}
|
||||
|
||||
/* ====================== 主函数 ====================== */
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
/* 传输层上下文 */
|
||||
struct transport_ctx tctx;
|
||||
tctx.sock = -1;
|
||||
|
||||
ha_transport_t transport = {
|
||||
.connect = transport_connect,
|
||||
.send = transport_send,
|
||||
.recv = transport_recv,
|
||||
.close = transport_close,
|
||||
.ctx = &tctx,
|
||||
};
|
||||
|
||||
/* ===== 声明式设备配置 ===== */
|
||||
|
||||
/* 声明设备能力 */
|
||||
const char *caps[] = {
|
||||
"status", "cmdrun", "deviceinfo",
|
||||
"camerasue", "screensee", "speakeruse",
|
||||
"computeruse", "clipboardsee", "clipboardsue",
|
||||
"screensue",
|
||||
NULL
|
||||
};
|
||||
|
||||
/* 声明命令处理表:设备支持哪些命令,以及对应的处理函数 */
|
||||
ha_cmd_handler_def_t handlers[] = {
|
||||
{.command = "shell", .handler = handle_shell},
|
||||
{.command = "camerasue", .handler = handle_camerasue},
|
||||
{.command = "screensee", .handler = handle_screensee},
|
||||
{.command = "speakeruse", .handler = handle_speakeruse},
|
||||
{.command = "computeruse", .handler = handle_computeruse},
|
||||
{.command = "clipboardsee", .handler = handle_clipboardsee},
|
||||
{.command = "clipboardsue", .handler = handle_clipboardsue},
|
||||
{.command = "screensue", .handler = handle_screensue},
|
||||
{.command = "deviceinfo", .handler = handle_deviceinfo},
|
||||
{.command = NULL}, /* 标记结束 */
|
||||
};
|
||||
|
||||
ha_config_t config = {
|
||||
.transport = transport,
|
||||
.server = "127.0.0.1:9890",
|
||||
.token = "your-token-here",
|
||||
.device = {
|
||||
.device_id = "{{.Plg.Name}}",
|
||||
.name = "{{.Plg.NameEn}}",
|
||||
.kind = "computer",
|
||||
.caps = caps,
|
||||
.info_json = "{\"platform\":\"linux\",\"arch\":\"x86_64\"}",
|
||||
},
|
||||
.handlers = handlers, /* 声明式命令处理表 */
|
||||
.on_state = on_state,
|
||||
.ping_interval = 30,
|
||||
};
|
||||
|
||||
ha_client_t *client = ha_client_new(&config);
|
||||
if (!client) {
|
||||
fprintf(stderr, "Failed to create client\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Starting remote device adapter: {{.Plg.Name}}\n");
|
||||
printf(" Server: %s\n", config.server);
|
||||
printf(" Device ID: %s\n", config.device.device_id);
|
||||
printf(" Kind: %s\n", config.device.kind);
|
||||
printf(" Caps: ");
|
||||
for (const char **p = caps; *p; p++) printf("%s ", *p);
|
||||
printf("\n");
|
||||
|
||||
ha_status_t st = ha_client_start(client);
|
||||
if (st != HA_OK) {
|
||||
fprintf(stderr, "Failed to connect: %d\n", st);
|
||||
ha_client_destroy(client);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Connected! Entering main loop...\n");
|
||||
|
||||
/* 主循环 */
|
||||
while (1) {
|
||||
ha_status_t st = ha_client_process(client);
|
||||
if (st == HA_ERR_DISCONNECTED) {
|
||||
printf("Disconnected, exiting.\n");
|
||||
break;
|
||||
}
|
||||
#if defined(_WIN32) || defined(_WIN64)
|
||||
Sleep(10);
|
||||
#else
|
||||
usleep(10000);
|
||||
#endif
|
||||
}
|
||||
|
||||
ha_client_stop(client);
|
||||
ha_client_destroy(client);
|
||||
return 0;
|
||||
}
|
||||
`
|
||||
|
||||
const tmplRemoteDeviceCMake = `cmake_minimum_required(VERSION 3.10)
|
||||
project({{.Plg.Name}} VERSION 0.1.0 LANGUAGES C)
|
||||
|
||||
# ============================================================
|
||||
# {{.Plg.Name}} — Remote Device Adapter
|
||||
# ============================================================
|
||||
|
||||
# 设置 SDK 路径(默认使用内置 SDK,也可通过 -DSDK_PATH=... 指定)
|
||||
set(SDK_PATH "${CMAKE_CURRENT_SOURCE_DIR}/ha_remotedevice"
|
||||
CACHE PATH "Path to ha_remotedevice SDK")
|
||||
|
||||
# 添加 SDK 子目录
|
||||
if(EXISTS "${SDK_PATH}/CMakeLists.txt")
|
||||
add_subdirectory(${SDK_PATH} ha_remotedevice)
|
||||
else()
|
||||
message(FATAL_ERROR "ha_remotedevice SDK not found at ${SDK_PATH}")
|
||||
endif()
|
||||
|
||||
# 创建设备适配器可执行文件
|
||||
add_executable(${PROJECT_NAME}
|
||||
main.c
|
||||
)
|
||||
|
||||
# 链接 SDK
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE ha_remotedevice)
|
||||
|
||||
# 包含 SDK 头文件
|
||||
target_include_directories(${PROJECT_NAME} PRIVATE
|
||||
${HA_REMOTEDEVICE_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
# 编译选项
|
||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(${PROJECT_NAME} PRIVATE
|
||||
-Wall -Wextra -Wpedantic
|
||||
-Wno-unused-parameter
|
||||
)
|
||||
endif()
|
||||
|
||||
# 安装
|
||||
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin)
|
||||
`
|
||||
|
||||
const tmplReadme = `# {{.Plg.Name}}
|
||||
|
||||
{{.Plg.Description}}
|
||||
|
||||
## Build
|
||||
|
||||
` + "```bash" + `
|
||||
hmapdev build
|
||||
` + "```" + `
|
||||
|
||||
## Install
|
||||
|
||||
Upload the .hmap file through the Plugin Manager API.
|
||||
`
|
||||
24
tools/hmapdev/templates/README.md.tmpl
Normal file
24
tools/hmapdev/templates/README.md.tmpl
Normal file
@ -0,0 +1,24 @@
|
||||
# {{.Plg.Name}}
|
||||
|
||||
{{.Plg.Description}}
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Upload the `.hmap` file through the Plugin Manager API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/plugins \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @dist/<name_en_snake>_linux_amd64.hmap
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `RegisterStopHandler` — runs on every stop (including reload/disable), before `Stop()`.
|
||||
- `RegisterOnRemoveHandler` — runs **only on uninstall (remove)**, after `Stop()`; clean up the plugin's own data files here. Reload/disable do NOT trigger it. See the onRemove demo in `main.go`.
|
||||
61
tools/hmapdev/templates/main.go.tmpl
Normal file
61
tools/hmapdev/templates/main.go.tmpl
Normal file
@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.sdk = s
|
||||
|
||||
s.RegisterStopHandler(func() {
|
||||
fmt.Printf("[%s] stop handler running\n", p.name)
|
||||
})
|
||||
s.RegisterOnRemoveHandler(func() {
|
||||
fmt.Printf("[%s] onRemove handler running\n", p.name)
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.{{.Plg.Name}}.example",
|
||||
Default: "hello",
|
||||
Type: "string",
|
||||
DisplayName: "示例配置",
|
||||
Description: "An example configuration key",
|
||||
Category: "{{.Plg.Name}}",
|
||||
})
|
||||
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||
Name: tp + "hello",
|
||||
Description: "A hello world tool",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleHello)
|
||||
|
||||
fmt.Printf("[%s] started\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
fmt.Printf("[%s] stopped\n", p.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleHello(args map[string]interface{}) (interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"content": "Hello from {{.Plg.Name}} plugin!",
|
||||
}, nil
|
||||
}
|
||||
45
tools/hmapdev/templates/main.lua.tmpl
Normal file
45
tools/hmapdev/templates/main.lua.tmpl
Normal file
@ -0,0 +1,45 @@
|
||||
local plugin = {
|
||||
name = "{{.Plg.Name}}",
|
||||
tools = {},
|
||||
stages = {},
|
||||
settings = {}
|
||||
}
|
||||
|
||||
function plugin:start(sdk)
|
||||
sdk.log("{{.Plg.Name}} plugin starting...")
|
||||
|
||||
-- Register a configuration setting
|
||||
-- sdk.settings.register({
|
||||
-- key = "{{.Plg.Name}}.example",
|
||||
-- default = "hello",
|
||||
-- type = "string",
|
||||
-- display_name = "Example Config",
|
||||
-- description = "An example configuration key"
|
||||
-- })
|
||||
|
||||
-- Register a tool
|
||||
local ok = sdk:register_tool("{{.Plg.Name}}_hello", {
|
||||
name = "{{.Plg.Name}}_hello",
|
||||
description = "A hello world tool",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {}
|
||||
}
|
||||
}, function(args)
|
||||
return { content = "Hello from {{.Plg.Name}} plugin!" }
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
sdk.log("error: failed to register tool")
|
||||
return false
|
||||
end
|
||||
|
||||
sdk.log("{{.Plg.Name}} plugin started")
|
||||
return true
|
||||
end
|
||||
|
||||
function plugin:stop()
|
||||
return true
|
||||
end
|
||||
|
||||
return plugin
|
||||
11
tools/hmapdev/templates/plg.json.tmpl
Normal file
11
tools/hmapdev/templates/plg.json.tmpl
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "{{.Plg.Name}}",
|
||||
"name_zh": "{{.Plg.NameZh}}",
|
||||
"name_en": "{{.Plg.NameEn}}",
|
||||
"version": "{{.Plg.Version}}",
|
||||
"description": "{{.Plg.Description}}",
|
||||
"author": "{{.Plg.Author}}",
|
||||
"entry": "{{.Plg.Entry}}",
|
||||
"tags": {{.Plg.Tags}},
|
||||
"targets": "{{.Plg.Targets}}"
|
||||
}
|
||||
1767
tools/hmapdev/templates/proc_main.go.tmpl
Normal file
1767
tools/hmapdev/templates/proc_main.go.tmpl
Normal file
File diff suppressed because it is too large
Load Diff
54
tools/hmapdev/templates/proc_shm_unix.go.tmpl
Normal file
54
tools/hmapdev/templates/proc_shm_unix.go.tmpl
Normal file
@ -0,0 +1,54 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Unix 侧共享段挂载:内核经 ExtraFiles 传入继承的 fd。
|
||||
//
|
||||
// 统一共享内存区域布局(§13.1):
|
||||
//
|
||||
// fd 3 = 统一区域(SuperBlock + StageContext + EvtRing)
|
||||
// fd 4 = 事件通知(Linux eventfd / macOS pipe 读端)
|
||||
//
|
||||
// 继承的 fd 无需文件名,也不残留——这是选 memfd 而非 /dev/shm 的原因。
|
||||
const (
|
||||
fdUnifiedShm = 3
|
||||
fdEvtNotifier = 4
|
||||
)
|
||||
|
||||
// attachUnifiedShm 挂载统一共享内存区域。
|
||||
//
|
||||
// 各进程 mmap 到不同虚拟地址,段内一律用相对偏移而非指针,故仍能正确解引用
|
||||
// (实验 2 已验证父子 mmap 基址不同时偏移解引用正确)。
|
||||
func attachUnifiedShm(size int) ([]byte, error) {
|
||||
return syscall.Mmap(fdUnifiedShm, 0, size,
|
||||
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
|
||||
}
|
||||
|
||||
// openEvtNotifier 打开事件通知读端。
|
||||
func openEvtNotifier() (evtWaiter, error) {
|
||||
f := os.NewFile(fdEvtNotifier, "evtnotify")
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("fd %d 不是有效的通知句柄", fdEvtNotifier)
|
||||
}
|
||||
return &unixEvtWaiter{f: f}, nil
|
||||
}
|
||||
|
||||
// unixEvtWaiter 用 eventfd/pipe 的阻塞 Read 等待通知。
|
||||
//
|
||||
// os.NewFile 把 fd 注册进 runtime netpoller,Read 阻塞时只 park goroutine,
|
||||
// 不占 OS 线程(实验 1:200 个等待者仅增 1 个 OS 线程)。
|
||||
// 反面对照是经 cgo 调 sem_wait——那会阻塞整个 M。
|
||||
type unixEvtWaiter struct {
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func (w *unixEvtWaiter) Wait(buf []byte) error {
|
||||
_, err := w.f.Read(buf)
|
||||
return err
|
||||
}
|
||||
153
tools/hmapdev/templates/proc_shm_windows.go.tmpl
Normal file
153
tools/hmapdev/templates/proc_shm_windows.go.tmpl
Normal file
@ -0,0 +1,153 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Windows 侧共享段挂载:走命名对象而非继承 fd。
|
||||
//
|
||||
// 为何不能照抄 Unix:Windows 没有 fd 继承语义,`ExtraFiles` 在 os/exec 的
|
||||
// Windows 实现里不被支持。等价机制是命名内核对象——父进程用
|
||||
// CreateFileMapping / CreateEvent 建带名字的对象,子进程按同名 Open 拿到同一对象。
|
||||
//
|
||||
// 名字经环境变量传入(内核 internal/plugin/proc/plugin_windows.go 设置),
|
||||
// 而不是硬编码:多个 homed 实例并存时不能撞名。
|
||||
//
|
||||
// **这是 §9.2 的正解**:C ABI 时代 Windows 是第三套独立 ABI 实现,
|
||||
// stage 只下发 3 个字段且完全没有写回,sanitizer 这类改写型插件静默失效。
|
||||
// 现在 Windows 与 Unix 共用同一份 RPC 逻辑与同一份共享段布局,
|
||||
// 差异被收敛到本文件的三个函数里。
|
||||
const (
|
||||
envStageShmName = "HOMEAGENT_SHM_STAGE"
|
||||
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
|
||||
envEvtEventName = "HOMEAGENT_EVT_EVENT"
|
||||
)
|
||||
|
||||
// Windows API 绑定:用 LazyDLL 而非 golang.org/x/sys/windows。
|
||||
//
|
||||
// 原因:OpenFileMappingW / OpenEventW 未被标准库 syscall 包导出。
|
||||
// 引入 x/sys 会给**每个插件的 go.mod 加一个新依赖**,
|
||||
// 而「外部插件零改动」是本次迁移的硬约束(插件仅依赖公开 SDK)。
|
||||
// LazyDLL 属于标准库 syscall,零新增依赖。
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procOpenFileMappingW = kernel32.NewProc("OpenFileMappingW")
|
||||
procOpenEventW = kernel32.NewProc("OpenEventW")
|
||||
)
|
||||
|
||||
const (
|
||||
winEventModifyState = 0x0002
|
||||
winSynchronize = 0x00100000
|
||||
)
|
||||
|
||||
// openFileMappingW 封装 OpenFileMappingW。
|
||||
func openFileMappingW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
|
||||
var inheritFlag uintptr
|
||||
if inherit {
|
||||
inheritFlag = 1
|
||||
}
|
||||
r, _, err := procOpenFileMappingW.Call(
|
||||
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
|
||||
if r == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return syscall.Handle(r), nil
|
||||
}
|
||||
|
||||
// openEventW 封装 OpenEventW。
|
||||
func openEventW(access uint32, inherit bool, name *uint16) (syscall.Handle, error) {
|
||||
var inheritFlag uintptr
|
||||
if inherit {
|
||||
inheritFlag = 1
|
||||
}
|
||||
r, _, err := procOpenEventW.Call(
|
||||
uintptr(access), inheritFlag, uintptr(unsafe.Pointer(name)))
|
||||
if r == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return syscall.Handle(r), nil
|
||||
}
|
||||
|
||||
// attachStageShm 按名字打开 StageContext 段并映射。
|
||||
func attachStageShm(size int) ([]byte, error) {
|
||||
return openNamedMapping(os.Getenv(envStageShmName), size, "StageContext 段")
|
||||
}
|
||||
|
||||
// attachEvtRingShm 按名字打开事件环段并映射。
|
||||
func attachEvtRingShm(size int) ([]byte, error) {
|
||||
return openNamedMapping(os.Getenv(envEvtRingName), size, "事件环段")
|
||||
}
|
||||
|
||||
// openNamedMapping 打开命名共享段并映射为 []byte。
|
||||
//
|
||||
// 与 Unix 的 mmap 语义对齐:MapViewOfFile 返回的地址在本进程虚拟空间,
|
||||
// 段内偏移仍是相对的,故跨进程解引用正确。
|
||||
func openNamedMapping(name string, size int, what string) ([]byte, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%s 名字未经环境变量传入", what)
|
||||
}
|
||||
namePtr, err := syscall.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 名字非法: %w", what, err)
|
||||
}
|
||||
|
||||
h, err := openFileMappingW(syscall.FILE_MAP_WRITE, false, namePtr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开 %s(%s): %w", what, name, err)
|
||||
}
|
||||
|
||||
addr, err := syscall.MapViewOfFile(h, syscall.FILE_MAP_WRITE, 0, 0, uintptr(size))
|
||||
if err != nil {
|
||||
syscall.CloseHandle(h)
|
||||
return nil, fmt.Errorf("映射 %s: %w", what, err)
|
||||
}
|
||||
// 句柄不关:视图存活期间必须保持句柄有效,进程退出时由 OS 回收。
|
||||
|
||||
return unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), nil
|
||||
}
|
||||
|
||||
// openEvtNotifier 按名字打开事件通知对象。
|
||||
func openEvtNotifier() (evtWaiter, error) {
|
||||
name := os.Getenv(envEvtEventName)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("事件通知对象名字未经环境变量传入")
|
||||
}
|
||||
namePtr, err := syscall.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("事件对象名字非法: %w", err)
|
||||
}
|
||||
h, err := openEventW(winSynchronize|winEventModifyState, false, namePtr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开事件对象(%s): %w", name, err)
|
||||
}
|
||||
return &windowsEvtWaiter{h: h}, nil
|
||||
}
|
||||
|
||||
// windowsEvtWaiter 用命名 Event 对象等待通知。
|
||||
//
|
||||
// 与 eventfd 的差异:Event 是二元信号而非计数器,多次 SetEvent 只对应
|
||||
// 一次唤醒。这不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq
|
||||
// 批量 drain,一次唤醒能处理累积的全部事件。
|
||||
//
|
||||
// WaitForSingleObject 阻塞的是 OS 线程而非仅 goroutine,故不如 eventfd
|
||||
// 的 netpoller 路径省线程。每插件一个消费 goroutine,17 插件即 17 线程,
|
||||
// 在可接受范围(实验 5 实测 17 子进程共 84 线程)。
|
||||
type windowsEvtWaiter struct {
|
||||
h syscall.Handle
|
||||
}
|
||||
|
||||
func (w *windowsEvtWaiter) Wait(buf []byte) error {
|
||||
ev, err := syscall.WaitForSingleObject(w.h, syscall.INFINITE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev != syscall.WAIT_OBJECT_0 {
|
||||
return fmt.Errorf("等待事件对象返回 0x%x", ev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
279
tools/hmapdev/yaegi/interp.go
Normal file
279
tools/hmapdev/yaegi/interp.go
Normal file
@ -0,0 +1,279 @@
|
||||
package yaegi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/tools/hmapdev/yaegi/mocksdk"
|
||||
)
|
||||
|
||||
type YaegiDebugger struct {
|
||||
Dir string
|
||||
PluginName string
|
||||
interp *interp.Interpreter
|
||||
}
|
||||
|
||||
func findSDKGoPath(pluginDir string) string {
|
||||
// Try to find SDK root from plugin's go.mod replace directive
|
||||
gm := filepath.Join(pluginDir, "go.mod")
|
||||
if data, err := os.ReadFile(gm); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "replace ") && strings.Contains(line, "homeagent-sdk") {
|
||||
parts := strings.Fields(line)
|
||||
for _, p := range parts {
|
||||
if strings.Contains(p, "homeagent-sdk") && strings.Contains(p, string(filepath.Separator)) {
|
||||
return filepath.Dir(filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: check common relative locations
|
||||
candidates := []string{
|
||||
filepath.Join(pluginDir, "..", ".."),
|
||||
filepath.Join(pluginDir, "..", "..", ".."),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
abs, _ := filepath.Abs(c)
|
||||
if _, err := os.Stat(filepath.Join(abs, "homeagentsdk", "go.mod")); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findModuleDir(dir string) string {
|
||||
abs, _ := filepath.Abs(dir)
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(abs, "go.mod")); err == nil {
|
||||
return abs
|
||||
}
|
||||
parent := filepath.Dir(abs)
|
||||
if parent == abs {
|
||||
return ""
|
||||
}
|
||||
abs = parent
|
||||
}
|
||||
}
|
||||
|
||||
func NewYaegiDebugger(dir string, replaces []string) (*YaegiDebugger, error) {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve dir: %w", err)
|
||||
}
|
||||
|
||||
goPath := findSDKGoPath(dir)
|
||||
// Add replace target directories to GoPath for Yaegi resolution
|
||||
for _, r := range replaces {
|
||||
_, to, found := strings.Cut(r, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
to = strings.TrimSpace(to)
|
||||
absTo, err := filepath.Abs(to)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
absTo = strings.ReplaceAll(absTo, "\\", "/")
|
||||
// Walk up to find module root (contains go.mod)
|
||||
modDir := findModuleDir(absTo)
|
||||
if modDir != "" {
|
||||
parent := filepath.Dir(modDir)
|
||||
if goPath == "" {
|
||||
goPath = parent
|
||||
} else if !strings.Contains(goPath, parent) {
|
||||
goPath += string(os.PathListSeparator) + parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
i := interp.New(interp.Options{
|
||||
GoPath: goPath,
|
||||
})
|
||||
i.Use(stdlib.Symbols)
|
||||
|
||||
sdkExports := make(interp.Exports)
|
||||
pkg := make(map[string]reflect.Value)
|
||||
pkg["New"] = reflect.ValueOf(mocksdk.New)
|
||||
pkg["NewPluginSDK"] = reflect.ValueOf(mocksdk.NewPluginSDK)
|
||||
pkg["StageOnInput"] = reflect.ValueOf(mocksdk.StageOnInput)
|
||||
pkg["StagePreAction"] = reflect.ValueOf(mocksdk.StagePreAction)
|
||||
pkg["StagePostAction"] = reflect.ValueOf(mocksdk.StagePostAction)
|
||||
pkg["StageBeforeToolcall"] = reflect.ValueOf(mocksdk.StageBeforeToolcall)
|
||||
pkg["StageAfterToolcall"] = reflect.ValueOf(mocksdk.StageAfterToolcall)
|
||||
pkg["StageBeforeOutput"] = reflect.ValueOf(mocksdk.StageBeforeOutput)
|
||||
pkg["StageAfterOutput"] = reflect.ValueOf(mocksdk.StageAfterOutput)
|
||||
pkg["StageScopeGlobal"] = reflect.ValueOf(mocksdk.StageScopeGlobal)
|
||||
pkg["StageScopeOwnTools"] = reflect.ValueOf(mocksdk.StageScopeOwnTools)
|
||||
|
||||
typeRegistry := []interface{}{
|
||||
(*mocksdk.PluginSDK)(nil),
|
||||
(*mocksdk.Plugin)(nil),
|
||||
mocksdk.ToolDef{},
|
||||
mocksdk.ToolHandler(nil),
|
||||
mocksdk.StageHandler(nil),
|
||||
(*mocksdk.StageContext)(nil),
|
||||
mocksdk.Stage(""),
|
||||
mocksdk.ConfigDef{},
|
||||
mocksdk.Entity{},
|
||||
mocksdk.Relation{},
|
||||
mocksdk.Triple{},
|
||||
(*mocksdk.Doc)(nil),
|
||||
mocksdk.TextEvent{},
|
||||
(*mocksdk.Knowledge)(nil),
|
||||
(*mocksdk.PersonProfile)(nil),
|
||||
mocksdk.SocialRelation{},
|
||||
mocksdk.MemItem{},
|
||||
mocksdk.ToolCall{},
|
||||
mocksdk.ToolResult{},
|
||||
(*mocksdk.SettingsAPI)(nil),
|
||||
(*mocksdk.MemoryAPI)(nil),
|
||||
(*mocksdk.DocMemoryAPI)(nil),
|
||||
(*mocksdk.TextMemoryAPI)(nil),
|
||||
(*mocksdk.KnowledgeAPI)(nil),
|
||||
(*mocksdk.SocialAPI)(nil),
|
||||
(*mocksdk.LLMAPI)(nil),
|
||||
(*mocksdk.IOInjector)(nil),
|
||||
}
|
||||
|
||||
for _, t := range typeRegistry {
|
||||
rt := reflect.TypeOf(t)
|
||||
name := rt.Name()
|
||||
if name == "" {
|
||||
name = rt.Elem().Name()
|
||||
}
|
||||
pkg[name] = reflect.ValueOf(t)
|
||||
}
|
||||
|
||||
sdkExports["gitcode.com/JianFeeeee/homeagent-sdk/sdk"] = pkg
|
||||
i.Use(sdkExports)
|
||||
|
||||
return &YaegiDebugger{
|
||||
Dir: absDir,
|
||||
PluginName: filepath.Base(absDir),
|
||||
interp: i,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *YaegiDebugger) LoadPlugin() error {
|
||||
entries, err := os.ReadDir(d.Dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
if entry.Name() == "debug_main.go" {
|
||||
continue
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(filepath.Join(d.Dir, entry.Name()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
_, err = d.interp.Eval(string(src))
|
||||
if err != nil {
|
||||
return fmt.Errorf("eval %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *YaegiDebugger) HasNewPluginFactory() (bool, error) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, d.Dir, nil, 0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, pkg := range pkgs {
|
||||
for _, f := range pkg.Files {
|
||||
for _, decl := range f.Decls {
|
||||
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "NewPluginFactory" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *YaegiDebugger) StartREPL() error {
|
||||
hasFactory, _ := d.HasNewPluginFactory()
|
||||
|
||||
if hasFactory {
|
||||
v, err := d.interp.Eval(fmt.Sprintf(`NewPluginFactory("%s", nil)`, d.PluginName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("call NewPluginFactory: %w", err)
|
||||
}
|
||||
|
||||
plugin := v.Interface().(mocksdk.Plugin)
|
||||
fmt.Printf("[debug] Plugin: %s\n", plugin.Name())
|
||||
|
||||
sdk := mocksdk.New(d.PluginName)
|
||||
if err := plugin.Start(sdk); err != nil {
|
||||
return fmt.Errorf("plugin.Start: %w", err)
|
||||
}
|
||||
fmt.Printf("[debug] Plugin started. Registered %d tools.\n", len(sdk.ListTools()))
|
||||
for _, def := range sdk.ListTools() {
|
||||
fmt.Printf(" - %s: %s\n", def.Name, def.Description)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Yaegi REPL ===")
|
||||
fmt.Println("Type Go expressions, 'tools' to list, 'call <name> <json>' to invoke, 'exit' to quit.")
|
||||
fmt.Println()
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for {
|
||||
fmt.Print("> ")
|
||||
if !scanner.Scan() {
|
||||
break
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line == "exit" || line == "quit" || line == "q" {
|
||||
break
|
||||
}
|
||||
if line == "tools" {
|
||||
if hasFactory {
|
||||
v, _ := d.interp.Eval(fmt.Sprintf(`NewPluginFactory("%s", nil)`, d.PluginName))
|
||||
plugin := v.Interface().(mocksdk.Plugin)
|
||||
sdk := mocksdk.New(d.PluginName)
|
||||
plugin.Start(sdk)
|
||||
for _, def := range sdk.ListTools() {
|
||||
fmt.Printf(" %s: %s\n", def.Name, def.Description)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
v, err := d.interp.Eval(line)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
} else if v.IsValid() && v.CanInterface() {
|
||||
result := v.Interface()
|
||||
fmt.Printf("%+v\n", result)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
537
tools/hmapdev/yaegi/mocksdk/plugin.go
Normal file
537
tools/hmapdev/yaegi/mocksdk/plugin.go
Normal file
@ -0,0 +1,537 @@
|
||||
package mocksdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
DebugLog = false
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
if DebugLog {
|
||||
fmt.Fprintf(os.Stderr, "[mocksdk] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
type StageHandler func(ctx *StageContext) error
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageOnInput Stage = "on_input"
|
||||
StagePreAction Stage = "pre_action"
|
||||
StagePostAction Stage = "post_action"
|
||||
StageBeforeToolcall Stage = "before_toolcall"
|
||||
StageAfterToolcall Stage = "after_toolcall"
|
||||
StageBeforeOutput Stage = "before_output"
|
||||
StageAfterOutput Stage = "after_output"
|
||||
)
|
||||
|
||||
type StageContext struct {
|
||||
mu sync.RWMutex
|
||||
RawMessage string
|
||||
UserID string
|
||||
GroupID string
|
||||
ContextMsgs []map[string]interface{}
|
||||
LLMText string
|
||||
ReasoningContent string
|
||||
TokenUsage map[string]int
|
||||
ToolCalls []ToolCall
|
||||
ToolResults []ToolResult
|
||||
FinalText string
|
||||
Response *string
|
||||
Phase Stage
|
||||
Memory []MemItem
|
||||
NoMemory bool
|
||||
Extra map[string]interface{}
|
||||
Errors []string
|
||||
}
|
||||
|
||||
type MemItem struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
Cleaner func(string) string `json:"-"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
}
|
||||
|
||||
// 上下文策略取值,与公共 SDK 一致。
|
||||
const (
|
||||
ContextPolicyNone = "none"
|
||||
ContextPolicyPrune = "prune"
|
||||
)
|
||||
|
||||
// InjectOptions 与公共 SDK 同构:声明一次注入是否记入记忆、是否据此裁剪上下文、
|
||||
// 以及用哪个已注册的通道 cleaner 清洗注入内容。
|
||||
type InjectOptions struct {
|
||||
NoMemory bool
|
||||
ContextPolicy string
|
||||
CleanerName string
|
||||
}
|
||||
|
||||
type IOInjector interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
// InjectInputSync 注入输入事件并同步等待 agent 回复(无回复时返回空串)。
|
||||
// 通道类插件(qq / a2a 等)靠它完成「收到入站 → agent 处理 → 回复取回」闭环,
|
||||
// 而 mock 此前只有带 flags 的 InjectInputSyncOpts、没有这个零值糖——
|
||||
// 于是一个能在 plugin.bin 里编译通过、在 yaegi 下却调不通的方法就长住了。
|
||||
InjectInputSync(source, channel, text string) string
|
||||
// 1.1.0 媒体注入。与公共 SDK 同构:插件在 yaegi 下调得通的方法,
|
||||
// 编成 plugin.bin 后必须也调得通,否则调试期与真实运行行为不一致。
|
||||
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||
SetToolBlocks(blocks []ContentBlock)
|
||||
|
||||
// 1.2.0 带标志位的注入,与公共 SDK 同构。
|
||||
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
}
|
||||
|
||||
// ContentBlock 与公共 SDK 同构(OpenAI 多模态内容块格式)。
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||
AudioURL *AudioURL `json:"audio_url,omitempty"`
|
||||
}
|
||||
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type AudioURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
EventSystem EventType = "system"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type EventHandler func(evt *Event)
|
||||
|
||||
type EventSubscriber interface {
|
||||
Subscribe(eventType EventType, handler EventHandler) func()
|
||||
}
|
||||
|
||||
type StageScope int
|
||||
|
||||
const (
|
||||
StageScopeGlobal StageScope = 0
|
||||
StageScopeOwnTools StageScope = 1
|
||||
)
|
||||
|
||||
type ConfigDef struct {
|
||||
Key string `json:"key"`
|
||||
Default string `json:"default"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type SettingsAPI interface {
|
||||
Get(key string) (interface{}, error)
|
||||
Set(key string, value interface{}) error
|
||||
List(prefix string) ([]string, error)
|
||||
GetCore(key string) (interface{}, error)
|
||||
SetCore(key string, value interface{}) error
|
||||
ListCore(prefix string) ([]string, error)
|
||||
GetPlugin(plugin, key string) (interface{}, error)
|
||||
SetPlugin(plugin, key string, value interface{}) error
|
||||
ListPlugin(plugin, prefix string) ([]string, error)
|
||||
RegisterDef(def ConfigDef)
|
||||
Defs(prefix string) []*ConfigDef
|
||||
Dump() map[string]interface{}
|
||||
Plugins() []string
|
||||
}
|
||||
|
||||
type mockSettings struct{ data map[string]interface{} }
|
||||
|
||||
func (s *mockSettings) Get(key string) (interface{}, error) {
|
||||
v, ok := s.data[key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *mockSettings) Set(key string, value interface{}) error { s.data[key] = value; return nil }
|
||||
func (s *mockSettings) List(prefix string) ([]string, error) {
|
||||
var ks []string
|
||||
for k := range s.data {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
}
|
||||
return ks, nil
|
||||
}
|
||||
func (s *mockSettings) GetCore(key string) (interface{}, error) { return nil, nil }
|
||||
func (s *mockSettings) SetCore(key string, value interface{}) error { return nil }
|
||||
func (s *mockSettings) ListCore(prefix string) ([]string, error) { return nil, nil }
|
||||
func (s *mockSettings) GetPlugin(p, k string) (interface{}, error) { return nil, nil }
|
||||
func (s *mockSettings) SetPlugin(p, k string, v interface{}) error { return nil }
|
||||
func (s *mockSettings) ListPlugin(p, prefix string) ([]string, error) { return nil, nil }
|
||||
func (s *mockSettings) RegisterDef(def ConfigDef) {
|
||||
logf("config def: %s = %s", def.Key, def.Default)
|
||||
}
|
||||
func (s *mockSettings) Defs(prefix string) []*ConfigDef { return nil }
|
||||
func (s *mockSettings) Dump() map[string]interface{} { return s.data }
|
||||
func (s *mockSettings) Plugins() []string { return nil }
|
||||
|
||||
type Entity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Properties map[string]string `json:"properties,omitempty"`
|
||||
}
|
||||
|
||||
type Relation struct {
|
||||
Subject string `json:"subject"`
|
||||
Predicate string `json:"predicate"`
|
||||
Object string `json:"object"`
|
||||
}
|
||||
|
||||
// Triple 与公共 SDK 同构。
|
||||
//
|
||||
// ❗字段名曾是 `Predicate`,而公共 SDK 一直叫 `Relation`。
|
||||
// yaegi 解释器下插件写 `Relation:` 会报未知字段,写 `Predicate:` 则在
|
||||
// 编成 plugin.bin 时报错——谁都不对。没人发现是因为没有任何代码
|
||||
// 对着 mocksdk 编译,漂移不会被编译器抓到。
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
SentenceText string `json:"sentence_text,omitempty"`
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryAPI interface {
|
||||
Recall(q []string, depth int) ([]Entity, []Relation, error)
|
||||
Commit(triples []Triple) error
|
||||
Introspect() (map[string]interface{}, error)
|
||||
MergeEntities(source, target string) (int, error)
|
||||
Purge(conditions map[string]string, mode string) (int, error)
|
||||
}
|
||||
|
||||
type mockMemory struct{}
|
||||
|
||||
func (mockMemory) Recall(q []string, d int) ([]Entity, []Relation, error) { return nil, nil, nil }
|
||||
func (mockMemory) Commit(t []Triple) error { return nil }
|
||||
func (mockMemory) Introspect() (map[string]interface{}, error) { return map[string]interface{}{}, nil }
|
||||
func (mockMemory) MergeEntities(s, t string) (int, error) { return 0, nil }
|
||||
func (mockMemory) Purge(c map[string]string, m string) (int, error) { return 0, nil }
|
||||
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
// 1.1.0:媒体字段。与公共 SDK 保持同构,否则插件在 yaegi 下跑得通、
|
||||
// 编成 plugin.bin 却编不过(或反之)。
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// MediaAttachment 与公共 SDK 同构:写入时给 Data+MIME,引用已有内容时只给 Digest。
|
||||
type MediaAttachment struct {
|
||||
Digest string `json:"digest,omitempty"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type DocMemoryAPI interface {
|
||||
Query(text string, topK int) []*Doc
|
||||
Insert(doc *Doc) error
|
||||
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
|
||||
Remove(id string)
|
||||
Stats() map[string]interface{}
|
||||
}
|
||||
|
||||
type mockDocMemory struct{}
|
||||
|
||||
func (mockDocMemory) Query(t string, k int) []*Doc { return nil }
|
||||
func (mockDocMemory) Insert(doc *Doc) error { return nil }
|
||||
func (mockDocMemory) InsertWithMedia(doc *Doc, atts []MediaAttachment) error {
|
||||
logf("doc_insert_with_media: %d 份附件", len(atts))
|
||||
return nil
|
||||
}
|
||||
func (mockDocMemory) Remove(id string) {}
|
||||
func (mockDocMemory) Stats() map[string]interface{} { return nil }
|
||||
|
||||
type TextEvent struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Source string `json:"source"`
|
||||
// 1.1.0:附件。读回时内核从正文标记反解,写入时内核把标记并进正文。
|
||||
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type TextMemoryAPI interface {
|
||||
Append(evt TextEvent) error
|
||||
}
|
||||
|
||||
type mockTextMemory struct{}
|
||||
|
||||
func (mockTextMemory) Append(evt TextEvent) error { return nil }
|
||||
|
||||
type Knowledge struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type KnowledgeAPI interface {
|
||||
Search(query string, topK int) ([]*Knowledge, error)
|
||||
Add(name, content string) error
|
||||
List() ([]string, error)
|
||||
}
|
||||
|
||||
type mockKnowledge struct{}
|
||||
|
||||
func (mockKnowledge) Search(q string, k int) ([]*Knowledge, error) { return nil, nil }
|
||||
func (mockKnowledge) Add(n, c string) error { return nil }
|
||||
func (mockKnowledge) List() ([]string, error) { return nil, nil }
|
||||
|
||||
type PersonProfile struct {
|
||||
Name string `json:"name"`
|
||||
Traits map[string]string `json:"traits"`
|
||||
}
|
||||
|
||||
type SocialRelation struct {
|
||||
Target string `json:"target"`
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
|
||||
type SocialAPI interface {
|
||||
GetPerson(name string) (*PersonProfile, error)
|
||||
GetTrait(name, trait string) (string, bool)
|
||||
GetRelations(name string) ([]SocialRelation, error)
|
||||
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||
ListPersons() ([]string, error)
|
||||
}
|
||||
|
||||
type mockSocial struct{}
|
||||
|
||||
func (mockSocial) GetPerson(n string) (*PersonProfile, error) { return nil, nil }
|
||||
func (mockSocial) GetTrait(n, t string) (string, bool) { return "", false }
|
||||
func (mockSocial) GetRelations(name string) ([]SocialRelation, error) { return nil, nil }
|
||||
func (mockSocial) GetNetwork(n string, d int) ([]*PersonProfile, error) { return nil, nil }
|
||||
func (mockSocial) ListPersons() ([]string, error) { return nil, nil }
|
||||
|
||||
type LLMAPI interface {
|
||||
ListSources() []string
|
||||
SetSource(name string) error
|
||||
CurrentSource() string
|
||||
}
|
||||
|
||||
type mockLLM struct{}
|
||||
|
||||
func (mockLLM) ListSources() []string { return nil }
|
||||
func (mockLLM) SetSource(n string) error { return nil }
|
||||
func (mockLLM) CurrentSource() string { return "" }
|
||||
|
||||
type IOInjectorImpl struct{}
|
||||
|
||||
func (IOInjectorImpl) InjectInterruptText(source, channel, text string) {
|
||||
logf("inject_interrupt: source=%s channel=%s", source, channel)
|
||||
}
|
||||
func (IOInjectorImpl) InjectText(source, channel, text string) {
|
||||
logf("inject_text: source=%s channel=%s", source, channel)
|
||||
}
|
||||
func (IOInjectorImpl) InjectTextNoMemory(source, channel, text string) {
|
||||
logf("inject_text_no_memory: source=%s channel=%s", source, channel)
|
||||
}
|
||||
func (IOInjectorImpl) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
||||
logf("inject_input_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
|
||||
}
|
||||
func (IOInjectorImpl) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
||||
logf("inject_input_media_sync: source=%s channel=%s blocks=%d", source, channel, len(blocks))
|
||||
return ""
|
||||
}
|
||||
func (IOInjectorImpl) InjectInterruptMedia(source, channel, text string, blocks []ContentBlock) {
|
||||
logf("inject_interrupt_media: source=%s channel=%s blocks=%d", source, channel, len(blocks))
|
||||
}
|
||||
func (IOInjectorImpl) SetToolBlocks(blocks []ContentBlock) {
|
||||
logf("set_tool_blocks: blocks=%d", len(blocks))
|
||||
}
|
||||
|
||||
// ---- 带 InjectOptions 的注入 ----
|
||||
|
||||
func (IOInjectorImpl) InjectInputSync(source, channel, text string) string {
|
||||
logf("inject_sync: source=%s channel=%s", source, channel)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (IOInjectorImpl) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
logf("inject_text_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||
}
|
||||
func (IOInjectorImpl) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
logf("inject_interrupt_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||
}
|
||||
func (IOInjectorImpl) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
|
||||
logf("inject_sync_opts: source=%s channel=%s no_memory=%v policy=%s", source, channel, opts.NoMemory, opts.ContextPolicy)
|
||||
return ""
|
||||
}
|
||||
func (IOInjectorImpl) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||
logf("inject_input_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||
}
|
||||
func (IOInjectorImpl) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
|
||||
logf("inject_input_media_sync_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||
return ""
|
||||
}
|
||||
func (IOInjectorImpl) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||
logf("inject_interrupt_media_opts: source=%s channel=%s blocks=%d no_memory=%v policy=%s", source, channel, len(blocks), opts.NoMemory, opts.ContextPolicy)
|
||||
}
|
||||
|
||||
type PluginSDK struct {
|
||||
Name string
|
||||
mu sync.RWMutex
|
||||
toolDefs map[string]ToolDef
|
||||
toolHandlers map[string]ToolHandler
|
||||
stageHandlers map[string]StageHandler
|
||||
outChannels map[string]ToolHandler
|
||||
Settings SettingsAPI
|
||||
IO IOInjector
|
||||
}
|
||||
|
||||
func New(name string) *PluginSDK {
|
||||
return &PluginSDK{
|
||||
Name: name,
|
||||
toolDefs: make(map[string]ToolDef),
|
||||
toolHandlers: make(map[string]ToolHandler),
|
||||
stageHandlers: make(map[string]StageHandler),
|
||||
outChannels: make(map[string]ToolHandler),
|
||||
Settings: &mockSettings{data: map[string]interface{}{}},
|
||||
IO: IOInjectorImpl{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) {
|
||||
logf("register_tool: %s", name)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.toolDefs[name] = def
|
||||
s.toolHandlers[name] = handler
|
||||
}
|
||||
|
||||
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) {
|
||||
logf("register_stage: %s", string(stage))
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stageHandlers[string(stage)] = handler
|
||||
}
|
||||
|
||||
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) {
|
||||
logf("register_output_channel: %s", name)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.outChannels[name] = handler
|
||||
}
|
||||
|
||||
func (s *PluginSDK) RegisterPluginAPI(name string) {
|
||||
logf("register_api: %s", name)
|
||||
}
|
||||
|
||||
func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
handler, ok := s.toolHandlers[name]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool not found: %s", name)
|
||||
}
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error {
|
||||
s.mu.RLock()
|
||||
handler, ok := s.stageHandlers[stage]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return handler(ctx)
|
||||
}
|
||||
|
||||
func (s *PluginSDK) ListTools() []ToolDef {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
defs := make([]ToolDef, 0, len(s.toolDefs))
|
||||
for _, def := range s.toolDefs {
|
||||
defs = append(defs, def)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (s *PluginSDK) ListToolsJSON() string {
|
||||
defs := s.ListTools()
|
||||
b, _ := json.MarshalIndent(defs, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func NewPluginSDK(name string) *PluginSDK {
|
||||
return New(name)
|
||||
}
|
||||
Reference in New Issue
Block a user