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:
root
2026-07-04 12:51:32 +08:00
parent 77e12f7329
commit fab58e709a
30 changed files with 4956 additions and 578 deletions

85
MILESTONE.md Normal file
View File

@ -0,0 +1,85 @@
# MILESTONE — 项目进展与路线图
## 已完成
### 插件系统基础
- [x] agentcli 插件6 个 PTY 终端工具create/write/read/resize/close/list完整按键映射
- [x] cmd 插件:`cmd_run` 工具command/timeout/workdir8 单元测试
- [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 — 定时器

View File

@ -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()

View File

@ -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 — 图索引器

View File

@ -36,8 +36,9 @@ HomeAgent 是一个**持续运行的个人智能管家**。它像一个随时在
- 倒计时结束后它会主动通知你 - 倒计时结束后它会主动通知你
### 🔌 可扩展(插件) ### 🔌 可扩展(插件)
- **Web 控制台** — 在浏览器中管理和配置 - **Web 控制台** — 在浏览器中管理和配置7 标签页 SPA
- **命令行** — 通过终端快速交互 - **命令行** — 通过终端快速交互
- **健康检查** — 自动检测系统各组件状态LLM 驱动故障排查
- **更多能力** — 开发者可以写插件接入任何服务 - **更多能力** — 开发者可以写插件接入任何服务
## 它是如何工作的?(简述) ## 它是如何工作的?(简述)

9
go.mod
View File

@ -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
)

View File

@ -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()

View 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)
}
}

View File

@ -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)
} }

View 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)

View File

@ -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 {

View 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()
}

View 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)
}
}
}

View File

@ -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"

View 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")
}

View 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"])
}
}

View 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
}

View 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")
}
}

View 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")
}

View File

@ -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
} }

View 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)
}

View 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
}
}

View 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}`);
});

View 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

View File

@ -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
}

View File

@ -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")
}
}

View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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>

View File

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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>`)

View File

@ -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")

View File

@ -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)

View File

@ -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)