Files
mulit-agent/controller/app.go
T
toot aa57ea69de feat(controller): 工具仓库、Skills 录入、多轮追问与终端运行日志
- 插件/MCP 改为通用工具仓库:内置、HTTP MCP、本机命令(stdio)三类工具,可增删改、
  按 Agent 分配;定义变更自动提升下发版本并重发,删除即回收分配并通知 Agent 清空。
- 旧 desktop-filesystem 降级为仓库中的一个内置条目;本机预设工具改为总控只展示、
  Agent 默认启用,任务执行来源新增 builtin(审计 task.builtin_tool_execution)。
- Skills 补齐录入闭环:新建或按名称覆盖更新,并可从旧版技能目录(SKILL.md + skill.json)
  批量导入;同名更新保持标识不变并自动重新应用,旧包入口脚本只提示不执行。
- 单独指派支持基于某条已完成任务的多轮追问:任务内容框仍下发无上下文的一次性任务,
  追问时由总控组装历史(仅成功轮次、默认最近 50 轮 / 约 6 万字符),会话按 Agent 隔离;
  执行动态把同一会话合并为一条记录,任务详情弹窗底部提供继续对话输入框。
- 任务详情支持运行中观测:打开期间每 2 秒刷新,终端风格输出全部事件并自动滚动;
  progress 事件不改变任务状态且顺延无进展超时,避免正在执行工具的任务被误判超时。
- 修复 build.ps1 缺 UTF-8 BOM 导致 build.bat 在 PowerShell 5.1 下语法报错。
2026-09-16 15:30:50 +08:00

432 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
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")
}