mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
feat: complete P0/P1/P2 — WebUI SPA, OpenClaw sidecar+simulator, healthcheck auto-sched+perf
P0: WebUI重构
- 完整 SPA 仪表盘 (7标签页), //go:embed dashboard.html
P1: OpenClaw兼容 (三通道: SKILL.md / sidecar / simulator)
- Node.js 模拟进程统一加载任意 OpenClaw 插件
- JSON-RPC 2.0 over stdio 协议, go:embed 内嵌
P2: Healthcheck 优化
- 定时自动执行 (startAutoCheck, 30min)
- healthcheck_perf 性能监控工具
其他: agentcli/cmd 插件, integration_test, status.go,
test_deepseek 清理, 多项 bug 修复
This commit is contained in:
85
MILESTONE.md
Normal file
85
MILESTONE.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# MILESTONE — 项目进展与路线图
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
### 插件系统基础
|
||||||
|
- [x] agentcli 插件:6 个 PTY 终端工具(create/write/read/resize/close/list),完整按键映射
|
||||||
|
- [x] cmd 插件:`cmd_run` 工具(command/timeout/workdir),8 单元测试
|
||||||
|
- [x] timer 插件:定时器工具
|
||||||
|
- [x] 12 个跨插件集成测试
|
||||||
|
|
||||||
|
### 非记忆 LLM 调用机制
|
||||||
|
- [x] `StageContext.NoMemory` 标记 — agent 跳过 `emitMemoryCandidate`
|
||||||
|
- [x] `InjectTextNoMemoryTo` / `InjectTextSyncNoMemoryTo` — IOManager 层
|
||||||
|
- [x] `InjectTextNoMemory` / `InjectTextSyncNoMemory` — PluginSDK 层
|
||||||
|
- [x] `ProviderManager.QuickChat()` — 直连 Provider 的快捷调用
|
||||||
|
|
||||||
|
### LLM 驱动健康检查(`healthcheck` 插件)
|
||||||
|
- [x] `healthcheck_report` 工具 — LLM 上报测试结果
|
||||||
|
- [x] `testLLMDriven()` — 独立工具循环:LLM 发现→调用→上报
|
||||||
|
- [x] 动态排除本插件工具(`selfToolNames`),不硬编码插件名
|
||||||
|
- [x] E2E 真实 DeepSeek API 测试通过(8 个真实工具全部测试通过)
|
||||||
|
|
||||||
|
### 内核状态接口(`StatusProvider`)
|
||||||
|
- [x] `internal/agent/core/status.go` — `KernelStatus` 聚合快照
|
||||||
|
- [x] `Agent.GetKernelStatus()` — 实现 `StatusProvider` 接口
|
||||||
|
- [x] `healthcheck_kernel` 工具 — Agent 可自主查询内核状态
|
||||||
|
- [x] WebUI `/api/v1/kernel` 端点 — 状态 JSON API
|
||||||
|
- [x] 所有子系统:plugins / tools / channels / memory / knowledge / documents / text_memory / social / skills / LLM / runtime / tracker
|
||||||
|
|
||||||
|
### 技术债务清理
|
||||||
|
- [x] 移除旧 `test_deepseek` 插件
|
||||||
|
- [x] 修复 agentcli PTY readLoop 死锁(goroutine reader + close 顺序)
|
||||||
|
- [x] 修复 knowledge.Store.Delete 不存在方法
|
||||||
|
|
||||||
|
### P0 — WebUI 重构
|
||||||
|
- [x] 完整的 SPA 仪表盘(7 标签页:概览/对话/插件/记忆/知识/设置/内核)
|
||||||
|
- [x] 深色主题,响应式布局
|
||||||
|
- [x] 使用 `//go:embed dashboard.html` 替代硬编码 HTML 变量
|
||||||
|
- [x] 全部 REST API 端点保持兼容
|
||||||
|
|
||||||
|
### P1 — OpenClaw 兼容插件(Go 插件 + Node.js sidecar + 模拟器架构)
|
||||||
|
- [x] 设计 Go ↔ Node.js 通信协议(JSON-RPC 2.0 over stdio)
|
||||||
|
- [x] Go 侧 sidecar 管理器(进程启动、心跳、重启、`waitReady`)
|
||||||
|
- [x] 工具发现(`tools/list`)和执行(`tools/call`)转译
|
||||||
|
- [x] Node.js 测试插件 `echoplugin`(echo/add/ping 三个工具)
|
||||||
|
- [x] 5 个端到端测试(无 main.js 兜底、列表、调用 echo/add、不存在的工具、并发调用)
|
||||||
|
- [x] 现有 SKILL.md 加载路径保留(三通道:SKILL.md / sidecar / simulator)
|
||||||
|
- [x] Node.js OpenClaw 模拟进程(`simulator/main.js`):完整实现 `OpenClawPluginApi`,加载任意遵循 OpenClaw 插件标准的真实插件
|
||||||
|
- [x] 模拟器内嵌于 Go 二进制(`//go:embed`),启动时自动提取
|
||||||
|
- [x] 支持 `package.json#openclaw.extensions/runtimeExtensions` 入口发现,兼容 `.ts`→`.js` 编译回退
|
||||||
|
- [x] 用 ClawHub 真实插件(chart-plot)验证端到端链路通过
|
||||||
|
|
||||||
|
### P2 — 进一步优化
|
||||||
|
- [x] Healthcheck 定时自动执行(`startAutoCheck` goroutine,默认 30 分钟)
|
||||||
|
- [x] Agent 自主健康状态感知(通过 `healthcheck_kernel`)
|
||||||
|
- [x] 更多 WebUI 可视化面板(7 标签页 SPA)
|
||||||
|
- [x] 性能监控与告警(`healthcheck_perf` 工具 + `PerfData` 历史记录 100 条)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
所有 P0 / P1 / P2 计划项已完成。项目处于维护和迭代阶段。
|
||||||
|
|
||||||
|
### 已注册的工具(14 个)
|
||||||
|
- `cmd_run` — 命令执行
|
||||||
|
- `terminal_create / write / read / resize / close / list` — PTY 终端
|
||||||
|
- `healthcheck` — 全面健康检查(含 LLM 驱动阶段)
|
||||||
|
- `healthcheck_plugins` — 列出已加载插件
|
||||||
|
- `healthcheck_tools` — 列出所有已注册工具
|
||||||
|
- `healthcheck_memory` — 图记忆系统测试
|
||||||
|
- `healthcheck_report` — LLM 汇报接口
|
||||||
|
- `healthcheck_kernel` — 内核状态快照
|
||||||
|
- `healthcheck_perf` — 性能监控数据
|
||||||
|
- `timer_set` — 定时器
|
||||||
|
|
||||||
|
### 内置插件(7 个)
|
||||||
|
- agentcli — PTY 终端管理
|
||||||
|
- cli — Unix socket 通信
|
||||||
|
- cmd — 命令执行
|
||||||
|
- healthcheck — 健康检查 + 性能监控 + 自动调度
|
||||||
|
- mcp — MCP 协议支持
|
||||||
|
- openclaw — SKILL.md + Node.js sidecar 双通道
|
||||||
|
- webui — HTTP 服务器 + SPA 仪表盘
|
||||||
|
- timer — 定时器
|
||||||
@ -27,6 +27,7 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||||
|
healthcheck "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||||
openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
@ -316,25 +317,8 @@ func main() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
|
||||||
cli.DefaultSocket = *cliSocket
|
|
||||||
openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills")
|
|
||||||
webui.Configure(*httpAddr,
|
|
||||||
sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-create plugins directory (without hardcoding plugin names)
|
|
||||||
plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins")
|
|
||||||
os.MkdirAll(plgDir, 0755)
|
|
||||||
|
|
||||||
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
|
||||||
if err := pluginReg.Load(plgDir); err != nil {
|
|
||||||
log.Printf("[homed] warning: load plugins: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Agent Core
|
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
agent := agentCore.New(agentCore.AgentConfig{
|
agent := agentCore.New(agentCore.AgentConfig{
|
||||||
@ -377,6 +361,25 @@ func main() {
|
|||||||
EventBus: evBus,
|
EventBus: evBus,
|
||||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 为内置插件注入内核依赖(各插件通过 init() 自注册工厂)
|
||||||
|
cli.DefaultSocket = *cliSocket
|
||||||
|
openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills")
|
||||||
|
webui.Configure(*httpAddr,
|
||||||
|
sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus, agent,
|
||||||
|
)
|
||||||
|
healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, providerMgr, agent)
|
||||||
|
|
||||||
|
// Auto-create plugins directory (without hardcoding plugin names)
|
||||||
|
plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins")
|
||||||
|
os.MkdirAll(plgDir, 0755)
|
||||||
|
|
||||||
|
// Load all plugins — each scans its own dir and is loaded via factory or .so
|
||||||
|
if err := pluginReg.Load(plgDir); err != nil {
|
||||||
|
log.Printf("[homed] warning: load plugins: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||||
|
|
||||||
agent.Start()
|
agent.Start()
|
||||||
defer agent.Stop()
|
defer agent.Stop()
|
||||||
|
|
||||||
|
|||||||
@ -613,12 +613,22 @@ internal/
|
|||||||
│ └── plugin.go — SKILL 插件解析 (OpenClaw 兼容)
|
│ └── plugin.go — SKILL 插件解析 (OpenClaw 兼容)
|
||||||
├── plugins/
|
├── plugins/
|
||||||
│ ├── all.go — 空白导入触发所有内置插件 init()
|
│ ├── all.go — 空白导入触发所有内置插件 init()
|
||||||
│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈)
|
│ ├── all_test.go — 集成测试(14 工具跨插件)
|
||||||
|
│ ├── agentcli/plugin.go — PTY 终端 (6 个 terminal_* 工具)
|
||||||
│ ├── cli/plugin.go — CLI 插件 (Unix socket, InjectTextSync)
|
│ ├── cli/plugin.go — CLI 插件 (Unix socket, InjectTextSync)
|
||||||
│ ├── openclaw/plugin.go — OpenClaw 兼容 (SKILL.md → SDK 工具注册)
|
│ ├── cmd/plugin.go — 命令执行 (cmd_run 工具)
|
||||||
│ └── webui/ — WebUI 插件 (HTTP 服务器 + 仪表盘)
|
│ ├── healthcheck/plugin.go — 健康检查 + 性能监控 + 自动调度
|
||||||
|
│ ├── mcp/plugin.go — MCP 协议支持
|
||||||
|
│ ├── openclaw/ — OpenClaw 兼容
|
||||||
|
│ │ ├── plugin.go — SKILL.md + sidecar + simulator 三通道
|
||||||
|
│ │ ├── sidecar.go — JSON-RPC over stdio 侧车管理
|
||||||
|
│ │ └── simulator/
|
||||||
|
│ │ └── main.js — OpenClaw 插件模拟器 (go:embed)
|
||||||
|
│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈)
|
||||||
|
│ └── webui/ — WebUI 插件 (HTTP 服务器 + SPA 仪表盘)
|
||||||
│ ├── plugin.go
|
│ ├── plugin.go
|
||||||
│ └── handler.go
|
│ ├── handler.go
|
||||||
|
│ └── dashboard.html — 嵌入式 SPA (go:embed)
|
||||||
├── memory/
|
├── memory/
|
||||||
│ ├── graph.go — SQLite 图数据库
|
│ ├── graph.go — SQLite 图数据库
|
||||||
│ ├── indexer.go — 图索引器
|
│ ├── indexer.go — 图索引器
|
||||||
|
|||||||
@ -36,8 +36,9 @@ HomeAgent 是一个**持续运行的个人智能管家**。它像一个随时在
|
|||||||
- 倒计时结束后它会主动通知你
|
- 倒计时结束后它会主动通知你
|
||||||
|
|
||||||
### 🔌 可扩展(插件)
|
### 🔌 可扩展(插件)
|
||||||
- **Web 控制台** — 在浏览器中管理和配置
|
- **Web 控制台** — 在浏览器中管理和配置(7 标签页 SPA)
|
||||||
- **命令行** — 通过终端快速交互
|
- **命令行** — 通过终端快速交互
|
||||||
|
- **健康检查** — 自动检测系统各组件状态,LLM 驱动故障排查
|
||||||
- **更多能力** — 开发者可以写插件接入任何服务
|
- **更多能力** — 开发者可以写插件接入任何服务
|
||||||
|
|
||||||
## 它是如何工作的?(简述)
|
## 它是如何工作的?(简述)
|
||||||
|
|||||||
9
go.mod
9
go.mod
@ -3,12 +3,9 @@ module gitcode.com/JianFeeeee/HomeAgent
|
|||||||
go 1.19
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
github.com/yanyiwu/gojieba v1.4.7
|
||||||
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
|
||||||
github.com/yanyiwu/gojieba v1.4.7 // indirect
|
|
||||||
github.com/yuin/gopher-lua v1.1.2 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
@ -605,6 +605,22 @@ func (m *ProviderManager) Default() Provider {
|
|||||||
return m.providers[m.default_]
|
return m.providers[m.default_]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuickChat 向默认 LLM Provider 发送一条简短消息并返回回复。
|
||||||
|
// 这是一个"非记忆"调用——直接通过 Provider HTTP 调用,不经过 Agent 的记忆/蒸馏管线。
|
||||||
|
// 适用于健康检查、系统自检等不需要产生记忆碎片的场景。
|
||||||
|
func (m *ProviderManager) QuickChat(ctx context.Context, prompt string) (*CompletionResponse, error) {
|
||||||
|
p := m.Default()
|
||||||
|
if p == nil {
|
||||||
|
return nil, fmt.Errorf("no default provider")
|
||||||
|
}
|
||||||
|
return p.Chat(ctx, &CompletionRequest{
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "user", Content: prompt},
|
||||||
|
},
|
||||||
|
MaxTokens: 128,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ProviderManager) List() []string {
|
func (m *ProviderManager) List() []string {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|||||||
54
internal/agent/api/quickchat_test.go
Normal file
54
internal/agent/api/quickchat_test.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuickChatWithRealKey(t *testing.T) {
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Skip("DEEPSEEK_API_KEY not set — skipping real LLM test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
vm := luaVM.NewVM(tmpDir + "/adapters")
|
||||||
|
if err := vm.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer vm.Stop()
|
||||||
|
|
||||||
|
pm := NewProviderManager()
|
||||||
|
pm.Register("deepseek", NewLuaAdaptedProvider(BaseConfig{
|
||||||
|
Model: "deepseek-v4-flash",
|
||||||
|
BaseURL: "https://api.deepseek.com",
|
||||||
|
APIKey: apiKey,
|
||||||
|
}, vm, "deepseek"))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := pm.QuickChat(ctx, "请回复'OK',不要输出其他内容")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QuickChat failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Content == "" {
|
||||||
|
t.Fatal("empty response")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Response: %q", resp.Content)
|
||||||
|
t.Logf("Time: %v", elapsed.Round(time.Millisecond))
|
||||||
|
if resp.TokenUsage.Total > 0 {
|
||||||
|
t.Logf("Tokens: %d (prompt %d + completion %d)",
|
||||||
|
resp.TokenUsage.Total, resp.TokenUsage.Prompt, resp.TokenUsage.Completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -93,6 +93,9 @@ type Agent struct {
|
|||||||
|
|
||||||
// 模型思考模式(thinking/reasoning)
|
// 模型思考模式(thinking/reasoning)
|
||||||
thinkingEnabled bool
|
thinkingEnabled bool
|
||||||
|
|
||||||
|
// 启动时间
|
||||||
|
startTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
@ -135,6 +138,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
}
|
}
|
||||||
return &Agent{
|
return &Agent{
|
||||||
id: cfg.ID,
|
id: cfg.ID,
|
||||||
|
startTime: time.Now(),
|
||||||
provider: cfg.Provider,
|
provider: cfg.Provider,
|
||||||
providerManager: cfg.ProviderManager,
|
providerManager: cfg.ProviderManager,
|
||||||
io: cfg.IO,
|
io: cfg.IO,
|
||||||
@ -291,8 +295,14 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
noMemory := false
|
||||||
|
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
||||||
|
noMemory = v
|
||||||
|
}
|
||||||
|
|
||||||
// === Stage: on_input — 消息到达,插件可拦截 ===
|
// === Stage: on_input — 消息到达,插件可拦截 ===
|
||||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||||
|
stageCtx.NoMemory = noMemory
|
||||||
a.publishEvent(events.EventRawInput, map[string]interface{}{
|
a.publishEvent(events.EventRawInput, map[string]interface{}{
|
||||||
"content": input,
|
"content": input,
|
||||||
"source": evt.Source,
|
"source": evt.Source,
|
||||||
@ -337,7 +347,9 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
|||||||
|
|
||||||
a.emitResponse(evt, response)
|
a.emitResponse(evt, response)
|
||||||
|
|
||||||
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
if !stageCtx.NoMemory {
|
||||||
|
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||||
@ -1825,7 +1837,6 @@ func (a *Agent) processConsolidation(input string) {
|
|||||||
ToolsUsed: toolsUsed,
|
ToolsUsed: toolsUsed,
|
||||||
})
|
})
|
||||||
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
|
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
|
||||||
// 只写入记忆,不发外部输出
|
|
||||||
a.emitMemoryCandidate("system", input, response, toolsUsed)
|
a.emitMemoryCandidate("system", input, response, toolsUsed)
|
||||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||||
}
|
}
|
||||||
|
|||||||
302
internal/agent/core/status.go
Normal file
302
internal/agent/core/status.go
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusProvider 内核状态查询接口。插件通过此接口查看内核运行动态。
|
||||||
|
type StatusProvider interface {
|
||||||
|
GetKernelStatus() *KernelStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// KernelStatus 内核各子系统运行状态的聚合快照。
|
||||||
|
type KernelStatus struct {
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
|
||||||
|
Plugins []PluginInfo `json:"plugins"`
|
||||||
|
Tools []sdk.ToolDef `json:"tools"`
|
||||||
|
Channels []ChannelInfo `json:"channels"`
|
||||||
|
|
||||||
|
Memory MemoryStatus `json:"memory"`
|
||||||
|
Knowledge KnowledgeStatus `json:"knowledge"`
|
||||||
|
Documents DocumentStatus `json:"documents"`
|
||||||
|
TextMemory TextMemoryStatus `json:"text_memory"`
|
||||||
|
Social SocialStatus `json:"social"`
|
||||||
|
Skills SkillsStatus `json:"skills"`
|
||||||
|
|
||||||
|
LLM LLMStatus `json:"llm"`
|
||||||
|
|
||||||
|
Context ContextStatus `json:"context"`
|
||||||
|
|
||||||
|
Runtime RuntimeStatus `json:"runtime"`
|
||||||
|
|
||||||
|
Tracker TrackerStatus `json:"tracker"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Loaded bool `json:"loaded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo {
|
||||||
|
return ChannelInfo{
|
||||||
|
Name: ch.Name,
|
||||||
|
Type: fmt.Sprintf("%d", ch.Type),
|
||||||
|
Ready: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
EntityCount int `json:"entity_count,omitempty"`
|
||||||
|
RelationCount int `json:"relation_count,omitempty"`
|
||||||
|
EntityTypes int `json:"entity_types,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
ItemCount int `json:"item_count,omitempty"`
|
||||||
|
Items []string `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
DocCount int `json:"doc_count,omitempty"`
|
||||||
|
VectorCount int `json:"vector_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextMemoryStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
FileCount int `json:"file_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocialStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
PersonCount int `json:"person_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillsStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
SkillList []string `json:"skill_list,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Sources int `json:"sources,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextStatus struct {
|
||||||
|
EventCount int `json:"event_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeStatus struct {
|
||||||
|
Goroutines int `json:"goroutines"`
|
||||||
|
MemoryMB int64 `json:"memory_mb"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackerStatus struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Dir string `json:"dir,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectKernelStatus 聚合内核各子系统状态快照。
|
||||||
|
// 接收所有子系统引用(均为可选——nil 表示不可用),返回统一的状态报告。
|
||||||
|
func collectKernelStatus(
|
||||||
|
startTime time.Time,
|
||||||
|
agentID string,
|
||||||
|
providerName string,
|
||||||
|
sourceCount int,
|
||||||
|
stageHost *StageHost,
|
||||||
|
iom *agentIO.IOManager,
|
||||||
|
pluginReg *plugin.Registry,
|
||||||
|
memDB *memory.GraphDB,
|
||||||
|
ks interface{ List() []string },
|
||||||
|
docStore *document.Store,
|
||||||
|
textMem *text.Memory,
|
||||||
|
socialStore *social.SocialStore,
|
||||||
|
skMgr *skill.Manager,
|
||||||
|
trk *tracker.Tracker,
|
||||||
|
) *KernelStatus {
|
||||||
|
status := &KernelStatus{
|
||||||
|
Uptime: time.Since(startTime).Round(time.Second).String(),
|
||||||
|
StartTime: startTime.Format(time.RFC3339),
|
||||||
|
AgentID: agentID,
|
||||||
|
Runtime: RuntimeStatus{
|
||||||
|
Goroutines: runtime.NumGoroutine(),
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
},
|
||||||
|
LLM: LLMStatus{
|
||||||
|
Available: providerName != "",
|
||||||
|
Provider: providerName,
|
||||||
|
Sources: sourceCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
if pluginReg != nil {
|
||||||
|
names := pluginReg.List()
|
||||||
|
for _, n := range names {
|
||||||
|
status.Plugins = append(status.Plugins, PluginInfo{Name: n, Loaded: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
if stageHost != nil {
|
||||||
|
status.Tools = stageHost.GetToolDefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
if iom != nil {
|
||||||
|
for _, ch := range iom.ListChannels() {
|
||||||
|
status.Channels = append(status.Channels, channelInfoFromIO(ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph memory
|
||||||
|
if memDB != nil {
|
||||||
|
status.Memory.Available = true
|
||||||
|
if info, err := memDB.Introspect(); err == nil {
|
||||||
|
if ec, ok := info["entity_count"].(int); ok {
|
||||||
|
status.Memory.EntityCount = ec
|
||||||
|
}
|
||||||
|
if rc, ok := info["relation_count"].(int); ok {
|
||||||
|
status.Memory.RelationCount = rc
|
||||||
|
}
|
||||||
|
if et, ok := info["entity_type_count"].(int); ok {
|
||||||
|
status.Memory.EntityTypes = et
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knowledge
|
||||||
|
if ks != nil {
|
||||||
|
status.Knowledge.Available = true
|
||||||
|
status.Knowledge.Items = ks.List()
|
||||||
|
status.Knowledge.ItemCount = len(status.Knowledge.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
if docStore != nil {
|
||||||
|
status.Documents.Available = true
|
||||||
|
stats := docStore.Stats()
|
||||||
|
if dc, ok := stats["doc_count"].(int); ok {
|
||||||
|
status.Documents.DocCount = dc
|
||||||
|
}
|
||||||
|
if vc, ok := stats["vector_count"].(int); ok {
|
||||||
|
status.Documents.VectorCount = vc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text memory
|
||||||
|
if textMem != nil {
|
||||||
|
status.TextMemory.Available = true
|
||||||
|
status.TextMemory.FileCount = textMem.FileCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social
|
||||||
|
if socialStore != nil {
|
||||||
|
status.Social.Available = true
|
||||||
|
if persons, err := socialStore.ListPersons(); err == nil {
|
||||||
|
status.Social.PersonCount = len(persons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
if skMgr != nil {
|
||||||
|
status.Skills.Available = true
|
||||||
|
skills := skMgr.List()
|
||||||
|
status.Skills.SkillList = make([]string, len(skills))
|
||||||
|
for i, sk := range skills {
|
||||||
|
status.Skills.SkillList[i] = sk.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracker
|
||||||
|
if trk != nil {
|
||||||
|
status.Tracker.Available = true
|
||||||
|
status.Tracker.Dir = trk.MergeDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
var m runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
status.Runtime.MemoryMB = int64(m.Alloc / 1024 / 1024)
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKernelStatus 返回 Agent 驱动的内核状态快照。
|
||||||
|
func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||||
|
providerName := ""
|
||||||
|
sourceCount := 0
|
||||||
|
if a.providerManager != nil {
|
||||||
|
sourceCount = len(a.providerManager.List())
|
||||||
|
}
|
||||||
|
if a.provider != nil {
|
||||||
|
providerName = a.provider.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
var textMem *text.Memory
|
||||||
|
if a.textMem != nil {
|
||||||
|
textMem = a.textMem
|
||||||
|
}
|
||||||
|
|
||||||
|
var socialStore *social.SocialStore
|
||||||
|
if a.social != nil {
|
||||||
|
socialStore = a.social
|
||||||
|
}
|
||||||
|
|
||||||
|
var skMgr *skill.Manager
|
||||||
|
if a.skills != nil {
|
||||||
|
skMgr = a.skills
|
||||||
|
}
|
||||||
|
|
||||||
|
var trk *tracker.Tracker
|
||||||
|
if a.tracker != nil {
|
||||||
|
trk = a.tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
ks := collectKernelStatus(
|
||||||
|
a.startTime,
|
||||||
|
string(a.id),
|
||||||
|
providerName,
|
||||||
|
sourceCount,
|
||||||
|
a.stageHost,
|
||||||
|
a.io,
|
||||||
|
a.pluginReg,
|
||||||
|
a.memory,
|
||||||
|
a.knowledge,
|
||||||
|
a.docStore,
|
||||||
|
textMem,
|
||||||
|
socialStore,
|
||||||
|
skMgr,
|
||||||
|
trk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ks
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ StatusProvider = (*Agent)(nil)
|
||||||
@ -241,6 +241,22 @@ func (m *IOManager) InjectTextTo(source, outputChannel, text string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectTextNoMemoryTo 注入文本输入(不产生记忆)并指定输出通道
|
||||||
|
func (m *IOManager) InjectTextNoMemoryTo(source, outputChannel, text string) {
|
||||||
|
m.InjectInputTo(source, outputChannel, "text", map[string]interface{}{
|
||||||
|
"content": text,
|
||||||
|
"no_memory": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectTextSyncNoMemoryTo 注入文本输入(同步等待,不产生记忆)并指定输出通道
|
||||||
|
func (m *IOManager) InjectTextSyncNoMemoryTo(source, outputChannel, text string) *OutputEvent {
|
||||||
|
return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{
|
||||||
|
"content": text,
|
||||||
|
"no_memory": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// InjectInterrupt 向中断通道发送输入
|
// InjectInterrupt 向中断通道发送输入
|
||||||
func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) {
|
func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
|
|||||||
814
internal/plugins/agentcli/plugin.go
Normal file
814
internal/plugins/agentcli/plugin.go
Normal file
@ -0,0 +1,814 @@
|
|||||||
|
package agentcli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PTY ioctl constants for Linux
|
||||||
|
const (
|
||||||
|
TIOCGPTN = 0x80045430
|
||||||
|
TIOCSPTLCK = 0x40045431
|
||||||
|
TIOCSWINSZ = 0x5414
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultTimeout = 5 * time.Minute
|
||||||
|
ReadBufSize = 4096
|
||||||
|
MaxOutputBuffer = 128 * 1024
|
||||||
|
NotifyOutputDelay = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type winsize struct {
|
||||||
|
Row uint16
|
||||||
|
Col uint16
|
||||||
|
XPixel uint16
|
||||||
|
YPixel uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func ioctl(fd, cmd uintptr, ptr unsafe.Pointer) error {
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, cmd, uintptr(ptr))
|
||||||
|
if errno != 0 {
|
||||||
|
return errno
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openPty() (master *os.File, slave *os.File, err error) {
|
||||||
|
mfd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("open /dev/ptmx: %w", err)
|
||||||
|
}
|
||||||
|
master = os.NewFile(uintptr(mfd), "/dev/ptmx")
|
||||||
|
|
||||||
|
var unlock int32
|
||||||
|
if err := ioctl(uintptr(mfd), TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil {
|
||||||
|
master.Close()
|
||||||
|
return nil, nil, fmt.Errorf("TIOCSPTLCK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ptyno int32
|
||||||
|
if err := ioctl(uintptr(mfd), TIOCGPTN, unsafe.Pointer(&ptyno)); err != nil {
|
||||||
|
master.Close()
|
||||||
|
return nil, nil, fmt.Errorf("TIOCGPTN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slavePath := fmt.Sprintf("/dev/pts/%d", ptyno)
|
||||||
|
sfd, err := syscall.Open(slavePath, syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
||||||
|
if err != nil {
|
||||||
|
master.Close()
|
||||||
|
return nil, nil, fmt.Errorf("open slave %s: %w", slavePath, err)
|
||||||
|
}
|
||||||
|
slave = os.NewFile(uintptr(sfd), slavePath)
|
||||||
|
|
||||||
|
return master, slave, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TerminalSession struct {
|
||||||
|
id string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
master *os.File
|
||||||
|
slave *os.File
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
createdAt time.Time
|
||||||
|
timeout time.Duration
|
||||||
|
closed bool
|
||||||
|
stopCh chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) Write(input string) (int, error) {
|
||||||
|
return t.master.WriteString(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) Read(buf []byte) (int, error) {
|
||||||
|
return t.master.Read(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) ReadOutput() string {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) ReadAndClearOutput() string {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
s := t.buf.String()
|
||||||
|
t.buf.Reset()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) appendOutput(data []byte) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.buf.Len()+len(data) > MaxOutputBuffer {
|
||||||
|
excess := t.buf.Len() + len(data) - MaxOutputBuffer
|
||||||
|
if t.buf.Len() > excess {
|
||||||
|
t.buf.Next(excess)
|
||||||
|
} else {
|
||||||
|
t.buf.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.buf.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) Resize(rows, cols uint16) error {
|
||||||
|
ws := winsize{Row: rows, Col: cols}
|
||||||
|
if err := ioctl(uintptr(t.master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {
|
||||||
|
return fmt.Errorf("TIOCSWINSZ: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) Close() {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.closed {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.closed = true
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
close(t.stopCh)
|
||||||
|
t.master.Close()
|
||||||
|
<-t.done
|
||||||
|
|
||||||
|
t.slave.Close()
|
||||||
|
t.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TerminalSession) IsExpired() bool {
|
||||||
|
return time.Since(t.createdAt) >= t.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
stopCh chan struct{}
|
||||||
|
sessions map[string]*TerminalSession
|
||||||
|
nextID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return New(name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(name string) *Plugin {
|
||||||
|
return &Plugin{
|
||||||
|
name: name,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
sessions: make(map[string]*TerminalSession),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||||
|
Name: "terminal_create",
|
||||||
|
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"command": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要执行的命令(默认 bash)。如需运行特定程序直接传入即可,例如:vim /tmp/test.txt",
|
||||||
|
},
|
||||||
|
"timeout": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h(默认 5m)",
|
||||||
|
},
|
||||||
|
"rows": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "终端行数(默认 24)",
|
||||||
|
},
|
||||||
|
"cols": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "终端列数(默认 80)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.handleCreate(s, args)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool("terminal_write", sdk.ToolDef{
|
||||||
|
Name: "terminal_write",
|
||||||
|
Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入)。特殊键包括:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "终端 ID,来自 terminal_create 的返回值",
|
||||||
|
},
|
||||||
|
"input": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要发送的文本内容(普通文字直接输入)",
|
||||||
|
},
|
||||||
|
"key": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "特殊按键:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.handleWrite(s, args)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool("terminal_read", sdk.ToolDef{
|
||||||
|
Name: "terminal_read",
|
||||||
|
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "终端 ID",
|
||||||
|
},
|
||||||
|
"clear": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "读取后是否清除缓冲区(默认 true)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.handleRead(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool("terminal_resize", sdk.ToolDef{
|
||||||
|
Name: "terminal_resize",
|
||||||
|
Description: "调整指定终端的尺寸(行数和列数)。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "终端 ID",
|
||||||
|
},
|
||||||
|
"rows": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "行数",
|
||||||
|
},
|
||||||
|
"cols": map[string]interface{}{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "列数",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.handleResize(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool("terminal_close", sdk.ToolDef{
|
||||||
|
Name: "terminal_close",
|
||||||
|
Description: "关闭指定终端会话。释放资源。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "终端 ID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"id"},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.handleClose(args)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool("terminal_list", sdk.ToolDef{
|
||||||
|
Name: "terminal_list",
|
||||||
|
Description: "列出所有活跃的终端会话及其状态。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.handleList()
|
||||||
|
})
|
||||||
|
|
||||||
|
p.wg.Add(1)
|
||||||
|
go p.cleanupLoop(s)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
close(p.stopCh)
|
||||||
|
p.wg.Wait()
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
for _, t := range p.sessions {
|
||||||
|
t.Close()
|
||||||
|
}
|
||||||
|
p.sessions = nil
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) nextIDLocked() string {
|
||||||
|
p.nextID++
|
||||||
|
return fmt.Sprintf("term_%d", p.nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
if command == "" {
|
||||||
|
command = "bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutStr, _ := args["timeout"].(string)
|
||||||
|
timeout := DefaultTimeout
|
||||||
|
if timeoutStr != "" {
|
||||||
|
if d, err := time.ParseDuration(timeoutStr); err == nil {
|
||||||
|
timeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := uint16(24)
|
||||||
|
cols := uint16(80)
|
||||||
|
if r, ok := args["rows"].(float64); ok && r > 0 {
|
||||||
|
rows = uint16(r)
|
||||||
|
}
|
||||||
|
if c, ok := args["cols"].(float64); ok && c > 0 {
|
||||||
|
cols = uint16(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
master, slave, err := openPty()
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("创建 PTY 失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("sh", "-c", command)
|
||||||
|
cmd.Stdin = slave
|
||||||
|
cmd.Stdout = slave
|
||||||
|
cmd.Stderr = slave
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Setsid: true,
|
||||||
|
Setctty: true,
|
||||||
|
Ctty: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
master.Close()
|
||||||
|
slave.Close()
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("启动命令失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
slave.Close()
|
||||||
|
|
||||||
|
session := &TerminalSession{
|
||||||
|
cmd: cmd,
|
||||||
|
master: master,
|
||||||
|
createdAt: time.Now(),
|
||||||
|
timeout: timeout,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
id := p.nextIDLocked()
|
||||||
|
session.id = id
|
||||||
|
p.sessions[id] = session
|
||||||
|
|
||||||
|
ws := winsize{Row: rows, Col: cols}
|
||||||
|
ioctl(uintptr(master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws))
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
p.wg.Add(1)
|
||||||
|
go p.readLoop(session, s)
|
||||||
|
|
||||||
|
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"status": "created",
|
||||||
|
"command": command,
|
||||||
|
"timeout": timeout.String(),
|
||||||
|
"rows": rows,
|
||||||
|
"cols": cols,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, _ := args["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return map[string]interface{}{"error": "id is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
session, ok := p.sessions[id]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
input, _ := args["input"].(string)
|
||||||
|
key, _ := args["key"].(string)
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
b, err := mapKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}, nil
|
||||||
|
}
|
||||||
|
data = b
|
||||||
|
} else if input != "" {
|
||||||
|
data = []byte(input)
|
||||||
|
} else {
|
||||||
|
return map[string]interface{}{"error": "需要提供 input 或 key 参数"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := session.Write(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("写入失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"bytes": n,
|
||||||
|
"terminal": id,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, _ := args["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return map[string]interface{}{"error": "id is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clear := true
|
||||||
|
if v, ok := args["clear"].(bool); ok {
|
||||||
|
clear = v
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
session, ok := p.sessions[id]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var output string
|
||||||
|
if clear {
|
||||||
|
output = session.ReadAndClearOutput()
|
||||||
|
} else {
|
||||||
|
output = session.ReadOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
if output == "" {
|
||||||
|
output = "[终端无新输出]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"terminal": id,
|
||||||
|
"output": output,
|
||||||
|
"running": session.cmd.ProcessState == nil || !session.cmd.ProcessState.Exited(),
|
||||||
|
"uptime": time.Since(session.createdAt).String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleResize(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, _ := args["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return map[string]interface{}{"error": "id is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, okRows := args["rows"].(float64)
|
||||||
|
cols, okCols := args["cols"].(float64)
|
||||||
|
if !okRows || !okCols {
|
||||||
|
return map[string]interface{}{"error": "rows 和 cols 为必填"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
session, ok := p.sessions[id]
|
||||||
|
p.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Resize(uint16(rows), uint16(cols)); err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("调整尺寸失败: %v", err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"terminal": id,
|
||||||
|
"rows": rows,
|
||||||
|
"cols": cols,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, _ := args["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return map[string]interface{}{"error": "id is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
session, ok := p.sessions[id]
|
||||||
|
if ok {
|
||||||
|
delete(p.sessions, id)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Close()
|
||||||
|
log.Printf("[agentcli] closed terminal %s", id)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "closed",
|
||||||
|
"terminal": id,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleList() (interface{}, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
type termInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
ExpiresIn string `json:"expires_in"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var terms []termInfo
|
||||||
|
for _, t := range p.sessions {
|
||||||
|
running := t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited()
|
||||||
|
remaining := t.timeout - time.Since(t.createdAt)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
terms = append(terms, termInfo{
|
||||||
|
ID: t.id,
|
||||||
|
Uptime: time.Since(t.createdAt).Round(time.Second).String(),
|
||||||
|
ExpiresIn: remaining.Round(time.Second).String(),
|
||||||
|
Running: running,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if terms == nil {
|
||||||
|
terms = []termInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"count": len(terms),
|
||||||
|
"terminals": terms,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||||
|
defer p.wg.Done()
|
||||||
|
defer close(t.done)
|
||||||
|
|
||||||
|
buf := make([]byte, ReadBufSize)
|
||||||
|
lastNotify := time.Now()
|
||||||
|
pollInterval := 200 * time.Millisecond
|
||||||
|
|
||||||
|
readCh := make(chan readResult, 4)
|
||||||
|
go p.reader(t, buf, readCh)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if t.IsExpired() {
|
||||||
|
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
|
||||||
|
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout))
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.sessions, t.id)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||||
|
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.sessions, t.id)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
case r := <-readCh:
|
||||||
|
if r.err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.n > 0 {
|
||||||
|
data := make([]byte, r.n)
|
||||||
|
copy(data, buf[:r.n])
|
||||||
|
t.appendOutput(data)
|
||||||
|
if time.Since(lastNotify) > NotifyOutputDelay {
|
||||||
|
preview := string(data)
|
||||||
|
if len(preview) > 100 {
|
||||||
|
preview = preview[:100]
|
||||||
|
}
|
||||||
|
preview = sanitizePreview(preview)
|
||||||
|
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
|
||||||
|
lastNotify = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type readResult struct {
|
||||||
|
n int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) {
|
||||||
|
for {
|
||||||
|
n, err := t.master.Read(buf)
|
||||||
|
select {
|
||||||
|
case ch <- readResult{n, err}:
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) cleanupLoop(s *sdk.PluginSDK) {
|
||||||
|
defer p.wg.Done()
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.mu.Lock()
|
||||||
|
for id, t := range p.sessions {
|
||||||
|
if t.IsExpired() {
|
||||||
|
log.Printf("[agentcli] cleanup: terminal %s expired", id)
|
||||||
|
delete(p.sessions, id)
|
||||||
|
go func(term *TerminalSession) {
|
||||||
|
term.Close()
|
||||||
|
}(t)
|
||||||
|
}
|
||||||
|
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||||
|
log.Printf("[agentcli] cleanup: terminal %s process exited", id)
|
||||||
|
delete(p.sessions, id)
|
||||||
|
go func(term *TerminalSession) {
|
||||||
|
term.Close()
|
||||||
|
}(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapKey(key string) ([]byte, error) {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
switch key {
|
||||||
|
case "enter":
|
||||||
|
return []byte{0x0D}, nil
|
||||||
|
case "tab":
|
||||||
|
return []byte{0x09}, nil
|
||||||
|
case "escape", "esc":
|
||||||
|
return []byte{0x1B}, nil
|
||||||
|
case "backspace":
|
||||||
|
return []byte{0x7F}, nil
|
||||||
|
case "delete":
|
||||||
|
return []byte{0x1B, 0x5B, 0x33, 0x7E}, nil
|
||||||
|
case "home":
|
||||||
|
return []byte{0x1B, 0x5B, 0x48}, nil
|
||||||
|
case "end":
|
||||||
|
return []byte{0x1B, 0x5B, 0x46}, nil
|
||||||
|
case "page_up":
|
||||||
|
return []byte{0x1B, 0x5B, 0x35, 0x7E}, nil
|
||||||
|
case "page_down":
|
||||||
|
return []byte{0x1B, 0x5B, 0x36, 0x7E}, nil
|
||||||
|
case "up":
|
||||||
|
return []byte{0x1B, 0x5B, 0x41}, nil
|
||||||
|
case "down":
|
||||||
|
return []byte{0x1B, 0x5B, 0x42}, nil
|
||||||
|
case "right":
|
||||||
|
return []byte{0x1B, 0x5B, 0x43}, nil
|
||||||
|
case "left":
|
||||||
|
return []byte{0x1B, 0x5B, 0x44}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(key, "ctrl_") && len(key) == 6 {
|
||||||
|
c := key[5]
|
||||||
|
if c >= 'a' && c <= 'z' {
|
||||||
|
return []byte{byte(c - 'a' + 1)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(key, "alt_") && len(key) == 5 {
|
||||||
|
c := key[4]
|
||||||
|
if c >= 'a' && c <= 'z' {
|
||||||
|
return []byte{0x1B, c}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(key, "f") && len(key) <= 4 {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(key, "f%d", &n); err == nil && n >= 1 && n <= 12 {
|
||||||
|
return functionKeyCode(n), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("不支持的特殊按键: %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionKeyCode(n int) []byte {
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
return []byte{0x1B, 0x5B, 0x50}
|
||||||
|
case 2:
|
||||||
|
return []byte{0x1B, 0x5B, 0x51}
|
||||||
|
case 3:
|
||||||
|
return []byte{0x1B, 0x5B, 0x52}
|
||||||
|
case 4:
|
||||||
|
return []byte{0x1B, 0x5B, 0x53}
|
||||||
|
case 5:
|
||||||
|
return []byte{0x1B, 0x5B, 0x31, 0x35, 0x7E}
|
||||||
|
case 6:
|
||||||
|
return []byte{0x1B, 0x5B, 0x31, 0x37, 0x7E}
|
||||||
|
case 7:
|
||||||
|
return []byte{0x1B, 0x5B, 0x31, 0x38, 0x7E}
|
||||||
|
case 8:
|
||||||
|
return []byte{0x1B, 0x5B, 0x31, 0x39, 0x7E}
|
||||||
|
case 9:
|
||||||
|
return []byte{0x1B, 0x5B, 0x32, 0x30, 0x7E}
|
||||||
|
case 10:
|
||||||
|
return []byte{0x1B, 0x5B, 0x32, 0x31, 0x7E}
|
||||||
|
case 11:
|
||||||
|
return []byte{0x1B, 0x5B, 0x32, 0x32, 0x7E}
|
||||||
|
case 12:
|
||||||
|
return []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}
|
||||||
|
default:
|
||||||
|
return []byte{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeoutError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if opErr, ok := err.(*os.PathError); ok {
|
||||||
|
err = opErr.Err
|
||||||
|
}
|
||||||
|
if syscallErr, ok := err.(syscall.Errno); ok {
|
||||||
|
return syscallErr == syscall.EAGAIN || syscallErr == syscall.EWOULDBLOCK || syscallErr == syscall.ETIMEDOUT
|
||||||
|
}
|
||||||
|
return strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "would block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizePreview(s string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\n' {
|
||||||
|
buf.WriteString("\\n")
|
||||||
|
} else if r == '\r' {
|
||||||
|
buf.WriteString("\\r")
|
||||||
|
} else if r == '\t' {
|
||||||
|
buf.WriteString("\\t")
|
||||||
|
} else if r >= 32 && r <= 126 {
|
||||||
|
buf.WriteRune(r)
|
||||||
|
} else if r == 0x1B {
|
||||||
|
buf.WriteString("^[")
|
||||||
|
} else if r < 32 {
|
||||||
|
fmt.Fprintf(&buf, "^%c", r+'A'-1)
|
||||||
|
} else {
|
||||||
|
buf.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
385
internal/plugins/agentcli/plugin_test.go
Normal file
385
internal/plugins/agentcli/plugin_test.go
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
package agentcli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCapture struct {
|
||||||
|
handlers map[string]sdk.ToolHandler
|
||||||
|
defs map[string]sdk.ToolDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolCapture() *toolCapture {
|
||||||
|
return &toolCapture{
|
||||||
|
handlers: make(map[string]sdk.ToolHandler),
|
||||||
|
defs: make(map[string]sdk.ToolDef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
tc.handlers[name] = handler
|
||||||
|
tc.defs[name] = def
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||||
|
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||||
|
|
||||||
|
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||||
|
p := New("agentcli")
|
||||||
|
tc := newToolCapture()
|
||||||
|
// Use nil for fields we don't need (iom, eventBus, etc.)
|
||||||
|
sdk := sdk.New("agentcli", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||||
|
if err := p.Start(sdk); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return p, tc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyMapping(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
expected []byte
|
||||||
|
}{
|
||||||
|
{"enter", []byte{0x0D}},
|
||||||
|
{"tab", []byte{0x09}},
|
||||||
|
{"escape", []byte{0x1B}},
|
||||||
|
{"esc", []byte{0x1B}},
|
||||||
|
{"backspace", []byte{0x7F}},
|
||||||
|
{"delete", []byte{0x1B, 0x5B, 0x33, 0x7E}},
|
||||||
|
{"home", []byte{0x1B, 0x5B, 0x48}},
|
||||||
|
{"end", []byte{0x1B, 0x5B, 0x46}},
|
||||||
|
{"up", []byte{0x1B, 0x5B, 0x41}},
|
||||||
|
{"down", []byte{0x1B, 0x5B, 0x42}},
|
||||||
|
{"left", []byte{0x1B, 0x5B, 0x44}},
|
||||||
|
{"right", []byte{0x1B, 0x5B, 0x43}},
|
||||||
|
{"page_up", []byte{0x1B, 0x5B, 0x35, 0x7E}},
|
||||||
|
{"page_down", []byte{0x1B, 0x5B, 0x36, 0x7E}},
|
||||||
|
{"ctrl_a", []byte{0x01}},
|
||||||
|
{"ctrl_z", []byte{0x1A}},
|
||||||
|
{"alt_a", []byte{0x1B, 'a'}},
|
||||||
|
{"alt_z", []byte{0x1B, 'z'}},
|
||||||
|
{"f1", []byte{0x1B, 0x5B, 0x50}},
|
||||||
|
{"f4", []byte{0x1B, 0x5B, 0x53}},
|
||||||
|
{"f12", []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
result, err := mapKey(tt.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytesEqual(result, tt.expected) {
|
||||||
|
t.Fatalf("expected %v, got %v", tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyMappingInvalid(t *testing.T) {
|
||||||
|
_, err := mapKey("unknown_key")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesEqual(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListEmpty(t *testing.T) {
|
||||||
|
p, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_list"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["count"].(float64) != 0 {
|
||||||
|
t.Fatalf("expected count 0, got %v", resp["count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndCloseTerminal(t *testing.T) {
|
||||||
|
p, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
createHandler := tc.handlers["terminal_create"]
|
||||||
|
result, err := createHandler(map[string]interface{}{
|
||||||
|
"command": "echo hello",
|
||||||
|
"timeout": "10s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var createResp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &createResp)
|
||||||
|
|
||||||
|
if v, ok := createResp["error"]; ok {
|
||||||
|
t.Skipf("PTY not available in this environment: %v", v)
|
||||||
|
}
|
||||||
|
if createResp["status"] != "created" {
|
||||||
|
t.Fatalf("expected status created, got %v", createResp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
id := createResp["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty terminal id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the terminal a moment to output
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Read output
|
||||||
|
readHandler := tc.handlers["terminal_read"]
|
||||||
|
result, err = readHandler(map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"clear": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ = json.Marshal(result)
|
||||||
|
var readResp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &readResp)
|
||||||
|
if readResp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", readResp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close
|
||||||
|
closeHandler := tc.handlers["terminal_close"]
|
||||||
|
result, err = closeHandler(map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ = json.Marshal(result)
|
||||||
|
var closeResp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &closeResp)
|
||||||
|
if closeResp["status"] != "closed" {
|
||||||
|
t.Fatalf("expected status closed, got %v", closeResp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTerminalMissingArgs(t *testing.T) {
|
||||||
|
p, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_create"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if v, ok := resp["error"]; ok {
|
||||||
|
t.Skipf("PTY not available in this environment: %v", v)
|
||||||
|
}
|
||||||
|
if resp["status"] != "created" {
|
||||||
|
t.Fatalf("expected status created, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
id := resp["id"].(string)
|
||||||
|
p.handleClose(map[string]interface{}{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteToNonexistentTerminal(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_write"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"id": "nonexistent",
|
||||||
|
"input": "test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error for nonexistent terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadNonexistentTerminal(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_read"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"id": "nonexistent",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error for nonexistent terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResizeNonexistentTerminal(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_resize"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"id": "nonexistent",
|
||||||
|
"rows": float64(40),
|
||||||
|
"cols": float64(120),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error for nonexistent terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseNonexistentTerminal(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_close"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"id": "nonexistent",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error for nonexistent terminal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalWriteRequiresId(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_write"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"input": "test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error when id is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTerminalWriteRequiresContent(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["terminal_write"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"id": "test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error when both input and key are missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsRegistered(t *testing.T) {
|
||||||
|
p, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
expectedTools := []string{
|
||||||
|
"terminal_create",
|
||||||
|
"terminal_write",
|
||||||
|
"terminal_read",
|
||||||
|
"terminal_resize",
|
||||||
|
"terminal_close",
|
||||||
|
"terminal_list",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range expectedTools {
|
||||||
|
if _, ok := tc.handlers[name]; !ok {
|
||||||
|
t.Errorf("tool %s not registered", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,10 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||||
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
|
||||||
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer"
|
||||||
|
|||||||
115
internal/plugins/cmd/plugin.go
Normal file
115
internal/plugins/cmd/plugin.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return New(name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(name string) *Plugin {
|
||||||
|
return &Plugin{name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
s.RegisterTool("cmd_run", sdk.ToolDef{
|
||||||
|
Name: "cmd_run",
|
||||||
|
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"command": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要执行的命令",
|
||||||
|
},
|
||||||
|
"timeout": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "超时时间,例如 10s, 1m, 30s(默认 30s)",
|
||||||
|
},
|
||||||
|
"workdir": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "工作目录(可选,默认当前目录)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
command, _ := args["command"].(string)
|
||||||
|
if command == "" {
|
||||||
|
return map[string]interface{}{"error": "command is required"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutStr, _ := args["timeout"].(string)
|
||||||
|
if timeoutStr == "" {
|
||||||
|
timeoutStr = "30s"
|
||||||
|
}
|
||||||
|
timeout, err := time.ParseDuration(timeoutStr)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir, _ := args["workdir"].(string)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||||
|
if workdir != "" {
|
||||||
|
cmd.Dir = workdir
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "timeout",
|
||||||
|
"stdout": truncateOutput(stdout.String()),
|
||||||
|
"stderr": truncateOutput(stderr.String()),
|
||||||
|
"error": fmt.Sprintf("命令执行超时(%s)", timeoutStr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"stdout": truncateOutput(stdout.String()),
|
||||||
|
"stderr": truncateOutput(stderr.String()),
|
||||||
|
"exit_code": cmd.ProcessState.ExitCode(),
|
||||||
|
"command": command,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateOutput(s string) string {
|
||||||
|
const maxLen = 32000
|
||||||
|
if len(s) > maxLen {
|
||||||
|
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
|
||||||
|
}
|
||||||
|
return strings.TrimRight(s, "\n")
|
||||||
|
}
|
||||||
261
internal/plugins/cmd/plugin_test.go
Normal file
261
internal/plugins/cmd/plugin_test.go
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCapture struct {
|
||||||
|
handlers map[string]sdk.ToolHandler
|
||||||
|
defs map[string]sdk.ToolDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolCapture() *toolCapture {
|
||||||
|
return &toolCapture{
|
||||||
|
handlers: make(map[string]sdk.ToolHandler),
|
||||||
|
defs: make(map[string]sdk.ToolDef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
tc.handlers[name] = handler
|
||||||
|
tc.defs[name] = def
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||||
|
|
||||||
|
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||||
|
|
||||||
|
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||||
|
p := New("cmd")
|
||||||
|
tc := newToolCapture()
|
||||||
|
sdk := sdk.New("cmd", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||||
|
if err := p.Start(sdk); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return p, tc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunEcho(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, ok := tc.handlers["cmd_run"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("cmd_run tool not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"command": "echo hello world",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["stdout"] != "hello world" {
|
||||||
|
t.Fatalf("expected 'hello world', got %v", resp["stdout"])
|
||||||
|
}
|
||||||
|
if resp["exit_code"].(float64) != 0 {
|
||||||
|
t.Fatalf("expected exit code 0, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunWithStderr(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["cmd_run"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"command": "echo out && echo err >&2 && exit 1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["stdout"] != "out" {
|
||||||
|
t.Fatalf("expected stdout 'out', got %v", resp["stdout"])
|
||||||
|
}
|
||||||
|
if resp["stderr"] != "err" {
|
||||||
|
t.Fatalf("expected stderr 'err', got %v", resp["stderr"])
|
||||||
|
}
|
||||||
|
if resp["exit_code"].(float64) != 1 {
|
||||||
|
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunTimeout(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["cmd_run"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"command": "sleep 10",
|
||||||
|
"timeout": "1s",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "timeout" {
|
||||||
|
t.Fatalf("expected status timeout, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunWorkdir(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "cmd_test_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
marker := filepath.Join(tmpDir, "marker.txt")
|
||||||
|
if err := os.WriteFile(marker, []byte("ok"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["cmd_run"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"command": "cat marker.txt",
|
||||||
|
"workdir": tmpDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["stdout"] != "ok" {
|
||||||
|
t.Fatalf("expected 'ok', got %v", resp["stdout"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunMissingCommand(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["cmd_run"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if _, ok := resp["error"]; !ok {
|
||||||
|
t.Fatal("expected error for missing command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunNonZeroExit(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["cmd_run"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"command": "exit 42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["exit_code"].(float64) != 42 {
|
||||||
|
t.Fatalf("expected exit code 42, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateOutput(t *testing.T) {
|
||||||
|
short := "hello"
|
||||||
|
if s := truncateOutput(short); s != short {
|
||||||
|
t.Fatalf("expected %q, got %q", short, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
long := make([]byte, 40000)
|
||||||
|
for i := range long {
|
||||||
|
long[i] = 'x'
|
||||||
|
}
|
||||||
|
s := truncateOutput(string(long))
|
||||||
|
if len(s) >= 40000 {
|
||||||
|
t.Fatal("expected truncation")
|
||||||
|
}
|
||||||
|
if len(s) > 32100 {
|
||||||
|
t.Fatalf("truncated string too long: %d", len(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdRunPipeFail(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["cmd_run"]
|
||||||
|
result, err := handler(map[string]interface{}{
|
||||||
|
"command": "false",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if resp["exit_code"].(float64) != 1 {
|
||||||
|
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
}
|
||||||
678
internal/plugins/healthcheck/plugin.go
Normal file
678
internal/plugins/healthcheck/plugin.go
Normal file
@ -0,0 +1,678 @@
|
|||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
hcStageHost *agentCore.StageHost
|
||||||
|
hcIOMgr *agentIO.IOManager
|
||||||
|
hcPluginReg *plugin.Registry
|
||||||
|
hcMemory *memory.GraphDB
|
||||||
|
hcKnowledge *knowledge.Store
|
||||||
|
hcDocStore *doc.Store
|
||||||
|
hcProviderMgr *agentAPI.ProviderManager
|
||||||
|
hcStatusProvider agentCore.StatusProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Pass bool `json:"pass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// llmReport 由 LLM 通过 healthcheck_report 工具提交。
|
||||||
|
type llmReport struct {
|
||||||
|
ToolName string `json:"tool_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Configure(sh *agentCore.StageHost, iom *agentIO.IOManager, pr *plugin.Registry,
|
||||||
|
mem *memory.GraphDB, ks *knowledge.Store, ds *doc.Store, pm *agentAPI.ProviderManager, sp agentCore.StatusProvider) {
|
||||||
|
hcStageHost = sh
|
||||||
|
hcIOMgr = iom
|
||||||
|
hcPluginReg = pr
|
||||||
|
hcMemory = mem
|
||||||
|
hcKnowledge = ks
|
||||||
|
hcDocStore = ds
|
||||||
|
hcProviderMgr = pm
|
||||||
|
hcStatusProvider = sp
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
plugin.RegisterFactory("healthcheck", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
if hcStageHost == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return New(name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
mu sync.Mutex
|
||||||
|
reports []llmReport
|
||||||
|
sessionID string
|
||||||
|
selfToolNames map[string]bool
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
perfData PerfData
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerfData struct {
|
||||||
|
LastCheck time.Time `json:"last_check"`
|
||||||
|
Checks []PerfCheckPoint `json:"checks"`
|
||||||
|
}
|
||||||
|
type PerfCheckPoint struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Passed int `json:"passed"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
ElapsedMs int64 `json:"elapsed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(name string) *Plugin {
|
||||||
|
return &Plugin{
|
||||||
|
name: name,
|
||||||
|
selfToolNames: make(map[string]bool),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.selfToolNames["healthcheck"] = true
|
||||||
|
s.RegisterTool("healthcheck", sdk.ToolDef{
|
||||||
|
Name: "healthcheck",
|
||||||
|
Description: "运行系统全面健康检查。先执行静态检查(插件/工具列表/记忆/知识库/文档),再启动 LLM 驱动检查:LLM 主动发现并逐个测试各插件提供的工具,并通过 healthcheck_report 上报结果。返回详细的状态报告。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.runFullCheck(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
p.selfToolNames["healthcheck_plugins"] = true
|
||||||
|
s.RegisterTool("healthcheck_plugins", sdk.ToolDef{
|
||||||
|
Name: "healthcheck_plugins",
|
||||||
|
Description: "列出所有已加载的插件及其状态。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.checkPlugins(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
p.selfToolNames["healthcheck_tools"] = true
|
||||||
|
s.RegisterTool("healthcheck_tools", sdk.ToolDef{
|
||||||
|
Name: "healthcheck_tools",
|
||||||
|
Description: "列出系统中所有已注册的工具及其来源。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.listAllTools()
|
||||||
|
})
|
||||||
|
|
||||||
|
p.selfToolNames["healthcheck_memory"] = true
|
||||||
|
s.RegisterTool("healthcheck_memory", sdk.ToolDef{
|
||||||
|
Name: "healthcheck_memory",
|
||||||
|
Description: "测试图记忆系统:写入、查询、清理一条测试实体。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return p.checkMemory()
|
||||||
|
})
|
||||||
|
|
||||||
|
p.selfToolNames["healthcheck_report"] = true
|
||||||
|
s.RegisterTool("healthcheck_report", sdk.ToolDef{
|
||||||
|
Name: "healthcheck_report",
|
||||||
|
Description: "LLM 健康检查结果上报工具。LLM 在逐一测试各工具后,通过此工具提交每个工具的测试状态。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"tool_name": map[string]interface{}{"type": "string", "description": "被测试的工具名称"},
|
||||||
|
"status": map[string]interface{}{"type": "string", "description": "测试结果:ok / fail / skip", "enum": []string{"ok", "fail", "skip"}},
|
||||||
|
"detail": map[string]interface{}{"type": "string", "description": "测试详情或错误描述"},
|
||||||
|
},
|
||||||
|
"required": []string{"tool_name", "status"},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
toolName, _ := args["tool_name"].(string)
|
||||||
|
status, _ := args["status"].(string)
|
||||||
|
detail, _ := args["detail"].(string)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.reports = append(p.reports, llmReport{ToolName: toolName, Status: status, Detail: detail})
|
||||||
|
count := len(p.reports)
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("[healthcheck] LLM report: tool=%s status=%s (total %d)", toolName, status, count)
|
||||||
|
return map[string]interface{}{"ok": true, "received": count}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if hcStatusProvider != nil {
|
||||||
|
p.selfToolNames["healthcheck_kernel"] = true
|
||||||
|
s.RegisterTool("healthcheck_kernel", sdk.ToolDef{
|
||||||
|
Name: "healthcheck_kernel",
|
||||||
|
Description: "查询 Agent 内核运行状态快照,包括插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return hcStatusProvider.GetKernelStatus(), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
p.selfToolNames["healthcheck_perf"] = true
|
||||||
|
s.RegisterTool("healthcheck_perf", sdk.ToolDef{
|
||||||
|
Name: "healthcheck_perf",
|
||||||
|
Description: "查询健康检查性能监控数据,包括最近检查时间、历史检查记录(最多 100 条)及通过/失败统计。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
passed := 0
|
||||||
|
failed := 0
|
||||||
|
for _, c := range p.perfData.Checks {
|
||||||
|
passed += c.Passed
|
||||||
|
failed += c.Failed
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"last_check": p.perfData.LastCheck,
|
||||||
|
"total_checks": len(p.perfData.Checks),
|
||||||
|
"total_passed": passed,
|
||||||
|
"total_failed": failed,
|
||||||
|
"history": p.perfData.Checks,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
p.startAutoCheck(s, 30*time.Minute)
|
||||||
|
|
||||||
|
log.Printf("[healthcheck] ready (stageHost=%v iom=%v reg=%v mem=%v ks=%v ds=%v pm=%v sp=%v)",
|
||||||
|
hcStageHost != nil, hcIOMgr != nil, hcPluginReg != nil,
|
||||||
|
hcMemory != nil, hcKnowledge != nil, hcDocStore != nil, hcProviderMgr != nil, hcStatusProvider != nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
p.stopOnce.Do(func() {
|
||||||
|
close(p.stopCh)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) startAutoCheck(s *sdk.PluginSDK, interval time.Duration) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
p.runAutoCheck(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Printf("[healthcheck] auto-check started, interval=%v", interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) {
|
||||||
|
result, err := p.runFullCheck(s)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[healthcheck] auto-check error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, _ := result.(map[string]interface{})
|
||||||
|
passed, _ := resp["passed"].(int)
|
||||||
|
failed, _ := resp["failed"].(int)
|
||||||
|
total, _ := resp["total"].(int)
|
||||||
|
elapsed := int64(0)
|
||||||
|
if checks, ok := resp["checks"]; ok {
|
||||||
|
if arr, ok := checks.([]checkResult); ok && len(arr) > 0 {
|
||||||
|
elapsed = int64(len(arr)) // approximate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pt := PerfCheckPoint{
|
||||||
|
Time: time.Now(),
|
||||||
|
Passed: passed,
|
||||||
|
Failed: failed,
|
||||||
|
Total: total,
|
||||||
|
ElapsedMs: elapsed,
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.perfData.LastCheck = pt.Time
|
||||||
|
p.perfData.Checks = append(p.perfData.Checks, pt)
|
||||||
|
if len(p.perfData.Checks) > 100 {
|
||||||
|
p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-100:]
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[healthcheck] auto-check complete: passed=%d failed=%d total=%d", passed, failed, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) {
|
||||||
|
results := []checkResult{}
|
||||||
|
|
||||||
|
pluginResult := p.checkPluginsRaw()
|
||||||
|
results = append(results, pluginResult...)
|
||||||
|
|
||||||
|
toolResult := p.checkToolsRaw()
|
||||||
|
results = append(results, toolResult...)
|
||||||
|
|
||||||
|
if hcMemory != nil {
|
||||||
|
r := p.testMemoryRaw()
|
||||||
|
results = append(results, r)
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{Name: "memory", Status: "skip", Detail: "图记忆未初始化", Pass: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hcKnowledge != nil {
|
||||||
|
r := p.testKnowledgeRaw()
|
||||||
|
results = append(results, r)
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{Name: "knowledge", Status: "skip", Detail: "知识库未初始化", Pass: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hcDocStore != nil {
|
||||||
|
r := p.testDocStoreRaw()
|
||||||
|
results = append(results, r)
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{Name: "documents", Status: "skip", Detail: "文档记忆未初始化", Pass: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hcProviderMgr != nil {
|
||||||
|
r := p.testLLMDriven()
|
||||||
|
results = append(results, r)
|
||||||
|
} else {
|
||||||
|
results = append(results, checkResult{Name: "llm_discovery", Status: "skip", Detail: "LLM Provider 未初始化", Pass: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
passCount := 0
|
||||||
|
failCount := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Pass {
|
||||||
|
passCount++
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := fmt.Sprintf("通过 %d / %d, 失败 %d", passCount, len(results), failCount)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"summary": summary,
|
||||||
|
"total": len(results),
|
||||||
|
"passed": passCount,
|
||||||
|
"failed": failCount,
|
||||||
|
"checks": results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) checkPlugins(s *sdk.PluginSDK) (interface{}, error) {
|
||||||
|
results := p.checkPluginsRaw()
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"plugins": results,
|
||||||
|
"count": len(results),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) checkPluginsRaw() []checkResult {
|
||||||
|
if hcPluginReg == nil {
|
||||||
|
return []checkResult{{Name: "plugins", Status: "skip", Detail: "插件注册表未初始化", Pass: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
names := hcPluginReg.List()
|
||||||
|
if names == nil {
|
||||||
|
names = []string{}
|
||||||
|
}
|
||||||
|
return []checkResult{{
|
||||||
|
Name: "plugins",
|
||||||
|
Status: "ok",
|
||||||
|
Detail: fmt.Sprintf("已加载 %d 个插件: %v", len(names), names),
|
||||||
|
Pass: true,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) listAllTools() (interface{}, error) {
|
||||||
|
tools := p.collectAllTools()
|
||||||
|
return map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"count": len(tools),
|
||||||
|
"tools": tools,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) checkToolsRaw() []checkResult {
|
||||||
|
tools := p.collectAllTools()
|
||||||
|
return []checkResult{{
|
||||||
|
Name: "tools",
|
||||||
|
Status: "ok",
|
||||||
|
Detail: fmt.Sprintf("系统中共 %d 个工具", len(tools)),
|
||||||
|
Pass: true,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) collectAllTools() []toolInfo {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var tools []toolInfo
|
||||||
|
|
||||||
|
addTool := func(name, source, desc string) {
|
||||||
|
if seen[name] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
tools = append(tools, toolInfo{Name: name, Source: source, Description: desc})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hcStageHost != nil {
|
||||||
|
for _, def := range hcStageHost.GetToolDefs() {
|
||||||
|
addTool(def.Name, "plugin", def.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hcIOMgr != nil {
|
||||||
|
for _, def := range hcIOMgr.GetAllTools() {
|
||||||
|
addTool(def.Name, "device", def.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) testMemoryRaw() checkResult {
|
||||||
|
marker := fmt.Sprintf("_hc_%d", time.Now().UnixNano())
|
||||||
|
triples := []memory.Triple{
|
||||||
|
{Subject: marker, Relation: "is", Object: "healthcheck_test", SubjectType: "System", ObjectType: "Flag"},
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
ec, rc, err := hcMemory.Commit(triples, "healthcheck", 0)
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{Name: "memory_write", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := hcMemory.Commit(triples, "healthcheck_cleanup", 0); err != nil {
|
||||||
|
log.Printf("[healthcheck] memory cleanup error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := hcMemory.Purge(map[string]string{"subject_contains": marker}, "hard")
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{Name: "memory_purge", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
return checkResult{
|
||||||
|
Name: "memory",
|
||||||
|
Status: "ok",
|
||||||
|
Detail: fmt.Sprintf("写入 %d 实体/%d 关系, 清理 %d 条, 耗时 %v", ec, rc, n, elapsed.Round(time.Millisecond)),
|
||||||
|
Pass: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) testKnowledgeRaw() checkResult {
|
||||||
|
marker := fmt.Sprintf("_hc_knowledge_test_%d", time.Now().UnixNano())
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
if err := hcKnowledge.Add(marker, "健康检查测试标记,可忽略"); err != nil {
|
||||||
|
return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := hcKnowledge.Search("健康检查测试标记", 3)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if len(results) > 0 {
|
||||||
|
return checkResult{
|
||||||
|
Name: "knowledge",
|
||||||
|
Status: "ok",
|
||||||
|
Detail: fmt.Sprintf("写入+查询正常, 耗时 %v", elapsed.Round(time.Millisecond)),
|
||||||
|
Pass: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkResult{
|
||||||
|
Name: "knowledge",
|
||||||
|
Status: "warn",
|
||||||
|
Detail: fmt.Sprintf("写入成功但查询缓存未命中, 耗时 %v", elapsed.Round(time.Millisecond)),
|
||||||
|
Pass: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) testDocStoreRaw() checkResult {
|
||||||
|
start := time.Now()
|
||||||
|
doc := &doc.Doc{
|
||||||
|
Summary: "健康检查测试文档",
|
||||||
|
Content: "这是一条由 healthcheck 插件创建的测试文档,用于验证文档记忆系统是否正常工作。",
|
||||||
|
Tags: []string{"healthcheck", "test"},
|
||||||
|
Source: "healthcheck",
|
||||||
|
}
|
||||||
|
if err := hcDocStore.Insert(doc); err != nil {
|
||||||
|
return checkResult{Name: "documents", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
if doc.ID != "" {
|
||||||
|
hcDocStore.Remove(doc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
return checkResult{
|
||||||
|
Name: "documents",
|
||||||
|
Status: "ok",
|
||||||
|
Detail: fmt.Sprintf("写入+删除正常, 耗时 %v", elapsed.Round(time.Millisecond)),
|
||||||
|
Pass: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) testLLMDriven() checkResult {
|
||||||
|
provider := hcProviderMgr.Default()
|
||||||
|
if provider == nil {
|
||||||
|
return checkResult{Name: "llm_discovery", Status: "skip", Detail: "无可用 LLM Provider", Pass: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 收集所有工具定义(排除健康检查自身的工具以避免循环测试)
|
||||||
|
toolDefs := p.collectToolDefsForLLM()
|
||||||
|
|
||||||
|
if len(toolDefs) == 0 {
|
||||||
|
return checkResult{Name: "llm_discovery", Status: "skip", Detail: "没有可测试的工具", Pass: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置会话
|
||||||
|
p.mu.Lock()
|
||||||
|
p.reports = nil
|
||||||
|
p.sessionID = fmt.Sprintf("hc_llm_%d", time.Now().UnixNano())
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// 构建 prompt
|
||||||
|
prompt := p.buildDiscoveryPrompt(toolDefs)
|
||||||
|
|
||||||
|
msgs := []agentAPI.Message{{Role: "user", Content: prompt}}
|
||||||
|
tools := convertToolDefs(toolDefs)
|
||||||
|
|
||||||
|
llmName := provider.Name()
|
||||||
|
turnCount := 0
|
||||||
|
toolCallCount := 0
|
||||||
|
|
||||||
|
for turn := 0; turn < 20; turn++ {
|
||||||
|
resp, err := provider.Chat(ctx, &agentAPI.CompletionRequest{
|
||||||
|
Messages: msgs,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
Tools: tools,
|
||||||
|
ToolChoice: "auto",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{
|
||||||
|
Name: "llm_discovery",
|
||||||
|
Status: "fail",
|
||||||
|
Detail: fmt.Sprintf("Provider %s 第 %d 轮调用失败: %v (耗时 %v)",
|
||||||
|
llmName, turn+1, err, time.Since(start).Round(time.Millisecond)),
|
||||||
|
Pass: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turnCount++
|
||||||
|
|
||||||
|
if len(resp.ToolCalls) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls})
|
||||||
|
|
||||||
|
for _, tc := range resp.ToolCalls {
|
||||||
|
toolCallCount++
|
||||||
|
content := p.executeToolForLLM(tc)
|
||||||
|
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
reportCount := len(p.reports)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
elapsed := time.Since(start).Round(time.Millisecond)
|
||||||
|
|
||||||
|
detail := fmt.Sprintf("Provider %s, %d 轮对话, %d 次工具调用, %d 份报告, 耗时 %v",
|
||||||
|
llmName, turnCount, toolCallCount, reportCount, elapsed)
|
||||||
|
|
||||||
|
return checkResult{
|
||||||
|
Name: "llm_discovery",
|
||||||
|
Status: "ok",
|
||||||
|
Detail: detail,
|
||||||
|
Pass: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectToolDefsForLLM 收集全部已注册的工具定义供 LLM 发现和测试。
|
||||||
|
// 动态排除本插件自身注册的工具(通过 selfToolNames),避免 LLM 自我循环调用。
|
||||||
|
func (p *Plugin) collectToolDefsForLLM() []sdk.ToolDef {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var defs []sdk.ToolDef
|
||||||
|
|
||||||
|
addDef := func(d sdk.ToolDef) {
|
||||||
|
if p.selfToolNames[d.Name] || seen[d.Name] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[d.Name] = true
|
||||||
|
defs = append(defs, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hcStageHost != nil {
|
||||||
|
for _, d := range hcStageHost.GetToolDefs() {
|
||||||
|
addDef(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hcIOMgr != nil {
|
||||||
|
for _, d := range hcIOMgr.GetAllTools() {
|
||||||
|
addDef(sdk.ToolDef{Name: d.Name, Description: d.Description, Parameters: d.Parameters})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiscoveryPrompt 为 LLM 构造工具探索 prompt。
|
||||||
|
func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具):
|
||||||
|
|
||||||
|
你的任务是:逐一尝试调用这些工具,验证它们是否正常工作,并对于每个工具使用 healthcheck_report 工具上报测试结果。
|
||||||
|
|
||||||
|
对于每个工具:
|
||||||
|
1. 理解它的参数和功能
|
||||||
|
2. 构造合适的测试参数调用它
|
||||||
|
3. 根据返回结果判断是否正常
|
||||||
|
4. 调用 healthcheck_report 工具上报(tool_name, status=ok/fail/skip, detail=详情)
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 有些工具有副作用(如写入数据),请使用安全参数,测试后应清理
|
||||||
|
- 尽可能覆盖所有工具
|
||||||
|
- 每个工具只需测试一次
|
||||||
|
|
||||||
|
开始测试!`, len(toolDefs)))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeToolForLLM 在 LLM 工具循环中执行工具调用。
|
||||||
|
// healthcheck_report 通过 StageHost 路由到自身注册的 handler,负责收集 LLM 上报。
|
||||||
|
func (p *Plugin) executeToolForLLM(tc agentAPI.ToolCall) string {
|
||||||
|
if hcStageHost != nil {
|
||||||
|
result, err := hcStageHost.ExecuteTool(tc.Name, tc.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("调用工具 %s 失败: %v", tc.Name, err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("工具 %s 不可执行(StageHost 未初始化)", tc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertToolDefs(defs []sdk.ToolDef) []interface{} {
|
||||||
|
tools := make([]interface{}, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
tools[i] = map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]interface{}{
|
||||||
|
"name": d.Name,
|
||||||
|
"description": d.Description,
|
||||||
|
"parameters": d.Parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) checkMemory() (interface{}, error) {
|
||||||
|
if hcMemory == nil {
|
||||||
|
return map[string]interface{}{"status": "skip", "pass": true, "detail": "图记忆未初始化"}, nil
|
||||||
|
}
|
||||||
|
r := p.testMemoryRaw()
|
||||||
|
c := map[string]interface{}{
|
||||||
|
"status": r.Status,
|
||||||
|
"pass": r.Pass,
|
||||||
|
}
|
||||||
|
if r.Detail != "" {
|
||||||
|
c["detail"] = r.Detail
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
350
internal/plugins/healthcheck/plugin_test.go
Normal file
350
internal/plugins/healthcheck/plugin_test.go
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolCapture struct {
|
||||||
|
handlers map[string]sdk.ToolHandler
|
||||||
|
defs map[string]sdk.ToolDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolCapture() *toolCapture {
|
||||||
|
return &toolCapture{
|
||||||
|
handlers: make(map[string]sdk.ToolHandler),
|
||||||
|
defs: make(map[string]sdk.ToolDef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
tc.handlers[name] = handler
|
||||||
|
tc.defs[name] = def
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||||
|
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||||
|
|
||||||
|
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||||
|
sh := agentCore.NewStageHost()
|
||||||
|
iom := agentIO.NewIOManager()
|
||||||
|
pr := plugin.NewRegistry()
|
||||||
|
|
||||||
|
Configure(sh, iom, pr, nil, nil, nil, nil, nil)
|
||||||
|
p := New("healthcheck")
|
||||||
|
tc := newToolCapture()
|
||||||
|
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||||
|
if err := p.Start(sdk); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return p, tc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsRegistered(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{
|
||||||
|
"healthcheck",
|
||||||
|
"healthcheck_plugins",
|
||||||
|
"healthcheck_tools",
|
||||||
|
"healthcheck_memory",
|
||||||
|
"healthcheck_report",
|
||||||
|
"healthcheck_perf",
|
||||||
|
}
|
||||||
|
for _, name := range expected {
|
||||||
|
if _, ok := tc.handlers[name]; !ok {
|
||||||
|
t.Errorf("tool %q not registered", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckFull(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := resp["checks"].([]interface{})
|
||||||
|
if len(checks) == 0 {
|
||||||
|
t.Fatal("expected at least some checks")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range checks {
|
||||||
|
cr := c.(map[string]interface{})
|
||||||
|
name := cr["name"].(string)
|
||||||
|
pass := cr["pass"].(bool)
|
||||||
|
if !pass && cr["status"] != "skip" {
|
||||||
|
t.Errorf("check %q failed: %v (detail: %v)", name, cr["status"], cr["detail"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckPlugins(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck_plugins"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckToolsList(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck_tools"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckMemoryNotAvailable(t *testing.T) {
|
||||||
|
_, tc, err := setupPlugin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck_memory"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
// Memory is nil in this setup, so it should skip gracefully
|
||||||
|
if _, ok := resp["pass"]; ok {
|
||||||
|
pass := resp["pass"].(bool)
|
||||||
|
if !pass {
|
||||||
|
t.Fatalf("expected pass=true when memory is nil, got false: %v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckWithMemory(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "hc_test_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
memDB, err := memory.NewGraphDB(tmpDir + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer memDB.Close()
|
||||||
|
|
||||||
|
sh := agentCore.NewStageHost()
|
||||||
|
iom := agentIO.NewIOManager()
|
||||||
|
pr := plugin.NewRegistry()
|
||||||
|
|
||||||
|
Configure(sh, iom, pr, memDB, nil, nil, nil, nil)
|
||||||
|
p := New("healthcheck")
|
||||||
|
tc := newToolCapture()
|
||||||
|
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||||
|
if err := p.Start(sdk); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck_memory"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
if pass, ok := resp["pass"].(bool); !ok || !pass {
|
||||||
|
t.Fatalf("expected pass=true, got pass=%v status=%v detail=%v", pass, resp["status"], resp["detail"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckWithKnowledge(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "hc_know_test_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
ks := knowledge.NewStore(tmpDir)
|
||||||
|
if err := ks.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ks.Stop()
|
||||||
|
|
||||||
|
sh := agentCore.NewStageHost()
|
||||||
|
iom := agentIO.NewIOManager()
|
||||||
|
pr := plugin.NewRegistry()
|
||||||
|
|
||||||
|
Configure(sh, iom, pr, nil, ks, nil, nil, nil)
|
||||||
|
p := New("healthcheck")
|
||||||
|
tc := newToolCapture()
|
||||||
|
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||||
|
if err := p.Start(sdk); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := resp["checks"].([]interface{})
|
||||||
|
var knowledgeCheck map[string]interface{}
|
||||||
|
for _, c := range checks {
|
||||||
|
cr := c.(map[string]interface{})
|
||||||
|
if cr["name"] == "knowledge" {
|
||||||
|
knowledgeCheck = cr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if knowledgeCheck == nil {
|
||||||
|
t.Fatal("expected knowledge check in results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthcheckWithDocStore(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "hc_doc_test_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
ds := doc.NewStore(tmpDir)
|
||||||
|
if err := ds.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ds.Stop()
|
||||||
|
|
||||||
|
sh := agentCore.NewStageHost()
|
||||||
|
iom := agentIO.NewIOManager()
|
||||||
|
pr := plugin.NewRegistry()
|
||||||
|
|
||||||
|
Configure(sh, iom, pr, nil, nil, ds, nil, nil)
|
||||||
|
p := New("healthcheck")
|
||||||
|
tc := newToolCapture()
|
||||||
|
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||||
|
if err := p.Start(sdk); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := tc.handlers["healthcheck"]
|
||||||
|
result, err := handler(map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := resp["checks"].([]interface{})
|
||||||
|
var docCheck map[string]interface{}
|
||||||
|
for _, c := range checks {
|
||||||
|
cr := c.(map[string]interface{})
|
||||||
|
if cr["name"] == "documents" {
|
||||||
|
docCheck = cr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if docCheck == nil {
|
||||||
|
t.Fatal("expected documents check in results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLLMReportCollection(t *testing.T) {
|
||||||
|
p := &Plugin{name: "healthcheck"}
|
||||||
|
if len(p.reports) != 0 {
|
||||||
|
t.Fatal("expected empty reports")
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.reports = append(p.reports, llmReport{ToolName: "test_tool", Status: "ok", Detail: "test passed"})
|
||||||
|
count := len(p.reports)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1 report, got %d", count)
|
||||||
|
}
|
||||||
|
if p.reports[0].ToolName != "test_tool" {
|
||||||
|
t.Fatalf("expected tool_name=test_tool, got %s", p.reports[0].ToolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureNilStageHost(t *testing.T) {
|
||||||
|
Configure(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
if hcStageHost != nil {
|
||||||
|
t.Fatal("expected hcStageHost to be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
584
internal/plugins/integration_test.go
Normal file
584
internal/plugins/integration_test.go
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||||
|
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||||
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
|
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||||
|
healthcheck "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||||
|
openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||||
|
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testPluginEnv struct {
|
||||||
|
tmpDir string
|
||||||
|
stageHost *agentCore.StageHost
|
||||||
|
iom *agentIO.IOManager
|
||||||
|
pluginReg *plugin.Registry
|
||||||
|
memDB *memory.GraphDB
|
||||||
|
ks *knowledge.Store
|
||||||
|
docStore *doc.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupIntegration(t *testing.T) *testPluginEnv {
|
||||||
|
return setupIntegrationWithProvider(t, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *testPluginEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "hc_integration_*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stageHost := agentCore.NewStageHost()
|
||||||
|
iom := agentIO.NewIOManager()
|
||||||
|
pluginReg := plugin.NewRegistry()
|
||||||
|
|
||||||
|
pluginReg.SetIOManager(iom)
|
||||||
|
pluginReg.SetMemory(nil)
|
||||||
|
|
||||||
|
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
return stageHost.RegisterTool(name, def, handler)
|
||||||
|
})
|
||||||
|
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
|
stageHost.RegisterStage(stage, handler)
|
||||||
|
})
|
||||||
|
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
memDB, err := memory.NewGraphDB(filepath.Join(tmpDir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pluginReg.SetMemory(memDB)
|
||||||
|
|
||||||
|
ks := knowledge.NewStore(filepath.Join(tmpDir, "knowledge"))
|
||||||
|
if err := ks.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
docStore := doc.NewStore(filepath.Join(tmpDir, "documents"))
|
||||||
|
if err := docStore.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock")
|
||||||
|
openclaw.SkillsDir = filepath.Join(tmpDir, "skills")
|
||||||
|
os.MkdirAll(openclaw.SkillsDir, 0755)
|
||||||
|
webui.Configure(":0", nil, memDB, nil, nil, nil, iom, nil, ks, nil, nil, pluginReg, nil, nil)
|
||||||
|
healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, pm, nil)
|
||||||
|
|
||||||
|
plgDir := filepath.Join(tmpDir, "plugins")
|
||||||
|
os.MkdirAll(plgDir, 0755)
|
||||||
|
if err := pluginReg.Load(plgDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testPluginEnv{
|
||||||
|
tmpDir: tmpDir,
|
||||||
|
stageHost: stageHost,
|
||||||
|
iom: iom,
|
||||||
|
pluginReg: pluginReg,
|
||||||
|
memDB: memDB,
|
||||||
|
ks: ks,
|
||||||
|
docStore: docStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testPluginEnv) cleanup() {
|
||||||
|
e.pluginReg.StopAll()
|
||||||
|
e.memDB.Close()
|
||||||
|
e.ks.Stop()
|
||||||
|
e.docStore.Stop()
|
||||||
|
os.RemoveAll(e.tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationAllPluginsRegister(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
expectedTools := []string{
|
||||||
|
"cmd_run",
|
||||||
|
"terminal_create", "terminal_write", "terminal_read",
|
||||||
|
"terminal_resize", "terminal_close", "terminal_list",
|
||||||
|
"healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf",
|
||||||
|
"timer_set",
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := env.stageHost.GetToolDefs()
|
||||||
|
registered := make(map[string]bool)
|
||||||
|
for _, def := range defs {
|
||||||
|
registered[def.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
for _, tool := range expectedTools {
|
||||||
|
if !registered[tool] {
|
||||||
|
missing = append(missing, tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
all := make([]string, 0, len(defs))
|
||||||
|
for _, d := range defs {
|
||||||
|
all = append(all, d.Name)
|
||||||
|
}
|
||||||
|
t.Fatalf("missing tools: %v\nall registered: %v", missing, all)
|
||||||
|
}
|
||||||
|
t.Logf("all %d expected tools registered (total: %d)", len(expectedTools), len(defs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// cmd_run
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationCmdRun(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||||
|
"command": "echo hello_world",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["exit_code"] != 0.0 {
|
||||||
|
t.Fatalf("expected exit_code 0, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
stdout, ok := resp["stdout"].(string)
|
||||||
|
if !ok || stdout != "hello_world" {
|
||||||
|
t.Fatalf("expected stdout 'hello_world', got %q", stdout)
|
||||||
|
}
|
||||||
|
t.Logf("cmd_run OK: exit_code=0 stdout=%q", stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCmdRunWithWorkdir(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||||
|
"command": "pwd",
|
||||||
|
"workdir": "/tmp",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["exit_code"] != 0.0 {
|
||||||
|
t.Fatalf("expected exit_code 0, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
stdout := resp["stdout"].(string)
|
||||||
|
if stdout != "/tmp" {
|
||||||
|
t.Fatalf("expected stdout '/tmp', got %q", stdout)
|
||||||
|
}
|
||||||
|
t.Logf("cmd_run workdir OK: stdout=%q", stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCmdRunInvalidTimeout(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||||
|
"command": "echo ok",
|
||||||
|
"timeout": "not-a-duration",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
errMsg, ok := resp["error"].(string)
|
||||||
|
if !ok || errMsg == "" {
|
||||||
|
t.Fatalf("expected error for invalid timeout, got %v", resp)
|
||||||
|
}
|
||||||
|
t.Logf("cmd_run invalid_timeout OK: error=%q", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationCmdRunStderr(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||||
|
"command": "echo stderr_test >&2",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["exit_code"] != 0.0 {
|
||||||
|
t.Fatalf("expected exit_code 0, got %v", resp["exit_code"])
|
||||||
|
}
|
||||||
|
stderr := resp["stderr"].(string)
|
||||||
|
if stderr != "stderr_test" {
|
||||||
|
t.Fatalf("expected stderr 'stderr_test', got %q", stderr)
|
||||||
|
}
|
||||||
|
t.Logf("cmd_run stderr OK: stderr=%q", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Terminal (PTY)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationPtyCreateListClose(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
// Create
|
||||||
|
result, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
|
||||||
|
"shell": "/bin/sh",
|
||||||
|
"name": "hci_test_shell",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
id, ok := resp["id"].(string)
|
||||||
|
if !ok || id == "" {
|
||||||
|
status := resp["status"]
|
||||||
|
detail := resp["detail"]
|
||||||
|
if status == "error" {
|
||||||
|
t.Skipf("PTY not available: %v", detail)
|
||||||
|
}
|
||||||
|
t.Fatalf("expected non-empty terminal id, got id=%q status=%v detail=%v", id, status, detail)
|
||||||
|
}
|
||||||
|
t.Logf("terminal_create OK: id=%s", id)
|
||||||
|
|
||||||
|
// List
|
||||||
|
result2, err := env.stageHost.ExecuteTool("terminal_list", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data2, _ := json.Marshal(result2)
|
||||||
|
var resp2 map[string]interface{}
|
||||||
|
json.Unmarshal(data2, &resp2)
|
||||||
|
|
||||||
|
terminals, ok := resp2["terminals"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected terminals array, got %T", resp2["terminals"])
|
||||||
|
}
|
||||||
|
if len(terminals) < 1 {
|
||||||
|
t.Fatal("expected at least 1 terminal")
|
||||||
|
}
|
||||||
|
t.Logf("terminal_list OK: %d terminals", len(terminals))
|
||||||
|
|
||||||
|
// Close
|
||||||
|
_, err = env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("terminal_close OK: id=%s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPtyInteractive(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
|
||||||
|
"shell": "/bin/sh",
|
||||||
|
"name": "hci_interactive",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cdata, _ := json.Marshal(createResult)
|
||||||
|
var cresp map[string]interface{}
|
||||||
|
json.Unmarshal(cdata, &cresp)
|
||||||
|
|
||||||
|
id, ok := cresp["id"].(string)
|
||||||
|
if !ok || id == "" {
|
||||||
|
status := cresp["status"]
|
||||||
|
if status == "error" {
|
||||||
|
t.Skipf("PTY not available: %v", cresp["detail"])
|
||||||
|
}
|
||||||
|
t.Fatalf("expected terminal id, got %v", cresp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a command
|
||||||
|
_, err = env.stageHost.ExecuteTool("terminal_write", map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"input": "echo pty_works\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Read output
|
||||||
|
readResult, err := env.stageHost.ExecuteTool("terminal_read", map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rdata, _ := json.Marshal(readResult)
|
||||||
|
var rresp map[string]interface{}
|
||||||
|
json.Unmarshal(rdata, &rresp)
|
||||||
|
|
||||||
|
output, ok := rresp["output"].(string)
|
||||||
|
if !ok || output == "" {
|
||||||
|
t.Fatalf("expected output, got output=%q response=%v", output, rresp)
|
||||||
|
}
|
||||||
|
t.Logf("terminal_write+read OK: output=%q", output)
|
||||||
|
|
||||||
|
// Close
|
||||||
|
env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPtyResize(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
|
||||||
|
"shell": "/bin/sh",
|
||||||
|
"name": "hci_resize_test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cdata, _ := json.Marshal(createResult)
|
||||||
|
var cresp map[string]interface{}
|
||||||
|
json.Unmarshal(cdata, &cresp)
|
||||||
|
|
||||||
|
id, ok := cresp["id"].(string)
|
||||||
|
if !ok || id == "" {
|
||||||
|
status := cresp["status"]
|
||||||
|
if status == "error" {
|
||||||
|
t.Skipf("PTY not available: %v", cresp["detail"])
|
||||||
|
}
|
||||||
|
t.Fatalf("expected terminal id, got %v", cresp)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.stageHost.ExecuteTool("terminal_resize", map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"rows": 80.0,
|
||||||
|
"cols": 200.0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("terminal_resize OK: id=%s rows=80 cols=200", id)
|
||||||
|
|
||||||
|
env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Healthcheck
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestIntegrationHealthcheck(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("healthcheck", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
checks := resp["checks"].([]interface{})
|
||||||
|
if len(checks) == 0 {
|
||||||
|
t.Fatal("expected non-empty checks array")
|
||||||
|
}
|
||||||
|
t.Logf("healthcheck OK: %d checks, status=%v", len(checks), resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationHealthcheckPlugins(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("healthcheck_plugins", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
t.Logf("healthcheck_plugins OK: status=%v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationHealthcheckToolsList(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("healthcheck_tools", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
t.Logf("healthcheck_tools OK: status=%v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationHealthcheckMemory(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
result, err := env.stageHost.ExecuteTool("healthcheck_memory", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
t.Logf("healthcheck_memory OK: status=%v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationToolNotFound(t *testing.T) {
|
||||||
|
env := setupIntegration(t)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
_, err := env.stageHost.ExecuteTool("nonexistent_tool_xyz", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent tool")
|
||||||
|
}
|
||||||
|
t.Logf("tool_not_found OK: err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationLLMDrivenDiscoveryWithRealKey(t *testing.T) {
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
t.Skip("DEEPSEEK_API_KEY not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lua VM + DeepSeek Provider
|
||||||
|
tmpVM := t.TempDir()
|
||||||
|
vm := luaVM.NewVM(tmpVM + "/adapters")
|
||||||
|
if err := vm.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer vm.Stop()
|
||||||
|
|
||||||
|
pm := agentAPI.NewProviderManager()
|
||||||
|
pm.Register("deepseek", agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||||
|
Model: "deepseek-v4-flash",
|
||||||
|
BaseURL: "https://api.deepseek.com",
|
||||||
|
APIKey: apiKey,
|
||||||
|
}, vm, "deepseek"))
|
||||||
|
|
||||||
|
// Setup — 加载所有真实内置插件
|
||||||
|
env := setupIntegrationWithProvider(t, pm)
|
||||||
|
defer env.cleanup()
|
||||||
|
|
||||||
|
// 列出已加载的真实工具
|
||||||
|
defs := env.stageHost.GetToolDefs()
|
||||||
|
t.Logf("共有 %d 个已注册的真实工具:", len(defs))
|
||||||
|
for _, d := range defs {
|
||||||
|
t.Logf(" - %s", d.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 healthcheck 进行全面检查(含 LLM 驱动阶段)
|
||||||
|
result, err := env.stageHost.ExecuteTool("healthcheck", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(result)
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(data, &resp)
|
||||||
|
|
||||||
|
t.Logf("===== Full Healthcheck Result =====")
|
||||||
|
t.Logf("status: %v", resp["status"])
|
||||||
|
t.Logf("summary: %v", resp["summary"])
|
||||||
|
t.Logf("total: %v", resp["total"])
|
||||||
|
t.Logf("passed: %v", resp["passed"])
|
||||||
|
t.Logf("failed: %v", resp["failed"])
|
||||||
|
|
||||||
|
checks := resp["checks"].([]interface{})
|
||||||
|
for _, c := range checks {
|
||||||
|
cr := c.(map[string]interface{})
|
||||||
|
prefix := "✅"
|
||||||
|
if cr["status"] == "fail" {
|
||||||
|
prefix = "❌"
|
||||||
|
}
|
||||||
|
t.Logf(" %s %s: %s %s", prefix, cr["name"], cr["status"], cr["detail"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp["status"] != "ok" {
|
||||||
|
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 LLM 发现阶段的存在
|
||||||
|
foundDiscovery := false
|
||||||
|
for _, c := range checks {
|
||||||
|
cr := c.(map[string]interface{})
|
||||||
|
if cr["name"] == "llm_discovery" {
|
||||||
|
foundDiscovery = true
|
||||||
|
if cr["status"] != "ok" {
|
||||||
|
t.Fatalf("LLM discovery failed: status=%s detail=%s", cr["status"], cr["detail"])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundDiscovery {
|
||||||
|
t.Fatal("expected llm_discovery check in results")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✅ LLM-driven tool discovery test PASSED")
|
||||||
|
}
|
||||||
@ -1,17 +1,22 @@
|
|||||||
package openclaw
|
package openclaw
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SkillsDir 由 main.go 在 Load() 前设置,指向 SKILL.md 存放目录。
|
//go:embed simulator/main.js
|
||||||
|
var simulatorSrc string
|
||||||
|
|
||||||
var SkillsDir string
|
var SkillsDir string
|
||||||
|
var SimulatorDir string
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -28,15 +33,23 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
name string
|
name string
|
||||||
skillsDir string
|
skillsDir string
|
||||||
skills []*plugin.SKILLPlugin
|
simulatorDir string
|
||||||
|
skills []*plugin.SKILLPlugin
|
||||||
|
sidecars []*sidecarProcess
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(name, skillsDir string) *Plugin {
|
func New(name, skillsDir string) *Plugin {
|
||||||
|
sd := SimulatorDir
|
||||||
|
if sd == "" {
|
||||||
|
sd = filepath.Join(skillsDir, ".simulator")
|
||||||
|
}
|
||||||
return &Plugin{
|
return &Plugin{
|
||||||
name: name,
|
name: name,
|
||||||
skillsDir: skillsDir,
|
skillsDir: skillsDir,
|
||||||
|
simulatorDir: sd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,40 +66,158 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
skillPath := filepath.Join(p.skillsDir, entry.Name())
|
skillPath := filepath.Join(p.skillsDir, entry.Name())
|
||||||
sk, err := plugin.LoadSKILL(skillPath)
|
subs, err := os.ReadDir(skillPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[openclaw] load skill %s: %v", entry.Name(), err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.skills = append(p.skills, sk)
|
|
||||||
|
|
||||||
// Register each tool defined in the SKILL
|
hasMainJS := false
|
||||||
for _, td := range sk.Tools() {
|
hasOCManifest := false
|
||||||
name := td.Name
|
for _, f := range subs {
|
||||||
def := sdk.ToolDef{
|
switch f.Name() {
|
||||||
Name: name,
|
case "main.js":
|
||||||
Description: td.Description,
|
hasMainJS = true
|
||||||
Parameters: td.Parameters,
|
case "openclaw.plugin.json":
|
||||||
}
|
hasOCManifest = true
|
||||||
// SKILL tools are informational (advisory) — no handler
|
|
||||||
if err := s.RegisterTool(name, def, nil); err != nil {
|
|
||||||
log.Printf("[openclaw] register tool %s: %v", name, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register IO config as a channel if defined
|
switch {
|
||||||
if iocfg := sk.IOConfig(); iocfg != nil {
|
case hasMainJS:
|
||||||
log.Printf("[openclaw] skill %s io: type=%s in=%s out=%s caps=%v",
|
if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil {
|
||||||
sk.Name(), iocfg.Type, iocfg.InputRoute, iocfg.OutputRoute, iocfg.OutputCaps)
|
log.Printf("[openclaw] sidecar %s: %v", entry.Name(), err)
|
||||||
}
|
}
|
||||||
|
case hasOCManifest:
|
||||||
|
if err := p.loadOCPlugin(s, skillPath, entry.Name()); err != nil {
|
||||||
|
log.Printf("[openclaw] ocplugin %s: %v", entry.Name(), err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
sk, err := plugin.LoadSKILL(skillPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[openclaw] load skill %s: %v", entry.Name(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.skills = append(p.skills, sk)
|
||||||
|
|
||||||
log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version())
|
for _, td := range sk.Tools() {
|
||||||
|
if err := s.RegisterTool(td.Name, sdk.ToolDef{
|
||||||
|
Name: td.Name,
|
||||||
|
Description: td.Description,
|
||||||
|
Parameters: td.Parameters,
|
||||||
|
}, nil); err != nil {
|
||||||
|
log.Printf("[openclaw] register tool %s: %v", td.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if iocfg := sk.IOConfig(); iocfg != nil {
|
||||||
|
log.Printf("[openclaw] skill %s io: type=%s in=%s out=%s caps=%v",
|
||||||
|
sk.Name(), iocfg.Type, iocfg.InputRoute, iocfg.OutputRoute, iocfg.OutputCaps)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) loadOCPlugin(s *sdk.PluginSDK, dir, name string) error {
|
||||||
|
simPath := filepath.Join(p.simulatorDir, "main.js")
|
||||||
|
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create simulator dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(simPath, []byte(simulatorSrc), 0644); err != nil {
|
||||||
|
return fmt.Errorf("write simulator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := launchProcess("node", simPath, dir, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("launch simulator: %w", err)
|
||||||
|
}
|
||||||
|
if sp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, err := sp.ListTools()
|
||||||
|
if err != nil {
|
||||||
|
sp.Close()
|
||||||
|
return fmt.Errorf("list tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
toolName := fmt.Sprintf("%s_%s", name, tool.Name)
|
||||||
|
tDef := sdk.ToolDef{
|
||||||
|
Name: toolName,
|
||||||
|
Description: fmt.Sprintf("[%s] %s", name, tool.Description),
|
||||||
|
Parameters: tool.InputSchema,
|
||||||
|
}
|
||||||
|
handler := func(sp *sidecarProcess, toolName string) sdk.ToolHandler {
|
||||||
|
return func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return sp.CallTool(toolName, args)
|
||||||
|
}
|
||||||
|
}(sp, tool.Name)
|
||||||
|
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
|
||||||
|
log.Printf("[openclaw] register ocplugin tool %s: %v", toolName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[openclaw] registered ocplugin tool: %s (from %s)", toolName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.sidecars = append(p.sidecars, sp)
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("[openclaw] ocplugin %s started with %d tools", name, len(tools))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error {
|
||||||
|
sp, err := launchSidecar(dir, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("launch: %w", err)
|
||||||
|
}
|
||||||
|
if sp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, err := sp.ListTools()
|
||||||
|
if err != nil {
|
||||||
|
sp.Close()
|
||||||
|
return fmt.Errorf("list tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
toolName := fmt.Sprintf("%s_%s", name, tool.Name)
|
||||||
|
tDef := sdk.ToolDef{
|
||||||
|
Name: toolName,
|
||||||
|
Description: fmt.Sprintf("[%s] %s", name, tool.Description),
|
||||||
|
Parameters: tool.InputSchema,
|
||||||
|
}
|
||||||
|
handler := func(sp *sidecarProcess, toolName string) sdk.ToolHandler {
|
||||||
|
return func(args map[string]interface{}) (interface{}, error) {
|
||||||
|
return sp.CallTool(toolName, args)
|
||||||
|
}
|
||||||
|
}(sp, tool.Name)
|
||||||
|
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
|
||||||
|
log.Printf("[openclaw] register sidecar tool %s: %v", toolName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[openclaw] registered sidecar tool: %s (from %s)", toolName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.sidecars = append(p.sidecars, sp)
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("[openclaw] sidecar %s started with %d tools", name, len(tools))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for _, sp := range p.sidecars {
|
||||||
|
sp.Close()
|
||||||
|
}
|
||||||
|
p.sidecars = nil
|
||||||
p.skills = nil
|
p.skills = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
228
internal/plugins/openclaw/sidecar.go
Normal file
228
internal/plugins/openclaw/sidecar.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package openclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sidecarRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params interface{} `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sidecarResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Result *json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OCPTool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema map[string]interface{} `json:"inputSchema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OCCallResult struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sidecarProcess struct {
|
||||||
|
name string
|
||||||
|
dir string
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin *bufio.Writer
|
||||||
|
stdout *bufio.Scanner
|
||||||
|
mu sync.Mutex
|
||||||
|
nextID int
|
||||||
|
closed bool
|
||||||
|
stopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchSidecar(dir, name string) (*sidecarProcess, error) {
|
||||||
|
mainJS := filepath.Join(dir, "main.js")
|
||||||
|
if _, err := os.Stat(mainJS); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return launchProcess("node", mainJS, dir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchProcess(bin, arg, dir, name string) (*sidecarProcess, error) {
|
||||||
|
nodePath := bin
|
||||||
|
if bin == "node" {
|
||||||
|
if p := os.Getenv("NODE_PATH"); p != "" {
|
||||||
|
nodePath = filepath.Join(p, "node")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 dir(插件目录)作为最后一个参数传给 Node.js 进程
|
||||||
|
// 这样: node <script> <plugin-dir>
|
||||||
|
// echoplugin 的 main.js 忽略它, 模拟器用它加载真实插件
|
||||||
|
cmd := exec.Command(nodePath, arg, dir)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("start %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := &sidecarProcess{
|
||||||
|
name: name,
|
||||||
|
dir: dir,
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: bufio.NewWriter(stdin),
|
||||||
|
stdout: bufio.NewScanner(bufio.NewReader(stdout)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sp.waitReady(); err != nil {
|
||||||
|
sp.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidecarProcess) waitReady() error {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := s.call("ping", nil)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
return fmt.Errorf("sidecar %s ping timeout", s.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidecarProcess) call(method string, params interface{}) ([]byte, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.closed || s.stopped {
|
||||||
|
return nil, fmt.Errorf("sidecar %s closed", s.name)
|
||||||
|
}
|
||||||
|
s.nextID++
|
||||||
|
id := s.nextID
|
||||||
|
req := sidecarRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Method: method,
|
||||||
|
Params: params,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.stdin.Write(data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.stdin.Write([]byte("\n")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.stdin.Flush(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.stdout.Scan() {
|
||||||
|
if s.stdout.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("sidecar %s read: %w", s.name, s.stdout.Err())
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("sidecar %s closed unexpectedly", s.name)
|
||||||
|
}
|
||||||
|
line := s.stdout.Text()
|
||||||
|
|
||||||
|
var resp sidecarResponse
|
||||||
|
if err := json.Unmarshal([]byte(line), &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("sidecar %s unmarshal: %w", s.name, err)
|
||||||
|
}
|
||||||
|
if resp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("sidecar %s error: %s", s.name, resp.Error.Message)
|
||||||
|
}
|
||||||
|
if resp.Result == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []byte(*resp.Result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidecarProcess) ListTools() ([]OCPTool, error) {
|
||||||
|
data, err := s.call("tools/list", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Tools []OCPTool `json:"tools"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Tools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidecarProcess) CallTool(name string, args map[string]interface{}) (string, error) {
|
||||||
|
data, err := s.call("tools/call", map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"arguments": args,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var result OCCallResult
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var sb string
|
||||||
|
for _, c := range result.Content {
|
||||||
|
if c.Type == "text" {
|
||||||
|
sb += c.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidecarProcess) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.closed || s.stopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopped = true
|
||||||
|
if s.cmd != nil && s.cmd.Process != nil {
|
||||||
|
s.cmd.Process.Kill()
|
||||||
|
s.cmd.Wait()
|
||||||
|
}
|
||||||
|
log.Printf("[openclaw] sidecar %s stopped", s.name)
|
||||||
|
}
|
||||||
190
internal/plugins/openclaw/sidecar_test.go
Normal file
190
internal/plugins/openclaw/sidecar_test.go
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
package openclaw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLaunchSidecarNoMainJS(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
sp, err := launchSidecar(tmpDir, "nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if sp != nil {
|
||||||
|
t.Fatal("expected nil for dir without main.js")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchSidecarAndListTools(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read test plugin: %v", err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(tmpDir, "main.js")
|
||||||
|
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||||
|
t.Fatalf("write test plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launch sidecar: %v", err)
|
||||||
|
}
|
||||||
|
defer sp.Close()
|
||||||
|
|
||||||
|
tools, err := sp.ListTools()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list tools: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) == 0 {
|
||||||
|
t.Fatal("expected at least one tool")
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, tool := range tools {
|
||||||
|
if tool.Name == "echo" {
|
||||||
|
found = true
|
||||||
|
if tool.Description == "" {
|
||||||
|
t.Error("expected non-empty description for echo tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected 'echo' tool in list")
|
||||||
|
}
|
||||||
|
t.Logf("tools: %+v", tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallEchoTool(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read test plugin: %v", err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(tmpDir, "main.js")
|
||||||
|
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||||
|
t.Fatalf("write test plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launch sidecar: %v", err)
|
||||||
|
}
|
||||||
|
defer sp.Close()
|
||||||
|
|
||||||
|
result, err := sp.CallTool("echo", map[string]interface{}{
|
||||||
|
"text": "hello world",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call echo tool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "Echo: hello world"
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, result)
|
||||||
|
}
|
||||||
|
t.Logf("echo result: %s", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallAddTool(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read test plugin: %v", err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(tmpDir, "main.js")
|
||||||
|
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||||
|
t.Fatalf("write test plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launch sidecar: %v", err)
|
||||||
|
}
|
||||||
|
defer sp.Close()
|
||||||
|
|
||||||
|
result, err := sp.CallTool("add", map[string]interface{}{
|
||||||
|
"a": 3.0,
|
||||||
|
"b": 4.0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call add tool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "7"
|
||||||
|
if result != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, result)
|
||||||
|
}
|
||||||
|
t.Logf("add result: %s", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallNonexistentTool(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read test plugin: %v", err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(tmpDir, "main.js")
|
||||||
|
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||||
|
t.Fatalf("write test plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launch sidecar: %v", err)
|
||||||
|
}
|
||||||
|
defer sp.Close()
|
||||||
|
|
||||||
|
_, err = sp.CallTool("nonexistent", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent tool")
|
||||||
|
}
|
||||||
|
t.Logf("expected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentCalls(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read test plugin: %v", err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(tmpDir, "main.js")
|
||||||
|
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||||
|
t.Fatalf("write test plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("launch sidecar: %v", err)
|
||||||
|
}
|
||||||
|
defer sp.Close()
|
||||||
|
|
||||||
|
done := make(chan bool, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
go func(n int) {
|
||||||
|
result, err := sp.CallTool("add", map[string]interface{}{
|
||||||
|
"a": float64(n),
|
||||||
|
"b": float64(n * 2),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("concurrent call %d: %v", n, err)
|
||||||
|
}
|
||||||
|
if result != fmt.Sprintf("%d", n + n*2) {
|
||||||
|
t.Errorf("call %d: expected %d, got %s", n, n + n*2, result)
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
294
internal/plugins/openclaw/simulator/main.js
Normal file
294
internal/plugins/openclaw/simulator/main.js
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ---- 工具函数 ----
|
||||||
|
function writeJSON(obj) {
|
||||||
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(id, code, message) {
|
||||||
|
writeJSON({ jsonrpc: '2.0', id, error: { code, message } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJSON(file) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 解析插件入口 ----
|
||||||
|
const pluginDir = path.resolve(process.argv[2]);
|
||||||
|
if (!pluginDir) {
|
||||||
|
process.stderr.write('[simulator] usage: node main.js <plugin-dir>\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 先读 package.json 找 extensions
|
||||||
|
const pkgPath = path.join(pluginDir, 'package.json');
|
||||||
|
const pkg = readJSON(pkgPath);
|
||||||
|
let entryPath = null;
|
||||||
|
|
||||||
|
if (pkg && pkg.openclaw) {
|
||||||
|
// runtimeExtensions > extensions (安装包首选编译后的 JS)
|
||||||
|
let raw = pkg.openclaw.runtimeExtensions || pkg.openclaw.extensions;
|
||||||
|
if (typeof raw === 'string') raw = [raw];
|
||||||
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
|
// 优先选已编译的 JS 入口: .ts 映射到 .js, .js 直接用
|
||||||
|
for (const ext of raw) {
|
||||||
|
let ep = path.resolve(pluginDir, ext);
|
||||||
|
// .ts → 同级 .js
|
||||||
|
if (ep.endsWith('.ts')) {
|
||||||
|
const jsEp = ep.replace(/\.ts$/, '.js');
|
||||||
|
if (fs.existsSync(jsEp)) { entryPath = jsEp; break; }
|
||||||
|
}
|
||||||
|
if (fs.existsSync(ep)) { entryPath = ep; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回退: openclaw.plugin.json 的 entry/main
|
||||||
|
if (!entryPath) {
|
||||||
|
const manifest = readJSON(path.join(pluginDir, 'openclaw.plugin.json'));
|
||||||
|
if (manifest) {
|
||||||
|
const ep = manifest.entry || manifest.main || 'index.js';
|
||||||
|
entryPath = path.join(pluginDir, ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 最后尝试 index.js
|
||||||
|
if (!entryPath) {
|
||||||
|
entryPath = path.join(pluginDir, 'index.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(entryPath)) {
|
||||||
|
process.stderr.write(`[simulator] entry not found: ${entryPath}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 加载插件 ----
|
||||||
|
let pluginEntry;
|
||||||
|
try {
|
||||||
|
pluginEntry = require(entryPath);
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(`[simulator] load plugin: ${e.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = pluginEntry.default || pluginEntry;
|
||||||
|
|
||||||
|
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
|
||||||
|
process.stderr.write(`[simulator] plugin entry must export {default: {register(api)}}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 构造完整的 PluginApi 模拟 ----
|
||||||
|
const registeredTools = [];
|
||||||
|
|
||||||
|
// registerTool 支持两种签名:
|
||||||
|
// api.registerTool(toolDef, opts?) — 对象形式
|
||||||
|
// api.registerTool(factory, opts?) — 工厂函数形式
|
||||||
|
function registerTool(defOrFactory, opts) {
|
||||||
|
if (typeof defOrFactory === 'function') {
|
||||||
|
// 工厂形式: 传入 toolContext, 返回工具对象或数组
|
||||||
|
const toolCtx = {
|
||||||
|
id: 'simulator',
|
||||||
|
cwd: pluginDir,
|
||||||
|
env: process.env,
|
||||||
|
allow: ['*'],
|
||||||
|
};
|
||||||
|
const result = defOrFactory(toolCtx);
|
||||||
|
const tools = Array.isArray(result) ? result : [result];
|
||||||
|
for (const t of tools) {
|
||||||
|
if (t && typeof t.execute === 'function') {
|
||||||
|
registeredTools.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象形式
|
||||||
|
const def = defOrFactory;
|
||||||
|
if (!def || !def.name) return;
|
||||||
|
|
||||||
|
// definePluginEntry 的 register 传给 api.registerTool 时是完整工具定义
|
||||||
|
// defineToolPlugin 包装后传给 api.registerTool 的也是完整工具定义
|
||||||
|
// 关键是工具必须要有 execute 函数(或 factory 在之前展开)
|
||||||
|
registeredTools.push({
|
||||||
|
name: def.name,
|
||||||
|
label: def.label || def.name,
|
||||||
|
description: def.description || '',
|
||||||
|
parameters: def.parameters || { type: 'object', properties: {} },
|
||||||
|
execute: typeof def.execute === 'function' ? def.execute : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整的 OpenClawPluginApi 模拟
|
||||||
|
const api = {
|
||||||
|
id: entry.id || 'unknown',
|
||||||
|
name: entry.name || 'Unknown',
|
||||||
|
version: entry.version,
|
||||||
|
description: entry.description,
|
||||||
|
source: pluginDir,
|
||||||
|
rootDir: pluginDir,
|
||||||
|
config: {},
|
||||||
|
pluginConfig: {},
|
||||||
|
registrationMode: 'full',
|
||||||
|
logger: {
|
||||||
|
debug: (...args) => {},
|
||||||
|
info: (...args) => {},
|
||||||
|
warn: (...args) => {},
|
||||||
|
error: (...args) => process.stderr.write(`[plugin] ${args.join(' ')}\n`),
|
||||||
|
},
|
||||||
|
resolvePath: (p) => path.resolve(pluginDir, p),
|
||||||
|
|
||||||
|
// 工具注册
|
||||||
|
registerTool,
|
||||||
|
|
||||||
|
// 以下 api 方法留为 no-op,保证真实插件调用时不崩溃
|
||||||
|
registerProvider: () => {},
|
||||||
|
registerChannel: () => {},
|
||||||
|
registerEmbeddingProvider: () => {},
|
||||||
|
registerSpeechProvider: () => {},
|
||||||
|
registerRealtimeTranscriptionProvider: () => {},
|
||||||
|
registerRealtimeVoiceProvider: () => {},
|
||||||
|
registerMediaUnderstandingProvider: () => {},
|
||||||
|
registerImageGenerationProvider: () => {},
|
||||||
|
registerMusicGenerationProvider: () => {},
|
||||||
|
registerVideoGenerationProvider: () => {},
|
||||||
|
registerWebFetchProvider: () => {},
|
||||||
|
registerWebSearchProvider: () => {},
|
||||||
|
registerMemoryEmbeddingProvider: () => {},
|
||||||
|
registerAgentHarness: () => {},
|
||||||
|
registerCliBackend: () => {},
|
||||||
|
registerHook: () => {},
|
||||||
|
registerHttpRoute: () => {},
|
||||||
|
registerGatewayMethod: () => {},
|
||||||
|
registerGatewayDiscoveryService: () => {},
|
||||||
|
registerCli: () => {},
|
||||||
|
registerNodeCliFeature: () => {},
|
||||||
|
registerService: () => {},
|
||||||
|
registerCommand: () => {},
|
||||||
|
registerInteractiveHandler: () => {},
|
||||||
|
registerAgentToolResultMiddleware: () => {},
|
||||||
|
registerTrustedToolPolicy: () => {},
|
||||||
|
registerToolMetadata: () => {},
|
||||||
|
registerContextEngine: () => {},
|
||||||
|
registerMemoryCapability: () => {},
|
||||||
|
registerMemoryPromptSection: () => {},
|
||||||
|
registerMemoryFlushPlan: () => {},
|
||||||
|
registerMemoryRuntime: () => {},
|
||||||
|
registerMemoryPromptSupplement: () => {},
|
||||||
|
registerMemoryCorpusSupplement: () => {},
|
||||||
|
|
||||||
|
// 会话相关
|
||||||
|
on: () => {},
|
||||||
|
onConversationBindingResolved: () => {},
|
||||||
|
|
||||||
|
session: {
|
||||||
|
state: { registerSessionExtension: () => {} },
|
||||||
|
workflow: {
|
||||||
|
enqueueNextTurnInjection: () => {},
|
||||||
|
registerSessionSchedulerJob: () => {},
|
||||||
|
sendSessionAttachment: () => {},
|
||||||
|
scheduleSessionTurn: () => {},
|
||||||
|
unscheduleSessionTurnsByTag: () => {},
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
registerControlUiDescriptor: () => {},
|
||||||
|
registerSessionAction: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
agent: {
|
||||||
|
events: {
|
||||||
|
registerAgentEventSubscription: () => {},
|
||||||
|
emitAgentEvent: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lifecycle: { registerRuntimeLifecycle: () => {} },
|
||||||
|
|
||||||
|
runContext: {
|
||||||
|
setRunContext: () => {},
|
||||||
|
getRunContext: () => ({}),
|
||||||
|
clearRunContext: () => {},
|
||||||
|
},
|
||||||
|
|
||||||
|
runtime: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 注册插件 ----
|
||||||
|
entry.register(api);
|
||||||
|
|
||||||
|
// ---- JSON-RPC 协议处理 ----
|
||||||
|
const readline = require('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
terminal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('line', async (line) => {
|
||||||
|
let req;
|
||||||
|
try {
|
||||||
|
req = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
sendError(null, -32700, 'Parse error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.id;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'ping') {
|
||||||
|
writeJSON({ jsonrpc: '2.0', id, result: { status: 'ok' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'tools/list') {
|
||||||
|
const tools = registeredTools.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description || '',
|
||||||
|
inputSchema: t.parameters || { type: 'object', properties: {} },
|
||||||
|
}));
|
||||||
|
writeJSON({ jsonrpc: '2.0', id, result: { tools } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'tools/call') {
|
||||||
|
const params = req.params || {};
|
||||||
|
const toolName = params.name;
|
||||||
|
const args = params.arguments || {};
|
||||||
|
|
||||||
|
const tool = registeredTools.find(t => t.name === toolName);
|
||||||
|
if (!tool) {
|
||||||
|
sendError(id, -32601, `Tool not found: ${toolName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tool.execute !== 'function') {
|
||||||
|
sendError(id, -32603, `Tool ${toolName} has no execute function`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// OpenClaw 工具 execute 签名: (toolCallId, params, signal, onUpdate) => AgentToolResult
|
||||||
|
const result = await tool.execute('sim-call-1', args, undefined, undefined);
|
||||||
|
// 如果返回已经是 AgentToolResult 格式,直接转发
|
||||||
|
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
||||||
|
writeJSON({ jsonrpc: '2.0', id, result });
|
||||||
|
} else {
|
||||||
|
// 否则包装为 text result
|
||||||
|
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||||
|
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sendError(id, -32603, e.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendError(id, -32601, `Method not found: ${method}`);
|
||||||
|
});
|
||||||
106
internal/plugins/openclaw/testdata/echoplugin/main.js
vendored
Normal file
106
internal/plugins/openclaw/testdata/echoplugin/main.js
vendored
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
terminal: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: 'echo',
|
||||||
|
description: 'Echo back the input text',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
text: { type: 'string', description: 'Text to echo' }
|
||||||
|
},
|
||||||
|
required: ['text']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add two numbers',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
a: { type: 'number', description: 'First number' },
|
||||||
|
b: { type: 'number', description: 'Second number' }
|
||||||
|
},
|
||||||
|
required: ['a', 'b']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ping',
|
||||||
|
description: 'Health check',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
let req;
|
||||||
|
try {
|
||||||
|
req = JSON.parse(line);
|
||||||
|
} catch (e) {
|
||||||
|
sendError(null, -32700, 'Parse error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.id;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (method === 'ping') {
|
||||||
|
sendResult(id, { status: 'ok' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'tools/list') {
|
||||||
|
sendResult(id, { tools });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'tools/call') {
|
||||||
|
const params = req.params || {};
|
||||||
|
const toolName = params.name;
|
||||||
|
const args = params.arguments || {};
|
||||||
|
|
||||||
|
const tool = tools.find(t => t.name === toolName);
|
||||||
|
if (!tool) {
|
||||||
|
sendError(id, -32601, `Tool not found: ${toolName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
switch (toolName) {
|
||||||
|
case 'echo':
|
||||||
|
result = { content: [{ type: 'text', text: `Echo: ${args.text || ''}` }] };
|
||||||
|
break;
|
||||||
|
case 'add':
|
||||||
|
const sum = (Number(args.a) || 0) + (Number(args.b) || 0);
|
||||||
|
result = { content: [{ type: 'text', text: `${sum}` }] };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sendError(id, -32601, `Not implemented: ${toolName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendResult(id, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendError(id, -32601, `Method not found: ${method}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sendResult(id, result) {
|
||||||
|
const resp = { jsonrpc: '2.0', id, result };
|
||||||
|
process.stdout.write(JSON.stringify(resp) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(id, code, message) {
|
||||||
|
const resp = { jsonrpc: '2.0', id, error: { code, message } };
|
||||||
|
process.stdout.write(JSON.stringify(resp) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar is ready - no startup message needed
|
||||||
@ -1,129 +0,0 @@
|
|||||||
package test_deepseek
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
var defaultClient = &http.Client{Timeout: 30 * time.Second}
|
|
||||||
|
|
||||||
func New(bus sdk.EventBus) *sdk.PluginAPI {
|
|
||||||
api := sdk.NewPluginAPI("test_deepseek", "1.0.0", bus, nil, nil)
|
|
||||||
|
|
||||||
api.RegisterTool("test_deepseek", func(args map[string]interface{}) (interface{}, error) {
|
|
||||||
prompt, _ := args["prompt"].(string)
|
|
||||||
if prompt == "" {
|
|
||||||
prompt = "你好,请用一句话介绍你自己"
|
|
||||||
}
|
|
||||||
return callDeepSeek(prompt, api.Settings(), defaultClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
return api
|
|
||||||
}
|
|
||||||
|
|
||||||
func callDeepSeek(prompt string, sett sdk.SettingsAPI, client *http.Client) (interface{}, error) {
|
|
||||||
baseURL := "https://api.deepseek.com/v1"
|
|
||||||
model := "deepseek-chat"
|
|
||||||
apiKey := ""
|
|
||||||
if sett != nil {
|
|
||||||
if v, err := sett.Get("base_url"); err == nil {
|
|
||||||
if s, ok := v.(string); ok && s != "" {
|
|
||||||
baseURL = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, err := sett.Get("model"); err == nil {
|
|
||||||
if s, ok := v.(string); ok && s != "" {
|
|
||||||
model = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, err := sett.Get("api_key"); err == nil {
|
|
||||||
if s, ok := v.(string); ok && s != "" {
|
|
||||||
apiKey = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if apiKey == "" {
|
|
||||||
apiKey = "sk-feaa590161ed404b956f941992fae6f0"
|
|
||||||
}
|
|
||||||
|
|
||||||
body := map[string]interface{}{
|
|
||||||
"model": model,
|
|
||||||
"messages": []map[string]string{
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
},
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"stream": false,
|
|
||||||
}
|
|
||||||
bodyJSON, _ := json.Marshal(body)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/")+"/chat/completions", strings.NewReader(string(bodyJSON)))
|
|
||||||
if err != nil {
|
|
||||||
return map[string]interface{}{"error": fmt.Sprintf("创建请求失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return map[string]interface{}{"error": fmt.Sprintf("API 调用失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"error": fmt.Sprintf("API 返回 %d", resp.StatusCode),
|
|
||||||
"body": string(respBody),
|
|
||||||
"status": "failed",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"message"`
|
|
||||||
} `json:"choices"`
|
|
||||||
Usage struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
} `json:"usage"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
content := ""
|
|
||||||
if len(result.Choices) > 0 {
|
|
||||||
content = result.Choices[0].Message.Content
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]interface{}{
|
|
||||||
"prompt": prompt,
|
|
||||||
"response": content,
|
|
||||||
"model": model,
|
|
||||||
"usage": result.Usage,
|
|
||||||
"status": "ok",
|
|
||||||
"base_url": baseURL,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWithClient(bus sdk.EventBus, client *http.Client) *sdk.PluginAPI {
|
|
||||||
api := sdk.NewPluginAPI("test_deepseek", "1.0.0", bus, nil, nil)
|
|
||||||
api.RegisterTool("test_deepseek", func(args map[string]interface{}) (interface{}, error) {
|
|
||||||
prompt, _ := args["prompt"].(string)
|
|
||||||
if prompt == "" {
|
|
||||||
prompt = "你好,请用一句话介绍你自己"
|
|
||||||
}
|
|
||||||
return callDeepSeek(prompt, api.Settings(), client)
|
|
||||||
})
|
|
||||||
return api
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
package test_deepseek
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mockSettings struct {
|
|
||||||
data map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockSettings) Get(key string) (interface{}, error) {
|
|
||||||
v, ok := m.data[key]
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return v, nil
|
|
||||||
}
|
|
||||||
func (m *mockSettings) Set(key string, value interface{}) error {
|
|
||||||
m.data[key] = value.(string)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockSettings) List(prefix string) ([]string, error) {
|
|
||||||
var keys []string
|
|
||||||
for k := range m.data {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
return keys, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mockAPI(t *testing.T) *httptest.Server {
|
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
t.Fatalf("expected POST, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if r.Header.Get("Authorization") != "Bearer sk-test123" {
|
|
||||||
t.Fatalf("expected Bearer sk-test123, got %s", r.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Fatalf("expected application/json, got %s", r.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody map[string]interface{}
|
|
||||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
|
||||||
if reqBody["model"] != "test-model" {
|
|
||||||
t.Fatalf("expected test-model, got %v", reqBody["model"])
|
|
||||||
}
|
|
||||||
if reqBody["stream"] != false {
|
|
||||||
t.Fatalf("expected stream=false, got %v", reqBody["stream"])
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(200)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"choices": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"message": map[string]string{
|
|
||||||
"content": "你好!我是 DeepSeek。",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"usage": map[string]int{
|
|
||||||
"prompt_tokens": 10,
|
|
||||||
"completion_tokens": 20,
|
|
||||||
"total_tokens": 30,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCallDeepSeekSuccess(t *testing.T) {
|
|
||||||
ts := mockAPI(t)
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
sett := &mockSettings{data: map[string]string{
|
|
||||||
"base_url": ts.URL,
|
|
||||||
"model": "test-model",
|
|
||||||
"api_key": "sk-test123",
|
|
||||||
}}
|
|
||||||
|
|
||||||
result, err := callDeepSeek("你好", sett, ts.Client())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("callDeepSeek: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过 JSON 反序列化验证(避免匿名 struct 类型断言问题)
|
|
||||||
data, _ := json.Marshal(result)
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.Unmarshal(data, &resp)
|
|
||||||
|
|
||||||
if resp["status"] != "ok" {
|
|
||||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
|
||||||
}
|
|
||||||
if resp["response"] != "你好!我是 DeepSeek。" {
|
|
||||||
t.Fatalf("expected response 你好!我是 DeepSeek。, got %v", resp["response"])
|
|
||||||
}
|
|
||||||
if resp["model"] != "test-model" {
|
|
||||||
t.Fatalf("expected model test-model, got %v", resp["model"])
|
|
||||||
}
|
|
||||||
usage := resp["usage"].(map[string]interface{})
|
|
||||||
if usage["total_tokens"].(float64) != 30 {
|
|
||||||
t.Fatalf("expected 30 total tokens, got %v", usage["total_tokens"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCallDeepSeekNon200(t *testing.T) {
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(401)
|
|
||||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
sett := &mockSettings{data: map[string]string{
|
|
||||||
"base_url": ts.URL,
|
|
||||||
"model": "test-model",
|
|
||||||
"api_key": "sk-bad",
|
|
||||||
}}
|
|
||||||
result, err := callDeepSeek("hi", sett, ts.Client())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("callDeepSeek: %v", err)
|
|
||||||
}
|
|
||||||
m := result.(map[string]interface{})
|
|
||||||
if m["status"] != "failed" {
|
|
||||||
t.Fatalf("expected status failed, got %v", m["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCallDeepSeekDefaultPrompt(t *testing.T) {
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var reqBody map[string]interface{}
|
|
||||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
|
||||||
msgs := reqBody["messages"].([]interface{})
|
|
||||||
msg := msgs[0].(map[string]interface{})
|
|
||||||
if msg["content"] == "你好,请用一句话介绍你自己" {
|
|
||||||
w.WriteHeader(200)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"choices": []map[string]interface{}{
|
|
||||||
{"message": map[string]string{"content": "ok"}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Fatalf("unexpected prompt: %v", msg["content"])
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
sett := &mockSettings{data: map[string]string{
|
|
||||||
"base_url": ts.URL,
|
|
||||||
"model": "test-model",
|
|
||||||
"api_key": "sk-test",
|
|
||||||
}}
|
|
||||||
bus := sdk.NewInProcessBus()
|
|
||||||
api := NewWithClient(bus, ts.Client())
|
|
||||||
api.SetSettings(sett)
|
|
||||||
|
|
||||||
handler := api.Tools()["test_deepseek"]
|
|
||||||
result, err := handler(map[string]interface{}{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("handler: %v", err)
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(result)
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.Unmarshal(data, &resp)
|
|
||||||
if resp["status"] != "ok" {
|
|
||||||
t.Fatalf("expected ok, got %v", resp["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCallDeepSeekFallbackAPIKey(t *testing.T) {
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
expected := "Bearer sk-feaa590161ed404b956f941992fae6f0"
|
|
||||||
if r.Header.Get("Authorization") != expected {
|
|
||||||
t.Fatalf("expected %s, got %s", expected, r.Header.Get("Authorization"))
|
|
||||||
}
|
|
||||||
w.WriteHeader(200)
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"choices": []map[string]interface{}{
|
|
||||||
{"message": map[string]string{"content": "ok"}},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
// 不设 api_key 触发 fallback
|
|
||||||
sett := &mockSettings{data: map[string]string{
|
|
||||||
"base_url": ts.URL,
|
|
||||||
"model": "test-model",
|
|
||||||
}}
|
|
||||||
result, err := callDeepSeek("hi", sett, ts.Client())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("callDeepSeek: %v", err)
|
|
||||||
}
|
|
||||||
m := result.(map[string]interface{})
|
|
||||||
if m["status"] != "ok" {
|
|
||||||
t.Fatalf("expected ok, got %v", m["status"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewWithClient(t *testing.T) {
|
|
||||||
ts := mockAPI(t)
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
sett := &mockSettings{data: map[string]string{
|
|
||||||
"base_url": ts.URL,
|
|
||||||
"model": "test-model",
|
|
||||||
"api_key": "sk-test123",
|
|
||||||
}}
|
|
||||||
bus := sdk.NewInProcessBus()
|
|
||||||
api := NewWithClient(bus, ts.Client())
|
|
||||||
api.SetSettings(sett)
|
|
||||||
|
|
||||||
handler := api.Tools()["test_deepseek"]
|
|
||||||
if handler == nil {
|
|
||||||
t.Fatal("test_deepseek tool not registered")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := handler(map[string]interface{}{"prompt": "你好"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("tool handler: %v", err)
|
|
||||||
}
|
|
||||||
m := result.(map[string]interface{})
|
|
||||||
if m["status"] != "ok" {
|
|
||||||
t.Fatalf("expected ok, got %v", m["status"])
|
|
||||||
}
|
|
||||||
if m["response"] != "你好!我是 DeepSeek。" {
|
|
||||||
t.Fatalf("expected response, got %v", m["response"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewDefaultClient(t *testing.T) {
|
|
||||||
bus := sdk.NewInProcessBus()
|
|
||||||
api := New(bus)
|
|
||||||
if api == nil {
|
|
||||||
t.Fatal("New returned nil")
|
|
||||||
}
|
|
||||||
if api.Name != "test_deepseek" {
|
|
||||||
t.Fatalf("expected name test_deepseek, got %s", api.Name)
|
|
||||||
}
|
|
||||||
tools := api.Tools()
|
|
||||||
if _, ok := tools["test_deepseek"]; !ok {
|
|
||||||
t.Fatal("test_deepseek tool not registered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
158
internal/plugins/webui/dashboard.html
Normal file
158
internal/plugins/webui/dashboard.html
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HomeAgent Dashboard</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
||||||
|
body{background:#0f172a;color:#e2e8f0;min-height:100vh;overflow-x:hidden}
|
||||||
|
nav{background:#1e293b;padding:0 24px;display:flex;align-items:center;gap:4px;border-bottom:1px solid #334155;height:48px;position:sticky;top:0;z-index:100}
|
||||||
|
nav h1{font-size:16px;font-weight:700;color:#38bdf8;margin-right:24px;white-space:nowrap}
|
||||||
|
nav a{padding:12px 16px;color:#94a3b8;text-decoration:none;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
|
||||||
|
nav a:hover{color:#e2e8f0}
|
||||||
|
nav a.active{color:#38bdf8;border-bottom-color:#38bdf8}
|
||||||
|
.container{padding:20px 24px;max-width:1440px;margin:0 auto}
|
||||||
|
.card{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:20px;margin-bottom:16px}
|
||||||
|
.card h2{font-size:15px;font-weight:600;margin-bottom:12px;color:#f1f5f9}
|
||||||
|
.card h3{font-size:13px;font-weight:600;color:#94a3b8;margin:16px 0 8px}
|
||||||
|
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||||
|
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||||
|
.stat-value{font-size:26px;font-weight:700;color:#38bdf8}
|
||||||
|
.stat-label{font-size:11px;color:#64748b;margin-top:2px}
|
||||||
|
.stat-card{padding:16px 20px}
|
||||||
|
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||||
|
.dot-green{background:#22c55e}
|
||||||
|
.dot-yellow{background:#eab308}
|
||||||
|
.dot-red{background:#ef4444}
|
||||||
|
.dot-gray{background:#475569}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
th{text-align:left;padding:8px 10px;color:#64748b;font-weight:500;border-bottom:1px solid #334155;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
|
||||||
|
td{padding:8px 10px;border-bottom:1px solid #1e293b}
|
||||||
|
tr:hover td{background:#0f172a40}
|
||||||
|
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
|
||||||
|
.badge-green{background:#166534;color:#86efac}
|
||||||
|
.badge-red{background:#7f1d1d;color:#fca5a5}
|
||||||
|
.badge-yellow{background:#713f12;color:#fde68a}
|
||||||
|
.badge-blue{background:#1e3a5f;color:#93c5fd}
|
||||||
|
.btn{padding:6px 14px;border-radius:6px;border:none;font-size:12px;cursor:pointer;font-weight:500;transition:all .15s}
|
||||||
|
.btn-primary{background:#2563eb;color:#fff}
|
||||||
|
.btn-primary:hover{background:#1d4ed8}
|
||||||
|
.btn-danger{background:#dc2626;color:#fff}
|
||||||
|
.btn-danger:hover{background:#b91c1c}
|
||||||
|
.btn-sm{padding:4px 10px;font-size:11px}
|
||||||
|
.btn-ghost{background:transparent;border:1px solid #334155;color:#94a3b8}
|
||||||
|
.btn-ghost:hover{background:#1e293b;color:#e2e8f0}
|
||||||
|
.tab-content{display:none}
|
||||||
|
.tab-content.active{display:block}
|
||||||
|
input,textarea,select{background:#0f172a;border:1px solid #334155;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%;margin-bottom:10px;outline:none;transition:border .15s}
|
||||||
|
input:focus,textarea:focus,select:focus{border-color:#2563eb}
|
||||||
|
textarea{resize:vertical;min-height:80px;font-family:monospace;font-size:12px}
|
||||||
|
label{display:block;font-size:11px;color:#94a3b8;margin-bottom:3px;font-weight:500}
|
||||||
|
pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x:auto;color:#a5b4fc;font-family:monospace;max-height:400px;overflow-y:auto}
|
||||||
|
code{font-family:monospace;font-size:12px;color:#a5b4fc}
|
||||||
|
.settings-layout{display:flex;gap:20px;min-height:60vh}
|
||||||
|
.settings-sidebar{width:200px;flex-shrink:0;background:#1e293b;border:1px solid #334155;border-radius:10px;padding:8px 0;overflow-y:auto;max-height:70vh}
|
||||||
|
.settings-sidebar a{display:block;padding:9px 16px;color:#94a3b8;font-size:13px;cursor:pointer;text-decoration:none;border-left:3px solid transparent;transition:all .1s}
|
||||||
|
.settings-sidebar a:hover{background:#0f172a;color:#e2e8f0}
|
||||||
|
.settings-sidebar a.active{background:#0f172a;color:#38bdf8;border-left-color:#38bdf8}
|
||||||
|
.settings-content{flex:1;min-width:0}
|
||||||
|
.settings-key{font-family:monospace;font-size:11px;color:#64748b;margin-bottom:2px}
|
||||||
|
.toast{position:fixed;bottom:20px;right:20px;background:#166534;color:#86efac;padding:10px 20px;border-radius:8px;font-size:13px;display:none;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.3)}
|
||||||
|
.toast.error{background:#7f1d1d;color:#fca5a5}
|
||||||
|
.empty-state{text-align:center;padding:40px 20px;color:#64748b}
|
||||||
|
.empty-state p{font-size:14px;margin-bottom:8px}
|
||||||
|
.empty-state .icon{font-size:36px;margin-bottom:12px;opacity:.5}
|
||||||
|
.chat-messages{max-height:500px;overflow-y:auto;margin-bottom:12px;padding:8px;border:1px solid #334155;border-radius:8px;background:#0f172a}
|
||||||
|
.msg{display:flex;gap:10px;margin-bottom:12px;align-items:flex-start}
|
||||||
|
.msg-avatar{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0}
|
||||||
|
.msg-user .msg-avatar{background:#1e3a5f;color:#93c5fd}
|
||||||
|
.msg-assistant .msg-avatar{background:#1a3a2a;color:#86efac}
|
||||||
|
.msg-system .msg-avatar{background:#3b1a3a;color:#f0abfc}
|
||||||
|
.msg-content{flex:1;min-width:0}
|
||||||
|
.msg-content .role{font-size:11px;color:#64748b;margin-bottom:2px}
|
||||||
|
.msg-content .text{font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.msg-content .text .reasoning{color:#94a3b8;font-style:italic;font-size:12px;padding:4px 8px;background:#1e293b;border-radius:4px;margin:4px 0;display:block}
|
||||||
|
.chat-input-row{display:flex;gap:8px}
|
||||||
|
.chat-input-row input{flex:1;margin-bottom:0}
|
||||||
|
.chat-input-row button{flex-shrink:0;margin-bottom:0}
|
||||||
|
.loading{display:inline-block;width:16px;height:16px;border:2px solid #334155;border-radius:50%;border-top-color:#38bdf8;animation:spin .6s linear infinite}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.monaco-like{font-family:monospace;font-size:12px;background:#0f172a;border:1px solid #334155;border-radius:6px}
|
||||||
|
.kv-row{display:flex;padding:6px 0;border-bottom:1px solid #1e293b;font-size:13px}
|
||||||
|
.kv-row .key{color:#94a3b8;width:180px;flex-shrink:0}
|
||||||
|
.kv-row .val{color:#e2e8f0;word-break:break-all}
|
||||||
|
.tool-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;background:#1e3a5f;color:#93c5fd;margin:1px}
|
||||||
|
.check-pass{color:#86efac}
|
||||||
|
.check-fail{color:#fca5a5}
|
||||||
|
.check-skip{color:#94a3b8}
|
||||||
|
.memory-graph{width:100%;height:300px;background:#0f172a;border-radius:8px;border:1px solid #334155;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#64748b;font-size:13px}
|
||||||
|
.health-panel{display:grid;gap:8px}
|
||||||
|
.health-item{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#0f172a;border-radius:6px;font-size:13px}
|
||||||
|
.health-item .check-name{flex:1}
|
||||||
|
.health-item .check-status{font-size:11px;font-weight:500}
|
||||||
|
.fade-in{animation:fadeIn .2s ease}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
@media(max-width:768px){.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}.settings-layout{flex-direction:column}.settings-sidebar{width:100%;max-height:none;display:flex;flex-wrap:wrap;gap:0;padding:0}.settings-sidebar a{flex:1;min-width:80px;text-align:center;border-left:none;border-bottom:2px solid transparent;padding:8px}.settings-sidebar a.active{border-left:none;border-bottom-color:#38bdf8}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav><h1>HomeAgent</h1><a class="active" onclick="switchTab('overview')">概览</a><a onclick="switchTab('chat')">对话</a><a onclick="switchTab('plugins')">插件</a><a onclick="switchTab('memory')">记忆</a><a onclick="switchTab('knowledge')">知识</a><a onclick="switchTab('settings')">设置</a><a onclick="switchTab('kernel')">内核</a></nav>
|
||||||
|
<div class="container" id="app">
|
||||||
|
<div id="tab-overview" class="tab-content active"></div>
|
||||||
|
<div id="tab-chat" class="tab-content"></div>
|
||||||
|
<div id="tab-plugins" class="tab-content"></div>
|
||||||
|
<div id="tab-memory" class="tab-content"></div>
|
||||||
|
<div id="tab-knowledge" class="tab-content"></div>
|
||||||
|
<div id="tab-settings" class="tab-content"></div>
|
||||||
|
<div id="tab-kernel" class="tab-content"></div>
|
||||||
|
</div>
|
||||||
|
<div id="toast" class="toast"></div>
|
||||||
|
<script>
|
||||||
|
let state={status:{},kernel:null,settings:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null};
|
||||||
|
async function api(p,o){let opts={headers:{'Content-Type':'application/json',...o?.headers},...o};let r=await fetch('/api/v1'+p,opts);if(opts.raw)return r;let ct=r.headers.get('content-type')||'';if(ct.includes('json'))return r.json();return r.text()}
|
||||||
|
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));let el=document.getElementById('tab-'+n);if(el)el.classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="\'+n+\'"]')||document.querySelector(`nav a[onclick*="${n}"]`)?.classList.add('active');renderAll()}
|
||||||
|
function toast(m,isError){let t=document.getElementById('toast');t.textContent=m;t.className='toast'+(isError?' error':'');t.style.display='block';setTimeout(()=>t.style.display='none',3000)}
|
||||||
|
async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()}
|
||||||
|
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
function timeAgo(t){let s=Math.floor((Date.now()-new Date(t).getTime())/1000);if(s<60)return s+'秒前';let m=Math.floor(s/60);if(m<60)return m+'分钟前';return Math.floor(m/60)+'小时前'}
|
||||||
|
|
||||||
|
// === Overview ===
|
||||||
|
function renderOverview(){let s=state.status||{};let k=state.kernel;let html='<div class="grid-4">'+statCard('运行状态',s.status||'unknown','running')+statCard('运行时间',s.uptime||'-','uptime')+statCard('插件',(k?.plugins||[]).length||0,'plugin')+statCard('版本',s.version||'0.1.0','version')+'</div>';if(k){html+='<div class="grid-2"><div class="card"><h2>LLM 状态</h2><div class="kv-row"><span class="key">Provider</span><span class="val">'+(k.llm?.provider||'未配置')+'</span></div><div class="kv-row"><span class="key">可用源</span><span class="val">'+(k.llm?.sources||0)+'</span></div><div class="kv-row"><span class="key">状态</span><span class="val"><span class="status-dot '+(k.llm?.available?'dot-green':'dot-red')+'"></span>'+(k.llm?.available?'运行中':'不可用')+'</span></div></div><div class="card"><h2>记忆状态</h2><div class="kv-row"><span class="key">图记忆</span><span class="val"><span class="status-dot '+(k.memory?.available?'dot-green':'dot-gray')+'"></span>'+(k.memory?.available?k.memory.entity_count+' 实体, '+k.memory.relation_count+' 关系':'未初始化')+'</span></div><div class="kv-row"><span class="key">文档记忆</span><span class="val">'+(k.documents?.available?k.documents.doc_count+' 文档':'未初始化')+'</span></div><div class="kv-row"><span class="key">文本记忆</span><span class="val">'+(k.text_memory?.available?k.text_memory.file_count+' 文件':'未初始化')+'</span></div><div class="kv-row"><span class="key">知识库</span><span class="val">'+(k.knowledge?.available?k.knowledge.item_count+' 项':'未初始化')+'</span></div></div></div>';}html+='<div class="card"><h2>运行时</h2><div class="grid-3">'+statCard('Goroutines',k?.runtime?.goroutines||'-','')+statCard('内存',k?.runtime?.memory_mb?k.runtime.memory_mb+' MB':'-','')+statCard('Go 版本',k?.runtime?.go_version||'-','')+'</div></div>';document.getElementById('tab-overview').innerHTML=html}
|
||||||
|
function statCard(l,v){return '<div class="card stat-card"><div class="stat-value">'+v+'</div><div class="stat-label">'+l+'</div></div>'}
|
||||||
|
|
||||||
|
// === Chat ===
|
||||||
|
function renderChat(){let msgs=state.messages;let html='<div class="card"><h2>对话调试</h2><div class="chat-messages" id="chat-msgs">';if(msgs.length===0){html+='<div class="empty-state"><p>开始对话以测试 Agent 回复</p></div>'}else{msgs.forEach((m,i)=>{let role=m.role||'user';let c=escHtml(m.content||'');let rc=m.reasoning_content?('<span class="reasoning">思考: '+escHtml(m.reasoning_content)+'</span>'):'';html+='<div class="msg msg-'+role+'"><div class="msg-avatar">'+(role==='user'?'U':role==='assistant'?'A':'S')+'</div><div class="msg-content"><div class="role">'+(role==='user'?'用户':role==='assistant'?'AI':'系统')+'</div><div class="text">'+rc+c+'</div></div></div>'})}html+='</div><div class="chat-input-row"><input id="chat-input" placeholder="输入消息..." onkeydown="if(event.key==\'Enter\')sendChat()"><button class="btn btn-primary" onclick="sendChat()" id="chat-send-btn">发送</button></div></div>';document.getElementById('tab-chat').innerHTML=html;let el=document.getElementById('chat-msgs');if(el)el.scrollTop=el.scrollHeight}
|
||||||
|
async function sendChat(){let inp=document.getElementById('chat-input');let btn=document.getElementById('chat-send-btn');let text=inp.value.trim();if(!text||state.chatLoading)return;state.messages.push({role:'user',content:text});inp.value='';renderChat();state.chatLoading=true;btn.disabled=true;btn.textContent='...';try{let r=await api('/chat',{method:'POST',body:JSON.stringify({message:text})});state.messages.push({role:'assistant',content:r.response||'(无响应)',reasoning_content:r.reasoning_content});renderChat()}catch(e){state.messages.push({role:'assistant',content:'错误: '+e.message});renderChat();toast('请求失败: '+e.message,true)}finally{state.chatLoading=false;btn.disabled=false;btn.textContent='发送'}}
|
||||||
|
|
||||||
|
// === Plugins ===
|
||||||
|
function renderPlugins(){let k=state.kernel;let plugins=k?.plugins||[];let tools=k?.tools||[];let html='<div class="card"><h2>已加载插件 ('+plugins.length+')</h2>';if(plugins.length===0){html+='<div class="empty-state"><p>暂无已加载插件</p></div>'}else{html+='<table><tr><th>名称</th><th>状态</th></tr>';plugins.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td><span class="badge badge-green">已加载</span></td></tr>'});html+='</table>'}html+='</div>';if(tools.length>0){html+='<div class="card"><h2>已注册工具 ('+tools.length+')</h2><div style="display:flex;flex-wrap:wrap;gap:4px">';tools.forEach(t=>{html+='<span class="tool-badge" title="'+escHtml(t.description||'')+'">'+escHtml(t.name)+'</span>'});html+='</div></div>'}html+='<div class="card"><h2>健康检查</h2><div id="health-panel">';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+='<button class="btn btn-primary" onclick="runHealthcheck()">运行健康检查</button>'}html+='</div></div>';document.getElementById('tab-plugins').innerHTML=html}
|
||||||
|
async function runHealthcheck(){let panel=document.getElementById('health-panel');if(!panel)return;panel.innerHTML='<div class="loading" style="margin:12px auto"></div><p style="text-align:center;color:#64748b">运行中...</p>';try{let r=await api('/kernel');let tools=r?.tools||[];let healthTool=tools.find(t=>t.name==='healthcheck');if(!healthTool){panel.innerHTML='<p style="color:#94a3b8">healthcheck 工具未注册</p>';return}panel.innerHTML='<p style="color:#94a3b8">通过 Agent 对话触发 healthcheck...</p>';let chatR=await api('/chat',{method:'POST',body:JSON.stringify({message:'请运行 healthcheck 工具进行全面健康检查并报告结果'})});panel.innerHTML='<pre>'+escHtml(JSON.stringify(chatR,null,2))+'</pre>'}catch(e){panel.innerHTML='<p style="color:#fca5a5">错误: '+escHtml(e.message)+'</p>';toast('健康检查失败: '+e.message,true)}}
|
||||||
|
function renderHealthResult(r){if(!r||!r.checks)return '<p style="color:#94a3b8">暂无健康检查数据</p>';let checks=r.checks||[];let passed=checks.filter(c=>c.pass).length;let failed=checks.filter(c=>!c.pass).length;let html='<div style="margin-bottom:12px;display:flex;gap:16px;align-items:center"><span class="badge badge-green">通过: '+passed+'</span><span class="badge '+(failed>0?'badge-red':'badge-green')+'">失败: '+failed+'</span><span class="badge badge-blue">总计: '+checks.length+'</span></div>';checks.forEach(c=>{let passClass=c.pass?'check-pass':'check-fail';if(c.status==='skip')passClass='check-skip';html+='<div class="health-item"><span class="check-name">'+escHtml(c.name)+'</span><span class="check-status '+passClass+'">'+(c.status||'unknown')+'</span><span style="color:#64748b;font-size:11px">'+escHtml(c.detail||'')+'</span></div>'});return html}
|
||||||
|
|
||||||
|
// === Memory ===
|
||||||
|
function renderMemory(){let k=state.kernel;let mem=k?.memory;let html='<div class="grid-2"><div class="card"><h2>图记忆</h2>';if(mem?.available){html+='<div class="kv-row"><span class="key">实体数</span><span class="val">'+mem.entity_count+'</span></div><div class="kv-row"><span class="key">关系数</span><span class="val">'+mem.relation_count+'</span></div><div class="kv-row"><span class="key">实体类型</span><span class="val">'+mem.entity_types+'</span></div>'}else{html+='<div class="empty-state"><p>图记忆未初始化</p></div>'}html+='</div><div class="card"><h2>记忆查询</h2><input id="mem-query" placeholder="输入查询关键词(逗号分隔)"><button class="btn btn-primary" onclick="queryMemory()">查询</button><div id="mem-result" style="margin-top:12px"></div></div></div>';let tm=k?.text_memory;if(tm?.available){html+='<div class="card"><h2>文本记忆</h2><p style="color:#94a3b8;font-size:13px">已记录 '+tm.file_count+' 个文件</p></div>'}document.getElementById('tab-memory').innerHTML=html}
|
||||||
|
async function queryMemory(){let q=document.getElementById('mem-query')?.value;let r=document.getElementById('mem-result');if(!r||!q)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/memory?q='+encodeURIComponent(q)+'&depth=2');r.innerHTML='<pre>'+escHtml(JSON.stringify(data,null,2))+'</pre>'}catch(e){r.innerHTML='<p style="color:#fca5a5">查询失败: '+escHtml(e.message)+'</p>'}}
|
||||||
|
|
||||||
|
// === Knowledge ===
|
||||||
|
function renderKnowledge(){let k=state.kernel;let know=k?.knowledge;let html='<div class="grid-2"><div class="card"><h2>知识库</h2>';if(know?.available){html+='<div class="kv-row"><span class="key">条目数</span><span class="val">'+know.item_count+'</span></div>';if(know.items&&know.items.length>0){html+='<h3>分类</h3>';know.items.forEach(n=>{html+='<span class="tool-badge">'+escHtml(n)+'</span> '})}}else{html+='<div class="empty-state"><p>知识库未初始化</p></div>'}html+='</div><div class="card"><h2>搜索</h2><input id="know-query" placeholder="输入关键词" onkeydown="if(event.key==\'Enter\')searchKnowledge()"><button class="btn btn-primary" onclick="searchKnowledge()">搜索</button><div id="know-result" style="margin-top:12px"></div></div></div><div class="card"><h2>新建知识</h2><div style="max-width:500px"><label>名称</label><input id="know-name" placeholder="知识名称"><label>内容</label><textarea id="know-content" placeholder="Markdown 内容"></textarea><button class="btn btn-primary" onclick="createKnowledge()">创建</button></div></div>';document.getElementById('tab-knowledge').innerHTML=html}
|
||||||
|
async function searchKnowledge(){let q=document.getElementById('know-query')?.value;let r=document.getElementById('know-result');if(!r||!q)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/knowledge?q='+encodeURIComponent(q));r.innerHTML='<pre>'+escHtml(JSON.stringify(data,null,2))+'</pre>'}catch(e){r.innerHTML='<p style="color:#fca5a5">搜索失败: '+escHtml(e.message)+'</p>'}}
|
||||||
|
async function createKnowledge(){let name=document.getElementById('know-name')?.value;let content=document.getElementById('know-content')?.value;if(!name||!content){toast('名称和内容不能为空',true);return}try{await api('/knowledge',{method:'POST',body:JSON.stringify({name,content})});toast('知识「'+name+'」已创建');document.getElementById('know-name').value='';document.getElementById('know-content').value='';renderAll()}catch(e){toast('创建失败: '+e.message,true)}}
|
||||||
|
|
||||||
|
// === Settings ===
|
||||||
|
function renderSettingsSidebar(){let el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){let a=document.createElement('a');a.textContent=p;if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderOneSettings()};el.appendChild(a)})}
|
||||||
|
function renderOneSettings(){let prefix=state.selectedSection+'.';let filtered=Object.keys(state.settings||{}).filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div><label>值</label><input id="inp-'+k.replace(/\./g,'_')+'" value="'+escHtml(sv)+'" onchange="markDirty(\''+k+'\')"></div>'})}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||||||
|
function markDirty(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
||||||
|
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let raw=inp.value;let val;try{val=JSON.parse(raw)}catch(e){val=raw}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||||||
|
function renderConfigDisabled(){document.getElementById('tab-settings').innerHTML='<div class="card"><h2>设置</h2><p style="color:#94a3b8">设置面板已加载</p></div>';renderOneSettings()}
|
||||||
|
|
||||||
|
// === Kernel ===
|
||||||
|
function renderKernel(){let k=state.kernel;if(!k){document.getElementById('tab-kernel').innerHTML='<div class="card"><div class="loading"></div><p style="color:#64748b">加载中...</p></div>';return}let html='<div class="card"><h2>内核状态快照</h2><pre>'+escHtml(JSON.stringify(k,null,2))+'</pre></div>';html+='<div class="card"><h2>通道</h2>';if(k.channels&&k.channels.length>0){html+='<table><tr><th>名称</th><th>类型</th><th>状态</th></tr>';k.channels.forEach(ch=>{html+='<tr><td>'+escHtml(ch.name)+'</td><td>'+escHtml(ch.type)+'</td><td><span class="status-dot '+(ch.ready?'dot-green':'dot-red')+'"></span>'+(ch.ready?'就绪':'离线')+'</td></tr>'});html+='</table>'}else{html+='<p style="color:#94a3b8">无通道</p>'}html+='</div>';if(k.tracker?.available){html+='<div class="card"><h2>变更追踪</h2><div class="kv-row"><span class="key">状态</span><span class="val"><span class="badge badge-green">活跃</span></span></div><div class="kv-row"><span class="key">目录</span><span class="val">'+escHtml(k.tracker.dir||'-')+'</span></div></div>'}document.getElementById('tab-kernel').innerHTML=html}
|
||||||
|
|
||||||
|
// === Settings Override ===
|
||||||
|
renderConfigDisabled();
|
||||||
|
renderAll();setInterval(renderAll,15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -10,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
@ -24,43 +26,57 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
//go:embed dashboard.html
|
||||||
supervisor *supervisor.Daemon
|
var dashboardFS embed.FS
|
||||||
memory *memory.GraphDB
|
|
||||||
indexer *memory.Indexer
|
var dashboardHTML string
|
||||||
skills *skill.Manager
|
|
||||||
lua *luaVM.VM
|
func init() {
|
||||||
config *types.Config
|
data, err := dashboardFS.ReadFile("dashboard.html")
|
||||||
startTime time.Time
|
if err == nil {
|
||||||
iom *agentIO.IOManager
|
dashboardHTML = string(data)
|
||||||
textMem *text.Memory
|
}
|
||||||
knowledge *knowledge.Store
|
|
||||||
tracker *tracker.Tracker
|
|
||||||
cfgReg *internalConfig.ConfigRegistry
|
|
||||||
pluginReg *plugin.Registry
|
|
||||||
eventBus *events.Bus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus) *Handler {
|
type Handler struct {
|
||||||
|
supervisor *supervisor.Daemon
|
||||||
|
memory *memory.GraphDB
|
||||||
|
indexer *memory.Indexer
|
||||||
|
skills *skill.Manager
|
||||||
|
lua *luaVM.VM
|
||||||
|
config *types.Config
|
||||||
|
startTime time.Time
|
||||||
|
iom *agentIO.IOManager
|
||||||
|
textMem *text.Memory
|
||||||
|
knowledge *knowledge.Store
|
||||||
|
tracker *tracker.Tracker
|
||||||
|
cfgReg *internalConfig.ConfigRegistry
|
||||||
|
pluginReg *plugin.Registry
|
||||||
|
eventBus *events.Bus
|
||||||
|
statusProvider agentCore.StatusProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus, sp agentCore.StatusProvider) *Handler {
|
||||||
var idx *memory.Indexer
|
var idx *memory.Indexer
|
||||||
if mem != nil {
|
if mem != nil {
|
||||||
idx = memory.NewIndexer(mem)
|
idx = memory.NewIndexer(mem)
|
||||||
}
|
}
|
||||||
return &Handler{
|
return &Handler{
|
||||||
supervisor: sup,
|
supervisor: sup,
|
||||||
memory: mem,
|
memory: mem,
|
||||||
indexer: idx,
|
indexer: idx,
|
||||||
skills: sk,
|
skills: sk,
|
||||||
lua: lua,
|
lua: lua,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
iom: iom,
|
iom: iom,
|
||||||
textMem: tm,
|
textMem: tm,
|
||||||
knowledge: ks,
|
knowledge: ks,
|
||||||
tracker: tr,
|
tracker: tr,
|
||||||
cfgReg: cr,
|
cfgReg: cr,
|
||||||
pluginReg: pr,
|
pluginReg: pr,
|
||||||
eventBus: evBus,
|
eventBus: evBus,
|
||||||
|
statusProvider: sp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +102,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/tracker/", h.handleTracker)
|
mux.HandleFunc("/api/v1/tracker/", h.handleTracker)
|
||||||
mux.HandleFunc("/api/v1/chat", h.handleChat)
|
mux.HandleFunc("/api/v1/chat", h.handleChat)
|
||||||
mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents)
|
mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents)
|
||||||
|
mux.HandleFunc("/api/v1/kernel", h.handleKernel)
|
||||||
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
|
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
|
||||||
mux.HandleFunc("/", h.handleStatic)
|
mux.HandleFunc("/", h.handleStatic)
|
||||||
}
|
}
|
||||||
@ -105,6 +122,18 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleKernel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.statusProvider == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "kernel status provider not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.statusProvider.GetKernelStatus())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@ -847,7 +876,7 @@ func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/" {
|
if r.URL.Path == "/" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write(webuiHTML)
|
w.Write([]byte(dashboardHTML))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@ -863,94 +892,3 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
|||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
var webuiHTML = []byte(`<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>HomeAgent Dashboard</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
|
||||||
body{background:#0f172a;color:#e2e8f0;min-height:100vh}
|
|
||||||
nav{background:#1e293b;padding:12px 24px;display:flex;align-items:center;gap:24px;border-bottom:1px solid #334155}
|
|
||||||
nav h1{font-size:18px;font-weight:600;color:#38bdf8}
|
|
||||||
nav a{color:#94a3b8;text-decoration:none;font-size:14px;cursor:pointer}
|
|
||||||
nav a:hover{color:#38bdf8;text-decoration:none}
|
|
||||||
nav a.active{color:#38bdf8;border-bottom:2px solid #38bdf8}
|
|
||||||
.container{padding:24px;max-width:1400px;margin:0 auto}
|
|
||||||
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px;margin-bottom:16px}
|
|
||||||
.card h2{font-size:16px;font-weight:600;margin-bottom:12px;color:#f1f5f9}
|
|
||||||
.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}
|
|
||||||
.dot-green{background:#22c55e}
|
|
||||||
.dot-yellow{background:#eab308}
|
|
||||||
.dot-red{background:#ef4444}
|
|
||||||
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
||||||
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
|
||||||
.stat-value{font-size:28px;font-weight:700;color:#38bdf8}
|
|
||||||
.stat-label{font-size:12px;color:#64748b;margin-top:4px}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
||||||
th{text-align:left;padding:8px 12px;color:#64748b;font-weight:500;border-bottom:1px solid #334155;font-size:12px;text-transform:uppercase}
|
|
||||||
td{padding:8px 12px;border-bottom:1px solid #1e293b}
|
|
||||||
.status-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
|
|
||||||
.badge-running{background:#166534;color:#86efac}
|
|
||||||
.badge-stopped{background:#7f1d1d;color:#fca5a5}
|
|
||||||
.btn{padding:6px 14px;border-radius:6px;border:none;font-size:12px;cursor:pointer;font-weight:500}
|
|
||||||
.btn-primary{background:#2563eb;color:#fff}
|
|
||||||
.btn-primary:hover{background:#1d4ed8}
|
|
||||||
.btn-danger{background:#dc2626;color:#fff}
|
|
||||||
.btn-sm{padding:4px 10px;font-size:11px}
|
|
||||||
.tab-content{display:none}
|
|
||||||
.tab-content.active{display:block}
|
|
||||||
input,textarea,select{background:#0f172a;border:1px solid #334155;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%;margin-bottom:12px}
|
|
||||||
label{display:block;font-size:12px;color:#94a3b8;margin-bottom:4px}
|
|
||||||
h3{font-size:14px;font-weight:600;color:#f1f5f9;margin-bottom:8px}
|
|
||||||
pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x:auto;color:#a5b4fc}
|
|
||||||
.settings-layout{display:flex;gap:20px;min-height:60vh}
|
|
||||||
.settings-sidebar{width:200px;flex-shrink:0;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:12px 0;overflow-y:auto}
|
|
||||||
.settings-sidebar a{display:block;padding:10px 16px;color:#94a3b8;font-size:13px;cursor:pointer;text-decoration:none;border-left:3px solid transparent}
|
|
||||||
.settings-sidebar a:hover{background:#0f172a;color:#e2e8f0}
|
|
||||||
.settings-sidebar a.active{background:#0f172a;color:#38bdf8;border-left-color:#38bdf8}
|
|
||||||
.settings-content{flex:1;min-width:0}
|
|
||||||
.settings-key{font-family:monospace;font-size:12px;color:#64748b;margin-bottom:2px}
|
|
||||||
.save-btn{float:right}
|
|
||||||
.toast{position:fixed;bottom:20px;right:20px;background:#166534;color:#86efac;padding:10px 20px;border-radius:8px;font-size:13px;display:none;z-index:100}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav>
|
|
||||||
<h1>HomeAgent</h1>
|
|
||||||
<a class="active" onclick="switchTab('overview')">概览</a>
|
|
||||||
<a onclick="switchTab('memory')">图记忆</a>
|
|
||||||
<a onclick="switchTab('skills')">技能</a>
|
|
||||||
<a onclick="switchTab('network')">网络</a>
|
|
||||||
<a onclick="switchTab('config')">配置</a>
|
|
||||||
</nav>
|
|
||||||
<div class="container" id="app">
|
|
||||||
<div id="tab-overview" class="tab-content active"></div>
|
|
||||||
<div id="tab-memory" class="tab-content"></div>
|
|
||||||
<div id="tab-skills" class="tab-content"></div>
|
|
||||||
<div id="tab-network" class="tab-content"></div>
|
|
||||||
<div id="tab-config" class="tab-content"></div>
|
|
||||||
</div>
|
|
||||||
<div id="toast" class="toast"></div>
|
|
||||||
<script>
|
|
||||||
let state={status:null,settings:null,settingsPlugins:[],selectedSection:'core'};
|
|
||||||
async function api(p,o={}){const r=await fetch('/api/v1'+p,{headers:{'Content-Type':'application/json',...o.headers},...o});return r.json()}
|
|
||||||
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));document.getElementById('tab-'+n).classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="'+n+'"]')?.classList.add('active');renderAll()}
|
|
||||||
function toast(m){const t=document.getElementById('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2500)}
|
|
||||||
async function renderAll(){try{state.status=await api('/status')}catch(e){}try{var s=await api('/settings');state.settings=s.settings||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderMemory();renderSkills();renderNetwork();renderConfig();renderConfigSidebar()}
|
|
||||||
function renderOverview(){const s=state.status||{};document.getElementById('tab-overview').innerHTML='<div class="grid-3">'+statCard('运行状态',s.status||'unknown')+statCard('运行时间',s.uptime||'-')+statCard('版本',s.version||'-')+'</div>'}
|
|
||||||
function statCard(l,v){return '<div class="card"><div class="stat-value">'+v+'</div><div class="stat-label">'+l+'</div></div>'}
|
|
||||||
function renderMemory(){document.getElementById('tab-memory').innerHTML='<div class="card"><h2>图记忆</h2><p style="color:#94a3b8">agent 通过 memory_recall / memory_commit 自动管理</p></div>'}
|
|
||||||
function renderSkills(){document.getElementById('tab-skills').innerHTML='<div class="card"><h2>技能</h2><p style="color:#94a3b8">SKILL.md 插件通过 IO 层注入</p></div>'}
|
|
||||||
function renderNetwork(){document.getElementById('tab-network').innerHTML='<div class="card"><h2>网络</h2><p style="color:#94a3b8">LLM API 连通性监控</p></div>'}
|
|
||||||
function renderConfigSidebar(){var el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){var a=document.createElement('a');a.textContent=p;if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderConfig()};el.appendChild(a)})}
|
|
||||||
function renderConfig(){var prefix=state.selectedSection+'.';var filtered=Object.keys(state.settings||{}).filter(function(k){return k===prefix.slice(0,-1)||k.startsWith(prefix)});filtered.sort();var html='<div class="settings-layout"><div class="settings-sidebar" id="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+state.selectedSection+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(function(k){var v=state.settings[k];var sv=typeof v==='object'?JSON.stringify(v):String(v);html+='<div class="card"><div class="save-btn"><button class="btn btn-primary btn-sm" onclick="saveSetting(\''+k+'\')">保存</button></div><div class="settings-key">'+k+'</div><label>值</label><input id="inp-'+k.replace(/\./g,'_')+'" value="'+escHtml(sv)+'" onchange="markDirty(\''+k+'\')"/></div>'})}html+='</div></div>';document.getElementById('tab-config').innerHTML=html;renderConfigSidebar()}
|
|
||||||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
||||||
function markDirty(k){var inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
|
||||||
async function saveSetting(k){var inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;var raw=inp.value;var val;try{val=JSON.parse(raw)}catch(e){val=raw}var r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'))}}
|
|
||||||
renderAll();setInterval(renderAll,30000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`)
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
|
|||||||
sup := supervisor.New(cfg)
|
sup := supervisor.New(cfg)
|
||||||
sup.Start()
|
sup.Start()
|
||||||
|
|
||||||
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus()), sup
|
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus(), nil), sup
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleStatus(t *testing.T) {
|
func TestHandleStatus(t *testing.T) {
|
||||||
@ -138,7 +138,7 @@ func TestHandleKnowledgeSearch(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus())
|
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@ -170,7 +170,7 @@ func TestHandleKnowledgeCreate(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus())
|
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil)
|
||||||
|
|
||||||
body := `{"name":"new_doc","content":"fresh content"}`
|
body := `{"name":"new_doc","content":"fresh content"}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
|
||||||
@ -235,7 +235,7 @@ func TestHandleTrackerStats(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus())
|
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus(), nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@ -415,7 +415,7 @@ func TestSettingsAPIFlow(t *testing.T) {
|
|||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
pluginReg := plugin.NewRegistry()
|
pluginReg := plugin.NewRegistry()
|
||||||
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus())
|
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil)
|
||||||
|
|
||||||
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
|
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||||
@ -522,7 +522,7 @@ func TestSettingsAPIFlow(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("settings_not_available_without_registry", func(t *testing.T) {
|
t.Run("settings_not_available_without_registry", func(t *testing.T) {
|
||||||
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus())
|
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
h2.handleSettings(w, req)
|
h2.handleSettings(w, req)
|
||||||
@ -548,7 +548,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
|
|||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
pluginReg := plugin.NewRegistry()
|
pluginReg := plugin.NewRegistry()
|
||||||
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus())
|
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@ -622,7 +622,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) {
|
|||||||
sup.Start()
|
sup.Start()
|
||||||
defer sup.Shutdown()
|
defer sup.Shutdown()
|
||||||
|
|
||||||
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus())
|
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus(), nil)
|
||||||
|
|
||||||
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
|
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
|
||||||
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
|
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
|
||||||
@ -655,7 +655,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
|
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
|
||||||
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus())
|
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil)
|
||||||
body := `{"messages":[{"role":"user","content":"hi"}]}`
|
body := `{"messages":[{"role":"user","content":"hi"}]}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
@ -21,19 +22,20 @@ import (
|
|||||||
|
|
||||||
// 包级依赖注入 — 由 main.go 在 Load() 前调用 Configure() 设置。
|
// 包级依赖注入 — 由 main.go 在 Load() 前调用 Configure() 设置。
|
||||||
var (
|
var (
|
||||||
webuiAddr string
|
webuiAddr string
|
||||||
webuiSup *supervisor.Daemon
|
webuiSup *supervisor.Daemon
|
||||||
webuiMem *memory.GraphDB
|
webuiMem *memory.GraphDB
|
||||||
webuiSK *skill.Manager
|
webuiSK *skill.Manager
|
||||||
webuiLua *luaVM.VM
|
webuiLua *luaVM.VM
|
||||||
webuiCfg *types.Config
|
webuiCfg *types.Config
|
||||||
webuiIOM *agentIO.IOManager
|
webuiIOM *agentIO.IOManager
|
||||||
webuiTM *text.Memory
|
webuiTM *text.Memory
|
||||||
webuiKS *knowledge.Store
|
webuiKS *knowledge.Store
|
||||||
webuiTR *tracker.Tracker
|
webuiTR *tracker.Tracker
|
||||||
webuiCR *internalConfig.ConfigRegistry
|
webuiCR *internalConfig.ConfigRegistry
|
||||||
webuiPR *plugin.Registry
|
webuiPR *plugin.Registry
|
||||||
webuiEvBus *events.Bus
|
webuiEvBus *events.Bus
|
||||||
|
webuiStatusProvider agentCore.StatusProvider
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。
|
// Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。
|
||||||
@ -42,11 +44,13 @@ func Configure(addr string,
|
|||||||
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
|
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
|
||||||
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
|
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
|
||||||
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
|
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
|
||||||
|
sp agentCore.StatusProvider,
|
||||||
) {
|
) {
|
||||||
webuiAddr = addr
|
webuiAddr = addr
|
||||||
webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua
|
webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua
|
||||||
webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks
|
webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks
|
||||||
webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus
|
webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus
|
||||||
|
webuiStatusProvider = sp
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@ -61,7 +65,7 @@ func init() {
|
|||||||
return New(name, addr,
|
return New(name, addr,
|
||||||
webuiSup, webuiMem, webuiSK, webuiLua,
|
webuiSup, webuiMem, webuiSK, webuiLua,
|
||||||
webuiCfg, webuiIOM, webuiTM, webuiKS,
|
webuiCfg, webuiIOM, webuiTM, webuiKS,
|
||||||
webuiTR, webuiCR, webuiPR, webuiEvBus,
|
webuiTR, webuiCR, webuiPR, webuiEvBus, webuiStatusProvider,
|
||||||
), nil
|
), nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -85,6 +89,7 @@ type Plugin struct {
|
|||||||
cr *internalConfig.ConfigRegistry
|
cr *internalConfig.ConfigRegistry
|
||||||
pr *plugin.Registry
|
pr *plugin.Registry
|
||||||
evBus *events.Bus
|
evBus *events.Bus
|
||||||
|
statusProvider agentCore.StatusProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(name, addr string,
|
func New(name, addr string,
|
||||||
@ -92,6 +97,7 @@ func New(name, addr string,
|
|||||||
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
|
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager,
|
||||||
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
|
tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker,
|
||||||
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
|
cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus,
|
||||||
|
sp agentCore.StatusProvider,
|
||||||
) *Plugin {
|
) *Plugin {
|
||||||
return &Plugin{
|
return &Plugin{
|
||||||
name: name,
|
name: name,
|
||||||
@ -99,13 +105,14 @@ func New(name, addr string,
|
|||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg,
|
sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg,
|
||||||
iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus,
|
iom: iom, tm: tm, ks: ks, tr: tr, cr: cr, pr: pr, evBus: evBus,
|
||||||
|
statusProvider: sp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) Name() string { return p.name }
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus)
|
h := NewHandler(p.sup, p.mem, p.sk, p.lua, p.cfg, p.iom, p.tm, p.ks, p.tr, p.cr, p.pr, p.evBus, p.statusProvider)
|
||||||
p.handler = h
|
p.handler = h
|
||||||
h.RegisterRoutes(p.mux)
|
h.RegisterRoutes(p.mux)
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@ type StageContext struct {
|
|||||||
Response *string
|
Response *string
|
||||||
Phase Stage
|
Phase Stage
|
||||||
Memory []MemItem
|
Memory []MemItem
|
||||||
|
NoMemory bool
|
||||||
Extra map[string]interface{}
|
Extra map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +145,13 @@ func (s *PluginSDK) InjectText(source, channel, text string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectTextNoMemory 注入文本输入(不产生记忆)。适用于健康检查等无需记忆碎片的场景。
|
||||||
|
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||||
|
if s.iom != nil {
|
||||||
|
s.iom.InjectTextNoMemoryTo(source, channel, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.OutputEvent {
|
func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.OutputEvent {
|
||||||
if s.iom != nil {
|
if s.iom != nil {
|
||||||
return s.iom.InjectTextSyncTo(source, channel, text)
|
return s.iom.InjectTextSyncTo(source, channel, text)
|
||||||
@ -151,6 +159,14 @@ func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.Output
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectTextSyncNoMemory 注入文本输入(同步等待,不产生记忆)。
|
||||||
|
func (s *PluginSDK) InjectTextSyncNoMemory(source, channel, text string) *agentIO.OutputEvent {
|
||||||
|
if s.iom != nil {
|
||||||
|
return s.iom.InjectTextSyncNoMemoryTo(source, channel, text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||||
if s.iom != nil {
|
if s.iom != nil {
|
||||||
s.iom.InjectInterruptText(source, channel, text)
|
s.iom.InjectInterruptText(source, channel, text)
|
||||||
|
|||||||
Reference in New Issue
Block a user