You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
439 lines
13 KiB
439 lines
13 KiB
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"MultiClawController/internal/controlplane"
|
|
|
|
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// App is the deliberately narrow bridge between the Wails frontend and the control plane.
|
|
type App struct {
|
|
ctx context.Context
|
|
control *controlplane.ControlPlane
|
|
startErr error
|
|
}
|
|
|
|
func NewApp() *App { return &App{} }
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
a.control, a.startErr = controlplane.Open(controllerConfigPath())
|
|
if a.startErr == nil {
|
|
a.startErr = a.control.StartAgentServer()
|
|
}
|
|
}
|
|
|
|
func (a *App) shutdown(context.Context) {
|
|
if a.control != nil {
|
|
a.control.Close()
|
|
}
|
|
}
|
|
|
|
// Snapshot returns dashboard data. It is safe to expose to the local WebView.
|
|
func (a *App) Snapshot() (controlplane.Snapshot, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.Snapshot{}, a.startErr
|
|
}
|
|
return a.control.Snapshot()
|
|
}
|
|
|
|
// CreatePairingCode returns a short-lived secret. The frontend never persists it.
|
|
func (a *App) CreatePairingCode() (string, error) {
|
|
if a.startErr != nil {
|
|
return "", a.startErr
|
|
}
|
|
return a.control.CreatePairingCode()
|
|
}
|
|
|
|
func (a *App) OnlineAgents() ([]controlplane.Agent, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.OnlineAgents()
|
|
}
|
|
|
|
func (a *App) Agents() ([]controlplane.Agent, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.Agents()
|
|
}
|
|
|
|
// AgentDirectory is the paged, server-filtered Agent list for the management page.
|
|
func (a *App) AgentDirectory(filter controlplane.AgentDirectoryFilter) (controlplane.AgentDirectoryPage, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.AgentDirectoryPage{}, a.startErr
|
|
}
|
|
return a.control.AgentDirectory(filter)
|
|
}
|
|
|
|
// AgentOperationalDetails returns bounded recent operational context for one Agent.
|
|
func (a *App) AgentOperationalDetails(agentID string) (controlplane.AgentOperationalDetail, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.AgentOperationalDetail{}, a.startErr
|
|
}
|
|
return a.control.AgentOperationalDetails(agentID)
|
|
}
|
|
|
|
func (a *App) RenameAgent(agentID, name string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.RenameAgent(agentID, name)
|
|
}
|
|
|
|
func (a *App) RevokeAgent(agentID string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.RevokeAgent(agentID)
|
|
}
|
|
|
|
// RestartAgent sends a paired, authenticated lifecycle command to one online Agent.
|
|
func (a *App) RestartAgent(agentID string) (controlplane.AgentRestart, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.AgentRestart{}, a.startErr
|
|
}
|
|
return a.control.RestartAgent(agentID)
|
|
}
|
|
|
|
func (a *App) CreateTask(agentID, instruction string, timeoutSeconds int) (string, error) {
|
|
if a.startErr != nil {
|
|
return "", a.startErr
|
|
}
|
|
return a.control.CreateTask(agentID, instruction, timeoutSeconds)
|
|
}
|
|
|
|
func (a *App) CreateTaskWithImages(agentID, instruction string, timeoutSeconds int, images []controlplane.TaskImageInput) (string, error) {
|
|
if a.startErr != nil {
|
|
return "", a.startErr
|
|
}
|
|
return a.control.CreateTaskWithImages(agentID, instruction, timeoutSeconds, images)
|
|
}
|
|
|
|
// CreateTasks creates independently-addressed tasks from the multi-Agent task board.
|
|
func (a *App) CreateTasks(requests []controlplane.TaskRequest) ([]controlplane.TaskDispatchResult, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.CreateTasks(requests)
|
|
}
|
|
|
|
// DispatchMentions expands newline-separated public @AgentName assignments into
|
|
// independently addressed tasks.
|
|
func (a *App) DispatchMentions(message string, timeoutSeconds int) (controlplane.MentionDispatchResponse, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.MentionDispatchResponse{}, a.startErr
|
|
}
|
|
return a.control.DispatchMentions(message, timeoutSeconds)
|
|
}
|
|
|
|
func (a *App) DispatchMentionsWithImages(message string, timeoutSeconds int, images []controlplane.TaskImageInput) (controlplane.MentionDispatchResponse, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.MentionDispatchResponse{}, a.startErr
|
|
}
|
|
return a.control.DispatchMentionsWithImages(message, timeoutSeconds, images)
|
|
}
|
|
|
|
// ContinueTaskSession dispatches a follow-up turn of the conversation that one
|
|
// completed task belongs to, carrying the earlier turns as context.
|
|
func (a *App) ContinueTaskSession(taskID, instruction string, timeoutSeconds int, images []controlplane.TaskImageInput) (controlplane.CreateTaskSessionResult, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.CreateTaskSessionResult{}, a.startErr
|
|
}
|
|
return a.control.ContinueTaskSession(taskID, instruction, timeoutSeconds, images)
|
|
}
|
|
|
|
// TaskSessionTurns lists the stored turns of one conversation, oldest first.
|
|
func (a *App) TaskSessionTurns(sessionID string) ([]controlplane.TaskSessionTurn, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.TaskSessionTurns(sessionID)
|
|
}
|
|
|
|
func (a *App) CancelTask(taskID string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.CancelTask(taskID)
|
|
}
|
|
|
|
func (a *App) RetryTask(taskID string) (string, error) {
|
|
if a.startErr != nil {
|
|
return "", a.startErr
|
|
}
|
|
return a.control.RetryTask(taskID)
|
|
}
|
|
|
|
func (a *App) RecentTasks() ([]controlplane.Task, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.RecentTasks()
|
|
}
|
|
|
|
func (a *App) ClearTaskHistory() (int, error) {
|
|
if a.startErr != nil {
|
|
return 0, a.startErr
|
|
}
|
|
return a.control.ClearTaskHistory()
|
|
}
|
|
|
|
func (a *App) TaskDetails(taskID string) (controlplane.TaskDetail, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.TaskDetail{}, a.startErr
|
|
}
|
|
return a.control.TaskDetails(taskID)
|
|
}
|
|
|
|
func (a *App) LLMProfiles() ([]controlplane.LLMProfile, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.LLMProfiles()
|
|
}
|
|
|
|
func (a *App) SaveLLMProfile(input controlplane.LLMProfileInput) (controlplane.LLMProfile, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.LLMProfile{}, a.startErr
|
|
}
|
|
return a.control.SaveLLMProfile(input)
|
|
}
|
|
|
|
func (a *App) DeployLLMProfile(profileID string, agentIDs []string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.DeployLLMProfile(profileID, agentIDs)
|
|
}
|
|
|
|
func (a *App) AgentLLMStatuses() ([]controlplane.AgentLLMStatus, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.AgentLLMStatuses()
|
|
}
|
|
|
|
func (a *App) Skills() ([]controlplane.Skill, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.Skills()
|
|
}
|
|
|
|
func (a *App) SkillDocument(skillID string) (controlplane.SkillDocument, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.SkillDocument{}, a.startErr
|
|
}
|
|
return a.control.SkillDocument(skillID)
|
|
}
|
|
|
|
func (a *App) SaveSkill(input controlplane.SkillInput) (controlplane.Skill, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.Skill{}, a.startErr
|
|
}
|
|
return a.control.SaveSkill(input)
|
|
}
|
|
|
|
func (a *App) DeploySkill(skillID string, agentIDs []string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.DeploySkill(skillID, agentIDs)
|
|
}
|
|
|
|
func (a *App) SetAgentSkills(agentID string, skillIDs []string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.SetAgentSkills(agentID, skillIDs)
|
|
}
|
|
|
|
func (a *App) AgentSkillStatuses() ([]controlplane.AgentSkillStatus, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.AgentSkillStatuses()
|
|
}
|
|
|
|
// SkillImportResult is a safe summary of one legacy Skill directory import.
|
|
type SkillImportResult struct {
|
|
Imported int `json:"imported"`
|
|
Notes []string `json:"notes"`
|
|
}
|
|
|
|
// ImportSkillsFromDirectory imports legacy skill folders that contain a SKILL.md
|
|
// file. Only the Markdown guide is imported: an entry-point script is never read
|
|
// into the controller, deployed or executed, matching the Markdown-only policy.
|
|
func (a *App) ImportSkillsFromDirectory() (SkillImportResult, error) {
|
|
result := SkillImportResult{Notes: make([]string, 0)}
|
|
if a.startErr != nil {
|
|
return result, a.startErr
|
|
}
|
|
directory, err := wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{Title: "选择包含 SKILL.md 的旧技能目录"})
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if strings.TrimSpace(directory) == "" {
|
|
return result, nil // The user cancelled the dialog.
|
|
}
|
|
sources, notes := collectSkillSources(directory)
|
|
result.Notes = append(result.Notes, notes...)
|
|
for _, source := range sources {
|
|
if _, err := a.control.SaveSkill(controlplane.SkillInput{Name: source.name, Version: source.version, Content: source.content}); err != nil {
|
|
result.Notes = append(result.Notes, source.name+":导入失败("+err.Error()+")")
|
|
continue
|
|
}
|
|
result.Imported++
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type skillSource struct{ name, version, content string }
|
|
|
|
// collectSkillSources accepts either one skill folder or a folder of skill
|
|
// folders, so both `skills/` and `skills/图生图工具/` can be selected.
|
|
func collectSkillSources(directory string) ([]skillSource, []string) {
|
|
sources, notes := make([]skillSource, 0), make([]string, 0)
|
|
if source, note := readSkillSource(directory); source != nil {
|
|
return append(sources, *source), notes
|
|
} else if note != "" {
|
|
return sources, append(notes, note)
|
|
}
|
|
entries, err := os.ReadDir(directory)
|
|
if err != nil {
|
|
return sources, append(notes, "无法读取所选目录。")
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
if len(sources) >= 100 {
|
|
notes = append(notes, "一次最多导入 100 个 Skill,其余已跳过。")
|
|
break
|
|
}
|
|
if source, note := readSkillSource(filepath.Join(directory, entry.Name())); source != nil {
|
|
sources = append(sources, *source)
|
|
} else if note != "" {
|
|
notes = append(notes, note)
|
|
}
|
|
}
|
|
return sources, notes
|
|
}
|
|
|
|
func readSkillSource(skillDirectory string) (*skillSource, string) {
|
|
markdownPath := filepath.Join(skillDirectory, "SKILL.md")
|
|
info, err := os.Stat(markdownPath)
|
|
if err != nil || info.IsDir() {
|
|
return nil, ""
|
|
}
|
|
if info.Size() > 1<<20 {
|
|
return nil, filepath.Base(skillDirectory) + ":SKILL.md 超过 1 MB,已跳过。"
|
|
}
|
|
raw, err := os.ReadFile(markdownPath)
|
|
if err != nil {
|
|
return nil, filepath.Base(skillDirectory) + ":无法读取 SKILL.md。"
|
|
}
|
|
content := strings.TrimSpace(string(raw))
|
|
if content == "" {
|
|
return nil, filepath.Base(skillDirectory) + ":SKILL.md 内容为空,已跳过。"
|
|
}
|
|
name, version, entryPoint := filepath.Base(skillDirectory), "1.0.0", ""
|
|
if metadata, err := os.ReadFile(filepath.Join(skillDirectory, "skill.json")); err == nil {
|
|
var parsed struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
EntryPoint *string `json:"entry_point"`
|
|
}
|
|
if json.Unmarshal(metadata, &parsed) == nil {
|
|
if strings.TrimSpace(parsed.Name) != "" {
|
|
name = strings.TrimSpace(parsed.Name)
|
|
}
|
|
if strings.TrimSpace(parsed.Version) != "" {
|
|
version = strings.TrimSpace(parsed.Version)
|
|
}
|
|
if parsed.EntryPoint != nil {
|
|
entryPoint = strings.TrimSpace(*parsed.EntryPoint)
|
|
}
|
|
}
|
|
}
|
|
note := ""
|
|
if entryPoint != "" {
|
|
note = name + ":旧包包含可执行入口 " + entryPoint + ",已按只读 Markdown 指南导入;入口脚本不会被下发或执行。"
|
|
}
|
|
return &skillSource{name: name, version: version, content: content}, note
|
|
}
|
|
|
|
func (a *App) MCPTools() ([]controlplane.MCPTool, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.MCPTools()
|
|
}
|
|
|
|
func (a *App) SaveMCPTool(input controlplane.MCPToolInput) (controlplane.MCPTool, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.MCPTool{}, a.startErr
|
|
}
|
|
return a.control.SaveMCPTool(input)
|
|
}
|
|
|
|
func (a *App) DeleteMCPTool(toolID string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.DeleteMCPTool(toolID)
|
|
}
|
|
|
|
// SetAgentMCPTools replaces one Agent's tool selection with the given ids.
|
|
func (a *App) SetAgentMCPTools(agentID string, toolIDs []string) error {
|
|
if a.startErr != nil {
|
|
return a.startErr
|
|
}
|
|
return a.control.SetAgentMCPTools(agentID, toolIDs)
|
|
}
|
|
|
|
func (a *App) AgentMCPStatuses() ([]controlplane.AgentMCPStatus, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.AgentMCPStatuses()
|
|
}
|
|
|
|
func (a *App) RecentAuditEvents() ([]controlplane.AuditEvent, error) {
|
|
if a.startErr != nil {
|
|
return nil, a.startErr
|
|
}
|
|
return a.control.RecentAuditEvents()
|
|
}
|
|
|
|
func (a *App) AuditTrail(filter controlplane.AuditFilter) (controlplane.AuditPage, error) {
|
|
if a.startErr != nil {
|
|
return controlplane.AuditPage{}, a.startErr
|
|
}
|
|
return a.control.AuditTrail(filter)
|
|
}
|
|
|
|
func controllerConfigPath() string {
|
|
if configured := os.Getenv("MULTICLAW_CONTROLLER_CONFIG_PATH"); configured != "" {
|
|
return configured
|
|
}
|
|
if executable, err := os.Executable(); err == nil {
|
|
candidate := filepath.Join(filepath.Dir(executable), "config", "controller-config.json")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
workingDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
return "config/controller-config.json"
|
|
}
|
|
return filepath.Join(workingDirectory, "config", "controller-config.json")
|
|
}
|
|
|