feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Beta Release / beta (push) Successful in 5m9s
All checks were successful
Beta Release / beta (push) Successful in 5m9s
Major additions: - RAG pipeline (indexing, chunking, search) with sidebar upload button - Memory system with CRUD API - Plugins and lessons modules - MCP discovery and MCP server - Advanced skills (auto-create, conditional, improver) - Agent browser/image support, delegate, sessions - File editor with CodeMirror in split panes - Markdown rendering via react-markdown + KaTeX + highlight.js - Raw markdown toggle - PWA manifest + service worker - Extension UI redesign with new design tokens and studio-style chat - Pipeline API for chat streaming - Mobile responsive layout 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
334
internal/plugins/loader.go
Normal file
334
internal/plugins/loader.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
)
|
||||
|
||||
func DiscoverPlugins(paths []string) []*DiscoveredPlugin {
|
||||
var plugins []*DiscoveredPlugin
|
||||
|
||||
for _, p := range paths {
|
||||
expanded := expandPath(p)
|
||||
info, err := os.Stat(expanded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
entries, err := os.ReadDir(expanded)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pluginDir := filepath.Join(expanded, entry.Name())
|
||||
if dp := scanPluginDir(pluginDir); dp != nil {
|
||||
plugins = append(plugins, dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
type DiscoveredPlugin struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func scanPluginDir(dir string) *DiscoveredPlugin {
|
||||
name := filepath.Base(dir)
|
||||
dp := &DiscoveredPlugin{
|
||||
Name: name,
|
||||
Path: dir,
|
||||
}
|
||||
|
||||
initPy := filepath.Join(dir, "__init__.py")
|
||||
mainGo := filepath.Join(dir, "main.go")
|
||||
manifest := filepath.Join(dir, "plugin.json")
|
||||
|
||||
if _, err := os.Stat(manifest); err == nil {
|
||||
dp.Type = "manifest"
|
||||
dp.Valid = true
|
||||
return dp
|
||||
}
|
||||
|
||||
if _, err := os.Stat(mainGo); err == nil {
|
||||
dp.Type = "go"
|
||||
dp.Valid = true
|
||||
return dp
|
||||
}
|
||||
|
||||
if _, err := os.Stat(initPy); err == nil {
|
||||
dp.Type = "python"
|
||||
dp.Valid = true
|
||||
return dp
|
||||
}
|
||||
|
||||
executables := []string{name, name + ".sh"}
|
||||
for _, exe := range executables {
|
||||
fullPath := filepath.Join(dir, exe)
|
||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||
dp.Type = "executable"
|
||||
dp.Valid = true
|
||||
dp.Path = fullPath
|
||||
return dp
|
||||
}
|
||||
}
|
||||
|
||||
return dp
|
||||
}
|
||||
|
||||
type PluginManifest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Tools []ManifestTool `json:"tools,omitempty"`
|
||||
Hooks []ManifestHook `json:"hooks,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Params json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
type ManifestHook struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func LoadManifest(path string) (*PluginManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
|
||||
var manifest PluginManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func LoadExecutablePlugin(discovered *DiscoveredPlugin) (*Plugin, error) {
|
||||
if !discovered.Valid {
|
||||
return nil, fmt.Errorf("invalid plugin: %s", discovered.Name)
|
||||
}
|
||||
|
||||
switch discovered.Type {
|
||||
case "manifest":
|
||||
return loadManifestPlugin(discovered)
|
||||
case "executable":
|
||||
return loadExecutableAsPlugin(discovered)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported plugin type: %s", discovered.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func loadManifestPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
|
||||
manifestPath := filepath.Join(dp.Path, "plugin.json")
|
||||
manifest, err := LoadManifest(manifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := NewPlugin(manifest.Name, manifest.Version, manifest.Description)
|
||||
|
||||
for _, mt := range manifest.Tools {
|
||||
handler := createExternalHandler(dp.Path, manifest)
|
||||
td := &ToolDefinition{
|
||||
Name: mt.Name,
|
||||
Description: mt.Description,
|
||||
Params: mt.Params,
|
||||
Handler: handler,
|
||||
}
|
||||
p.AddTool(td)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func loadExecutableAsPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
|
||||
p := NewPlugin(dp.Name, "0.0.1", "Executable plugin: "+dp.Name)
|
||||
|
||||
paramsSchema, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]string{"type": "string", "description": "Action to execute"},
|
||||
"args": map[string]string{"type": "object", "description": "Arguments for the action"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
})
|
||||
|
||||
td := &ToolDefinition{
|
||||
Name: dp.Name,
|
||||
Description: "External plugin tool: " + dp.Name,
|
||||
Params: paramsSchema,
|
||||
Handler: createScriptHandler(dp.Path),
|
||||
}
|
||||
p.AddTool(td)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func createExternalHandler(pluginDir string, manifest *PluginManifest) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||
if manifest.Command == "" {
|
||||
return agent.TextErrorResponse(fmt.Sprintf("no command configured for plugin %s", manifest.Name)), nil
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, manifest.Command, manifest.Args...)
|
||||
cmd.Dir = pluginDir
|
||||
cmd.Stdin = strings.NewReader(string(raw))
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return agent.TextErrorResponse(fmt.Sprintf("plugin execution failed: %v\n%s", err, string(output))), nil
|
||||
}
|
||||
|
||||
return agent.TextResponse(string(output)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func createScriptHandler(scriptPath string) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
|
||||
cmd := exec.CommandContext(ctx, scriptPath)
|
||||
cmd.Stdin = strings.NewReader(string(raw))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return agent.TextErrorResponse(fmt.Sprintf("script failed: %v\n%s", err, string(output))), nil
|
||||
}
|
||||
return agent.TextResponse(string(output)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPluginPaths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
configDir, err := configDir()
|
||||
if err != nil {
|
||||
return []string{filepath.Join(home, ".muyue", "plugins")}
|
||||
}
|
||||
|
||||
return []string{
|
||||
filepath.Join(configDir, "plugins"),
|
||||
filepath.Join(home, ".muyue", "plugins"),
|
||||
}
|
||||
}
|
||||
|
||||
func expandPath(p string) string {
|
||||
if strings.HasPrefix(p, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, p[2:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func configDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(configDir, "muyue"), nil
|
||||
}
|
||||
|
||||
func generatePluginSchema(v interface{}) (json.RawMessage, error) {
|
||||
t := reflect.TypeOf(v)
|
||||
if t == nil {
|
||||
return json.RawMessage(`{"type":"object","properties":{}}`), nil
|
||||
}
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return json.RawMessage(`{"type":"object","properties":{}}`), nil
|
||||
}
|
||||
|
||||
props := make(map[string]interface{})
|
||||
required := []string{}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
jsonName := field.Name
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
if parts[0] != "" {
|
||||
jsonName = parts[0]
|
||||
}
|
||||
omitempty := false
|
||||
for _, part := range parts[1:] {
|
||||
if part == "omitempty" {
|
||||
omitempty = true
|
||||
}
|
||||
}
|
||||
desc := field.Tag.Get("description")
|
||||
prop := map[string]interface{}{"type": goTypeToJSON(field.Type)}
|
||||
if desc != "" {
|
||||
prop["description"] = desc
|
||||
}
|
||||
props[jsonName] = prop
|
||||
if !omitempty {
|
||||
required = append(required, jsonName)
|
||||
}
|
||||
}
|
||||
|
||||
schema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
return json.Marshal(schema)
|
||||
}
|
||||
|
||||
func goTypeToJSON(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return "string"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return "integer"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "number"
|
||||
case reflect.Bool:
|
||||
return "boolean"
|
||||
case reflect.Slice:
|
||||
if t.Elem().Kind() == reflect.Uint8 {
|
||||
return "string"
|
||||
}
|
||||
return "array"
|
||||
case reflect.Map:
|
||||
return "object"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user