mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
feat: complete P0/P1/P2 — WebUI SPA, OpenClaw sidecar+simulator, healthcheck auto-sched+perf
P0: WebUI重构
- 完整 SPA 仪表盘 (7标签页), //go:embed dashboard.html
P1: OpenClaw兼容 (三通道: SKILL.md / sidecar / simulator)
- Node.js 模拟进程统一加载任意 OpenClaw 插件
- JSON-RPC 2.0 over stdio 协议, go:embed 内嵌
P2: Healthcheck 优化
- 定时自动执行 (startAutoCheck, 30min)
- healthcheck_perf 性能监控工具
其他: agentcli/cmd 插件, integration_test, status.go,
test_deepseek 清理, 多项 bug 修复
This commit is contained in:
85
MILESTONE.md
Normal file
85
MILESTONE.md
Normal file
@ -0,0 +1,85 @@
|
||||
# MILESTONE — 项目进展与路线图
|
||||
|
||||
## 已完成
|
||||
|
||||
### 插件系统基础
|
||||
- [x] agentcli 插件:6 个 PTY 终端工具(create/write/read/resize/close/list),完整按键映射
|
||||
- [x] cmd 插件:`cmd_run` 工具(command/timeout/workdir),8 单元测试
|
||||
- [x] timer 插件:定时器工具
|
||||
- [x] 12 个跨插件集成测试
|
||||
|
||||
### 非记忆 LLM 调用机制
|
||||
- [x] `StageContext.NoMemory` 标记 — agent 跳过 `emitMemoryCandidate`
|
||||
- [x] `InjectTextNoMemoryTo` / `InjectTextSyncNoMemoryTo` — IOManager 层
|
||||
- [x] `InjectTextNoMemory` / `InjectTextSyncNoMemory` — PluginSDK 层
|
||||
- [x] `ProviderManager.QuickChat()` — 直连 Provider 的快捷调用
|
||||
|
||||
### LLM 驱动健康检查(`healthcheck` 插件)
|
||||
- [x] `healthcheck_report` 工具 — LLM 上报测试结果
|
||||
- [x] `testLLMDriven()` — 独立工具循环:LLM 发现→调用→上报
|
||||
- [x] 动态排除本插件工具(`selfToolNames`),不硬编码插件名
|
||||
- [x] E2E 真实 DeepSeek API 测试通过(8 个真实工具全部测试通过)
|
||||
|
||||
### 内核状态接口(`StatusProvider`)
|
||||
- [x] `internal/agent/core/status.go` — `KernelStatus` 聚合快照
|
||||
- [x] `Agent.GetKernelStatus()` — 实现 `StatusProvider` 接口
|
||||
- [x] `healthcheck_kernel` 工具 — Agent 可自主查询内核状态
|
||||
- [x] WebUI `/api/v1/kernel` 端点 — 状态 JSON API
|
||||
- [x] 所有子系统:plugins / tools / channels / memory / knowledge / documents / text_memory / social / skills / LLM / runtime / tracker
|
||||
|
||||
### 技术债务清理
|
||||
- [x] 移除旧 `test_deepseek` 插件
|
||||
- [x] 修复 agentcli PTY readLoop 死锁(goroutine reader + close 顺序)
|
||||
- [x] 修复 knowledge.Store.Delete 不存在方法
|
||||
|
||||
### P0 — WebUI 重构
|
||||
- [x] 完整的 SPA 仪表盘(7 标签页:概览/对话/插件/记忆/知识/设置/内核)
|
||||
- [x] 深色主题,响应式布局
|
||||
- [x] 使用 `//go:embed dashboard.html` 替代硬编码 HTML 变量
|
||||
- [x] 全部 REST API 端点保持兼容
|
||||
|
||||
### P1 — OpenClaw 兼容插件(Go 插件 + Node.js sidecar + 模拟器架构)
|
||||
- [x] 设计 Go ↔ Node.js 通信协议(JSON-RPC 2.0 over stdio)
|
||||
- [x] Go 侧 sidecar 管理器(进程启动、心跳、重启、`waitReady`)
|
||||
- [x] 工具发现(`tools/list`)和执行(`tools/call`)转译
|
||||
- [x] Node.js 测试插件 `echoplugin`(echo/add/ping 三个工具)
|
||||
- [x] 5 个端到端测试(无 main.js 兜底、列表、调用 echo/add、不存在的工具、并发调用)
|
||||
- [x] 现有 SKILL.md 加载路径保留(三通道:SKILL.md / sidecar / simulator)
|
||||
- [x] Node.js OpenClaw 模拟进程(`simulator/main.js`):完整实现 `OpenClawPluginApi`,加载任意遵循 OpenClaw 插件标准的真实插件
|
||||
- [x] 模拟器内嵌于 Go 二进制(`//go:embed`),启动时自动提取
|
||||
- [x] 支持 `package.json#openclaw.extensions/runtimeExtensions` 入口发现,兼容 `.ts`→`.js` 编译回退
|
||||
- [x] 用 ClawHub 真实插件(chart-plot)验证端到端链路通过
|
||||
|
||||
### P2 — 进一步优化
|
||||
- [x] Healthcheck 定时自动执行(`startAutoCheck` goroutine,默认 30 分钟)
|
||||
- [x] Agent 自主健康状态感知(通过 `healthcheck_kernel`)
|
||||
- [x] 更多 WebUI 可视化面板(7 标签页 SPA)
|
||||
- [x] 性能监控与告警(`healthcheck_perf` 工具 + `PerfData` 历史记录 100 条)
|
||||
|
||||
---
|
||||
|
||||
## 当前状态
|
||||
|
||||
所有 P0 / P1 / P2 计划项已完成。项目处于维护和迭代阶段。
|
||||
|
||||
### 已注册的工具(14 个)
|
||||
- `cmd_run` — 命令执行
|
||||
- `terminal_create / write / read / resize / close / list` — PTY 终端
|
||||
- `healthcheck` — 全面健康检查(含 LLM 驱动阶段)
|
||||
- `healthcheck_plugins` — 列出已加载插件
|
||||
- `healthcheck_tools` — 列出所有已注册工具
|
||||
- `healthcheck_memory` — 图记忆系统测试
|
||||
- `healthcheck_report` — LLM 汇报接口
|
||||
- `healthcheck_kernel` — 内核状态快照
|
||||
- `healthcheck_perf` — 性能监控数据
|
||||
- `timer_set` — 定时器
|
||||
|
||||
### 内置插件(7 个)
|
||||
- agentcli — PTY 终端管理
|
||||
- cli — Unix socket 通信
|
||||
- cmd — 命令执行
|
||||
- healthcheck — 健康检查 + 性能监控 + 自动调度
|
||||
- mcp — MCP 协议支持
|
||||
- openclaw — SKILL.md + Node.js sidecar 双通道
|
||||
- webui — HTTP 服务器 + SPA 仪表盘
|
||||
- timer — 定时器
|
||||
@ -27,6 +27,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/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()
|
||||
|
||||
|
||||
@ -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 — 图索引器
|
||||
|
||||
@ -36,8 +36,9 @@ HomeAgent 是一个**持续运行的个人智能管家**。它像一个随时在
|
||||
- 倒计时结束后它会主动通知你
|
||||
|
||||
### 🔌 可扩展(插件)
|
||||
- **Web 控制台** — 在浏览器中管理和配置
|
||||
- **Web 控制台** — 在浏览器中管理和配置(7 标签页 SPA)
|
||||
- **命令行** — 通过终端快速交互
|
||||
- **健康检查** — 自动检测系统各组件状态,LLM 驱动故障排查
|
||||
- **更多能力** — 开发者可以写插件接入任何服务
|
||||
|
||||
## 它是如何工作的?(简述)
|
||||
|
||||
9
go.mod
9
go.mod
@ -3,12 +3,9 @@ module gitcode.com/JianFeeeee/HomeAgent
|
||||
go 1.19
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
54
internal/agent/api/quickchat_test.go
Normal file
54
internal/agent/api/quickchat_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
)
|
||||
|
||||
func TestQuickChatWithRealKey(t *testing.T) {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("DEEPSEEK_API_KEY not set — skipping real LLM test")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
vm := luaVM.NewVM(tmpDir + "/adapters")
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
pm := NewProviderManager()
|
||||
pm.Register("deepseek", NewLuaAdaptedProvider(BaseConfig{
|
||||
Model: "deepseek-v4-flash",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
APIKey: apiKey,
|
||||
}, vm, "deepseek"))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
resp, err := pm.QuickChat(ctx, "请回复'OK',不要输出其他内容")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("QuickChat failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
t.Fatal("empty response")
|
||||
}
|
||||
|
||||
t.Logf("Response: %q", resp.Content)
|
||||
t.Logf("Time: %v", elapsed.Round(time.Millisecond))
|
||||
if resp.TokenUsage.Total > 0 {
|
||||
t.Logf("Tokens: %d (prompt %d + completion %d)",
|
||||
resp.TokenUsage.Total, resp.TokenUsage.Prompt, resp.TokenUsage.Completion)
|
||||
}
|
||||
}
|
||||
@ -93,6 +93,9 @@ type Agent struct {
|
||||
|
||||
// 模型思考模式(thinking/reasoning)
|
||||
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)
|
||||
}
|
||||
|
||||
302
internal/agent/core/status.go
Normal file
302
internal/agent/core/status.go
Normal file
@ -0,0 +1,302 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
)
|
||||
|
||||
// StatusProvider 内核状态查询接口。插件通过此接口查看内核运行动态。
|
||||
type StatusProvider interface {
|
||||
GetKernelStatus() *KernelStatus
|
||||
}
|
||||
|
||||
// KernelStatus 内核各子系统运行状态的聚合快照。
|
||||
type KernelStatus struct {
|
||||
Uptime string `json:"uptime"`
|
||||
StartTime string `json:"start_time"`
|
||||
|
||||
AgentID string `json:"agent_id"`
|
||||
|
||||
Plugins []PluginInfo `json:"plugins"`
|
||||
Tools []sdk.ToolDef `json:"tools"`
|
||||
Channels []ChannelInfo `json:"channels"`
|
||||
|
||||
Memory MemoryStatus `json:"memory"`
|
||||
Knowledge KnowledgeStatus `json:"knowledge"`
|
||||
Documents DocumentStatus `json:"documents"`
|
||||
TextMemory TextMemoryStatus `json:"text_memory"`
|
||||
Social SocialStatus `json:"social"`
|
||||
Skills SkillsStatus `json:"skills"`
|
||||
|
||||
LLM LLMStatus `json:"llm"`
|
||||
|
||||
Context ContextStatus `json:"context"`
|
||||
|
||||
Runtime RuntimeStatus `json:"runtime"`
|
||||
|
||||
Tracker TrackerStatus `json:"tracker"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Loaded bool `json:"loaded"`
|
||||
}
|
||||
|
||||
type ChannelInfo struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
func channelInfoFromIO(ch agentIO.ChannelInfo) ChannelInfo {
|
||||
return ChannelInfo{
|
||||
Name: ch.Name,
|
||||
Type: fmt.Sprintf("%d", ch.Type),
|
||||
Ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
type MemoryStatus struct {
|
||||
Available bool `json:"available"`
|
||||
EntityCount int `json:"entity_count,omitempty"`
|
||||
RelationCount int `json:"relation_count,omitempty"`
|
||||
EntityTypes int `json:"entity_types,omitempty"`
|
||||
}
|
||||
|
||||
type KnowledgeStatus struct {
|
||||
Available bool `json:"available"`
|
||||
ItemCount int `json:"item_count,omitempty"`
|
||||
Items []string `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type DocumentStatus struct {
|
||||
Available bool `json:"available"`
|
||||
DocCount int `json:"doc_count,omitempty"`
|
||||
VectorCount int `json:"vector_count,omitempty"`
|
||||
}
|
||||
|
||||
type TextMemoryStatus struct {
|
||||
Available bool `json:"available"`
|
||||
FileCount int `json:"file_count,omitempty"`
|
||||
}
|
||||
|
||||
type SocialStatus struct {
|
||||
Available bool `json:"available"`
|
||||
PersonCount int `json:"person_count,omitempty"`
|
||||
}
|
||||
|
||||
type SkillsStatus struct {
|
||||
Available bool `json:"available"`
|
||||
SkillList []string `json:"skill_list,omitempty"`
|
||||
}
|
||||
|
||||
type LLMStatus struct {
|
||||
Available bool `json:"available"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Sources int `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
type ContextStatus struct {
|
||||
EventCount int `json:"event_count,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeStatus struct {
|
||||
Goroutines int `json:"goroutines"`
|
||||
MemoryMB int64 `json:"memory_mb"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
type TrackerStatus struct {
|
||||
Available bool `json:"available"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
}
|
||||
|
||||
// collectKernelStatus 聚合内核各子系统状态快照。
|
||||
// 接收所有子系统引用(均为可选——nil 表示不可用),返回统一的状态报告。
|
||||
func collectKernelStatus(
|
||||
startTime time.Time,
|
||||
agentID string,
|
||||
providerName string,
|
||||
sourceCount int,
|
||||
stageHost *StageHost,
|
||||
iom *agentIO.IOManager,
|
||||
pluginReg *plugin.Registry,
|
||||
memDB *memory.GraphDB,
|
||||
ks interface{ List() []string },
|
||||
docStore *document.Store,
|
||||
textMem *text.Memory,
|
||||
socialStore *social.SocialStore,
|
||||
skMgr *skill.Manager,
|
||||
trk *tracker.Tracker,
|
||||
) *KernelStatus {
|
||||
status := &KernelStatus{
|
||||
Uptime: time.Since(startTime).Round(time.Second).String(),
|
||||
StartTime: startTime.Format(time.RFC3339),
|
||||
AgentID: agentID,
|
||||
Runtime: RuntimeStatus{
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
GoVersion: runtime.Version(),
|
||||
},
|
||||
LLM: LLMStatus{
|
||||
Available: providerName != "",
|
||||
Provider: providerName,
|
||||
Sources: sourceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Plugins
|
||||
if pluginReg != nil {
|
||||
names := pluginReg.List()
|
||||
for _, n := range names {
|
||||
status.Plugins = append(status.Plugins, PluginInfo{Name: n, Loaded: true})
|
||||
}
|
||||
}
|
||||
|
||||
// Tools
|
||||
if stageHost != nil {
|
||||
status.Tools = stageHost.GetToolDefs()
|
||||
}
|
||||
|
||||
// Channels
|
||||
if iom != nil {
|
||||
for _, ch := range iom.ListChannels() {
|
||||
status.Channels = append(status.Channels, channelInfoFromIO(ch))
|
||||
}
|
||||
}
|
||||
|
||||
// Graph memory
|
||||
if memDB != nil {
|
||||
status.Memory.Available = true
|
||||
if info, err := memDB.Introspect(); err == nil {
|
||||
if ec, ok := info["entity_count"].(int); ok {
|
||||
status.Memory.EntityCount = ec
|
||||
}
|
||||
if rc, ok := info["relation_count"].(int); ok {
|
||||
status.Memory.RelationCount = rc
|
||||
}
|
||||
if et, ok := info["entity_type_count"].(int); ok {
|
||||
status.Memory.EntityTypes = et
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Knowledge
|
||||
if ks != nil {
|
||||
status.Knowledge.Available = true
|
||||
status.Knowledge.Items = ks.List()
|
||||
status.Knowledge.ItemCount = len(status.Knowledge.Items)
|
||||
}
|
||||
|
||||
// Documents
|
||||
if docStore != nil {
|
||||
status.Documents.Available = true
|
||||
stats := docStore.Stats()
|
||||
if dc, ok := stats["doc_count"].(int); ok {
|
||||
status.Documents.DocCount = dc
|
||||
}
|
||||
if vc, ok := stats["vector_count"].(int); ok {
|
||||
status.Documents.VectorCount = vc
|
||||
}
|
||||
}
|
||||
|
||||
// Text memory
|
||||
if textMem != nil {
|
||||
status.TextMemory.Available = true
|
||||
status.TextMemory.FileCount = textMem.FileCount()
|
||||
}
|
||||
|
||||
// Social
|
||||
if socialStore != nil {
|
||||
status.Social.Available = true
|
||||
if persons, err := socialStore.ListPersons(); err == nil {
|
||||
status.Social.PersonCount = len(persons)
|
||||
}
|
||||
}
|
||||
|
||||
// Skills
|
||||
if skMgr != nil {
|
||||
status.Skills.Available = true
|
||||
skills := skMgr.List()
|
||||
status.Skills.SkillList = make([]string, len(skills))
|
||||
for i, sk := range skills {
|
||||
status.Skills.SkillList[i] = sk.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Tracker
|
||||
if trk != nil {
|
||||
status.Tracker.Available = true
|
||||
status.Tracker.Dir = trk.MergeDir()
|
||||
}
|
||||
|
||||
// Memory
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
status.Runtime.MemoryMB = int64(m.Alloc / 1024 / 1024)
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// GetKernelStatus 返回 Agent 驱动的内核状态快照。
|
||||
func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
providerName := ""
|
||||
sourceCount := 0
|
||||
if a.providerManager != nil {
|
||||
sourceCount = len(a.providerManager.List())
|
||||
}
|
||||
if a.provider != nil {
|
||||
providerName = a.provider.Name()
|
||||
}
|
||||
|
||||
var textMem *text.Memory
|
||||
if a.textMem != nil {
|
||||
textMem = a.textMem
|
||||
}
|
||||
|
||||
var socialStore *social.SocialStore
|
||||
if a.social != nil {
|
||||
socialStore = a.social
|
||||
}
|
||||
|
||||
var skMgr *skill.Manager
|
||||
if a.skills != nil {
|
||||
skMgr = a.skills
|
||||
}
|
||||
|
||||
var trk *tracker.Tracker
|
||||
if a.tracker != nil {
|
||||
trk = a.tracker
|
||||
}
|
||||
|
||||
ks := collectKernelStatus(
|
||||
a.startTime,
|
||||
string(a.id),
|
||||
providerName,
|
||||
sourceCount,
|
||||
a.stageHost,
|
||||
a.io,
|
||||
a.pluginReg,
|
||||
a.memory,
|
||||
a.knowledge,
|
||||
a.docStore,
|
||||
textMem,
|
||||
socialStore,
|
||||
skMgr,
|
||||
trk,
|
||||
)
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
var _ StatusProvider = (*Agent)(nil)
|
||||
@ -241,6 +241,22 @@ func (m *IOManager) InjectTextTo(source, outputChannel, text string) {
|
||||
})
|
||||
}
|
||||
|
||||
// InjectTextNoMemoryTo 注入文本输入(不产生记忆)并指定输出通道
|
||||
func (m *IOManager) InjectTextNoMemoryTo(source, outputChannel, text string) {
|
||||
m.InjectInputTo(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"no_memory": true,
|
||||
})
|
||||
}
|
||||
|
||||
// InjectTextSyncNoMemoryTo 注入文本输入(同步等待,不产生记忆)并指定输出通道
|
||||
func (m *IOManager) InjectTextSyncNoMemoryTo(source, outputChannel, text string) *OutputEvent {
|
||||
return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"no_memory": true,
|
||||
})
|
||||
}
|
||||
|
||||
// InjectInterrupt 向中断通道发送输入
|
||||
func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) {
|
||||
if payload == nil {
|
||||
|
||||
814
internal/plugins/agentcli/plugin.go
Normal file
814
internal/plugins/agentcli/plugin.go
Normal file
@ -0,0 +1,814 @@
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// PTY ioctl constants for Linux
|
||||
const (
|
||||
TIOCGPTN = 0x80045430
|
||||
TIOCSPTLCK = 0x40045431
|
||||
TIOCSWINSZ = 0x5414
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
ReadBufSize = 4096
|
||||
MaxOutputBuffer = 128 * 1024
|
||||
NotifyOutputDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type winsize struct {
|
||||
Row uint16
|
||||
Col uint16
|
||||
XPixel uint16
|
||||
YPixel uint16
|
||||
}
|
||||
|
||||
func ioctl(fd, cmd uintptr, ptr unsafe.Pointer) error {
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, fd, cmd, uintptr(ptr))
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openPty() (master *os.File, slave *os.File, err error) {
|
||||
mfd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open /dev/ptmx: %w", err)
|
||||
}
|
||||
master = os.NewFile(uintptr(mfd), "/dev/ptmx")
|
||||
|
||||
var unlock int32
|
||||
if err := ioctl(uintptr(mfd), TIOCSPTLCK, unsafe.Pointer(&unlock)); err != nil {
|
||||
master.Close()
|
||||
return nil, nil, fmt.Errorf("TIOCSPTLCK: %w", err)
|
||||
}
|
||||
|
||||
var ptyno int32
|
||||
if err := ioctl(uintptr(mfd), TIOCGPTN, unsafe.Pointer(&ptyno)); err != nil {
|
||||
master.Close()
|
||||
return nil, nil, fmt.Errorf("TIOCGPTN: %w", err)
|
||||
}
|
||||
|
||||
slavePath := fmt.Sprintf("/dev/pts/%d", ptyno)
|
||||
sfd, err := syscall.Open(slavePath, syscall.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
master.Close()
|
||||
return nil, nil, fmt.Errorf("open slave %s: %w", slavePath, err)
|
||||
}
|
||||
slave = os.NewFile(uintptr(sfd), slavePath)
|
||||
|
||||
return master, slave, nil
|
||||
}
|
||||
|
||||
type TerminalSession struct {
|
||||
id string
|
||||
cmd *exec.Cmd
|
||||
master *os.File
|
||||
slave *os.File
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
createdAt time.Time
|
||||
timeout time.Duration
|
||||
closed bool
|
||||
stopCh chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (t *TerminalSession) Write(input string) (int, error) {
|
||||
return t.master.WriteString(input)
|
||||
}
|
||||
|
||||
func (t *TerminalSession) Read(buf []byte) (int, error) {
|
||||
return t.master.Read(buf)
|
||||
}
|
||||
|
||||
func (t *TerminalSession) ReadOutput() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.buf.String()
|
||||
}
|
||||
|
||||
func (t *TerminalSession) ReadAndClearOutput() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := t.buf.String()
|
||||
t.buf.Reset()
|
||||
return s
|
||||
}
|
||||
|
||||
func (t *TerminalSession) appendOutput(data []byte) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.buf.Len()+len(data) > MaxOutputBuffer {
|
||||
excess := t.buf.Len() + len(data) - MaxOutputBuffer
|
||||
if t.buf.Len() > excess {
|
||||
t.buf.Next(excess)
|
||||
} else {
|
||||
t.buf.Reset()
|
||||
}
|
||||
}
|
||||
t.buf.Write(data)
|
||||
}
|
||||
|
||||
func (t *TerminalSession) Resize(rows, cols uint16) error {
|
||||
ws := winsize{Row: rows, Col: cols}
|
||||
if err := ioctl(uintptr(t.master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws)); err != nil {
|
||||
return fmt.Errorf("TIOCSWINSZ: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TerminalSession) Close() {
|
||||
t.mu.Lock()
|
||||
if t.closed {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t.closed = true
|
||||
t.mu.Unlock()
|
||||
|
||||
close(t.stopCh)
|
||||
t.master.Close()
|
||||
<-t.done
|
||||
|
||||
t.slave.Close()
|
||||
t.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
func (t *TerminalSession) IsExpired() bool {
|
||||
return time.Since(t.createdAt) >= t.timeout
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
sessions map[string]*TerminalSession
|
||||
nextID int
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("agentcli", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{
|
||||
name: name,
|
||||
stopCh: make(chan struct{}),
|
||||
sessions: make(map[string]*TerminalSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||
Name: "terminal_create",
|
||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要执行的命令(默认 bash)。如需运行特定程序直接传入即可,例如:vim /tmp/test.txt",
|
||||
},
|
||||
"timeout": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h(默认 5m)",
|
||||
},
|
||||
"rows": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "终端行数(默认 24)",
|
||||
},
|
||||
"cols": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "终端列数(默认 80)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleCreate(s, args)
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_write", sdk.ToolDef{
|
||||
Name: "terminal_write",
|
||||
Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入)。特殊键包括:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端 ID,来自 terminal_create 的返回值",
|
||||
},
|
||||
"input": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要发送的文本内容(普通文字直接输入)",
|
||||
},
|
||||
"key": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "特殊按键:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down",
|
||||
},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleWrite(s, args)
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_read", sdk.ToolDef{
|
||||
Name: "terminal_read",
|
||||
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端 ID",
|
||||
},
|
||||
"clear": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "读取后是否清除缓冲区(默认 true)",
|
||||
},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleRead(args)
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_resize", sdk.ToolDef{
|
||||
Name: "terminal_resize",
|
||||
Description: "调整指定终端的尺寸(行数和列数)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端 ID",
|
||||
},
|
||||
"rows": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "行数",
|
||||
},
|
||||
"cols": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "列数",
|
||||
},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleResize(args)
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_close", sdk.ToolDef{
|
||||
Name: "terminal_close",
|
||||
Description: "关闭指定终端会话。释放资源。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "终端 ID",
|
||||
},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleClose(args)
|
||||
})
|
||||
|
||||
s.RegisterTool("terminal_list", sdk.ToolDef{
|
||||
Name: "terminal_list",
|
||||
Description: "列出所有活跃的终端会话及其状态。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.handleList()
|
||||
})
|
||||
|
||||
p.wg.Add(1)
|
||||
go p.cleanupLoop(s)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
close(p.stopCh)
|
||||
p.wg.Wait()
|
||||
|
||||
p.mu.Lock()
|
||||
for _, t := range p.sessions {
|
||||
t.Close()
|
||||
}
|
||||
p.sessions = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) nextIDLocked() string {
|
||||
p.nextID++
|
||||
return fmt.Sprintf("term_%d", p.nextID)
|
||||
}
|
||||
|
||||
func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
|
||||
command, _ := args["command"].(string)
|
||||
if command == "" {
|
||||
command = "bash"
|
||||
}
|
||||
|
||||
timeoutStr, _ := args["timeout"].(string)
|
||||
timeout := DefaultTimeout
|
||||
if timeoutStr != "" {
|
||||
if d, err := time.ParseDuration(timeoutStr); err == nil {
|
||||
timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
rows := uint16(24)
|
||||
cols := uint16(80)
|
||||
if r, ok := args["rows"].(float64); ok && r > 0 {
|
||||
rows = uint16(r)
|
||||
}
|
||||
if c, ok := args["cols"].(float64); ok && c > 0 {
|
||||
cols = uint16(c)
|
||||
}
|
||||
|
||||
master, slave, err := openPty()
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("创建 PTY 失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", command)
|
||||
cmd.Stdin = slave
|
||||
cmd.Stdout = slave
|
||||
cmd.Stderr = slave
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
Ctty: 0,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
master.Close()
|
||||
slave.Close()
|
||||
return map[string]interface{}{"error": fmt.Sprintf("启动命令失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
slave.Close()
|
||||
|
||||
session := &TerminalSession{
|
||||
cmd: cmd,
|
||||
master: master,
|
||||
createdAt: time.Now(),
|
||||
timeout: timeout,
|
||||
stopCh: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
id := p.nextIDLocked()
|
||||
session.id = id
|
||||
p.sessions[id] = session
|
||||
|
||||
ws := winsize{Row: rows, Col: cols}
|
||||
ioctl(uintptr(master.Fd()), TIOCSWINSZ, unsafe.Pointer(&ws))
|
||||
p.mu.Unlock()
|
||||
|
||||
p.wg.Add(1)
|
||||
go p.readLoop(session, s)
|
||||
|
||||
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"status": "created",
|
||||
"command": command,
|
||||
"timeout": timeout.String(),
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
session, ok := p.sessions[id]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||
}
|
||||
|
||||
input, _ := args["input"].(string)
|
||||
key, _ := args["key"].(string)
|
||||
|
||||
var data []byte
|
||||
|
||||
if key != "" {
|
||||
b, err := mapKey(key)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}, nil
|
||||
}
|
||||
data = b
|
||||
} else if input != "" {
|
||||
data = []byte(input)
|
||||
} else {
|
||||
return map[string]interface{}{"error": "需要提供 input 或 key 参数"}, nil
|
||||
}
|
||||
|
||||
n, err := session.Write(string(data))
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("写入失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"bytes": n,
|
||||
"terminal": id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
}
|
||||
|
||||
clear := true
|
||||
if v, ok := args["clear"].(bool); ok {
|
||||
clear = v
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
session, ok := p.sessions[id]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||
}
|
||||
|
||||
var output string
|
||||
if clear {
|
||||
output = session.ReadAndClearOutput()
|
||||
} else {
|
||||
output = session.ReadOutput()
|
||||
}
|
||||
|
||||
if output == "" {
|
||||
output = "[终端无新输出]"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"terminal": id,
|
||||
"output": output,
|
||||
"running": session.cmd.ProcessState == nil || !session.cmd.ProcessState.Exited(),
|
||||
"uptime": time.Since(session.createdAt).String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleResize(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
}
|
||||
|
||||
rows, okRows := args["rows"].(float64)
|
||||
cols, okCols := args["cols"].(float64)
|
||||
if !okRows || !okCols {
|
||||
return map[string]interface{}{"error": "rows 和 cols 为必填"}, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
session, ok := p.sessions[id]
|
||||
p.mu.Unlock()
|
||||
if !ok {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||
}
|
||||
|
||||
if err := session.Resize(uint16(rows), uint16(cols)); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("调整尺寸失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"terminal": id,
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return map[string]interface{}{"error": "id is required"}, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
session, ok := p.sessions[id]
|
||||
if ok {
|
||||
delete(p.sessions, id)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
|
||||
}
|
||||
|
||||
session.Close()
|
||||
log.Printf("[agentcli] closed terminal %s", id)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "closed",
|
||||
"terminal": id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleList() (interface{}, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
type termInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Uptime string `json:"uptime"`
|
||||
ExpiresIn string `json:"expires_in"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
var terms []termInfo
|
||||
for _, t := range p.sessions {
|
||||
running := t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited()
|
||||
remaining := t.timeout - time.Since(t.createdAt)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
terms = append(terms, termInfo{
|
||||
ID: t.id,
|
||||
Uptime: time.Since(t.createdAt).Round(time.Second).String(),
|
||||
ExpiresIn: remaining.Round(time.Second).String(),
|
||||
Running: running,
|
||||
})
|
||||
}
|
||||
|
||||
if terms == nil {
|
||||
terms = []termInfo{}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"count": len(terms),
|
||||
"terminals": terms,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
defer p.wg.Done()
|
||||
defer close(t.done)
|
||||
|
||||
buf := make([]byte, ReadBufSize)
|
||||
lastNotify := time.Now()
|
||||
pollInterval := 200 * time.Millisecond
|
||||
|
||||
readCh := make(chan readResult, 4)
|
||||
go p.reader(t, buf, readCh)
|
||||
|
||||
for {
|
||||
if t.IsExpired() {
|
||||
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout))
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, t.id)
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, t.id)
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
return
|
||||
case r := <-readCh:
|
||||
if r.err != nil {
|
||||
return
|
||||
}
|
||||
if r.n > 0 {
|
||||
data := make([]byte, r.n)
|
||||
copy(data, buf[:r.n])
|
||||
t.appendOutput(data)
|
||||
if time.Since(lastNotify) > NotifyOutputDelay {
|
||||
preview := string(data)
|
||||
if len(preview) > 100 {
|
||||
preview = preview[:100]
|
||||
}
|
||||
preview = sanitizePreview(preview)
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
|
||||
lastNotify = time.Now()
|
||||
}
|
||||
}
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type readResult struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) {
|
||||
for {
|
||||
n, err := t.master.Read(buf)
|
||||
select {
|
||||
case ch <- readResult{n, err}:
|
||||
case <-t.stopCh:
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) cleanupLoop(s *sdk.PluginSDK) {
|
||||
defer p.wg.Done()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.mu.Lock()
|
||||
for id, t := range p.sessions {
|
||||
if t.IsExpired() {
|
||||
log.Printf("[agentcli] cleanup: terminal %s expired", id)
|
||||
delete(p.sessions, id)
|
||||
go func(term *TerminalSession) {
|
||||
term.Close()
|
||||
}(t)
|
||||
}
|
||||
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||
log.Printf("[agentcli] cleanup: terminal %s process exited", id)
|
||||
delete(p.sessions, id)
|
||||
go func(term *TerminalSession) {
|
||||
term.Close()
|
||||
}(t)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapKey(key string) ([]byte, error) {
|
||||
key = strings.ToLower(key)
|
||||
switch key {
|
||||
case "enter":
|
||||
return []byte{0x0D}, nil
|
||||
case "tab":
|
||||
return []byte{0x09}, nil
|
||||
case "escape", "esc":
|
||||
return []byte{0x1B}, nil
|
||||
case "backspace":
|
||||
return []byte{0x7F}, nil
|
||||
case "delete":
|
||||
return []byte{0x1B, 0x5B, 0x33, 0x7E}, nil
|
||||
case "home":
|
||||
return []byte{0x1B, 0x5B, 0x48}, nil
|
||||
case "end":
|
||||
return []byte{0x1B, 0x5B, 0x46}, nil
|
||||
case "page_up":
|
||||
return []byte{0x1B, 0x5B, 0x35, 0x7E}, nil
|
||||
case "page_down":
|
||||
return []byte{0x1B, 0x5B, 0x36, 0x7E}, nil
|
||||
case "up":
|
||||
return []byte{0x1B, 0x5B, 0x41}, nil
|
||||
case "down":
|
||||
return []byte{0x1B, 0x5B, 0x42}, nil
|
||||
case "right":
|
||||
return []byte{0x1B, 0x5B, 0x43}, nil
|
||||
case "left":
|
||||
return []byte{0x1B, 0x5B, 0x44}, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, "ctrl_") && len(key) == 6 {
|
||||
c := key[5]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
return []byte{byte(c - 'a' + 1)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, "alt_") && len(key) == 5 {
|
||||
c := key[4]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
return []byte{0x1B, c}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, "f") && len(key) <= 4 {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(key, "f%d", &n); err == nil && n >= 1 && n <= 12 {
|
||||
return functionKeyCode(n), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("不支持的特殊按键: %s", key)
|
||||
}
|
||||
|
||||
func functionKeyCode(n int) []byte {
|
||||
switch n {
|
||||
case 1:
|
||||
return []byte{0x1B, 0x5B, 0x50}
|
||||
case 2:
|
||||
return []byte{0x1B, 0x5B, 0x51}
|
||||
case 3:
|
||||
return []byte{0x1B, 0x5B, 0x52}
|
||||
case 4:
|
||||
return []byte{0x1B, 0x5B, 0x53}
|
||||
case 5:
|
||||
return []byte{0x1B, 0x5B, 0x31, 0x35, 0x7E}
|
||||
case 6:
|
||||
return []byte{0x1B, 0x5B, 0x31, 0x37, 0x7E}
|
||||
case 7:
|
||||
return []byte{0x1B, 0x5B, 0x31, 0x38, 0x7E}
|
||||
case 8:
|
||||
return []byte{0x1B, 0x5B, 0x31, 0x39, 0x7E}
|
||||
case 9:
|
||||
return []byte{0x1B, 0x5B, 0x32, 0x30, 0x7E}
|
||||
case 10:
|
||||
return []byte{0x1B, 0x5B, 0x32, 0x31, 0x7E}
|
||||
case 11:
|
||||
return []byte{0x1B, 0x5B, 0x32, 0x32, 0x7E}
|
||||
case 12:
|
||||
return []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}
|
||||
default:
|
||||
return []byte{}
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if opErr, ok := err.(*os.PathError); ok {
|
||||
err = opErr.Err
|
||||
}
|
||||
if syscallErr, ok := err.(syscall.Errno); ok {
|
||||
return syscallErr == syscall.EAGAIN || syscallErr == syscall.EWOULDBLOCK || syscallErr == syscall.ETIMEDOUT
|
||||
}
|
||||
return strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "would block")
|
||||
}
|
||||
|
||||
func sanitizePreview(s string) string {
|
||||
var buf bytes.Buffer
|
||||
for _, r := range s {
|
||||
if r == '\n' {
|
||||
buf.WriteString("\\n")
|
||||
} else if r == '\r' {
|
||||
buf.WriteString("\\r")
|
||||
} else if r == '\t' {
|
||||
buf.WriteString("\\t")
|
||||
} else if r >= 32 && r <= 126 {
|
||||
buf.WriteRune(r)
|
||||
} else if r == 0x1B {
|
||||
buf.WriteString("^[")
|
||||
} else if r < 32 {
|
||||
fmt.Fprintf(&buf, "^%c", r+'A'-1)
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
385
internal/plugins/agentcli/plugin_test.go
Normal file
385
internal/plugins/agentcli/plugin_test.go
Normal file
@ -0,0 +1,385 @@
|
||||
package agentcli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type toolCapture struct {
|
||||
handlers map[string]sdk.ToolHandler
|
||||
defs map[string]sdk.ToolDef
|
||||
}
|
||||
|
||||
func newToolCapture() *toolCapture {
|
||||
return &toolCapture{
|
||||
handlers: make(map[string]sdk.ToolHandler),
|
||||
defs: make(map[string]sdk.ToolDef),
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
tc.handlers[name] = handler
|
||||
tc.defs[name] = def
|
||||
return nil
|
||||
}
|
||||
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||
|
||||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
p := New("agentcli")
|
||||
tc := newToolCapture()
|
||||
// Use nil for fields we don't need (iom, eventBus, etc.)
|
||||
sdk := sdk.New("agentcli", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
if err := p.Start(sdk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return p, tc, nil
|
||||
}
|
||||
|
||||
func TestKeyMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
expected []byte
|
||||
}{
|
||||
{"enter", []byte{0x0D}},
|
||||
{"tab", []byte{0x09}},
|
||||
{"escape", []byte{0x1B}},
|
||||
{"esc", []byte{0x1B}},
|
||||
{"backspace", []byte{0x7F}},
|
||||
{"delete", []byte{0x1B, 0x5B, 0x33, 0x7E}},
|
||||
{"home", []byte{0x1B, 0x5B, 0x48}},
|
||||
{"end", []byte{0x1B, 0x5B, 0x46}},
|
||||
{"up", []byte{0x1B, 0x5B, 0x41}},
|
||||
{"down", []byte{0x1B, 0x5B, 0x42}},
|
||||
{"left", []byte{0x1B, 0x5B, 0x44}},
|
||||
{"right", []byte{0x1B, 0x5B, 0x43}},
|
||||
{"page_up", []byte{0x1B, 0x5B, 0x35, 0x7E}},
|
||||
{"page_down", []byte{0x1B, 0x5B, 0x36, 0x7E}},
|
||||
{"ctrl_a", []byte{0x01}},
|
||||
{"ctrl_z", []byte{0x1A}},
|
||||
{"alt_a", []byte{0x1B, 'a'}},
|
||||
{"alt_z", []byte{0x1B, 'z'}},
|
||||
{"f1", []byte{0x1B, 0x5B, 0x50}},
|
||||
{"f4", []byte{0x1B, 0x5B, 0x53}},
|
||||
{"f12", []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
result, err := mapKey(tt.key)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !bytesEqual(result, tt.expected) {
|
||||
t.Fatalf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyMappingInvalid(t *testing.T) {
|
||||
_, err := mapKey("unknown_key")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestListEmpty(t *testing.T) {
|
||||
p, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
handler := tc.handlers["terminal_list"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["count"].(float64) != 0 {
|
||||
t.Fatalf("expected count 0, got %v", resp["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndCloseTerminal(t *testing.T) {
|
||||
p, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
createHandler := tc.handlers["terminal_create"]
|
||||
result, err := createHandler(map[string]interface{}{
|
||||
"command": "echo hello",
|
||||
"timeout": "10s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var createResp map[string]interface{}
|
||||
json.Unmarshal(data, &createResp)
|
||||
|
||||
if v, ok := createResp["error"]; ok {
|
||||
t.Skipf("PTY not available in this environment: %v", v)
|
||||
}
|
||||
if createResp["status"] != "created" {
|
||||
t.Fatalf("expected status created, got %v", createResp["status"])
|
||||
}
|
||||
|
||||
id := createResp["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty terminal id")
|
||||
}
|
||||
|
||||
// Give the terminal a moment to output
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Read output
|
||||
readHandler := tc.handlers["terminal_read"]
|
||||
result, err = readHandler(map[string]interface{}{
|
||||
"id": id,
|
||||
"clear": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ = json.Marshal(result)
|
||||
var readResp map[string]interface{}
|
||||
json.Unmarshal(data, &readResp)
|
||||
if readResp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", readResp["status"])
|
||||
}
|
||||
|
||||
// Close
|
||||
closeHandler := tc.handlers["terminal_close"]
|
||||
result, err = closeHandler(map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ = json.Marshal(result)
|
||||
var closeResp map[string]interface{}
|
||||
json.Unmarshal(data, &closeResp)
|
||||
if closeResp["status"] != "closed" {
|
||||
t.Fatalf("expected status closed, got %v", closeResp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTerminalMissingArgs(t *testing.T) {
|
||||
p, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
handler := tc.handlers["terminal_create"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if v, ok := resp["error"]; ok {
|
||||
t.Skipf("PTY not available in this environment: %v", v)
|
||||
}
|
||||
if resp["status"] != "created" {
|
||||
t.Fatalf("expected status created, got %v", resp["status"])
|
||||
}
|
||||
id := resp["id"].(string)
|
||||
p.handleClose(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
func TestWriteToNonexistentTerminal(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["terminal_write"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"id": "nonexistent",
|
||||
"input": "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error for nonexistent terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNonexistentTerminal(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["terminal_read"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"id": "nonexistent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error for nonexistent terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeNonexistentTerminal(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["terminal_resize"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"id": "nonexistent",
|
||||
"rows": float64(40),
|
||||
"cols": float64(120),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error for nonexistent terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseNonexistentTerminal(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["terminal_close"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"id": "nonexistent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error for nonexistent terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalWriteRequiresId(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["terminal_write"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"input": "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error when id is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalWriteRequiresContent(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["terminal_write"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"id": "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error when both input and key are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsRegistered(t *testing.T) {
|
||||
p, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
expectedTools := []string{
|
||||
"terminal_create",
|
||||
"terminal_write",
|
||||
"terminal_read",
|
||||
"terminal_resize",
|
||||
"terminal_close",
|
||||
"terminal_list",
|
||||
}
|
||||
|
||||
for _, name := range expectedTools {
|
||||
if _, ok := tc.handlers[name]; !ok {
|
||||
t.Errorf("tool %s not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
package plugins
|
||||
|
||||
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"
|
||||
|
||||
115
internal/plugins/cmd/plugin.go
Normal file
115
internal/plugins/cmd/plugin.go
Normal file
@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{name: name}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool("cmd_run", sdk.ToolDef{
|
||||
Name: "cmd_run",
|
||||
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要执行的命令",
|
||||
},
|
||||
"timeout": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "超时时间,例如 10s, 1m, 30s(默认 30s)",
|
||||
},
|
||||
"workdir": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "工作目录(可选,默认当前目录)",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
command, _ := args["command"].(string)
|
||||
if command == "" {
|
||||
return map[string]interface{}{"error": "command is required"}, nil
|
||||
}
|
||||
|
||||
timeoutStr, _ := args["timeout"].(string)
|
||||
if timeoutStr == "" {
|
||||
timeoutStr = "30s"
|
||||
}
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil
|
||||
}
|
||||
|
||||
workdir, _ := args["workdir"].(string)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return map[string]interface{}{
|
||||
"status": "timeout",
|
||||
"stdout": truncateOutput(stdout.String()),
|
||||
"stderr": truncateOutput(stderr.String()),
|
||||
"error": fmt.Sprintf("命令执行超时(%s)", timeoutStr),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"stdout": truncateOutput(stdout.String()),
|
||||
"stderr": truncateOutput(stderr.String()),
|
||||
"exit_code": cmd.ProcessState.ExitCode(),
|
||||
"command": command,
|
||||
}, nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateOutput(s string) string {
|
||||
const maxLen = 32000
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
|
||||
}
|
||||
return strings.TrimRight(s, "\n")
|
||||
}
|
||||
261
internal/plugins/cmd/plugin_test.go
Normal file
261
internal/plugins/cmd/plugin_test.go
Normal file
@ -0,0 +1,261 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type toolCapture struct {
|
||||
handlers map[string]sdk.ToolHandler
|
||||
defs map[string]sdk.ToolDef
|
||||
}
|
||||
|
||||
func newToolCapture() *toolCapture {
|
||||
return &toolCapture{
|
||||
handlers: make(map[string]sdk.ToolHandler),
|
||||
defs: make(map[string]sdk.ToolDef),
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
tc.handlers[name] = handler
|
||||
tc.defs[name] = def
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||
|
||||
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||
|
||||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
p := New("cmd")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("cmd", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
if err := p.Start(sdk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return p, tc, nil
|
||||
}
|
||||
|
||||
func TestCmdRunEcho(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler, ok := tc.handlers["cmd_run"]
|
||||
if !ok {
|
||||
t.Fatal("cmd_run tool not registered")
|
||||
}
|
||||
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "echo hello world",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["stdout"] != "hello world" {
|
||||
t.Fatalf("expected 'hello world', got %v", resp["stdout"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 0 {
|
||||
t.Fatalf("expected exit code 0, got %v", resp["exit_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdRunWithStderr(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "echo out && echo err >&2 && exit 1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["stdout"] != "out" {
|
||||
t.Fatalf("expected stdout 'out', got %v", resp["stdout"])
|
||||
}
|
||||
if resp["stderr"] != "err" {
|
||||
t.Fatalf("expected stderr 'err', got %v", resp["stderr"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 1 {
|
||||
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdRunTimeout(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "sleep 10",
|
||||
"timeout": "1s",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "timeout" {
|
||||
t.Fatalf("expected status timeout, got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdRunWorkdir(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "cmd_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
marker := filepath.Join(tmpDir, "marker.txt")
|
||||
if err := os.WriteFile(marker, []byte("ok"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "cat marker.txt",
|
||||
"workdir": tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["stdout"] != "ok" {
|
||||
t.Fatalf("expected 'ok', got %v", resp["stdout"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdRunMissingCommand(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if _, ok := resp["error"]; !ok {
|
||||
t.Fatal("expected error for missing command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdRunNonZeroExit(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "exit 42",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 42 {
|
||||
t.Fatalf("expected exit code 42, got %v", resp["exit_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateOutput(t *testing.T) {
|
||||
short := "hello"
|
||||
if s := truncateOutput(short); s != short {
|
||||
t.Fatalf("expected %q, got %q", short, s)
|
||||
}
|
||||
|
||||
long := make([]byte, 40000)
|
||||
for i := range long {
|
||||
long[i] = 'x'
|
||||
}
|
||||
s := truncateOutput(string(long))
|
||||
if len(s) >= 40000 {
|
||||
t.Fatal("expected truncation")
|
||||
}
|
||||
if len(s) > 32100 {
|
||||
t.Fatalf("truncated string too long: %d", len(s))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdRunPipeFail(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 1 {
|
||||
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||
}
|
||||
}
|
||||
678
internal/plugins/healthcheck/plugin.go
Normal file
678
internal/plugins/healthcheck/plugin.go
Normal file
@ -0,0 +1,678 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
var (
|
||||
hcStageHost *agentCore.StageHost
|
||||
hcIOMgr *agentIO.IOManager
|
||||
hcPluginReg *plugin.Registry
|
||||
hcMemory *memory.GraphDB
|
||||
hcKnowledge *knowledge.Store
|
||||
hcDocStore *doc.Store
|
||||
hcProviderMgr *agentAPI.ProviderManager
|
||||
hcStatusProvider agentCore.StatusProvider
|
||||
)
|
||||
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Pass bool `json:"pass"`
|
||||
}
|
||||
|
||||
// llmReport 由 LLM 通过 healthcheck_report 工具提交。
|
||||
type llmReport struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func Configure(sh *agentCore.StageHost, iom *agentIO.IOManager, pr *plugin.Registry,
|
||||
mem *memory.GraphDB, ks *knowledge.Store, ds *doc.Store, pm *agentAPI.ProviderManager, sp agentCore.StatusProvider) {
|
||||
hcStageHost = sh
|
||||
hcIOMgr = iom
|
||||
hcPluginReg = pr
|
||||
hcMemory = mem
|
||||
hcKnowledge = ks
|
||||
hcDocStore = ds
|
||||
hcProviderMgr = pm
|
||||
hcStatusProvider = sp
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("healthcheck", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
if hcStageHost == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
mu sync.Mutex
|
||||
reports []llmReport
|
||||
sessionID string
|
||||
selfToolNames map[string]bool
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
perfData PerfData
|
||||
}
|
||||
|
||||
type PerfData struct {
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
Checks []PerfCheckPoint `json:"checks"`
|
||||
}
|
||||
type PerfCheckPoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
Total int `json:"total"`
|
||||
ElapsedMs int64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{
|
||||
name: name,
|
||||
selfToolNames: make(map[string]bool),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.selfToolNames["healthcheck"] = true
|
||||
s.RegisterTool("healthcheck", sdk.ToolDef{
|
||||
Name: "healthcheck",
|
||||
Description: "运行系统全面健康检查。先执行静态检查(插件/工具列表/记忆/知识库/文档),再启动 LLM 驱动检查:LLM 主动发现并逐个测试各插件提供的工具,并通过 healthcheck_report 上报结果。返回详细的状态报告。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.runFullCheck(s)
|
||||
})
|
||||
|
||||
p.selfToolNames["healthcheck_plugins"] = true
|
||||
s.RegisterTool("healthcheck_plugins", sdk.ToolDef{
|
||||
Name: "healthcheck_plugins",
|
||||
Description: "列出所有已加载的插件及其状态。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.checkPlugins(s)
|
||||
})
|
||||
|
||||
p.selfToolNames["healthcheck_tools"] = true
|
||||
s.RegisterTool("healthcheck_tools", sdk.ToolDef{
|
||||
Name: "healthcheck_tools",
|
||||
Description: "列出系统中所有已注册的工具及其来源。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.listAllTools()
|
||||
})
|
||||
|
||||
p.selfToolNames["healthcheck_memory"] = true
|
||||
s.RegisterTool("healthcheck_memory", sdk.ToolDef{
|
||||
Name: "healthcheck_memory",
|
||||
Description: "测试图记忆系统:写入、查询、清理一条测试实体。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return p.checkMemory()
|
||||
})
|
||||
|
||||
p.selfToolNames["healthcheck_report"] = true
|
||||
s.RegisterTool("healthcheck_report", sdk.ToolDef{
|
||||
Name: "healthcheck_report",
|
||||
Description: "LLM 健康检查结果上报工具。LLM 在逐一测试各工具后,通过此工具提交每个工具的测试状态。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"tool_name": map[string]interface{}{"type": "string", "description": "被测试的工具名称"},
|
||||
"status": map[string]interface{}{"type": "string", "description": "测试结果:ok / fail / skip", "enum": []string{"ok", "fail", "skip"}},
|
||||
"detail": map[string]interface{}{"type": "string", "description": "测试详情或错误描述"},
|
||||
},
|
||||
"required": []string{"tool_name", "status"},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
toolName, _ := args["tool_name"].(string)
|
||||
status, _ := args["status"].(string)
|
||||
detail, _ := args["detail"].(string)
|
||||
p.mu.Lock()
|
||||
p.reports = append(p.reports, llmReport{ToolName: toolName, Status: status, Detail: detail})
|
||||
count := len(p.reports)
|
||||
p.mu.Unlock()
|
||||
log.Printf("[healthcheck] LLM report: tool=%s status=%s (total %d)", toolName, status, count)
|
||||
return map[string]interface{}{"ok": true, "received": count}, nil
|
||||
})
|
||||
|
||||
if hcStatusProvider != nil {
|
||||
p.selfToolNames["healthcheck_kernel"] = true
|
||||
s.RegisterTool("healthcheck_kernel", sdk.ToolDef{
|
||||
Name: "healthcheck_kernel",
|
||||
Description: "查询 Agent 内核运行状态快照,包括插件/工具/记忆/知识库/LLM Provider/运行时等各子系统信息。Agent 可通过此工具自主监测内核健康。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return hcStatusProvider.GetKernelStatus(), nil
|
||||
})
|
||||
}
|
||||
|
||||
p.selfToolNames["healthcheck_perf"] = true
|
||||
s.RegisterTool("healthcheck_perf", sdk.ToolDef{
|
||||
Name: "healthcheck_perf",
|
||||
Description: "查询健康检查性能监控数据,包括最近检查时间、历史检查记录(最多 100 条)及通过/失败统计。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
passed := 0
|
||||
failed := 0
|
||||
for _, c := range p.perfData.Checks {
|
||||
passed += c.Passed
|
||||
failed += c.Failed
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"last_check": p.perfData.LastCheck,
|
||||
"total_checks": len(p.perfData.Checks),
|
||||
"total_passed": passed,
|
||||
"total_failed": failed,
|
||||
"history": p.perfData.Checks,
|
||||
}, nil
|
||||
})
|
||||
|
||||
p.startAutoCheck(s, 30*time.Minute)
|
||||
|
||||
log.Printf("[healthcheck] ready (stageHost=%v iom=%v reg=%v mem=%v ks=%v ds=%v pm=%v sp=%v)",
|
||||
hcStageHost != nil, hcIOMgr != nil, hcPluginReg != nil,
|
||||
hcMemory != nil, hcKnowledge != nil, hcDocStore != nil, hcProviderMgr != nil, hcStatusProvider != nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopOnce.Do(func() {
|
||||
close(p.stopCh)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) startAutoCheck(s *sdk.PluginSDK, interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.runAutoCheck(s)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[healthcheck] auto-check started, interval=%v", interval)
|
||||
}
|
||||
|
||||
func (p *Plugin) runAutoCheck(s *sdk.PluginSDK) {
|
||||
result, err := p.runFullCheck(s)
|
||||
if err != nil {
|
||||
log.Printf("[healthcheck] auto-check error: %v", err)
|
||||
return
|
||||
}
|
||||
resp, _ := result.(map[string]interface{})
|
||||
passed, _ := resp["passed"].(int)
|
||||
failed, _ := resp["failed"].(int)
|
||||
total, _ := resp["total"].(int)
|
||||
elapsed := int64(0)
|
||||
if checks, ok := resp["checks"]; ok {
|
||||
if arr, ok := checks.([]checkResult); ok && len(arr) > 0 {
|
||||
elapsed = int64(len(arr)) // approximate
|
||||
}
|
||||
}
|
||||
|
||||
pt := PerfCheckPoint{
|
||||
Time: time.Now(),
|
||||
Passed: passed,
|
||||
Failed: failed,
|
||||
Total: total,
|
||||
ElapsedMs: elapsed,
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.perfData.LastCheck = pt.Time
|
||||
p.perfData.Checks = append(p.perfData.Checks, pt)
|
||||
if len(p.perfData.Checks) > 100 {
|
||||
p.perfData.Checks = p.perfData.Checks[len(p.perfData.Checks)-100:]
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
log.Printf("[healthcheck] auto-check complete: passed=%d failed=%d total=%d", passed, failed, total)
|
||||
}
|
||||
|
||||
func (p *Plugin) runFullCheck(s *sdk.PluginSDK) (interface{}, error) {
|
||||
results := []checkResult{}
|
||||
|
||||
pluginResult := p.checkPluginsRaw()
|
||||
results = append(results, pluginResult...)
|
||||
|
||||
toolResult := p.checkToolsRaw()
|
||||
results = append(results, toolResult...)
|
||||
|
||||
if hcMemory != nil {
|
||||
r := p.testMemoryRaw()
|
||||
results = append(results, r)
|
||||
} else {
|
||||
results = append(results, checkResult{Name: "memory", Status: "skip", Detail: "图记忆未初始化", Pass: true})
|
||||
}
|
||||
|
||||
if hcKnowledge != nil {
|
||||
r := p.testKnowledgeRaw()
|
||||
results = append(results, r)
|
||||
} else {
|
||||
results = append(results, checkResult{Name: "knowledge", Status: "skip", Detail: "知识库未初始化", Pass: true})
|
||||
}
|
||||
|
||||
if hcDocStore != nil {
|
||||
r := p.testDocStoreRaw()
|
||||
results = append(results, r)
|
||||
} else {
|
||||
results = append(results, checkResult{Name: "documents", Status: "skip", Detail: "文档记忆未初始化", Pass: true})
|
||||
}
|
||||
|
||||
if hcProviderMgr != nil {
|
||||
r := p.testLLMDriven()
|
||||
results = append(results, r)
|
||||
} else {
|
||||
results = append(results, checkResult{Name: "llm_discovery", Status: "skip", Detail: "LLM Provider 未初始化", Pass: true})
|
||||
}
|
||||
|
||||
passCount := 0
|
||||
failCount := 0
|
||||
for _, r := range results {
|
||||
if r.Pass {
|
||||
passCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("通过 %d / %d, 失败 %d", passCount, len(results), failCount)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"summary": summary,
|
||||
"total": len(results),
|
||||
"passed": passCount,
|
||||
"failed": failCount,
|
||||
"checks": results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) checkPlugins(s *sdk.PluginSDK) (interface{}, error) {
|
||||
results := p.checkPluginsRaw()
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"plugins": results,
|
||||
"count": len(results),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) checkPluginsRaw() []checkResult {
|
||||
if hcPluginReg == nil {
|
||||
return []checkResult{{Name: "plugins", Status: "skip", Detail: "插件注册表未初始化", Pass: true}}
|
||||
}
|
||||
|
||||
names := hcPluginReg.List()
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
return []checkResult{{
|
||||
Name: "plugins",
|
||||
Status: "ok",
|
||||
Detail: fmt.Sprintf("已加载 %d 个插件: %v", len(names), names),
|
||||
Pass: true,
|
||||
}}
|
||||
}
|
||||
|
||||
func (p *Plugin) listAllTools() (interface{}, error) {
|
||||
tools := p.collectAllTools()
|
||||
return map[string]interface{}{
|
||||
"status": "ok",
|
||||
"count": len(tools),
|
||||
"tools": tools,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) checkToolsRaw() []checkResult {
|
||||
tools := p.collectAllTools()
|
||||
return []checkResult{{
|
||||
Name: "tools",
|
||||
Status: "ok",
|
||||
Detail: fmt.Sprintf("系统中共 %d 个工具", len(tools)),
|
||||
Pass: true,
|
||||
}}
|
||||
}
|
||||
|
||||
func (p *Plugin) collectAllTools() []toolInfo {
|
||||
seen := map[string]bool{}
|
||||
var tools []toolInfo
|
||||
|
||||
addTool := func(name, source, desc string) {
|
||||
if seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
tools = append(tools, toolInfo{Name: name, Source: source, Description: desc})
|
||||
}
|
||||
|
||||
if hcStageHost != nil {
|
||||
for _, def := range hcStageHost.GetToolDefs() {
|
||||
addTool(def.Name, "plugin", def.Description)
|
||||
}
|
||||
}
|
||||
|
||||
if hcIOMgr != nil {
|
||||
for _, def := range hcIOMgr.GetAllTools() {
|
||||
addTool(def.Name, "device", def.Description)
|
||||
}
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
func (p *Plugin) testMemoryRaw() checkResult {
|
||||
marker := fmt.Sprintf("_hc_%d", time.Now().UnixNano())
|
||||
triples := []memory.Triple{
|
||||
{Subject: marker, Relation: "is", Object: "healthcheck_test", SubjectType: "System", ObjectType: "Flag"},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ec, rc, err := hcMemory.Commit(triples, "healthcheck", 0)
|
||||
if err != nil {
|
||||
return checkResult{Name: "memory_write", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
|
||||
}
|
||||
|
||||
if _, _, err := hcMemory.Commit(triples, "healthcheck_cleanup", 0); err != nil {
|
||||
log.Printf("[healthcheck] memory cleanup error: %v", err)
|
||||
}
|
||||
|
||||
n, err := hcMemory.Purge(map[string]string{"subject_contains": marker}, "hard")
|
||||
if err != nil {
|
||||
return checkResult{Name: "memory_purge", Status: "fail", Detail: fmt.Sprintf("清理失败: %v", err), Pass: false}
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
return checkResult{
|
||||
Name: "memory",
|
||||
Status: "ok",
|
||||
Detail: fmt.Sprintf("写入 %d 实体/%d 关系, 清理 %d 条, 耗时 %v", ec, rc, n, elapsed.Round(time.Millisecond)),
|
||||
Pass: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) testKnowledgeRaw() checkResult {
|
||||
marker := fmt.Sprintf("_hc_knowledge_test_%d", time.Now().UnixNano())
|
||||
start := time.Now()
|
||||
|
||||
if err := hcKnowledge.Add(marker, "健康检查测试标记,可忽略"); err != nil {
|
||||
return checkResult{Name: "knowledge", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
|
||||
}
|
||||
|
||||
results := hcKnowledge.Search("健康检查测试标记", 3)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if len(results) > 0 {
|
||||
return checkResult{
|
||||
Name: "knowledge",
|
||||
Status: "ok",
|
||||
Detail: fmt.Sprintf("写入+查询正常, 耗时 %v", elapsed.Round(time.Millisecond)),
|
||||
Pass: true,
|
||||
}
|
||||
}
|
||||
|
||||
return checkResult{
|
||||
Name: "knowledge",
|
||||
Status: "warn",
|
||||
Detail: fmt.Sprintf("写入成功但查询缓存未命中, 耗时 %v", elapsed.Round(time.Millisecond)),
|
||||
Pass: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) testDocStoreRaw() checkResult {
|
||||
start := time.Now()
|
||||
doc := &doc.Doc{
|
||||
Summary: "健康检查测试文档",
|
||||
Content: "这是一条由 healthcheck 插件创建的测试文档,用于验证文档记忆系统是否正常工作。",
|
||||
Tags: []string{"healthcheck", "test"},
|
||||
Source: "healthcheck",
|
||||
}
|
||||
if err := hcDocStore.Insert(doc); err != nil {
|
||||
return checkResult{Name: "documents", Status: "fail", Detail: fmt.Sprintf("写入失败: %v", err), Pass: false}
|
||||
}
|
||||
|
||||
if doc.ID != "" {
|
||||
hcDocStore.Remove(doc.ID)
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
return checkResult{
|
||||
Name: "documents",
|
||||
Status: "ok",
|
||||
Detail: fmt.Sprintf("写入+删除正常, 耗时 %v", elapsed.Round(time.Millisecond)),
|
||||
Pass: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) testLLMDriven() checkResult {
|
||||
provider := hcProviderMgr.Default()
|
||||
if provider == nil {
|
||||
return checkResult{Name: "llm_discovery", Status: "skip", Detail: "无可用 LLM Provider", Pass: true}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 收集所有工具定义(排除健康检查自身的工具以避免循环测试)
|
||||
toolDefs := p.collectToolDefsForLLM()
|
||||
|
||||
if len(toolDefs) == 0 {
|
||||
return checkResult{Name: "llm_discovery", Status: "skip", Detail: "没有可测试的工具", Pass: true}
|
||||
}
|
||||
|
||||
// 重置会话
|
||||
p.mu.Lock()
|
||||
p.reports = nil
|
||||
p.sessionID = fmt.Sprintf("hc_llm_%d", time.Now().UnixNano())
|
||||
p.mu.Unlock()
|
||||
|
||||
// 构建 prompt
|
||||
prompt := p.buildDiscoveryPrompt(toolDefs)
|
||||
|
||||
msgs := []agentAPI.Message{{Role: "user", Content: prompt}}
|
||||
tools := convertToolDefs(toolDefs)
|
||||
|
||||
llmName := provider.Name()
|
||||
turnCount := 0
|
||||
toolCallCount := 0
|
||||
|
||||
for turn := 0; turn < 20; turn++ {
|
||||
resp, err := provider.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: msgs,
|
||||
MaxTokens: 4096,
|
||||
Tools: tools,
|
||||
ToolChoice: "auto",
|
||||
})
|
||||
if err != nil {
|
||||
return checkResult{
|
||||
Name: "llm_discovery",
|
||||
Status: "fail",
|
||||
Detail: fmt.Sprintf("Provider %s 第 %d 轮调用失败: %v (耗时 %v)",
|
||||
llmName, turn+1, err, time.Since(start).Round(time.Millisecond)),
|
||||
Pass: false,
|
||||
}
|
||||
}
|
||||
|
||||
turnCount++
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls})
|
||||
|
||||
for _, tc := range resp.ToolCalls {
|
||||
toolCallCount++
|
||||
content := p.executeToolForLLM(tc)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: content})
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
reportCount := len(p.reports)
|
||||
p.mu.Unlock()
|
||||
|
||||
elapsed := time.Since(start).Round(time.Millisecond)
|
||||
|
||||
detail := fmt.Sprintf("Provider %s, %d 轮对话, %d 次工具调用, %d 份报告, 耗时 %v",
|
||||
llmName, turnCount, toolCallCount, reportCount, elapsed)
|
||||
|
||||
return checkResult{
|
||||
Name: "llm_discovery",
|
||||
Status: "ok",
|
||||
Detail: detail,
|
||||
Pass: true,
|
||||
}
|
||||
}
|
||||
|
||||
// collectToolDefsForLLM 收集全部已注册的工具定义供 LLM 发现和测试。
|
||||
// 动态排除本插件自身注册的工具(通过 selfToolNames),避免 LLM 自我循环调用。
|
||||
func (p *Plugin) collectToolDefsForLLM() []sdk.ToolDef {
|
||||
seen := map[string]bool{}
|
||||
var defs []sdk.ToolDef
|
||||
|
||||
addDef := func(d sdk.ToolDef) {
|
||||
if p.selfToolNames[d.Name] || seen[d.Name] {
|
||||
return
|
||||
}
|
||||
seen[d.Name] = true
|
||||
defs = append(defs, d)
|
||||
}
|
||||
|
||||
if hcStageHost != nil {
|
||||
for _, d := range hcStageHost.GetToolDefs() {
|
||||
addDef(d)
|
||||
}
|
||||
}
|
||||
if hcIOMgr != nil {
|
||||
for _, d := range hcIOMgr.GetAllTools() {
|
||||
addDef(sdk.ToolDef{Name: d.Name, Description: d.Description, Parameters: d.Parameters})
|
||||
}
|
||||
}
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
// buildDiscoveryPrompt 为 LLM 构造工具探索 prompt。
|
||||
func (p *Plugin) buildDiscoveryPrompt(toolDefs []sdk.ToolDef) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(`你是一名系统健康检查专家。以下是系统中各插件提供的 %d 个工具(已自动排除健康检查插件自身工具):
|
||||
|
||||
你的任务是:逐一尝试调用这些工具,验证它们是否正常工作,并对于每个工具使用 healthcheck_report 工具上报测试结果。
|
||||
|
||||
对于每个工具:
|
||||
1. 理解它的参数和功能
|
||||
2. 构造合适的测试参数调用它
|
||||
3. 根据返回结果判断是否正常
|
||||
4. 调用 healthcheck_report 工具上报(tool_name, status=ok/fail/skip, detail=详情)
|
||||
|
||||
注意:
|
||||
- 有些工具有副作用(如写入数据),请使用安全参数,测试后应清理
|
||||
- 尽可能覆盖所有工具
|
||||
- 每个工具只需测试一次
|
||||
|
||||
开始测试!`, len(toolDefs)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// executeToolForLLM 在 LLM 工具循环中执行工具调用。
|
||||
// healthcheck_report 通过 StageHost 路由到自身注册的 handler,负责收集 LLM 上报。
|
||||
func (p *Plugin) executeToolForLLM(tc agentAPI.ToolCall) string {
|
||||
if hcStageHost != nil {
|
||||
result, err := hcStageHost.ExecuteTool(tc.Name, tc.Arguments)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("调用工具 %s 失败: %v", tc.Name, err)
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("工具 %s 不可执行(StageHost 未初始化)", tc.Name)
|
||||
}
|
||||
|
||||
func convertToolDefs(defs []sdk.ToolDef) []interface{} {
|
||||
tools := make([]interface{}, len(defs))
|
||||
for i, d := range defs {
|
||||
tools[i] = map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": d.Name,
|
||||
"description": d.Description,
|
||||
"parameters": d.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func (p *Plugin) checkMemory() (interface{}, error) {
|
||||
if hcMemory == nil {
|
||||
return map[string]interface{}{"status": "skip", "pass": true, "detail": "图记忆未初始化"}, nil
|
||||
}
|
||||
r := p.testMemoryRaw()
|
||||
c := map[string]interface{}{
|
||||
"status": r.Status,
|
||||
"pass": r.Pass,
|
||||
}
|
||||
if r.Detail != "" {
|
||||
c["detail"] = r.Detail
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
350
internal/plugins/healthcheck/plugin_test.go
Normal file
350
internal/plugins/healthcheck/plugin_test.go
Normal file
@ -0,0 +1,350 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type toolCapture struct {
|
||||
handlers map[string]sdk.ToolHandler
|
||||
defs map[string]sdk.ToolDef
|
||||
}
|
||||
|
||||
func newToolCapture() *toolCapture {
|
||||
return &toolCapture{
|
||||
handlers: make(map[string]sdk.ToolHandler),
|
||||
defs: make(map[string]sdk.ToolDef),
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
tc.handlers[name] = handler
|
||||
tc.defs[name] = def
|
||||
return nil
|
||||
}
|
||||
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||||
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||||
|
||||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||||
sh := agentCore.NewStageHost()
|
||||
iom := agentIO.NewIOManager()
|
||||
pr := plugin.NewRegistry()
|
||||
|
||||
Configure(sh, iom, pr, nil, nil, nil, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
if err := p.Start(sdk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return p, tc, nil
|
||||
}
|
||||
|
||||
func TestToolsRegistered(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
"healthcheck",
|
||||
"healthcheck_plugins",
|
||||
"healthcheck_tools",
|
||||
"healthcheck_memory",
|
||||
"healthcheck_report",
|
||||
"healthcheck_perf",
|
||||
}
|
||||
for _, name := range expected {
|
||||
if _, ok := tc.handlers[name]; !ok {
|
||||
t.Errorf("tool %q not registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckFull(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
|
||||
checks := resp["checks"].([]interface{})
|
||||
if len(checks) == 0 {
|
||||
t.Fatal("expected at least some checks")
|
||||
}
|
||||
|
||||
for _, c := range checks {
|
||||
cr := c.(map[string]interface{})
|
||||
name := cr["name"].(string)
|
||||
pass := cr["pass"].(bool)
|
||||
if !pass && cr["status"] != "skip" {
|
||||
t.Errorf("check %q failed: %v (detail: %v)", name, cr["status"], cr["detail"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckPlugins(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck_plugins"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckToolsList(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck_tools"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckMemoryNotAvailable(t *testing.T) {
|
||||
_, tc, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck_memory"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
// Memory is nil in this setup, so it should skip gracefully
|
||||
if _, ok := resp["pass"]; ok {
|
||||
pass := resp["pass"].(bool)
|
||||
if !pass {
|
||||
t.Fatalf("expected pass=true when memory is nil, got false: %v", resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckWithMemory(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "hc_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
memDB, err := memory.NewGraphDB(tmpDir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer memDB.Close()
|
||||
|
||||
sh := agentCore.NewStageHost()
|
||||
iom := agentIO.NewIOManager()
|
||||
pr := plugin.NewRegistry()
|
||||
|
||||
Configure(sh, iom, pr, memDB, nil, nil, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck_memory"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if pass, ok := resp["pass"].(bool); !ok || !pass {
|
||||
t.Fatalf("expected pass=true, got pass=%v status=%v detail=%v", pass, resp["status"], resp["detail"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckWithKnowledge(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "hc_know_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ks := knowledge.NewStore(tmpDir)
|
||||
if err := ks.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ks.Stop()
|
||||
|
||||
sh := agentCore.NewStageHost()
|
||||
iom := agentIO.NewIOManager()
|
||||
pr := plugin.NewRegistry()
|
||||
|
||||
Configure(sh, iom, pr, nil, ks, nil, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
|
||||
checks := resp["checks"].([]interface{})
|
||||
var knowledgeCheck map[string]interface{}
|
||||
for _, c := range checks {
|
||||
cr := c.(map[string]interface{})
|
||||
if cr["name"] == "knowledge" {
|
||||
knowledgeCheck = cr
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if knowledgeCheck == nil {
|
||||
t.Fatal("expected knowledge check in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcheckWithDocStore(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "hc_doc_test_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := doc.NewStore(tmpDir)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
|
||||
sh := agentCore.NewStageHost()
|
||||
iom := agentIO.NewIOManager()
|
||||
pr := plugin.NewRegistry()
|
||||
|
||||
Configure(sh, iom, pr, nil, nil, ds, nil, nil)
|
||||
p := New("healthcheck")
|
||||
tc := newToolCapture()
|
||||
sdk := sdk.New("healthcheck", nil, nil, nil, nil, nil, nil, nil, nil, tc.RegisterTool, tc.RegisterStage, tc.RegisterAPI)
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := tc.handlers["healthcheck"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
|
||||
checks := resp["checks"].([]interface{})
|
||||
var docCheck map[string]interface{}
|
||||
for _, c := range checks {
|
||||
cr := c.(map[string]interface{})
|
||||
if cr["name"] == "documents" {
|
||||
docCheck = cr
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if docCheck == nil {
|
||||
t.Fatal("expected documents check in results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMReportCollection(t *testing.T) {
|
||||
p := &Plugin{name: "healthcheck"}
|
||||
if len(p.reports) != 0 {
|
||||
t.Fatal("expected empty reports")
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.reports = append(p.reports, llmReport{ToolName: "test_tool", Status: "ok", Detail: "test passed"})
|
||||
count := len(p.reports)
|
||||
p.mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 report, got %d", count)
|
||||
}
|
||||
if p.reports[0].ToolName != "test_tool" {
|
||||
t.Fatalf("expected tool_name=test_tool, got %s", p.reports[0].ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureNilStageHost(t *testing.T) {
|
||||
Configure(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
if hcStageHost != nil {
|
||||
t.Fatal("expected hcStageHost to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
584
internal/plugins/integration_test.go
Normal file
584
internal/plugins/integration_test.go
Normal file
@ -0,0 +1,584 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||
healthcheck "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||
webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type testPluginEnv struct {
|
||||
tmpDir string
|
||||
stageHost *agentCore.StageHost
|
||||
iom *agentIO.IOManager
|
||||
pluginReg *plugin.Registry
|
||||
memDB *memory.GraphDB
|
||||
ks *knowledge.Store
|
||||
docStore *doc.Store
|
||||
}
|
||||
|
||||
func setupIntegration(t *testing.T) *testPluginEnv {
|
||||
return setupIntegrationWithProvider(t, nil)
|
||||
}
|
||||
|
||||
func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *testPluginEnv {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "hc_integration_*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stageHost := agentCore.NewStageHost()
|
||||
iom := agentIO.NewIOManager()
|
||||
pluginReg := plugin.NewRegistry()
|
||||
|
||||
pluginReg.SetIOManager(iom)
|
||||
pluginReg.SetMemory(nil)
|
||||
|
||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
return stageHost.RegisterTool(name, def, handler)
|
||||
})
|
||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||
stageHost.RegisterStage(stage, handler)
|
||||
})
|
||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
memDB, err := memory.NewGraphDB(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pluginReg.SetMemory(memDB)
|
||||
|
||||
ks := knowledge.NewStore(filepath.Join(tmpDir, "knowledge"))
|
||||
if err := ks.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docStore := doc.NewStore(filepath.Join(tmpDir, "documents"))
|
||||
if err := docStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock")
|
||||
openclaw.SkillsDir = filepath.Join(tmpDir, "skills")
|
||||
os.MkdirAll(openclaw.SkillsDir, 0755)
|
||||
webui.Configure(":0", nil, memDB, nil, nil, nil, iom, nil, ks, nil, nil, pluginReg, nil, nil)
|
||||
healthcheck.Configure(stageHost, iom, pluginReg, memDB, ks, docStore, pm, nil)
|
||||
|
||||
plgDir := filepath.Join(tmpDir, "plugins")
|
||||
os.MkdirAll(plgDir, 0755)
|
||||
if err := pluginReg.Load(plgDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &testPluginEnv{
|
||||
tmpDir: tmpDir,
|
||||
stageHost: stageHost,
|
||||
iom: iom,
|
||||
pluginReg: pluginReg,
|
||||
memDB: memDB,
|
||||
ks: ks,
|
||||
docStore: docStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *testPluginEnv) cleanup() {
|
||||
e.pluginReg.StopAll()
|
||||
e.memDB.Close()
|
||||
e.ks.Stop()
|
||||
e.docStore.Stop()
|
||||
os.RemoveAll(e.tmpDir)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationAllPluginsRegister(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
expectedTools := []string{
|
||||
"cmd_run",
|
||||
"terminal_create", "terminal_write", "terminal_read",
|
||||
"terminal_resize", "terminal_close", "terminal_list",
|
||||
"healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf",
|
||||
"timer_set",
|
||||
}
|
||||
|
||||
defs := env.stageHost.GetToolDefs()
|
||||
registered := make(map[string]bool)
|
||||
for _, def := range defs {
|
||||
registered[def.Name] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, tool := range expectedTools {
|
||||
if !registered[tool] {
|
||||
missing = append(missing, tool)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
all := make([]string, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
all = append(all, d.Name)
|
||||
}
|
||||
t.Fatalf("missing tools: %v\nall registered: %v", missing, all)
|
||||
}
|
||||
t.Logf("all %d expected tools registered (total: %d)", len(expectedTools), len(defs))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cmd_run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationCmdRun(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||
"command": "echo hello_world",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["exit_code"] != 0.0 {
|
||||
t.Fatalf("expected exit_code 0, got %v", resp["exit_code"])
|
||||
}
|
||||
stdout, ok := resp["stdout"].(string)
|
||||
if !ok || stdout != "hello_world" {
|
||||
t.Fatalf("expected stdout 'hello_world', got %q", stdout)
|
||||
}
|
||||
t.Logf("cmd_run OK: exit_code=0 stdout=%q", stdout)
|
||||
}
|
||||
|
||||
func TestIntegrationCmdRunWithWorkdir(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||
"command": "pwd",
|
||||
"workdir": "/tmp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["exit_code"] != 0.0 {
|
||||
t.Fatalf("expected exit_code 0, got %v", resp["exit_code"])
|
||||
}
|
||||
stdout := resp["stdout"].(string)
|
||||
if stdout != "/tmp" {
|
||||
t.Fatalf("expected stdout '/tmp', got %q", stdout)
|
||||
}
|
||||
t.Logf("cmd_run workdir OK: stdout=%q", stdout)
|
||||
}
|
||||
|
||||
func TestIntegrationCmdRunInvalidTimeout(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||
"command": "echo ok",
|
||||
"timeout": "not-a-duration",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
errMsg, ok := resp["error"].(string)
|
||||
if !ok || errMsg == "" {
|
||||
t.Fatalf("expected error for invalid timeout, got %v", resp)
|
||||
}
|
||||
t.Logf("cmd_run invalid_timeout OK: error=%q", errMsg)
|
||||
}
|
||||
|
||||
func TestIntegrationCmdRunStderr(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("cmd_run", map[string]interface{}{
|
||||
"command": "echo stderr_test >&2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["exit_code"] != 0.0 {
|
||||
t.Fatalf("expected exit_code 0, got %v", resp["exit_code"])
|
||||
}
|
||||
stderr := resp["stderr"].(string)
|
||||
if stderr != "stderr_test" {
|
||||
t.Fatalf("expected stderr 'stderr_test', got %q", stderr)
|
||||
}
|
||||
t.Logf("cmd_run stderr OK: stderr=%q", stderr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal (PTY)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationPtyCreateListClose(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
// Create
|
||||
result, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
|
||||
"shell": "/bin/sh",
|
||||
"name": "hci_test_shell",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
id, ok := resp["id"].(string)
|
||||
if !ok || id == "" {
|
||||
status := resp["status"]
|
||||
detail := resp["detail"]
|
||||
if status == "error" {
|
||||
t.Skipf("PTY not available: %v", detail)
|
||||
}
|
||||
t.Fatalf("expected non-empty terminal id, got id=%q status=%v detail=%v", id, status, detail)
|
||||
}
|
||||
t.Logf("terminal_create OK: id=%s", id)
|
||||
|
||||
// List
|
||||
result2, err := env.stageHost.ExecuteTool("terminal_list", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data2, _ := json.Marshal(result2)
|
||||
var resp2 map[string]interface{}
|
||||
json.Unmarshal(data2, &resp2)
|
||||
|
||||
terminals, ok := resp2["terminals"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected terminals array, got %T", resp2["terminals"])
|
||||
}
|
||||
if len(terminals) < 1 {
|
||||
t.Fatal("expected at least 1 terminal")
|
||||
}
|
||||
t.Logf("terminal_list OK: %d terminals", len(terminals))
|
||||
|
||||
// Close
|
||||
_, err = env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("terminal_close OK: id=%s", id)
|
||||
}
|
||||
|
||||
func TestIntegrationPtyInteractive(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
|
||||
"shell": "/bin/sh",
|
||||
"name": "hci_interactive",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cdata, _ := json.Marshal(createResult)
|
||||
var cresp map[string]interface{}
|
||||
json.Unmarshal(cdata, &cresp)
|
||||
|
||||
id, ok := cresp["id"].(string)
|
||||
if !ok || id == "" {
|
||||
status := cresp["status"]
|
||||
if status == "error" {
|
||||
t.Skipf("PTY not available: %v", cresp["detail"])
|
||||
}
|
||||
t.Fatalf("expected terminal id, got %v", cresp)
|
||||
}
|
||||
|
||||
// Write a command
|
||||
_, err = env.stageHost.ExecuteTool("terminal_write", map[string]interface{}{
|
||||
"id": id,
|
||||
"input": "echo pty_works\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Read output
|
||||
readResult, err := env.stageHost.ExecuteTool("terminal_read", map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rdata, _ := json.Marshal(readResult)
|
||||
var rresp map[string]interface{}
|
||||
json.Unmarshal(rdata, &rresp)
|
||||
|
||||
output, ok := rresp["output"].(string)
|
||||
if !ok || output == "" {
|
||||
t.Fatalf("expected output, got output=%q response=%v", output, rresp)
|
||||
}
|
||||
t.Logf("terminal_write+read OK: output=%q", output)
|
||||
|
||||
// Close
|
||||
env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationPtyResize(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
createResult, err := env.stageHost.ExecuteTool("terminal_create", map[string]interface{}{
|
||||
"shell": "/bin/sh",
|
||||
"name": "hci_resize_test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cdata, _ := json.Marshal(createResult)
|
||||
var cresp map[string]interface{}
|
||||
json.Unmarshal(cdata, &cresp)
|
||||
|
||||
id, ok := cresp["id"].(string)
|
||||
if !ok || id == "" {
|
||||
status := cresp["status"]
|
||||
if status == "error" {
|
||||
t.Skipf("PTY not available: %v", cresp["detail"])
|
||||
}
|
||||
t.Fatalf("expected terminal id, got %v", cresp)
|
||||
}
|
||||
|
||||
_, err = env.stageHost.ExecuteTool("terminal_resize", map[string]interface{}{
|
||||
"id": id,
|
||||
"rows": 80.0,
|
||||
"cols": 200.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("terminal_resize OK: id=%s rows=80 cols=200", id)
|
||||
|
||||
env.stageHost.ExecuteTool("terminal_close", map[string]interface{}{
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Healthcheck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationHealthcheck(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("healthcheck", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||
}
|
||||
checks := resp["checks"].([]interface{})
|
||||
if len(checks) == 0 {
|
||||
t.Fatal("expected non-empty checks array")
|
||||
}
|
||||
t.Logf("healthcheck OK: %d checks, status=%v", len(checks), resp["status"])
|
||||
}
|
||||
|
||||
func TestIntegrationHealthcheckPlugins(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("healthcheck_plugins", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||
}
|
||||
t.Logf("healthcheck_plugins OK: status=%v", resp["status"])
|
||||
}
|
||||
|
||||
func TestIntegrationHealthcheckToolsList(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("healthcheck_tools", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||
}
|
||||
t.Logf("healthcheck_tools OK: status=%v", resp["status"])
|
||||
}
|
||||
|
||||
func TestIntegrationHealthcheckMemory(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
result, err := env.stageHost.ExecuteTool("healthcheck_memory", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||
}
|
||||
t.Logf("healthcheck_memory OK: status=%v", resp["status"])
|
||||
}
|
||||
|
||||
func TestIntegrationToolNotFound(t *testing.T) {
|
||||
env := setupIntegration(t)
|
||||
defer env.cleanup()
|
||||
|
||||
_, err := env.stageHost.ExecuteTool("nonexistent_tool_xyz", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent tool")
|
||||
}
|
||||
t.Logf("tool_not_found OK: err=%v", err)
|
||||
}
|
||||
|
||||
func TestIntegrationLLMDrivenDiscoveryWithRealKey(t *testing.T) {
|
||||
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||
if apiKey == "" {
|
||||
t.Skip("DEEPSEEK_API_KEY not set")
|
||||
}
|
||||
|
||||
// Lua VM + DeepSeek Provider
|
||||
tmpVM := t.TempDir()
|
||||
vm := luaVM.NewVM(tmpVM + "/adapters")
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
pm := agentAPI.NewProviderManager()
|
||||
pm.Register("deepseek", agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
|
||||
Model: "deepseek-v4-flash",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
APIKey: apiKey,
|
||||
}, vm, "deepseek"))
|
||||
|
||||
// Setup — 加载所有真实内置插件
|
||||
env := setupIntegrationWithProvider(t, pm)
|
||||
defer env.cleanup()
|
||||
|
||||
// 列出已加载的真实工具
|
||||
defs := env.stageHost.GetToolDefs()
|
||||
t.Logf("共有 %d 个已注册的真实工具:", len(defs))
|
||||
for _, d := range defs {
|
||||
t.Logf(" - %s", d.Name)
|
||||
}
|
||||
|
||||
// 调用 healthcheck 进行全面检查(含 LLM 驱动阶段)
|
||||
result, err := env.stageHost.ExecuteTool("healthcheck", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
t.Logf("===== Full Healthcheck Result =====")
|
||||
t.Logf("status: %v", resp["status"])
|
||||
t.Logf("summary: %v", resp["summary"])
|
||||
t.Logf("total: %v", resp["total"])
|
||||
t.Logf("passed: %v", resp["passed"])
|
||||
t.Logf("failed: %v", resp["failed"])
|
||||
|
||||
checks := resp["checks"].([]interface{})
|
||||
for _, c := range checks {
|
||||
cr := c.(map[string]interface{})
|
||||
prefix := "✅"
|
||||
if cr["status"] == "fail" {
|
||||
prefix = "❌"
|
||||
}
|
||||
t.Logf(" %s %s: %s %s", prefix, cr["name"], cr["status"], cr["detail"])
|
||||
}
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status=ok, got %v", resp["status"])
|
||||
}
|
||||
|
||||
// 验证 LLM 发现阶段的存在
|
||||
foundDiscovery := false
|
||||
for _, c := range checks {
|
||||
cr := c.(map[string]interface{})
|
||||
if cr["name"] == "llm_discovery" {
|
||||
foundDiscovery = true
|
||||
if cr["status"] != "ok" {
|
||||
t.Fatalf("LLM discovery failed: status=%s detail=%s", cr["status"], cr["detail"])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDiscovery {
|
||||
t.Fatal("expected llm_discovery check in results")
|
||||
}
|
||||
|
||||
t.Logf("✅ LLM-driven tool discovery test PASSED")
|
||||
}
|
||||
@ -1,17 +1,22 @@
|
||||
package openclaw
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
228
internal/plugins/openclaw/sidecar.go
Normal file
228
internal/plugins/openclaw/sidecar.go
Normal file
@ -0,0 +1,228 @@
|
||||
package openclaw
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sidecarRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type sidecarResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result *json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type OCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]interface{} `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type OCCallResult struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
type sidecarProcess struct {
|
||||
name string
|
||||
dir string
|
||||
cmd *exec.Cmd
|
||||
stdin *bufio.Writer
|
||||
stdout *bufio.Scanner
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
closed bool
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func launchSidecar(dir, name string) (*sidecarProcess, error) {
|
||||
mainJS := filepath.Join(dir, "main.js")
|
||||
if _, err := os.Stat(mainJS); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return launchProcess("node", mainJS, dir, name)
|
||||
}
|
||||
|
||||
func launchProcess(bin, arg, dir, name string) (*sidecarProcess, error) {
|
||||
nodePath := bin
|
||||
if bin == "node" {
|
||||
if p := os.Getenv("NODE_PATH"); p != "" {
|
||||
nodePath = filepath.Join(p, "node")
|
||||
}
|
||||
}
|
||||
|
||||
// 将 dir(插件目录)作为最后一个参数传给 Node.js 进程
|
||||
// 这样: node <script> <plugin-dir>
|
||||
// echoplugin 的 main.js 忽略它, 模拟器用它加载真实插件
|
||||
cmd := exec.Command(nodePath, arg, dir)
|
||||
cmd.Dir = dir
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start %s: %w", name, err)
|
||||
}
|
||||
|
||||
sp := &sidecarProcess{
|
||||
name: name,
|
||||
dir: dir,
|
||||
cmd: cmd,
|
||||
stdin: bufio.NewWriter(stdin),
|
||||
stdout: bufio.NewScanner(bufio.NewReader(stdout)),
|
||||
}
|
||||
|
||||
if err := sp.waitReady(); err != nil {
|
||||
sp.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) waitReady() error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.call("ping", nil)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
return fmt.Errorf("sidecar %s ping timeout", s.name)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) call(method string, params interface{}) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.closed || s.stopped {
|
||||
return nil, fmt.Errorf("sidecar %s closed", s.name)
|
||||
}
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
req := sidecarRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.stdin.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.stdin.Write([]byte("\n")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.stdin.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !s.stdout.Scan() {
|
||||
if s.stdout.Err() != nil {
|
||||
return nil, fmt.Errorf("sidecar %s read: %w", s.name, s.stdout.Err())
|
||||
}
|
||||
return nil, fmt.Errorf("sidecar %s closed unexpectedly", s.name)
|
||||
}
|
||||
line := s.stdout.Text()
|
||||
|
||||
var resp sidecarResponse
|
||||
if err := json.Unmarshal([]byte(line), &resp); err != nil {
|
||||
return nil, fmt.Errorf("sidecar %s unmarshal: %w", s.name, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("sidecar %s error: %s", s.name, resp.Error.Message)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(*resp.Result), nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) ListTools() ([]OCPTool, error) {
|
||||
data, err := s.call("tools/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var result struct {
|
||||
Tools []OCPTool `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Tools, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) CallTool(name string, args map[string]interface{}) (string, error) {
|
||||
data, err := s.call("tools/call", map[string]interface{}{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if data == nil {
|
||||
return "", nil
|
||||
}
|
||||
var result OCCallResult
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb string
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" {
|
||||
sb += c.Text
|
||||
}
|
||||
}
|
||||
return sb, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) Close() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed || s.stopped {
|
||||
return
|
||||
}
|
||||
s.stopped = true
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
s.cmd.Process.Kill()
|
||||
s.cmd.Wait()
|
||||
}
|
||||
log.Printf("[openclaw] sidecar %s stopped", s.name)
|
||||
}
|
||||
190
internal/plugins/openclaw/sidecar_test.go
Normal file
190
internal/plugins/openclaw/sidecar_test.go
Normal file
@ -0,0 +1,190 @@
|
||||
package openclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLaunchSidecarNoMainJS(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sp, err := launchSidecar(tmpDir, "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sp != nil {
|
||||
t.Fatal("expected nil for dir without main.js")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchSidecarAndListTools(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
t.Fatalf("list tools: %v", err)
|
||||
}
|
||||
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("expected at least one tool")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, tool := range tools {
|
||||
if tool.Name == "echo" {
|
||||
found = true
|
||||
if tool.Description == "" {
|
||||
t.Error("expected non-empty description for echo tool")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 'echo' tool in list")
|
||||
}
|
||||
t.Logf("tools: %+v", tools)
|
||||
}
|
||||
|
||||
func TestCallEchoTool(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
result, err := sp.CallTool("echo", map[string]interface{}{
|
||||
"text": "hello world",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call echo tool: %v", err)
|
||||
}
|
||||
|
||||
expected := "Echo: hello world"
|
||||
if result != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, result)
|
||||
}
|
||||
t.Logf("echo result: %s", result)
|
||||
}
|
||||
|
||||
func TestCallAddTool(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
result, err := sp.CallTool("add", map[string]interface{}{
|
||||
"a": 3.0,
|
||||
"b": 4.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call add tool: %v", err)
|
||||
}
|
||||
|
||||
expected := "7"
|
||||
if result != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, result)
|
||||
}
|
||||
t.Logf("add result: %s", result)
|
||||
}
|
||||
|
||||
func TestCallNonexistentTool(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
_, err = sp.CallTool("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent tool")
|
||||
}
|
||||
t.Logf("expected error: %v", err)
|
||||
}
|
||||
|
||||
func TestConcurrentCalls(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
done := make(chan bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(n int) {
|
||||
result, err := sp.CallTool("add", map[string]interface{}{
|
||||
"a": float64(n),
|
||||
"b": float64(n * 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("concurrent call %d: %v", n, err)
|
||||
}
|
||||
if result != fmt.Sprintf("%d", n + n*2) {
|
||||
t.Errorf("call %d: expected %d, got %s", n, n + n*2, result)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
294
internal/plugins/openclaw/simulator/main.js
Normal file
294
internal/plugins/openclaw/simulator/main.js
Normal file
@ -0,0 +1,294 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---- 工具函数 ----
|
||||
function writeJSON(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
function sendError(id, code, message) {
|
||||
writeJSON({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
function readJSON(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 解析插件入口 ----
|
||||
const pluginDir = path.resolve(process.argv[2]);
|
||||
if (!pluginDir) {
|
||||
process.stderr.write('[simulator] usage: node main.js <plugin-dir>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. 先读 package.json 找 extensions
|
||||
const pkgPath = path.join(pluginDir, 'package.json');
|
||||
const pkg = readJSON(pkgPath);
|
||||
let entryPath = null;
|
||||
|
||||
if (pkg && pkg.openclaw) {
|
||||
// runtimeExtensions > extensions (安装包首选编译后的 JS)
|
||||
let raw = pkg.openclaw.runtimeExtensions || pkg.openclaw.extensions;
|
||||
if (typeof raw === 'string') raw = [raw];
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
// 优先选已编译的 JS 入口: .ts 映射到 .js, .js 直接用
|
||||
for (const ext of raw) {
|
||||
let ep = path.resolve(pluginDir, ext);
|
||||
// .ts → 同级 .js
|
||||
if (ep.endsWith('.ts')) {
|
||||
const jsEp = ep.replace(/\.ts$/, '.js');
|
||||
if (fs.existsSync(jsEp)) { entryPath = jsEp; break; }
|
||||
}
|
||||
if (fs.existsSync(ep)) { entryPath = ep; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退: openclaw.plugin.json 的 entry/main
|
||||
if (!entryPath) {
|
||||
const manifest = readJSON(path.join(pluginDir, 'openclaw.plugin.json'));
|
||||
if (manifest) {
|
||||
const ep = manifest.entry || manifest.main || 'index.js';
|
||||
entryPath = path.join(pluginDir, ep);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 最后尝试 index.js
|
||||
if (!entryPath) {
|
||||
entryPath = path.join(pluginDir, 'index.js');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
process.stderr.write(`[simulator] entry not found: ${entryPath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- 加载插件 ----
|
||||
let pluginEntry;
|
||||
try {
|
||||
pluginEntry = require(entryPath);
|
||||
} catch (e) {
|
||||
process.stderr.write(`[simulator] load plugin: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entry = pluginEntry.default || pluginEntry;
|
||||
|
||||
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
|
||||
process.stderr.write(`[simulator] plugin entry must export {default: {register(api)}}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- 构造完整的 PluginApi 模拟 ----
|
||||
const registeredTools = [];
|
||||
|
||||
// registerTool 支持两种签名:
|
||||
// api.registerTool(toolDef, opts?) — 对象形式
|
||||
// api.registerTool(factory, opts?) — 工厂函数形式
|
||||
function registerTool(defOrFactory, opts) {
|
||||
if (typeof defOrFactory === 'function') {
|
||||
// 工厂形式: 传入 toolContext, 返回工具对象或数组
|
||||
const toolCtx = {
|
||||
id: 'simulator',
|
||||
cwd: pluginDir,
|
||||
env: process.env,
|
||||
allow: ['*'],
|
||||
};
|
||||
const result = defOrFactory(toolCtx);
|
||||
const tools = Array.isArray(result) ? result : [result];
|
||||
for (const t of tools) {
|
||||
if (t && typeof t.execute === 'function') {
|
||||
registeredTools.push(t);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 对象形式
|
||||
const def = defOrFactory;
|
||||
if (!def || !def.name) return;
|
||||
|
||||
// definePluginEntry 的 register 传给 api.registerTool 时是完整工具定义
|
||||
// defineToolPlugin 包装后传给 api.registerTool 的也是完整工具定义
|
||||
// 关键是工具必须要有 execute 函数(或 factory 在之前展开)
|
||||
registeredTools.push({
|
||||
name: def.name,
|
||||
label: def.label || def.name,
|
||||
description: def.description || '',
|
||||
parameters: def.parameters || { type: 'object', properties: {} },
|
||||
execute: typeof def.execute === 'function' ? def.execute : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 完整的 OpenClawPluginApi 模拟
|
||||
const api = {
|
||||
id: entry.id || 'unknown',
|
||||
name: entry.name || 'Unknown',
|
||||
version: entry.version,
|
||||
description: entry.description,
|
||||
source: pluginDir,
|
||||
rootDir: pluginDir,
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registrationMode: 'full',
|
||||
logger: {
|
||||
debug: (...args) => {},
|
||||
info: (...args) => {},
|
||||
warn: (...args) => {},
|
||||
error: (...args) => process.stderr.write(`[plugin] ${args.join(' ')}\n`),
|
||||
},
|
||||
resolvePath: (p) => path.resolve(pluginDir, p),
|
||||
|
||||
// 工具注册
|
||||
registerTool,
|
||||
|
||||
// 以下 api 方法留为 no-op,保证真实插件调用时不崩溃
|
||||
registerProvider: () => {},
|
||||
registerChannel: () => {},
|
||||
registerEmbeddingProvider: () => {},
|
||||
registerSpeechProvider: () => {},
|
||||
registerRealtimeTranscriptionProvider: () => {},
|
||||
registerRealtimeVoiceProvider: () => {},
|
||||
registerMediaUnderstandingProvider: () => {},
|
||||
registerImageGenerationProvider: () => {},
|
||||
registerMusicGenerationProvider: () => {},
|
||||
registerVideoGenerationProvider: () => {},
|
||||
registerWebFetchProvider: () => {},
|
||||
registerWebSearchProvider: () => {},
|
||||
registerMemoryEmbeddingProvider: () => {},
|
||||
registerAgentHarness: () => {},
|
||||
registerCliBackend: () => {},
|
||||
registerHook: () => {},
|
||||
registerHttpRoute: () => {},
|
||||
registerGatewayMethod: () => {},
|
||||
registerGatewayDiscoveryService: () => {},
|
||||
registerCli: () => {},
|
||||
registerNodeCliFeature: () => {},
|
||||
registerService: () => {},
|
||||
registerCommand: () => {},
|
||||
registerInteractiveHandler: () => {},
|
||||
registerAgentToolResultMiddleware: () => {},
|
||||
registerTrustedToolPolicy: () => {},
|
||||
registerToolMetadata: () => {},
|
||||
registerContextEngine: () => {},
|
||||
registerMemoryCapability: () => {},
|
||||
registerMemoryPromptSection: () => {},
|
||||
registerMemoryFlushPlan: () => {},
|
||||
registerMemoryRuntime: () => {},
|
||||
registerMemoryPromptSupplement: () => {},
|
||||
registerMemoryCorpusSupplement: () => {},
|
||||
|
||||
// 会话相关
|
||||
on: () => {},
|
||||
onConversationBindingResolved: () => {},
|
||||
|
||||
session: {
|
||||
state: { registerSessionExtension: () => {} },
|
||||
workflow: {
|
||||
enqueueNextTurnInjection: () => {},
|
||||
registerSessionSchedulerJob: () => {},
|
||||
sendSessionAttachment: () => {},
|
||||
scheduleSessionTurn: () => {},
|
||||
unscheduleSessionTurnsByTag: () => {},
|
||||
},
|
||||
controls: {
|
||||
registerControlUiDescriptor: () => {},
|
||||
registerSessionAction: () => {},
|
||||
},
|
||||
},
|
||||
|
||||
agent: {
|
||||
events: {
|
||||
registerAgentEventSubscription: () => {},
|
||||
emitAgentEvent: () => {},
|
||||
},
|
||||
},
|
||||
|
||||
lifecycle: { registerRuntimeLifecycle: () => {} },
|
||||
|
||||
runContext: {
|
||||
setRunContext: () => {},
|
||||
getRunContext: () => ({}),
|
||||
clearRunContext: () => {},
|
||||
},
|
||||
|
||||
runtime: {},
|
||||
};
|
||||
|
||||
// ---- 注册插件 ----
|
||||
entry.register(api);
|
||||
|
||||
// ---- JSON-RPC 协议处理 ----
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(line);
|
||||
} catch {
|
||||
sendError(null, -32700, 'Parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = req.id;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'ping') {
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { status: 'ok' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
const tools = registeredTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
inputSchema: t.parameters || { type: 'object', properties: {} },
|
||||
}));
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { tools } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = req.params || {};
|
||||
const toolName = params.name;
|
||||
const args = params.arguments || {};
|
||||
|
||||
const tool = registeredTools.find(t => t.name === toolName);
|
||||
if (!tool) {
|
||||
sendError(id, -32601, `Tool not found: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof tool.execute !== 'function') {
|
||||
sendError(id, -32603, `Tool ${toolName} has no execute function`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// OpenClaw 工具 execute 签名: (toolCallId, params, signal, onUpdate) => AgentToolResult
|
||||
const result = await tool.execute('sim-call-1', args, undefined, undefined);
|
||||
// 如果返回已经是 AgentToolResult 格式,直接转发
|
||||
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
||||
writeJSON({ jsonrpc: '2.0', id, result });
|
||||
} else {
|
||||
// 否则包装为 text result
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
||||
}
|
||||
} catch (e) {
|
||||
sendError(id, -32603, e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
});
|
||||
106
internal/plugins/openclaw/testdata/echoplugin/main.js
vendored
Normal file
106
internal/plugins/openclaw/testdata/echoplugin/main.js
vendored
Normal file
@ -0,0 +1,106 @@
|
||||
const readline = require('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Echo back the input text',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Text to echo' }
|
||||
},
|
||||
required: ['text']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ping',
|
||||
description: 'Health check',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
rl.on('line', (line) => {
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(line);
|
||||
} catch (e) {
|
||||
sendError(null, -32700, 'Parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = req.id;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'ping') {
|
||||
sendResult(id, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
sendResult(id, { tools });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = req.params || {};
|
||||
const toolName = params.name;
|
||||
const args = params.arguments || {};
|
||||
|
||||
const tool = tools.find(t => t.name === toolName);
|
||||
if (!tool) {
|
||||
sendError(id, -32601, `Tool not found: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
switch (toolName) {
|
||||
case 'echo':
|
||||
result = { content: [{ type: 'text', text: `Echo: ${args.text || ''}` }] };
|
||||
break;
|
||||
case 'add':
|
||||
const sum = (Number(args.a) || 0) + (Number(args.b) || 0);
|
||||
result = { content: [{ type: 'text', text: `${sum}` }] };
|
||||
break;
|
||||
default:
|
||||
sendError(id, -32601, `Not implemented: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
sendResult(id, result);
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
});
|
||||
|
||||
function sendResult(id, result) {
|
||||
const resp = { jsonrpc: '2.0', id, result };
|
||||
process.stdout.write(JSON.stringify(resp) + '\n');
|
||||
}
|
||||
|
||||
function sendError(id, code, message) {
|
||||
const resp = { jsonrpc: '2.0', id, error: { code, message } };
|
||||
process.stdout.write(JSON.stringify(resp) + '\n');
|
||||
}
|
||||
|
||||
// Sidecar is ready - no startup message needed
|
||||
@ -1,129 +0,0 @@
|
||||
package test_deepseek
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
||||
)
|
||||
|
||||
var defaultClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
func New(bus sdk.EventBus) *sdk.PluginAPI {
|
||||
api := sdk.NewPluginAPI("test_deepseek", "1.0.0", bus, nil, nil)
|
||||
|
||||
api.RegisterTool("test_deepseek", func(args map[string]interface{}) (interface{}, error) {
|
||||
prompt, _ := args["prompt"].(string)
|
||||
if prompt == "" {
|
||||
prompt = "你好,请用一句话介绍你自己"
|
||||
}
|
||||
return callDeepSeek(prompt, api.Settings(), defaultClient)
|
||||
})
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
func callDeepSeek(prompt string, sett sdk.SettingsAPI, client *http.Client) (interface{}, error) {
|
||||
baseURL := "https://api.deepseek.com/v1"
|
||||
model := "deepseek-chat"
|
||||
apiKey := ""
|
||||
if sett != nil {
|
||||
if v, err := sett.Get("base_url"); err == nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
baseURL = s
|
||||
}
|
||||
}
|
||||
if v, err := sett.Get("model"); err == nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
model = s
|
||||
}
|
||||
}
|
||||
if v, err := sett.Get("api_key"); err == nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
apiKey = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = "sk-feaa590161ed404b956f941992fae6f0"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": prompt},
|
||||
},
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1024,
|
||||
"stream": false,
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/")+"/chat/completions", strings.NewReader(string(bodyJSON)))
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("创建请求失败: %v", err)}, nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("API 调用失败: %v", err)}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return map[string]interface{}{
|
||||
"error": fmt.Sprintf("API 返回 %d", resp.StatusCode),
|
||||
"body": string(respBody),
|
||||
"status": "failed",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return map[string]interface{}{"error": fmt.Sprintf("解析响应失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
content := ""
|
||||
if len(result.Choices) > 0 {
|
||||
content = result.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"prompt": prompt,
|
||||
"response": content,
|
||||
"model": model,
|
||||
"usage": result.Usage,
|
||||
"status": "ok",
|
||||
"base_url": baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewWithClient(bus sdk.EventBus, client *http.Client) *sdk.PluginAPI {
|
||||
api := sdk.NewPluginAPI("test_deepseek", "1.0.0", bus, nil, nil)
|
||||
api.RegisterTool("test_deepseek", func(args map[string]interface{}) (interface{}, error) {
|
||||
prompt, _ := args["prompt"].(string)
|
||||
if prompt == "" {
|
||||
prompt = "你好,请用一句话介绍你自己"
|
||||
}
|
||||
return callDeepSeek(prompt, api.Settings(), client)
|
||||
})
|
||||
return api
|
||||
}
|
||||
@ -1,246 +0,0 @@
|
||||
package test_deepseek
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
||||
)
|
||||
|
||||
type mockSettings struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *mockSettings) Get(key string) (interface{}, error) {
|
||||
v, ok := m.data[key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (m *mockSettings) Set(key string, value interface{}) error {
|
||||
m.data[key] = value.(string)
|
||||
return nil
|
||||
}
|
||||
func (m *mockSettings) List(prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range m.data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func mockAPI(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer sk-test123" {
|
||||
t.Fatalf("expected Bearer sk-test123, got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("expected application/json, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
if reqBody["model"] != "test-model" {
|
||||
t.Fatalf("expected test-model, got %v", reqBody["model"])
|
||||
}
|
||||
if reqBody["stream"] != false {
|
||||
t.Fatalf("expected stream=false, got %v", reqBody["stream"])
|
||||
}
|
||||
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"message": map[string]string{
|
||||
"content": "你好!我是 DeepSeek。",
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]int{
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func TestCallDeepSeekSuccess(t *testing.T) {
|
||||
ts := mockAPI(t)
|
||||
defer ts.Close()
|
||||
|
||||
sett := &mockSettings{data: map[string]string{
|
||||
"base_url": ts.URL,
|
||||
"model": "test-model",
|
||||
"api_key": "sk-test123",
|
||||
}}
|
||||
|
||||
result, err := callDeepSeek("你好", sett, ts.Client())
|
||||
if err != nil {
|
||||
t.Fatalf("callDeepSeek: %v", err)
|
||||
}
|
||||
|
||||
// 通过 JSON 反序列化验证(避免匿名 struct 类型断言问题)
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["response"] != "你好!我是 DeepSeek。" {
|
||||
t.Fatalf("expected response 你好!我是 DeepSeek。, got %v", resp["response"])
|
||||
}
|
||||
if resp["model"] != "test-model" {
|
||||
t.Fatalf("expected model test-model, got %v", resp["model"])
|
||||
}
|
||||
usage := resp["usage"].(map[string]interface{})
|
||||
if usage["total_tokens"].(float64) != 30 {
|
||||
t.Fatalf("expected 30 total tokens, got %v", usage["total_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallDeepSeekNon200(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
sett := &mockSettings{data: map[string]string{
|
||||
"base_url": ts.URL,
|
||||
"model": "test-model",
|
||||
"api_key": "sk-bad",
|
||||
}}
|
||||
result, err := callDeepSeek("hi", sett, ts.Client())
|
||||
if err != nil {
|
||||
t.Fatalf("callDeepSeek: %v", err)
|
||||
}
|
||||
m := result.(map[string]interface{})
|
||||
if m["status"] != "failed" {
|
||||
t.Fatalf("expected status failed, got %v", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallDeepSeekDefaultPrompt(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var reqBody map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
msgs := reqBody["messages"].([]interface{})
|
||||
msg := msgs[0].(map[string]interface{})
|
||||
if msg["content"] == "你好,请用一句话介绍你自己" {
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{
|
||||
{"message": map[string]string{"content": "ok"}},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected prompt: %v", msg["content"])
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
sett := &mockSettings{data: map[string]string{
|
||||
"base_url": ts.URL,
|
||||
"model": "test-model",
|
||||
"api_key": "sk-test",
|
||||
}}
|
||||
bus := sdk.NewInProcessBus()
|
||||
api := NewWithClient(bus, ts.Client())
|
||||
api.SetSettings(sett)
|
||||
|
||||
handler := api.Tools()["test_deepseek"]
|
||||
result, err := handler(map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
data, _ := json.Marshal(result)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(data, &resp)
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected ok, got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallDeepSeekFallbackAPIKey(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := "Bearer sk-feaa590161ed404b956f941992fae6f0"
|
||||
if r.Header.Get("Authorization") != expected {
|
||||
t.Fatalf("expected %s, got %s", expected, r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{
|
||||
{"message": map[string]string{"content": "ok"}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// 不设 api_key 触发 fallback
|
||||
sett := &mockSettings{data: map[string]string{
|
||||
"base_url": ts.URL,
|
||||
"model": "test-model",
|
||||
}}
|
||||
result, err := callDeepSeek("hi", sett, ts.Client())
|
||||
if err != nil {
|
||||
t.Fatalf("callDeepSeek: %v", err)
|
||||
}
|
||||
m := result.(map[string]interface{})
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("expected ok, got %v", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWithClient(t *testing.T) {
|
||||
ts := mockAPI(t)
|
||||
defer ts.Close()
|
||||
|
||||
sett := &mockSettings{data: map[string]string{
|
||||
"base_url": ts.URL,
|
||||
"model": "test-model",
|
||||
"api_key": "sk-test123",
|
||||
}}
|
||||
bus := sdk.NewInProcessBus()
|
||||
api := NewWithClient(bus, ts.Client())
|
||||
api.SetSettings(sett)
|
||||
|
||||
handler := api.Tools()["test_deepseek"]
|
||||
if handler == nil {
|
||||
t.Fatal("test_deepseek tool not registered")
|
||||
}
|
||||
|
||||
result, err := handler(map[string]interface{}{"prompt": "你好"})
|
||||
if err != nil {
|
||||
t.Fatalf("tool handler: %v", err)
|
||||
}
|
||||
m := result.(map[string]interface{})
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("expected ok, got %v", m["status"])
|
||||
}
|
||||
if m["response"] != "你好!我是 DeepSeek。" {
|
||||
t.Fatalf("expected response, got %v", m["response"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultClient(t *testing.T) {
|
||||
bus := sdk.NewInProcessBus()
|
||||
api := New(bus)
|
||||
if api == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
if api.Name != "test_deepseek" {
|
||||
t.Fatalf("expected name test_deepseek, got %s", api.Name)
|
||||
}
|
||||
tools := api.Tools()
|
||||
if _, ok := tools["test_deepseek"]; !ok {
|
||||
t.Fatal("test_deepseek tool not registered")
|
||||
}
|
||||
}
|
||||
158
internal/plugins/webui/dashboard.html
Normal file
158
internal/plugins/webui/dashboard.html
Normal file
@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HomeAgent Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
|
||||
body{background:#0f172a;color:#e2e8f0;min-height:100vh;overflow-x:hidden}
|
||||
nav{background:#1e293b;padding:0 24px;display:flex;align-items:center;gap:4px;border-bottom:1px solid #334155;height:48px;position:sticky;top:0;z-index:100}
|
||||
nav h1{font-size:16px;font-weight:700;color:#38bdf8;margin-right:24px;white-space:nowrap}
|
||||
nav a{padding:12px 16px;color:#94a3b8;text-decoration:none;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
|
||||
nav a:hover{color:#e2e8f0}
|
||||
nav a.active{color:#38bdf8;border-bottom-color:#38bdf8}
|
||||
.container{padding:20px 24px;max-width:1440px;margin:0 auto}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:20px;margin-bottom:16px}
|
||||
.card h2{font-size:15px;font-weight:600;margin-bottom:12px;color:#f1f5f9}
|
||||
.card h3{font-size:13px;font-weight:600;color:#94a3b8;margin:16px 0 8px}
|
||||
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||
.stat-value{font-size:26px;font-weight:700;color:#38bdf8}
|
||||
.stat-label{font-size:11px;color:#64748b;margin-top:2px}
|
||||
.stat-card{padding:16px 20px}
|
||||
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||
.dot-green{background:#22c55e}
|
||||
.dot-yellow{background:#eab308}
|
||||
.dot-red{background:#ef4444}
|
||||
.dot-gray{background:#475569}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;padding:8px 10px;color:#64748b;font-weight:500;border-bottom:1px solid #334155;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
|
||||
td{padding:8px 10px;border-bottom:1px solid #1e293b}
|
||||
tr:hover td{background:#0f172a40}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:500}
|
||||
.badge-green{background:#166534;color:#86efac}
|
||||
.badge-red{background:#7f1d1d;color:#fca5a5}
|
||||
.badge-yellow{background:#713f12;color:#fde68a}
|
||||
.badge-blue{background:#1e3a5f;color:#93c5fd}
|
||||
.btn{padding:6px 14px;border-radius:6px;border:none;font-size:12px;cursor:pointer;font-weight:500;transition:all .15s}
|
||||
.btn-primary{background:#2563eb;color:#fff}
|
||||
.btn-primary:hover{background:#1d4ed8}
|
||||
.btn-danger{background:#dc2626;color:#fff}
|
||||
.btn-danger:hover{background:#b91c1c}
|
||||
.btn-sm{padding:4px 10px;font-size:11px}
|
||||
.btn-ghost{background:transparent;border:1px solid #334155;color:#94a3b8}
|
||||
.btn-ghost:hover{background:#1e293b;color:#e2e8f0}
|
||||
.tab-content{display:none}
|
||||
.tab-content.active{display:block}
|
||||
input,textarea,select{background:#0f172a;border:1px solid #334155;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%;margin-bottom:10px;outline:none;transition:border .15s}
|
||||
input:focus,textarea:focus,select:focus{border-color:#2563eb}
|
||||
textarea{resize:vertical;min-height:80px;font-family:monospace;font-size:12px}
|
||||
label{display:block;font-size:11px;color:#94a3b8;margin-bottom:3px;font-weight:500}
|
||||
pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x:auto;color:#a5b4fc;font-family:monospace;max-height:400px;overflow-y:auto}
|
||||
code{font-family:monospace;font-size:12px;color:#a5b4fc}
|
||||
.settings-layout{display:flex;gap:20px;min-height:60vh}
|
||||
.settings-sidebar{width:200px;flex-shrink:0;background:#1e293b;border:1px solid #334155;border-radius:10px;padding:8px 0;overflow-y:auto;max-height:70vh}
|
||||
.settings-sidebar a{display:block;padding:9px 16px;color:#94a3b8;font-size:13px;cursor:pointer;text-decoration:none;border-left:3px solid transparent;transition:all .1s}
|
||||
.settings-sidebar a:hover{background:#0f172a;color:#e2e8f0}
|
||||
.settings-sidebar a.active{background:#0f172a;color:#38bdf8;border-left-color:#38bdf8}
|
||||
.settings-content{flex:1;min-width:0}
|
||||
.settings-key{font-family:monospace;font-size:11px;color:#64748b;margin-bottom:2px}
|
||||
.toast{position:fixed;bottom:20px;right:20px;background:#166534;color:#86efac;padding:10px 20px;border-radius:8px;font-size:13px;display:none;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.3)}
|
||||
.toast.error{background:#7f1d1d;color:#fca5a5}
|
||||
.empty-state{text-align:center;padding:40px 20px;color:#64748b}
|
||||
.empty-state p{font-size:14px;margin-bottom:8px}
|
||||
.empty-state .icon{font-size:36px;margin-bottom:12px;opacity:.5}
|
||||
.chat-messages{max-height:500px;overflow-y:auto;margin-bottom:12px;padding:8px;border:1px solid #334155;border-radius:8px;background:#0f172a}
|
||||
.msg{display:flex;gap:10px;margin-bottom:12px;align-items:flex-start}
|
||||
.msg-avatar{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0}
|
||||
.msg-user .msg-avatar{background:#1e3a5f;color:#93c5fd}
|
||||
.msg-assistant .msg-avatar{background:#1a3a2a;color:#86efac}
|
||||
.msg-system .msg-avatar{background:#3b1a3a;color:#f0abfc}
|
||||
.msg-content{flex:1;min-width:0}
|
||||
.msg-content .role{font-size:11px;color:#64748b;margin-bottom:2px}
|
||||
.msg-content .text{font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.msg-content .text .reasoning{color:#94a3b8;font-style:italic;font-size:12px;padding:4px 8px;background:#1e293b;border-radius:4px;margin:4px 0;display:block}
|
||||
.chat-input-row{display:flex;gap:8px}
|
||||
.chat-input-row input{flex:1;margin-bottom:0}
|
||||
.chat-input-row button{flex-shrink:0;margin-bottom:0}
|
||||
.loading{display:inline-block;width:16px;height:16px;border:2px solid #334155;border-radius:50%;border-top-color:#38bdf8;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.monaco-like{font-family:monospace;font-size:12px;background:#0f172a;border:1px solid #334155;border-radius:6px}
|
||||
.kv-row{display:flex;padding:6px 0;border-bottom:1px solid #1e293b;font-size:13px}
|
||||
.kv-row .key{color:#94a3b8;width:180px;flex-shrink:0}
|
||||
.kv-row .val{color:#e2e8f0;word-break:break-all}
|
||||
.tool-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;background:#1e3a5f;color:#93c5fd;margin:1px}
|
||||
.check-pass{color:#86efac}
|
||||
.check-fail{color:#fca5a5}
|
||||
.check-skip{color:#94a3b8}
|
||||
.memory-graph{width:100%;height:300px;background:#0f172a;border-radius:8px;border:1px solid #334155;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:#64748b;font-size:13px}
|
||||
.health-panel{display:grid;gap:8px}
|
||||
.health-item{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#0f172a;border-radius:6px;font-size:13px}
|
||||
.health-item .check-name{flex:1}
|
||||
.health-item .check-status{font-size:11px;font-weight:500}
|
||||
.fade-in{animation:fadeIn .2s ease}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
||||
@media(max-width:768px){.grid-2,.grid-3,.grid-4{grid-template-columns:1fr}.settings-layout{flex-direction:column}.settings-sidebar{width:100%;max-height:none;display:flex;flex-wrap:wrap;gap:0;padding:0}.settings-sidebar a{flex:1;min-width:80px;text-align:center;border-left:none;border-bottom:2px solid transparent;padding:8px}.settings-sidebar a.active{border-left:none;border-bottom-color:#38bdf8}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav><h1>HomeAgent</h1><a class="active" onclick="switchTab('overview')">概览</a><a onclick="switchTab('chat')">对话</a><a onclick="switchTab('plugins')">插件</a><a onclick="switchTab('memory')">记忆</a><a onclick="switchTab('knowledge')">知识</a><a onclick="switchTab('settings')">设置</a><a onclick="switchTab('kernel')">内核</a></nav>
|
||||
<div class="container" id="app">
|
||||
<div id="tab-overview" class="tab-content active"></div>
|
||||
<div id="tab-chat" class="tab-content"></div>
|
||||
<div id="tab-plugins" class="tab-content"></div>
|
||||
<div id="tab-memory" class="tab-content"></div>
|
||||
<div id="tab-knowledge" class="tab-content"></div>
|
||||
<div id="tab-settings" class="tab-content"></div>
|
||||
<div id="tab-kernel" class="tab-content"></div>
|
||||
</div>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
let state={status:{},kernel:null,settings:{},settingsPlugins:['core'],selectedSection:'core',messages:[],chatLoading:false,healthResult:null};
|
||||
async function api(p,o){let opts={headers:{'Content-Type':'application/json',...o?.headers},...o};let r=await fetch('/api/v1'+p,opts);if(opts.raw)return r;let ct=r.headers.get('content-type')||'';if(ct.includes('json'))return r.json();return r.text()}
|
||||
function switchTab(n){document.querySelectorAll('.tab-content').forEach(e=>e.classList.remove('active'));let el=document.getElementById('tab-'+n);if(el)el.classList.add('active');document.querySelectorAll('nav a').forEach(e=>e.classList.remove('active'));document.querySelector('nav a[onclick*="\'+n+\'"]')||document.querySelector(`nav a[onclick*="${n}"]`)?.classList.add('active');renderAll()}
|
||||
function toast(m,isError){let t=document.getElementById('toast');t.textContent=m;t.className='toast'+(isError?' error':'');t.style.display='block';setTimeout(()=>t.style.display='none',3000)}
|
||||
async function renderAll(){try{let s=await api('/status');state.status=s}catch(e){}try{state.kernel=await api('/kernel')}catch(e){}try{let s=await api('/settings');state.settings=s.settings||{};state.settingsPlugins=s.plugins||['core']}catch(e){}renderOverview();renderChat();renderPlugins();renderMemory();renderKnowledge();renderKernel()}
|
||||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
function timeAgo(t){let s=Math.floor((Date.now()-new Date(t).getTime())/1000);if(s<60)return s+'秒前';let m=Math.floor(s/60);if(m<60)return m+'分钟前';return Math.floor(m/60)+'小时前'}
|
||||
|
||||
// === Overview ===
|
||||
function renderOverview(){let s=state.status||{};let k=state.kernel;let html='<div class="grid-4">'+statCard('运行状态',s.status||'unknown','running')+statCard('运行时间',s.uptime||'-','uptime')+statCard('插件',(k?.plugins||[]).length||0,'plugin')+statCard('版本',s.version||'0.1.0','version')+'</div>';if(k){html+='<div class="grid-2"><div class="card"><h2>LLM 状态</h2><div class="kv-row"><span class="key">Provider</span><span class="val">'+(k.llm?.provider||'未配置')+'</span></div><div class="kv-row"><span class="key">可用源</span><span class="val">'+(k.llm?.sources||0)+'</span></div><div class="kv-row"><span class="key">状态</span><span class="val"><span class="status-dot '+(k.llm?.available?'dot-green':'dot-red')+'"></span>'+(k.llm?.available?'运行中':'不可用')+'</span></div></div><div class="card"><h2>记忆状态</h2><div class="kv-row"><span class="key">图记忆</span><span class="val"><span class="status-dot '+(k.memory?.available?'dot-green':'dot-gray')+'"></span>'+(k.memory?.available?k.memory.entity_count+' 实体, '+k.memory.relation_count+' 关系':'未初始化')+'</span></div><div class="kv-row"><span class="key">文档记忆</span><span class="val">'+(k.documents?.available?k.documents.doc_count+' 文档':'未初始化')+'</span></div><div class="kv-row"><span class="key">文本记忆</span><span class="val">'+(k.text_memory?.available?k.text_memory.file_count+' 文件':'未初始化')+'</span></div><div class="kv-row"><span class="key">知识库</span><span class="val">'+(k.knowledge?.available?k.knowledge.item_count+' 项':'未初始化')+'</span></div></div></div>';}html+='<div class="card"><h2>运行时</h2><div class="grid-3">'+statCard('Goroutines',k?.runtime?.goroutines||'-','')+statCard('内存',k?.runtime?.memory_mb?k.runtime.memory_mb+' MB':'-','')+statCard('Go 版本',k?.runtime?.go_version||'-','')+'</div></div>';document.getElementById('tab-overview').innerHTML=html}
|
||||
function statCard(l,v){return '<div class="card stat-card"><div class="stat-value">'+v+'</div><div class="stat-label">'+l+'</div></div>'}
|
||||
|
||||
// === Chat ===
|
||||
function renderChat(){let msgs=state.messages;let html='<div class="card"><h2>对话调试</h2><div class="chat-messages" id="chat-msgs">';if(msgs.length===0){html+='<div class="empty-state"><p>开始对话以测试 Agent 回复</p></div>'}else{msgs.forEach((m,i)=>{let role=m.role||'user';let c=escHtml(m.content||'');let rc=m.reasoning_content?('<span class="reasoning">思考: '+escHtml(m.reasoning_content)+'</span>'):'';html+='<div class="msg msg-'+role+'"><div class="msg-avatar">'+(role==='user'?'U':role==='assistant'?'A':'S')+'</div><div class="msg-content"><div class="role">'+(role==='user'?'用户':role==='assistant'?'AI':'系统')+'</div><div class="text">'+rc+c+'</div></div></div>'})}html+='</div><div class="chat-input-row"><input id="chat-input" placeholder="输入消息..." onkeydown="if(event.key==\'Enter\')sendChat()"><button class="btn btn-primary" onclick="sendChat()" id="chat-send-btn">发送</button></div></div>';document.getElementById('tab-chat').innerHTML=html;let el=document.getElementById('chat-msgs');if(el)el.scrollTop=el.scrollHeight}
|
||||
async function sendChat(){let inp=document.getElementById('chat-input');let btn=document.getElementById('chat-send-btn');let text=inp.value.trim();if(!text||state.chatLoading)return;state.messages.push({role:'user',content:text});inp.value='';renderChat();state.chatLoading=true;btn.disabled=true;btn.textContent='...';try{let r=await api('/chat',{method:'POST',body:JSON.stringify({message:text})});state.messages.push({role:'assistant',content:r.response||'(无响应)',reasoning_content:r.reasoning_content});renderChat()}catch(e){state.messages.push({role:'assistant',content:'错误: '+e.message});renderChat();toast('请求失败: '+e.message,true)}finally{state.chatLoading=false;btn.disabled=false;btn.textContent='发送'}}
|
||||
|
||||
// === Plugins ===
|
||||
function renderPlugins(){let k=state.kernel;let plugins=k?.plugins||[];let tools=k?.tools||[];let html='<div class="card"><h2>已加载插件 ('+plugins.length+')</h2>';if(plugins.length===0){html+='<div class="empty-state"><p>暂无已加载插件</p></div>'}else{html+='<table><tr><th>名称</th><th>状态</th></tr>';plugins.forEach(p=>{html+='<tr><td>'+escHtml(p.name)+'</td><td><span class="badge badge-green">已加载</span></td></tr>'});html+='</table>'}html+='</div>';if(tools.length>0){html+='<div class="card"><h2>已注册工具 ('+tools.length+')</h2><div style="display:flex;flex-wrap:wrap;gap:4px">';tools.forEach(t=>{html+='<span class="tool-badge" title="'+escHtml(t.description||'')+'">'+escHtml(t.name)+'</span>'});html+='</div></div>'}html+='<div class="card"><h2>健康检查</h2><div id="health-panel">';if(state.healthResult){html+=renderHealthResult(state.healthResult)}else{html+='<button class="btn btn-primary" onclick="runHealthcheck()">运行健康检查</button>'}html+='</div></div>';document.getElementById('tab-plugins').innerHTML=html}
|
||||
async function runHealthcheck(){let panel=document.getElementById('health-panel');if(!panel)return;panel.innerHTML='<div class="loading" style="margin:12px auto"></div><p style="text-align:center;color:#64748b">运行中...</p>';try{let r=await api('/kernel');let tools=r?.tools||[];let healthTool=tools.find(t=>t.name==='healthcheck');if(!healthTool){panel.innerHTML='<p style="color:#94a3b8">healthcheck 工具未注册</p>';return}panel.innerHTML='<p style="color:#94a3b8">通过 Agent 对话触发 healthcheck...</p>';let chatR=await api('/chat',{method:'POST',body:JSON.stringify({message:'请运行 healthcheck 工具进行全面健康检查并报告结果'})});panel.innerHTML='<pre>'+escHtml(JSON.stringify(chatR,null,2))+'</pre>'}catch(e){panel.innerHTML='<p style="color:#fca5a5">错误: '+escHtml(e.message)+'</p>';toast('健康检查失败: '+e.message,true)}}
|
||||
function renderHealthResult(r){if(!r||!r.checks)return '<p style="color:#94a3b8">暂无健康检查数据</p>';let checks=r.checks||[];let passed=checks.filter(c=>c.pass).length;let failed=checks.filter(c=>!c.pass).length;let html='<div style="margin-bottom:12px;display:flex;gap:16px;align-items:center"><span class="badge badge-green">通过: '+passed+'</span><span class="badge '+(failed>0?'badge-red':'badge-green')+'">失败: '+failed+'</span><span class="badge badge-blue">总计: '+checks.length+'</span></div>';checks.forEach(c=>{let passClass=c.pass?'check-pass':'check-fail';if(c.status==='skip')passClass='check-skip';html+='<div class="health-item"><span class="check-name">'+escHtml(c.name)+'</span><span class="check-status '+passClass+'">'+(c.status||'unknown')+'</span><span style="color:#64748b;font-size:11px">'+escHtml(c.detail||'')+'</span></div>'});return html}
|
||||
|
||||
// === Memory ===
|
||||
function renderMemory(){let k=state.kernel;let mem=k?.memory;let html='<div class="grid-2"><div class="card"><h2>图记忆</h2>';if(mem?.available){html+='<div class="kv-row"><span class="key">实体数</span><span class="val">'+mem.entity_count+'</span></div><div class="kv-row"><span class="key">关系数</span><span class="val">'+mem.relation_count+'</span></div><div class="kv-row"><span class="key">实体类型</span><span class="val">'+mem.entity_types+'</span></div>'}else{html+='<div class="empty-state"><p>图记忆未初始化</p></div>'}html+='</div><div class="card"><h2>记忆查询</h2><input id="mem-query" placeholder="输入查询关键词(逗号分隔)"><button class="btn btn-primary" onclick="queryMemory()">查询</button><div id="mem-result" style="margin-top:12px"></div></div></div>';let tm=k?.text_memory;if(tm?.available){html+='<div class="card"><h2>文本记忆</h2><p style="color:#94a3b8;font-size:13px">已记录 '+tm.file_count+' 个文件</p></div>'}document.getElementById('tab-memory').innerHTML=html}
|
||||
async function queryMemory(){let q=document.getElementById('mem-query')?.value;let r=document.getElementById('mem-result');if(!r||!q)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/memory?q='+encodeURIComponent(q)+'&depth=2');r.innerHTML='<pre>'+escHtml(JSON.stringify(data,null,2))+'</pre>'}catch(e){r.innerHTML='<p style="color:#fca5a5">查询失败: '+escHtml(e.message)+'</p>'}}
|
||||
|
||||
// === Knowledge ===
|
||||
function renderKnowledge(){let k=state.kernel;let know=k?.knowledge;let html='<div class="grid-2"><div class="card"><h2>知识库</h2>';if(know?.available){html+='<div class="kv-row"><span class="key">条目数</span><span class="val">'+know.item_count+'</span></div>';if(know.items&&know.items.length>0){html+='<h3>分类</h3>';know.items.forEach(n=>{html+='<span class="tool-badge">'+escHtml(n)+'</span> '})}}else{html+='<div class="empty-state"><p>知识库未初始化</p></div>'}html+='</div><div class="card"><h2>搜索</h2><input id="know-query" placeholder="输入关键词" onkeydown="if(event.key==\'Enter\')searchKnowledge()"><button class="btn btn-primary" onclick="searchKnowledge()">搜索</button><div id="know-result" style="margin-top:12px"></div></div></div><div class="card"><h2>新建知识</h2><div style="max-width:500px"><label>名称</label><input id="know-name" placeholder="知识名称"><label>内容</label><textarea id="know-content" placeholder="Markdown 内容"></textarea><button class="btn btn-primary" onclick="createKnowledge()">创建</button></div></div>';document.getElementById('tab-knowledge').innerHTML=html}
|
||||
async function searchKnowledge(){let q=document.getElementById('know-query')?.value;let r=document.getElementById('know-result');if(!r||!q)return;r.innerHTML='<div class="loading"></div>';try{let data=await api('/knowledge?q='+encodeURIComponent(q));r.innerHTML='<pre>'+escHtml(JSON.stringify(data,null,2))+'</pre>'}catch(e){r.innerHTML='<p style="color:#fca5a5">搜索失败: '+escHtml(e.message)+'</p>'}}
|
||||
async function createKnowledge(){let name=document.getElementById('know-name')?.value;let content=document.getElementById('know-content')?.value;if(!name||!content){toast('名称和内容不能为空',true);return}try{await api('/knowledge',{method:'POST',body:JSON.stringify({name,content})});toast('知识「'+name+'」已创建');document.getElementById('know-name').value='';document.getElementById('know-content').value='';renderAll()}catch(e){toast('创建失败: '+e.message,true)}}
|
||||
|
||||
// === Settings ===
|
||||
function renderSettingsSidebar(){let el=document.querySelector('.settings-sidebar');if(!el)return;el.innerHTML='';state.settingsPlugins.forEach(function(p){let a=document.createElement('a');a.textContent=p;if(p===state.selectedSection)a.className='active';a.onclick=function(){state.selectedSection=p;renderOneSettings()};el.appendChild(a)})}
|
||||
function renderOneSettings(){let prefix=state.selectedSection+'.';let filtered=Object.keys(state.settings||{}).filter(k=>k===prefix.slice(0,-1)||k.startsWith(prefix));filtered.sort();let html='<div class="settings-layout"><div class="settings-sidebar"></div><div class="settings-content">';if(filtered.length===0){html+='<div class="card"><h2>'+escHtml(state.selectedSection)+'</h2><p style="color:#94a3b8">暂无设置项</p></div>'}else{filtered.forEach(k=>{let v=state.settings[k];let sv=typeof v==='object'?JSON.stringify(v):String(v);html+='<div class="card"><button class="btn btn-primary btn-sm save-btn" onclick="saveSetting(\''+k+'\')" style="float:right;margin-top:-4px">保存</button><div class="settings-key">'+escHtml(k)+'</div><label>值</label><input id="inp-'+k.replace(/\./g,'_')+'" value="'+escHtml(sv)+'" onchange="markDirty(\''+k+'\')"></div>'})}html+='</div></div>';document.getElementById('tab-settings').innerHTML=html;renderSettingsSidebar()}
|
||||
function markDirty(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
||||
async function saveSetting(k){let inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;let raw=inp.value;let val;try{val=JSON.parse(raw)}catch(e){val=raw}try{let r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'),true)}}catch(e){toast('保存失败: '+e.message,true)}}
|
||||
function renderConfigDisabled(){document.getElementById('tab-settings').innerHTML='<div class="card"><h2>设置</h2><p style="color:#94a3b8">设置面板已加载</p></div>';renderOneSettings()}
|
||||
|
||||
// === Kernel ===
|
||||
function renderKernel(){let k=state.kernel;if(!k){document.getElementById('tab-kernel').innerHTML='<div class="card"><div class="loading"></div><p style="color:#64748b">加载中...</p></div>';return}let html='<div class="card"><h2>内核状态快照</h2><pre>'+escHtml(JSON.stringify(k,null,2))+'</pre></div>';html+='<div class="card"><h2>通道</h2>';if(k.channels&&k.channels.length>0){html+='<table><tr><th>名称</th><th>类型</th><th>状态</th></tr>';k.channels.forEach(ch=>{html+='<tr><td>'+escHtml(ch.name)+'</td><td>'+escHtml(ch.type)+'</td><td><span class="status-dot '+(ch.ready?'dot-green':'dot-red')+'"></span>'+(ch.ready?'就绪':'离线')+'</td></tr>'});html+='</table>'}else{html+='<p style="color:#94a3b8">无通道</p>'}html+='</div>';if(k.tracker?.available){html+='<div class="card"><h2>变更追踪</h2><div class="kv-row"><span class="key">状态</span><span class="val"><span class="badge badge-green">活跃</span></span></div><div class="kv-row"><span class="key">目录</span><span class="val">'+escHtml(k.tracker.dir||'-')+'</span></div></div>'}document.getElementById('tab-kernel').innerHTML=html}
|
||||
|
||||
// === Settings Override ===
|
||||
renderConfigDisabled();
|
||||
renderAll();setInterval(renderAll,15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,6 +1,7 @@
|
||||
package webui
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
function markDirty(k){var inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(inp)inp.style.borderColor='#eab308'}
|
||||
async function saveSetting(k){var inp=document.getElementById('inp-'+k.replace(/\./g,'_'));if(!inp)return;var raw=inp.value;var val;try{val=JSON.parse(raw)}catch(e){val=raw}var r=await api('/settings',{method:'PUT',body:JSON.stringify({key:k,value:val})});if(r.status==='ok'){inp.style.borderColor='';state.settings[k]=val;toast('已保存: '+k)}else{toast('保存失败: '+(r.error||'unknown'))}}
|
||||
renderAll();setInterval(renderAll,30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
@ -34,7 +34,7 @@ func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
|
||||
sup := supervisor.New(cfg)
|
||||
sup.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")
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user