Files
HomeAgent/internal/plugin/plugin.go
root 2d314b3e9c 重构: 插件自注册 + .so 动态加载 + 中断打断机制
- 所有内置插件 init() 自注册 (plugin.RegisterFactory), 移除 main.go 硬编码
- 新增 .so 动态加载器 (internal/plugin/dynamic.go), 插件可编译为 plugin.so
- 新增 plugin.json 元数据 (internal/plugin/manifest.go)
- 新增 interceptLoop 独立 goroutine:
  (a) cancelLLM() 取消进行中的 HTTP 请求
  (b) interceptCh → drainInterrupt() 注入 [打断消息] 到 LLM 上下文
  (c) InjectInput 空闲时触发新处理循环
- 新增 internal/plugins/all.go 空白导入触发所有内置插件 init()
- internal/sdk/ 作为 PluginSDK 正式 Go API
- internal/api/ → internal/plugins/webui/ 迁移
- 删除旧 cmd/cli/, 使用 cmd/waiter/ 替代
- 更新 PLAN.md / ARCHITECTURE.md / README.md 文档
2026-07-03 16:53:34 +08:00

296 lines
7.5 KiB
Go

package plugin
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
type PluginType string
const (
PluginTypeSKILL PluginType = "skill"
)
type IOConfig struct {
Type string `json:"type"`
InputRoute string `json:"input_route"`
OutputRoute string `json:"output_route"`
OutputCaps []string `json:"output_caps"`
}
type Plugin interface {
Name() string
PluginType() PluginType
Description() string
Version() string
Tools() []ToolDef
Enabled() bool
SetEnabled(bool)
IOConfig() *IOConfig
RawContent() string
}
type ToolDef = agentIO.ToolDef
type SKILLPlugin struct {
mu sync.RWMutex
name string
description string
version string
author string
enabled bool
rawContent string
sourceDir string
toolDefs []ToolDef
ioConfig *IOConfig
}
func LoadSKILL(path string) (*SKILLPlugin, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", path, err)
}
name := filepath.Base(path)
p := &SKILLPlugin{
name: name,
sourceDir: path,
enabled: true,
}
if info.IsDir() {
skillFile := filepath.Join(path, "SKILL.md")
if data, err := os.ReadFile(skillFile); err == nil {
p.rawContent = string(data)
p.description = extractDescription(p.rawContent)
p.version = extractField(p.rawContent, "version")
p.author = extractField(p.rawContent, "author")
p.ioConfig = extractIOConfig(p.rawContent)
}
metaFile := filepath.Join(path, "skill.json")
if data, err := os.ReadFile(metaFile); err == nil {
var meta struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
IO *IOConfig `json:"io,omitempty"`
}
if err := json.Unmarshal(data, &meta); err == nil {
if meta.Name != "" { p.name = meta.Name }
if meta.Description != "" { p.description = meta.Description }
if meta.Version != "" { p.version = meta.Version }
if meta.Author != "" { p.author = meta.Author }
if meta.IO != nil { p.ioConfig = meta.IO }
}
}
} else if filepath.Ext(path) == ".md" {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
p.rawContent = string(data)
p.name = name[:len(name)-3]
p.description = extractDescription(p.rawContent)
p.ioConfig = extractIOConfig(p.rawContent)
}
if p.rawContent != "" {
p.toolDefs = extractToolDefs(p.rawContent)
}
log.Printf("[plugin] loaded SKILL: %s v%s", p.name, p.version)
return p, nil
}
func (p *SKILLPlugin) Name() string { return p.name }
func (p *SKILLPlugin) PluginType() PluginType { return PluginTypeSKILL }
func (p *SKILLPlugin) Description() string { return p.description }
func (p *SKILLPlugin) Version() string { return p.version }
func (p *SKILLPlugin) Enabled() bool { p.mu.RLock(); defer p.mu.RUnlock(); return p.enabled }
func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock(); p.enabled = v }
func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs }
func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig }
func (p *SKILLPlugin) RawContent() string { return p.rawContent }
func extractDescription(content string) string {
for _, line := range splitLines(content) {
line = trimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
return line
}
}
return ""
}
func extractField(content string, field string) string {
prefix := field + ":"
lowerPrefix := strings.ToLower(prefix)
for _, line := range splitLines(content) {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(trimmed), lowerPrefix) {
for i := 0; i < len(trimmed); i++ {
if trimmed[i] == ':' {
return strings.TrimSpace(trimmed[i+1:])
}
}
}
}
return ""
}
func extractToolDefs(content string) []ToolDef {
lines := splitLines(content)
var defs []ToolDef
var currentTool *ToolDef
inCodeBlock := false
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inCodeBlock = !inCodeBlock
continue
}
if inCodeBlock { continue }
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
if currentTool != nil && currentTool.Name != "" {
defs = append(defs, *currentTool)
}
currentTool = &ToolDef{}
namePart := strings.TrimPrefix(trimmed, "## ")
if isNonToolSection(namePart) {
currentTool = nil
continue
}
currentTool.Name = namePart
currentTool.Parameters = map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
continue
}
if strings.HasPrefix(trimmed, "### Tool: ") {
if currentTool != nil && currentTool.Name != "" {
defs = append(defs, *currentTool)
}
currentTool = &ToolDef{}
namePart := strings.TrimPrefix(trimmed, "### Tool: ")
currentTool.Name = namePart
currentTool.Parameters = map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
continue
}
if currentTool == nil || currentTool.Name == "" { continue }
if currentTool.Description == "" && trimmed != "" &&
!strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, "#") {
currentTool.Description = trimmed
continue
}
if strings.HasPrefix(trimmed, "- ") {
paramLine := strings.TrimPrefix(trimmed, "- ")
colonIdx := strings.Index(paramLine, ":")
if colonIdx > 0 {
paramName := strings.TrimSpace(paramLine[:colonIdx])
paramDesc := strings.TrimSpace(paramLine[colonIdx+1:])
if paramName != "" {
props := currentTool.Parameters["properties"].(map[string]interface{})
props[paramName] = map[string]interface{}{
"type": "string",
"description": paramDesc,
}
}
}
}
}
if currentTool != nil && currentTool.Name != "" {
defs = append(defs, *currentTool)
}
return defs
}
func isNonToolSection(name string) bool {
lower := strings.ToLower(name)
skip := []string{
"tools", "usage", "examples", "installation", "setup",
"configuration", "overview", "description", "notes",
"parameters", "return", "returns", "options", "syntax",
}
for _, s := range skip {
if lower == s || strings.HasPrefix(lower, s+" ") || strings.HasPrefix(lower, s+":") {
return true
}
}
return false
}
func extractIOConfig(content string) *IOConfig {
ioType := extractField(content, "io_type")
if ioType == "" { return nil }
cfg := &IOConfig{
Type: ioType,
InputRoute: extractField(content, "io_input_route"),
}
if cfg.InputRoute == "" {
cfg.InputRoute = extractField(content, "io_route")
}
cfg.OutputRoute = extractField(content, "io_output_route")
if cfg.OutputRoute == "" {
cfg.OutputRoute = cfg.InputRoute
}
capsStr := extractField(content, "io_output_caps")
if capsStr != "" {
for _, c := range strings.Split(capsStr, ",") {
cfg.OutputCaps = append(cfg.OutputCaps, strings.TrimSpace(c))
}
}
return cfg
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == '\n' {
if i > start {
lines = append(lines, s[start:i])
}
start = i + 1
}
}
return lines
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
end--
}
return s[start:end]
}
// KeepTypes 通过引用确保编译器不丢弃类型
var _ = []interface{}{
PluginTypeSKILL,
}