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 下语法报错。
This commit is contained in:
+158
-7
@@ -2,10 +2,14 @@ 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.
|
||||
@@ -129,6 +133,23 @@ func (a *App) DispatchMentionsWithImages(message string, timeoutSeconds int, ima
|
||||
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
|
||||
@@ -234,18 +255,148 @@ func (a *App) AgentSkillStatuses() ([]controlplane.AgentSkillStatus, error) {
|
||||
return a.control.AgentSkillStatuses()
|
||||
}
|
||||
|
||||
func (a *App) EnableDesktopFilesystem(agentIDs []string) error {
|
||||
if a.startErr != nil {
|
||||
return a.startErr
|
||||
}
|
||||
return a.control.EnableDesktopFilesystem(agentIDs)
|
||||
// SkillImportResult is a safe summary of one legacy Skill directory import.
|
||||
type SkillImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
func (a *App) AgentDeploymentStatuses() ([]controlplane.AgentDeploymentStatus, error) {
|
||||
// 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.AgentDeploymentStatuses()
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user