mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
- 所有内置插件 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 文档
296 lines
7.5 KiB
Go
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,
|
|
}
|