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/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"
@ -316,25 +317,8 @@ func main() {
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{
@ -377,6 +361,25 @@ func main() {
EventBus: evBus,
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()
defer agent.Stop()

View File

@ -613,12 +613,22 @@ internal/
│ └── plugin.go — SKILL 插件解析 (OpenClaw 兼容)
├── plugins/
│ ├── 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)
│ ├── openclaw/plugin.go — OpenClaw 兼容 (SKILL.md → SDK 工具注册)
── webui/ — WebUI 插件 (HTTP 服务器 + 仪表盘)
│ ├── cmd/plugin.go — 命令执行 (cmd_run 工具)
── 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
── handler.go
── handler.go
│ └── dashboard.html — 嵌入式 SPA (go:embed)
├── memory/
│ ├── graph.go — SQLite 图数据库
│ ├── 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
require (
github.com/gorilla/websocket v1.5.3
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
)
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_]
}
// 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 {
m.mu.RLock()
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
thinkingEnabled bool
// 启动时间
startTime time.Time
}
type AgentConfig struct {
@ -135,6 +138,7 @@ func New(cfg AgentConfig) *Agent {
}
return &Agent{
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
@ -291,8 +295,14 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
return
}
noMemory := false
if v, ok := evt.Payload["no_memory"].(bool); ok {
noMemory = v
}
// === Stage: on_input — 消息到达,插件可拦截 ===
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
stageCtx.NoMemory = noMemory
a.publishEvent(events.EventRawInput, map[string]interface{}{
"content": input,
"source": evt.Source,
@ -337,7 +347,9 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
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) {
@ -1825,7 +1837,6 @@ func (a *Agent) processConsolidation(input string) {
ToolsUsed: toolsUsed,
})
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
// 只写入记忆,不发外部输出
a.emitMemoryCandidate("system", input, response, 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 向中断通道发送输入
func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) {
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
import (
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
_ "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/openclaw"
_ "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
import (
_ "embed"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
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 SimulatorDir string
func init() {
plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -28,15 +33,23 @@ func init() {
}
type Plugin struct {
name string
skillsDir string
skills []*plugin.SKILLPlugin
name string
skillsDir string
simulatorDir string
skills []*plugin.SKILLPlugin
sidecars []*sidecarProcess
mu sync.Mutex
}
func New(name, skillsDir string) *Plugin {
sd := SimulatorDir
if sd == "" {
sd = filepath.Join(skillsDir, ".simulator")
}
return &Plugin{
name: name,
skillsDir: skillsDir,
name: name,
skillsDir: skillsDir,
simulatorDir: sd,
}
}
@ -53,40 +66,158 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
for _, entry := range entries {
skillPath := filepath.Join(p.skillsDir, entry.Name())
sk, err := plugin.LoadSKILL(skillPath)
subs, err := os.ReadDir(skillPath)
if err != nil {
log.Printf("[openclaw] load skill %s: %v", entry.Name(), err)
continue
}
p.skills = append(p.skills, sk)
// Register each tool defined in the SKILL
for _, td := range sk.Tools() {
name := td.Name
def := sdk.ToolDef{
Name: name,
Description: td.Description,
Parameters: td.Parameters,
}
// 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)
hasMainJS := false
hasOCManifest := false
for _, f := range subs {
switch f.Name() {
case "main.js":
hasMainJS = true
case "openclaw.plugin.json":
hasOCManifest = true
}
}
// Register IO config as a channel if defined
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)
}
switch {
case hasMainJS:
if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil {
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
}
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 {
p.mu.Lock()
defer p.mu.Unlock()
for _, sp := range p.sidecars {
sp.Close()
}
p.sidecars = nil
p.skills = 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
import (
"embed"
"encoding/json"
"fmt"
"net/http"
@ -10,6 +11,7 @@ import (
"strings"
"time"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
@ -24,43 +26,57 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
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
//go:embed dashboard.html
var dashboardFS embed.FS
var dashboardHTML string
func init() {
data, err := dashboardFS.ReadFile("dashboard.html")
if err == nil {
dashboardHTML = string(data)
}
}
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
if mem != nil {
idx = memory.NewIndexer(mem)
}
return &Handler{
supervisor: sup,
memory: mem,
indexer: idx,
skills: sk,
lua: lua,
config: cfg,
startTime: time.Now(),
iom: iom,
textMem: tm,
knowledge: ks,
tracker: tr,
cfgReg: cr,
pluginReg: pr,
eventBus: evBus,
supervisor: sup,
memory: mem,
indexer: idx,
skills: sk,
lua: lua,
config: cfg,
startTime: time.Now(),
iom: iom,
textMem: tm,
knowledge: ks,
tracker: tr,
cfgReg: cr,
pluginReg: pr,
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/chat", h.handleChat)
mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents)
mux.HandleFunc("/api/v1/kernel", h.handleKernel)
mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions)
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) {
switch r.Method {
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) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(webuiHTML)
w.Write([]byte(dashboardHTML))
return
}
http.NotFound(w, r)
@ -863,94 +892,3 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.WriteHeader(status)
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.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) {
@ -138,7 +138,7 @@ func TestHandleKnowledgeSearch(t *testing.T) {
sup.Start()
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)
w := httptest.NewRecorder()
@ -170,7 +170,7 @@ func TestHandleKnowledgeCreate(t *testing.T) {
sup.Start()
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"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
@ -235,7 +235,7 @@ func TestHandleTrackerStats(t *testing.T) {
sup.Start()
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)
w := httptest.NewRecorder()
@ -415,7 +415,7 @@ func TestSettingsAPIFlow(t *testing.T) {
defer sup.Shutdown()
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) {
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) {
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)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
@ -548,7 +548,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) {
defer sup.Shutdown()
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)
w := httptest.NewRecorder()
@ -622,7 +622,7 @@ func TestHandleCompletionsEndToEnd(t *testing.T) {
sup.Start()
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) {
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) {
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"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")

View File

@ -4,6 +4,7 @@ import (
"log"
"net/http"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
@ -21,19 +22,20 @@ import (
// 包级依赖注入 — 由 main.go 在 Load() 前调用 Configure() 设置。
var (
webuiAddr string
webuiSup *supervisor.Daemon
webuiMem *memory.GraphDB
webuiSK *skill.Manager
webuiLua *luaVM.VM
webuiCfg *types.Config
webuiIOM *agentIO.IOManager
webuiTM *text.Memory
webuiKS *knowledge.Store
webuiTR *tracker.Tracker
webuiCR *internalConfig.ConfigRegistry
webuiPR *plugin.Registry
webuiEvBus *events.Bus
webuiAddr string
webuiSup *supervisor.Daemon
webuiMem *memory.GraphDB
webuiSK *skill.Manager
webuiLua *luaVM.VM
webuiCfg *types.Config
webuiIOM *agentIO.IOManager
webuiTM *text.Memory
webuiKS *knowledge.Store
webuiTR *tracker.Tracker
webuiCR *internalConfig.ConfigRegistry
webuiPR *plugin.Registry
webuiEvBus *events.Bus
webuiStatusProvider agentCore.StatusProvider
)
// Configure 注入 WebUI 插件需要的内核依赖。必须在 Load() 之前调用。
@ -42,11 +44,13 @@ func Configure(addr string,
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,
) {
webuiAddr = addr
webuiSup, webuiMem, webuiSK, webuiLua = sup, mem, sk, lua
webuiCfg, webuiIOM, webuiTM, webuiKS = cfg, iom, tm, ks
webuiTR, webuiCR, webuiPR, webuiEvBus = tr, cr, pr, evBus
webuiStatusProvider = sp
}
func init() {
@ -61,7 +65,7 @@ func init() {
return New(name, addr,
webuiSup, webuiMem, webuiSK, webuiLua,
webuiCfg, webuiIOM, webuiTM, webuiKS,
webuiTR, webuiCR, webuiPR, webuiEvBus,
webuiTR, webuiCR, webuiPR, webuiEvBus, webuiStatusProvider,
), nil
})
}
@ -85,6 +89,7 @@ type Plugin struct {
cr *internalConfig.ConfigRegistry
pr *plugin.Registry
evBus *events.Bus
statusProvider agentCore.StatusProvider
}
func New(name, addr string,
@ -92,6 +97,7 @@ func New(name, addr string,
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,
) *Plugin {
return &Plugin{
name: name,
@ -99,13 +105,14 @@ func New(name, addr string,
mux: http.NewServeMux(),
sup: sup, mem: mem, sk: sk, lua: lua, cfg: cfg,
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) 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
h.RegisterRoutes(p.mux)

View File

@ -44,6 +44,7 @@ type StageContext struct {
Response *string
Phase Stage
Memory []MemItem
NoMemory bool
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 {
if s.iom != nil {
return s.iom.InjectTextSyncTo(source, channel, text)
@ -151,6 +159,14 @@ func (s *PluginSDK) InjectTextSync(source, channel, text string) *agentIO.Output
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) {
if s.iom != nil {
s.iom.InjectInterruptText(source, channel, text)