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") }