diff --git a/controller/app.go b/controller/app.go index 0ad5f76..d5da5b1 100644 --- a/controller/app.go +++ b/controller/app.go @@ -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) { diff --git a/controller/build.ps1 b/controller/build.ps1 index 0cb9209..a06b656 100644 --- a/controller/build.ps1 +++ b/controller/build.ps1 @@ -1,4 +1,4 @@ -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" $projectRoot = $PSScriptRoot $wailsCommand = Join-Path (go env GOPATH) "bin\wails.exe" diff --git a/controller/docs/STATUS.md b/controller/docs/STATUS.md index 19ed5eb..2814944 100644 --- a/controller/docs/STATUS.md +++ b/controller/docs/STATUS.md @@ -4,6 +4,11 @@ ## 已完成并验证 +- 插件与 MCP 已改为通用工具仓库:总控可新建/更新/删除工具,类型为 `builtin` 内置、`http` MCP 服务地址、或 `stdio` 本机命令(命令 + 参数,由 Agent 直接拉起子进程,不占端口);按 Agent 勾选下发、定义变更自动提升下发版本并重发、删除即回收分配并通知 Agent 清空。工具下发状态按 Agent×工具逐条显示,全部工具按最差状态汇总到 Agent 列表。旧 `desktop-filesystem` 已作为内置工具之一保留,不再是唯一入口。 +- 本机预设工具已明确区分:子 Agent 自带文件、打开、时间、进程、截图、剪贴板等工具并默认启用,总控无需下发;智能体详情页显示“本机预设工具(默认启用,无需配置)”,任务执行来源新增 `builtin`(界面显示“本机预设工具”,审计为 `task.builtin_tool_execution`)。 +- 单独指派支持“基于某条已完成任务继续对话”:任务内容框下发的始终是不带上下文的独立任务;任务完成后可在下方「执行动态」里就它继续追问,总控会把这条任务的内容与结果(以及此后的轮次)作为上下文一并下发(默认保留最近 50 轮、总字符预算约 6 万;只有成功的轮次进入上下文,失败/取消不入)。同一会话的多轮在「执行动态」里**合并为一条记录**(标注轮数、可展开逐轮查看提问与结果),对话输入框就在这条记录内部;会话按 Agent 隔离,重试沿用原会话;批量 `@` 指派不共享上下文。 +- 任务详情已支持运行中实时观测:弹窗打开期间跟随总控 2 秒轮询刷新(关闭或切换任务时不会回填旧数据),并以终端风格输出全部事件(生命周期 + 工具调用与返回),自动滚动到底部;`progress` 不改变任务状态、不会让已结束的任务新增步骤,同时顺延“无进展超时”,避免正在执行工具的任务被误判超时。 +- Skills 已补齐录入闭环:“技能”页可新建或按名称覆盖更新 Skill,并可从旧版技能目录(`SKILL.md` + `skill.json`)批量导入;同名更新保持 Skill 标识不变,已分配的 Agent 会自动重新应用。旧包中的可执行入口只记录提示,不会下发或执行。 - 图片附件任务闭环:批量 `@` 与单独指派均可粘贴/选择受限图片;多条有效批量任务必须以“图片 N”逐行路由,同一行多个目标只共享该编号图片。附件经认证 WSS 下发、本地持久化供重试复用,任务详情仅显示安全缩略图和数量;清除结束任务同时清除附件。未配置视觉能力的模型会安全拒绝,不会丢图或降级为文本。 - 新版 Agent 默认完全访问:总控仍只通过已认证 WSS 下发任务,但 Agent 可在其所在 Windows 电脑实际操作文件、程序、网络和桌面应用。任务详情会把实际调用过本机执行工具的结果标为“完全访问”;结果、日志与审计摘要持续脱敏,不保存 API Key、密码、凭据、图片 Base64 或内部推理。 @@ -33,19 +38,19 @@ ## 最近编译 -- `build/bin/MultiClawController.exe` 已于 2026-09-15 重新编译(含独立的批量指派页面、单独指派与 5 分钟无进展续期)。 +- `build/bin/MultiClawController.exe` 已于 2026-09-16 重新编译(含通用工具仓库、Skills 录入与导入、本机预设工具展示、任务详情实时运行日志与 `builtin` 执行来源)。 ## 接下来按旧版能力迁移的顺序 1. 优先补齐离线任务队列、重连补传和子 Agent 重启恢复,增强多机任务闭环的可靠性。 2. 需要复用任务时再实现模板、定时任务、工作流和委派,均由总控持久化和触发。 -3. 将 MCP 扩展为带版本、权限清单、哈希、回滚和多工具状态的受控仓库;插件包保留独立入口。 +3. 将工具仓库扩展为带签名、权限清单与哈希的受控包仓库,并恢复可执行插件包(旧版 Python 插件经 host 适配器转成 stdio MCP)的独立入口;当前 `http`/`stdio` 工具只承载地址或命令行与版本。 4. 最后实现 Agent 守护服务,提供远程启动、停止、重启和升级。 ## 已规划,尚未实现 - 总控定时任务、委派规则与工作流执行器。 -- Skills、插件与 MCP 工具的签名、版本回滚和完整包生命周期。 +- 工具与插件包的签名、哈希校验、权限清单和完整包生命周期(版本回滚已具备:定义变更提升下发版本,删除即撤下)。 - 定时任务、模板、工作流、委派和远程运维的审计扩展。 - Agent 守护服务及其远程启动、停止、重启、升级能力。 diff --git a/controller/docs/steps/14-MCP受控桌面工具闭环.md b/controller/docs/steps/14-MCP受控桌面工具闭环.md index 995e8ca..5fac705 100644 --- a/controller/docs/steps/14-MCP受控桌面工具闭环.md +++ b/controller/docs/steps/14-MCP受控桌面工具闭环.md @@ -16,4 +16,4 @@ ## 下一步 -将单一内置 MCP 升级为带版本、权限清单、哈希、签名和回滚的受控工具仓库;插件运行时扩展仍保持独立入口。 +已完成:单一内置 MCP 已升级为通用工具仓库(内置工具 + HTTP MCP 服务、按 Agent 分配、定义变更自动提升下发版本、删除即回收),`desktop-filesystem` 现在是仓库中的一个内置条目。后续为工具与插件包补齐签名、哈希与权限清单,并恢复可执行插件包的独立入口。 diff --git a/controller/frontend/src/App.css b/controller/frontend/src/App.css index 56397a2..6b151f7 100644 --- a/controller/frontend/src/App.css +++ b/controller/frontend/src/App.css @@ -23,7 +23,17 @@ textarea, select, input { display: block; width: 100%; margin-top: 7px; border: .agent-capability { margin-top: 14px; padding: 14px; border: 1px solid #30425e; border-radius: 9px; background: #111b2a; }.agent-capability h3 { margin: 0 0 6px; font-size: 14px; }.agent-capability p { margin: 0 0 10px; color: #9dafc8; font-size: 12px; }.agent-capability button { margin-top: 12px; }.execution-source { color: #9ceed0 !important; font-size: 12px !important; margin: 9px 0 !important; } .agent-directory-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin: 16px 0 18px; }.agent-directory-toolbar label { width: min(190px, 100%); margin: 0; }.agent-directory-toolbar label:first-child { width: min(320px, 100%); }.agent-directory-toolbar span { padding-bottom: 11px; color: #9dafc8; font-size: 12px; white-space: nowrap; }.directory-error { color: #ffbeca !important; }.directory-pagination { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 18px; color: #9dafc8; font-size: 12px; }.agent-card-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 13px; }.agent-card { overflow: hidden; border: 1px solid #30425e; border-radius: 11px; background: linear-gradient(145deg, #172640, #121e31); transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; }.agent-card:hover { border-color: #638ac9; box-shadow: 0 12px 30px rgba(0,0,0,.2); transform: translateY(-2px); }.agent-card-main { display: block; width: 100%; min-height: 172px; padding: 16px; border-radius: 0; background: transparent; color: inherit; text-align: left; font: inherit; font-weight: inherit; }.agent-card-main:hover { background: rgba(111,157,245,.06); }.agent-card-main:focus-visible { outline: 2px solid #91b7ff; outline-offset: -3px; }.agent-card-heading { display: grid; grid-template-columns: auto 1fr auto; align-items: start; gap: 11px; }.agent-card-heading .status-dot { margin-top: 5px; }.agent-card-heading strong, .agent-card-heading small { display: block; }.agent-card-heading strong { color: #eef3fb; font-size: 16px; }.agent-card-heading small { margin-top: 6px; color: #9dafc8; font-size: 11px; }.agent-card-summary { display: grid; gap: 6px; margin-top: 16px; padding-top: 12px; border-top: 1px solid #30425e; color: #c9daff; font-size: 12px; }.agent-card-link { display: block; margin-top: 14px; color: #91b7ff; font-size: 12px; font-weight: 700; }.agent-detail-modal { width: min(900px, 100%); }.agent-detail-modal .managed-agent { margin-top: 18px; }.agent-operational-detail { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }.agent-operational-detail > section { min-width: 0; padding: 14px; border: 1px solid #30425e; border-radius: 9px; background: #111b2a; }.agent-operational-detail h3 { margin: 0 0 8px; font-size: 14px; }.agent-operational-detail p { margin: 0; color: #9dafc8; font-size: 12px; }.agent-detail-list { display: grid; gap: 7px; }.agent-detail-list article { display: grid; gap: 4px; padding: 9px; border: 1px solid #30425e; border-radius: 7px; background: #0e1726; }.agent-detail-list strong { color: #dce8ff; font-size: 12px; overflow-wrap: anywhere; }.agent-detail-list span, .agent-detail-list small { color: #9dafc8; font-size: 11px; overflow-wrap: anywhere; }.failure-list small { color: #ffbeca; } .task-modal-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(4, 10, 20, .74); }.task-modal { width: min(760px, 100%); max-height: min(820px, calc(100vh - 48px)); overflow: auto; padding: 22px; border: 1px solid #45618d; border-radius: 14px; background: #142035; box-shadow: 0 24px 75px rgba(0,0,0,.46); }.task-modal-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.task-detail-meta { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0; }.task-detail-meta > span:not(.badge) { padding: 5px 8px; border-radius: 6px; background: #111b2a; color: #aebbd0; font-size: 12px; overflow-wrap: anywhere; }.task-detail-section { margin-top: 20px; }.task-detail-section h3 { margin: 0 0 8px; color: #dce8ff; font-size: 14px; }.task-detail-section pre { max-height: 260px; overflow: auto; margin: 0; padding: 12px; border: 1px solid #30425e; border-radius: 8px; background: #0e1726; color: #cbd8ec; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.55 ui-monospace, Consolas, monospace; }.task-timeline { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }.task-timeline li { display: grid; grid-template-columns: 150px 92px 1fr; gap: 10px; padding: 11px; border: 1px solid #30425e; border-radius: 8px; background: #111b2a; color: #b9c8de; font-size: 12px; }.task-timeline time { color: #8292aa; }.task-timeline strong { color: #9ceed0; }.task-timeline span { overflow-wrap: anywhere; } -.mention-panel { margin-bottom: 20px; }.mention-picker, .mention-selected { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 14px 0; }.mention-picker button { padding: 6px 9px; font-size: 12px; }.mention-selected { color: #9dafc8; font-size: 12px; }.mention-selected strong { padding: 4px 8px; border-radius: 99px; background: #1d3454; color: #bcd5ff; }.mention-compose, .direct-assignment-fields { display: grid; grid-template-columns: 1fr 180px; gap: 14px; align-items: end; }.mention-compose textarea { min-height: 130px; }.mention-results { display: grid; gap: 5px; margin: 13px 0 0; font-size: 12px; color: #aebbd0; }.mention-results p { margin: 0; }.direct-assignment { max-width: 820px; }.direct-assignment > label { display: block; margin-top: 15px; }.direct-assignment textarea { min-height: 160px; } +.mention-panel { margin-bottom: 20px; }.mention-picker, .mention-selected { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 14px 0; }.mention-picker button { padding: 6px 9px; font-size: 12px; }.mention-selected { color: #9dafc8; font-size: 12px; }.mention-selected strong { padding: 4px 8px; border-radius: 99px; background: #1d3454; color: #bcd5ff; }.mention-compose, .direct-assignment-fields { display: grid; grid-template-columns: 1fr 180px; gap: 14px; align-items: end; }.mention-compose textarea { min-height: 130px; }.mention-results { display: grid; gap: 5px; margin: 13px 0 0; font-size: 12px; color: #aebbd0; }.mention-results p { margin: 0; }.direct-assignment > label { display: block; margin-top: 15px; }.direct-assignment textarea { min-height: 160px; } .mention-editor { position: relative; min-width: 0; }.mention-editor textarea { width: 100%; }.mention-suggestions { position: absolute; z-index: 4; left: 0; top: calc(100% + 5px); width: min(360px, 100%); overflow: hidden; border: 1px solid #45618d; border-radius: 9px; background: #162339; box-shadow: 0 12px 28px #050a14aa; }.mention-suggestions button { width: 100%; display: flex; align-items: center; gap: 7px; padding: 9px 11px; border: 0; border-radius: 0; color: #e3ecfa; background: transparent; text-align: left; }.mention-suggestions button:hover, .mention-suggestions button.active { background: #284d7d; }.mention-suggestions small { margin-left: auto; color: #9dafc8; }.image-attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; margin-top: 12px; color: #9dafc8; font-size: 12px; }.image-attachments figure { position: relative; display: grid; gap: 3px; width: 76px; margin: 0; color: #cbd8ec; text-align: center; }.image-attachments img { width: 76px; height: 58px; object-fit: cover; border: 1px solid #45618d; border-radius: 6px; }.image-attachments figure button { position: absolute; top: -6px; right: -6px; width: 19px; height: 19px; padding: 0; border-radius: 50%; background: #692d3d; } .assignment-activity { margin-top: 20px; }.activity-row { display: grid; grid-template-columns: auto 100px minmax(180px, 1fr) minmax(250px, auto); gap: 10px; align-items: center; width: 100%; text-align: left; }.activity-row strong { color: #dce8ff; }.activity-row span:last-of-type { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.activity-row small { color: #aebbd0; white-space: nowrap; } @media (max-width: 780px) { .shell { padding: 18px; }.summary-grid, .workspace, .llm-grid, .deployment, .skill-browser, .agent-operational-detail, .mention-compose, .direct-assignment-fields { grid-template-columns: 1fr; }.topbar { align-items: flex-start; flex-direction: column; }.deployment > button { justify-self: stretch; }.deployment-status-row { grid-template-columns: 1fr; gap: 5px; } } +.form-hint { margin: 0 0 10px; color: #9dafc8; font-size: 12px; line-height: 1.5; }.task-terminal { max-height: 300px; overflow-y: auto; padding: 12px 14px; border: 1px solid #26364f; border-radius: 10px; background: #080e18; font-family: ui-monospace, Consolas, "Courier New", monospace; font-size: 12px; line-height: 1.75; } +.task-terminal-line { display: grid; grid-template-columns: 74px 56px 1fr; gap: 9px; margin: 0; color: #c9d6e8; } +.task-terminal-line time { color: #5c718a; } +.task-terminal-kind { color: #7b90aa; } +.task-terminal-text { white-space: pre-wrap; overflow-wrap: anywhere; } +.task-terminal-line.muted { display: block; color: #7b90aa; } +.task-terminal-line.running .task-terminal-kind, .task-terminal-line.accepted .task-terminal-kind, .task-terminal-line.progress .task-terminal-kind { color: #81aaff; } +.task-terminal-line.completed .task-terminal-kind { color: #64e2a4; } +.task-terminal-line.failed .task-terminal-kind, .task-terminal-line.timed_out .task-terminal-kind { color: #ff8b8b; } +.task-terminal-line.cancelled .task-terminal-kind, .task-terminal-line.cancelling .task-terminal-kind { color: #e8c46a; }.session-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border: 1px solid #30425e; border-radius: 9px; background: #111b2a; }.session-bar span { color: #9dafc8; font-size: 12px; }.session-bar button { margin-left: auto; }.task-continue-hint { margin: 0 0 8px !important; font-size: 12px !important; color: #9dafc8 !important; }.continue-compose p { margin: 0 0 8px; font-size: 12px; color: #9dafc8; }.continue-compose textarea { min-height: 84px; }.continue-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }.continue-actions span { color: #7f93ad; font-size: 12px; }.turn-tag { color: #81aaff !important; font-size: 11px !important; border: 1px solid #33507a; border-radius: 6px; padding: 1px 6px; }.conversation-turns { display: grid; gap: 8px; margin: 0 0 10px; padding: 0; list-style: none; }.conversation-turns li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid #26364f; border-radius: 8px; background: #0b1220; }.turn-index { color: #7f93ad; font-size: 11px; }.turn-ask { margin: 0 !important; color: #dbe6f5 !important; font-size: 13px !important; }.turn-answer { margin: 0 !important; font-size: 12px !important; color: #9dafc8 !important; white-space: pre-wrap; }.turn-answer.completed { color: #86e0b4 !important; } \ No newline at end of file diff --git a/controller/frontend/src/App.tsx b/controller/frontend/src/App.tsx index c2f2d98..7d507a3 100644 --- a/controller/frontend/src/App.tsx +++ b/controller/frontend/src/App.tsx @@ -1,7 +1,7 @@ import {FormEvent, useEffect, useRef, useState} from "react"; import "./App.css"; -import {AgentDeploymentStatuses, AgentDirectory, AgentLLMStatuses, AgentOperationalDetails, AgentSkillStatuses, Agents, AuditTrail, CancelTask, ClearTaskHistory, CreatePairingCode, CreateTaskWithImages, DeployLLMProfile, DispatchMentionsWithImages, EnableDesktopFilesystem, LLMProfiles, OnlineAgents, RecentTasks, RenameAgent, RetryTask, RevokeAgent, SaveLLMProfile, SetAgentSkills, SkillDocument, Skills, Snapshot, TaskDetails} from "../wailsjs/go/main/App"; -import {controlplane} from "../wailsjs/go/models"; +import {AgentDirectory, AgentLLMStatuses, AgentMCPStatuses, AgentOperationalDetails, AgentSkillStatuses, Agents, AuditTrail, CancelTask, ClearTaskHistory, ContinueTaskSession, CreatePairingCode, CreateTaskWithImages, DeleteMCPTool, DeployLLMProfile, DeploySkill, DispatchMentionsWithImages, ImportSkillsFromDirectory, LLMProfiles, MCPTools, OnlineAgents, RecentTasks, RenameAgent, RetryTask, RevokeAgent, SaveLLMProfile, SaveMCPTool, SaveSkill, SetAgentMCPTools, SetAgentSkills, SkillDocument, Skills, Snapshot, TaskDetails, TaskSessionTurns} from "../wailsjs/go/main/App"; +import {controlplane, main} from "../wailsjs/go/models"; type Page = "overview" | "agents" | "batch" | "direct" | "skills" | "plugins" | "keys" | "audit" | "settings"; type PastedImage = {id: string; name: string; dataURL: string}; @@ -18,7 +18,7 @@ const navigation: Array<{page: Page; label: string}> = [ {page: "settings", label: "设置"}, ]; -const statusLabel: Record = {queued: "等待下发", accepted: "已接收", running: "执行中", cancelling: "正在取消", completed: "已完成", failed: "失败", cancelled: "已取消", timed_out: "已超时"}; +const statusLabel: Record = {queued: "等待下发", accepted: "已接收", running: "执行中", cancelling: "正在取消", completed: "已完成", failed: "失败", cancelled: "已取消", timed_out: "已超时", progress: "进展"}; const mcpStatusLabel: Record = {pending: "等待连接", sent: "下发中", applied: "已应用", failed: "应用失败", "未启用": "未启用"}; function App() { @@ -33,18 +33,29 @@ function App() { const [selectedSkillDocument, setSelectedSkillDocument] = useState(); const [skillDocumentLoading, setSkillDocumentLoading] = useState(false); const [agentSkillStatuses, setAgentSkillStatuses] = useState([]); - const [agentDeploymentStatuses, setAgentDeploymentStatuses] = useState([]); + const [mcpTools, setMCPTools] = useState([]); + const [agentMCPStatuses, setAgentMCPStatuses] = useState([]); + const [skillImportNotes, setSkillImportNotes] = useState([]); const [pairingCode, setPairingCode] = useState(""); const [notice, setNotice] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [selectedTaskDetail, setSelectedTaskDetail] = useState(); const [taskDetailLoading, setTaskDetailLoading] = useState(false); + // The open task detail must follow the same 2 second cadence as the task list, + // otherwise a running task looks frozen until the dialog is reopened. + const openTaskID = useRef(""); const refresh = async () => { try { - const [nextSnapshot, nextAgents, nextTasks, nextManagedAgents, nextProfiles, nextLLMStatuses, nextSkills, nextSkillStatuses, nextDeployments] = await Promise.all([Snapshot(), OnlineAgents(), RecentTasks(), Agents(), LLMProfiles(), AgentLLMStatuses(), Skills(), AgentSkillStatuses(), AgentDeploymentStatuses()]); - setSnapshot(nextSnapshot); setAgents(nextAgents); setTasks(nextTasks); setManagedAgents(nextManagedAgents); setLLMProfiles(nextProfiles); setAgentLLMStatuses(nextLLMStatuses); setSkills(nextSkills); setAgentSkillStatuses(nextSkillStatuses); setAgentDeploymentStatuses(nextDeployments); + const [nextSnapshot, nextAgents, nextTasks, nextManagedAgents, nextProfiles, nextLLMStatuses, nextSkills, nextSkillStatuses, nextTools, nextMCPStatuses] = await Promise.all([Snapshot(), OnlineAgents(), RecentTasks(), Agents(), LLMProfiles(), AgentLLMStatuses(), Skills(), AgentSkillStatuses(), MCPTools(), AgentMCPStatuses()]); + setSnapshot(nextSnapshot); setAgents(nextAgents); setTasks(nextTasks); setManagedAgents(nextManagedAgents); setLLMProfiles(nextProfiles); setAgentLLMStatuses(nextLLMStatuses); setSkills(nextSkills); setAgentSkillStatuses(nextSkillStatuses); setMCPTools(nextTools); setAgentMCPStatuses(nextMCPStatuses); + const watchedTaskID = openTaskID.current; + if (watchedTaskID) { + const detail = await TaskDetails(watchedTaskID); + // Only apply a result that still belongs to the open dialog. + if (openTaskID.current === watchedTaskID) setSelectedTaskDetail(detail); + } setError(""); } catch (reason) { setError(String(reason)); } }; @@ -84,13 +95,27 @@ function App() { setLoading(true); try { const taskID = await CreateTaskWithImages(agentID, instruction, timeoutSeconds, images); - setNotice(`已向选定 Agent 下发独立任务 ${taskID.slice(0, 8)}。`); + setNotice(`已向选定 Agent 下发独立任务 ${taskID.slice(0, 8)}(不带上下文)。`); setError(""); await refresh(); } catch (reason) { setError(String(reason)); throw reason; } finally { setLoading(false); } }; + // A follow-up conversation is anchored on one completed task; the controller + // carries that task's own result plus the later turns as context. + const continueSessionTask = async (taskID: string, instruction: string) => { + setLoading(true); + try { + const result = await ContinueTaskSession(taskID, instruction, 300, []); + setNotice(`已下发会话第 ${result.turns} 轮任务 ${result.taskID.slice(0, 8)},本轮带上了之前的上下文。`); + setError(""); + await refresh(); + return result; + } catch (reason) { setError(String(reason)); throw reason; } + finally { setLoading(false); } + }; + const cancelTask = async (task: controlplane.Task) => { if (!window.confirm(`取消任务 ${task.id.slice(0, 8)}?子 Agent 会停止当前执行。`)) return; setLoading(true); @@ -117,18 +142,25 @@ function App() { setLoading(true); try { const deleted = await ClearTaskHistory(); - setSelectedTaskDetail(undefined); + closeTaskDetail(); setNotice(`已清除 ${deleted} 条已结束任务记录;进行中的任务和审计日志已保留。`); await refresh(); } catch (reason) { setError(String(reason)); } finally { setLoading(false); } }; + const closeTaskDetail = () => { + openTaskID.current = ""; + setSelectedTaskDetail(undefined); + }; + const showTaskDetail = async (task: controlplane.Task) => { + openTaskID.current = task.id; setSelectedTaskDetail(new controlplane.TaskDetail({task, events: []})); setTaskDetailLoading(true); try { - setSelectedTaskDetail(await TaskDetails(task.id)); + const detail = await TaskDetails(task.id); + if (openTaskID.current === task.id) setSelectedTaskDetail(detail); setError(""); } catch (reason) { setError(String(reason)); } finally { setTaskDetailLoading(false); } @@ -189,9 +221,48 @@ function App() { } catch (reason) { setError(String(reason)); } finally { setSkillDocumentLoading(false); } }; - const enableDesktopFilesystem = async (agentIDs: string[]) => { + const createSkill = async (input: controlplane.SkillInput) => { setLoading(true); - try { await EnableDesktopFilesystem(agentIDs); setNotice("已下发受控桌面文件工具,等待 Agent 确认。"); await refresh(); } + try { + const skill = await SaveSkill(input); + setNotice(`已保存 Skill“${skill.name}”第 ${skill.version} 版;已分配的 Agent 会自动重新应用。`); + await refresh(); + } catch (reason) { setError(String(reason)); throw reason; } + finally { setLoading(false); } + }; + const importSkills = async () => { + setLoading(true); + try { + const result: main.SkillImportResult = await ImportSkillsFromDirectory(); + setSkillImportNotes(result.notes); + setNotice(result.imported > 0 ? `已从旧技能目录导入 ${result.imported} 个 Skill(仅 Markdown 指南)。` : ""); + await refresh(); + } catch (reason) { setError(String(reason)); } + finally { setLoading(false); } + }; + const deploySkill = async (skillID: string, agentIDs: string[]) => { + setLoading(true); + try { await DeploySkill(skillID, agentIDs); setNotice(`已将 Skill 下发给 ${agentIDs.length} 个 Agent,正在等待应用确认。`); await refresh(); } + catch (reason) { setError(String(reason)); } finally { setLoading(false); } + }; + const saveMCPTool = async (input: controlplane.MCPToolInput) => { + setLoading(true); + try { + const tool = await SaveMCPTool(input); + setNotice(`已保存工具“${tool.name}”;定义变更会自动重新下发到已分配的 Agent。`); + await refresh(); + } catch (reason) { setError(String(reason)); throw reason; } + finally { setLoading(false); } + }; + const deleteMCPTool = async (tool: controlplane.MCPTool) => { + if (!window.confirm(`删除工具“${tool.name}”?已分配它的 ${tool.agentCount} 个 Agent 会收到空工具集合并停止使用该工具。`)) return; + setLoading(true); + try { await DeleteMCPTool(tool.id); setNotice(`已删除工具“${tool.name}”并回收其分配。`); await refresh(); } + catch (reason) { setError(String(reason)); } finally { setLoading(false); } + }; + const setAgentMCPTools = async (targetAgentID: string, toolIDs: string[]) => { + setLoading(true); + try { await SetAgentMCPTools(targetAgentID, toolIDs); setNotice(`已更新该 Agent 的 ${toolIDs.length} 个工具,正在等待应用确认。`); await refresh(); } catch (reason) { setError(String(reason)); } finally { setLoading(false); } }; @@ -206,14 +277,14 @@ function App() { {error &&
{error}
} {notice &&
{notice}
} {page === "overview" && <> void cancelTask(task)} onRetry={(task) => void retryTask(task)} onClearHistory={() => void clearTaskHistory()} onOpen={(task) => void showTaskDetail(task)}/>} - {page === "agents" && void renameAgent(agentID, name)} onSetSkills={(targetAgentID, skillIDs) => void setAgentSkills(targetAgentID, skillIDs)} onEnableMCP={agentID => void enableDesktopFilesystem([agentID])} onRevoke={(agent) => void revokeAgent(agent)} onPair={() => setPage("keys")}/>} + {page === "agents" && void renameAgent(agentID, name)} onSetSkills={(targetAgentID, skillIDs) => void setAgentSkills(targetAgentID, skillIDs)} onSetMCPTools={(targetAgentID, toolIDs) => void setAgentMCPTools(targetAgentID, toolIDs)} onRevoke={(agent) => void revokeAgent(agent)} onPair={() => setPage("keys")}/>} {page === "batch" && void showTaskDetail(task)}/>} {page === "direct" && void showTaskDetail(task)}/>} {page === "keys" && <> void createPairingCode()}/> void saveLLMProfile(input)} onDeploy={(profileID, agentIDs) => void deployLLMProfile(profileID, agentIDs)}/>} - {page === "skills" && void showSkillDocument(skill)}/>} - {page === "plugins" && } + {page === "skills" && void showSkillDocument(skill)} onCreate={(input) => void createSkill(input)} onImport={() => void importSkills()} onDeploy={(skillID, agentIDs) => void deploySkill(skillID, agentIDs)}/>} + {page === "plugins" && void saveMCPTool(input)} onDelete={(tool) => void deleteMCPTool(tool)}/>} {page === "audit" && } - {selectedTaskDetail && setSelectedTaskDetail(undefined)}/>}; + {selectedTaskDetail && }; } function Overview({snapshot, agents, onNavigate}: {snapshot?: controlplane.Snapshot; agents: controlplane.Agent[]; onNavigate: (page: Page) => void}) { @@ -229,7 +300,7 @@ function Overview({snapshot, agents, onNavigate}: {snapshot?: controlplane.Snaps ; } -function AgentManagement({skills, skillStatuses, loading, onRename, onSetSkills, onEnableMCP, onRevoke, onPair}: {skills: controlplane.Skill[]; skillStatuses: controlplane.AgentSkillStatus[]; loading: boolean; onRename: (agentID: string, name: string) => void; onSetSkills: (agentID: string, skillIDs: string[]) => void; onEnableMCP: (agentID: string) => void; onRevoke: (agent: controlplane.Agent) => void; onPair: () => void}) { +function AgentManagement({skills, skillStatuses, mcpTools, mcpStatuses, loading, onRename, onSetSkills, onSetMCPTools, onRevoke, onPair}: {skills: controlplane.Skill[]; skillStatuses: controlplane.AgentSkillStatus[]; mcpTools: controlplane.MCPTool[]; mcpStatuses: controlplane.AgentMCPStatus[]; loading: boolean; onRename: (agentID: string, name: string) => void; onSetSkills: (agentID: string, skillIDs: string[]) => void; onSetMCPTools: (agentID: string, toolIDs: string[]) => void; onRevoke: (agent: controlplane.Agent) => void; onPair: () => void}) { const [query, setQuery] = useState(""); const [connectionStatus, setConnectionStatus] = useState(""); const [mcpStatus, setMCPStatus] = useState(""); @@ -263,7 +334,7 @@ function AgentManagement({skills, skillStatuses, loading, onRename, onSetSkills,

名称搜索和所有状态筛选均在总控服务端执行。点击卡片可查看最近任务、审计、部署轨迹和失败原因。

{directoryLoading ? "正在查询…" : `共 ${directory.totalItems} 个 Agent`}
{directoryError &&

{directoryError}

}{directory.totalItems === 0 ?

还没有符合筛选条件的 Agent。

: <>
{directory.items.map(agent => void openAgent(agent)}/>)}
第 {directory.page} / {directory.totalPages} 页
} - {selectedAgent && status.agentID === selectedAgent.id).map(status => status.skillID)} deployment={selectedDetail?.deployment} loading={loading || detailLoading} onClose={() => { setSelectedAgent(undefined); setSelectedDetail(undefined); }} onRename={onRename} onSetSkills={onSetSkills} onEnableMCP={onEnableMCP} onRevoke={onRevoke}/>} + {selectedAgent && status.agentID === selectedAgent.id).map(status => status.skillID)} deployment={selectedDetail?.deployment} mcpTools={mcpTools} selectedToolIDs={mcpStatuses.filter(status => status.agentID === selectedAgent.id).map(status => status.toolID)} toolStatuses={mcpStatuses.filter(status => status.agentID === selectedAgent.id)} loading={loading || detailLoading} onClose={() => { setSelectedAgent(undefined); setSelectedDetail(undefined); }} onRename={onRename} onSetSkills={onSetSkills} onSetMCPTools={onSetMCPTools} onRevoke={onRevoke}/>} ; } @@ -284,20 +355,29 @@ function useCloseOnEscape(onClose: () => void) { }, [onClose]); } -function AgentDetailModal({agent, detail, skills, selectedSkillIDs, deployment, loading, onClose, onRename, onSetSkills, onEnableMCP, onRevoke}: {agent: controlplane.Agent; detail?: controlplane.AgentOperationalDetail; skills: controlplane.Skill[]; selectedSkillIDs: string[]; deployment?: controlplane.AgentDirectoryItem; loading: boolean; onClose: () => void; onRename: (agentID: string, name: string) => void; onSetSkills: (agentID: string, skillIDs: string[]) => void; onEnableMCP: (agentID: string) => void; onRevoke: (agent: controlplane.Agent) => void}) { +function AgentDetailModal({agent, detail, skills, selectedSkillIDs, deployment, mcpTools, selectedToolIDs, toolStatuses, loading, onClose, onRename, onSetSkills, onSetMCPTools, onRevoke}: {agent: controlplane.Agent; detail?: controlplane.AgentOperationalDetail; skills: controlplane.Skill[]; selectedSkillIDs: string[]; deployment?: controlplane.AgentDirectoryItem; mcpTools: controlplane.MCPTool[]; selectedToolIDs: string[]; toolStatuses: controlplane.AgentMCPStatus[]; loading: boolean; onClose: () => void; onRename: (agentID: string, name: string) => void; onSetSkills: (agentID: string, skillIDs: string[]) => void; onSetMCPTools: (agentID: string, toolIDs: string[]) => void; onRevoke: (agent: controlplane.Agent) => void}) { useCloseOnEscape(onClose); - return
{ if (event.currentTarget === event.target) onClose(); }}>

AGENT DETAIL

{agent.name}

{loading && !detail ?

正在读取 Agent 运行详情…

: <>}
; + return
{ if (event.currentTarget === event.target) onClose(); }}>

AGENT DETAIL

{agent.name}

{loading && !detail ?

正在读取 Agent 运行详情…

: <>}
; } -function AgentRow({agent, skills, selectedSkillIDs, deployment, loading, onRename, onSetSkills, onEnableMCP: _onEnableMCP, onRevoke}: {agent: controlplane.Agent; skills: controlplane.Skill[]; selectedSkillIDs: string[]; deployment?: controlplane.AgentDirectoryItem; loading: boolean; onRename: (agentID: string, name: string) => void; onSetSkills: (agentID: string, skillIDs: string[]) => void; onEnableMCP: (agentID: string) => void; onRevoke: (agent: controlplane.Agent) => void}) { +function AgentRow({agent, skills, selectedSkillIDs, deployment, mcpTools, selectedToolIDs, toolStatuses, loading, onRename, onSetSkills, onSetMCPTools, onRevoke}: {agent: controlplane.Agent; skills: controlplane.Skill[]; selectedSkillIDs: string[]; deployment?: controlplane.AgentDirectoryItem; mcpTools: controlplane.MCPTool[]; selectedToolIDs: string[]; toolStatuses: controlplane.AgentMCPStatus[]; loading: boolean; onRename: (agentID: string, name: string) => void; onSetSkills: (agentID: string, skillIDs: string[]) => void; onSetMCPTools: (agentID: string, toolIDs: string[]) => void; onRevoke: (agent: controlplane.Agent) => void}) { const [name, setName] = useState(agent.name); const [selected, setSelected] = useState(selectedSkillIDs); + const [selectedTools, setSelectedTools] = useState(selectedToolIDs); useEffect(() => setSelected(selectedSkillIDs), [agent.id, selectedSkillIDs.join("|")]); + useEffect(() => setSelectedTools(selectedToolIDs), [agent.id, selectedToolIDs.join("|")]); const changed = name.trim() !== agent.name; const lastSeen = agent.lastSeenAt ? new Date(agent.lastSeenAt).toLocaleString() : "无记录"; const toggleSkill = (skillID: string) => setSelected(current => current.includes(skillID) ? current.filter(value => value !== skillID) : [...current, skillID]); + const toggleTool = (toolID: string) => setSelectedTools(current => current.includes(toolID) ? current.filter(value => value !== toolID) : [...current, toolID]); const selectionChanged = selected.slice().sort().join("|") !== selectedSkillIDs.slice().sort().join("|"); - return
setName(event.target.value)}/>{agent.id}
{agent.status === "online" ? "在线" : "离线"}
最后心跳:{lastSeen}状态:{agent.status}
LLM:{deployment?.llmProfile ? `${deployment.llmProfile} · ${deployment.llmStatus}` : "未部署"}Skills:{deployment ? `${deployment.skillApplied}/${deployment.skillTotal} 已应用 · ${deployment.skillStatus}` : "未选择"}本机完全访问:默认启用{deployment?.llmFailure && LLM:{deployment.llmFailure}}{deployment?.skillFailure && Skill:{deployment.skillFailure}}

该 Agent 的 Skills

{skills.length === 0 ?

Skill 仓库为空。

:
{skills.map(skill => )}
}

本机执行权限

完全访问:默认启用。该 Agent 可实际操作所在电脑的文件、程序、网络与桌面应用;任务只能来自已认证总控。

旧桌面文件工具状态:{mcpStatusLabel[deployment?.mcpStatus ?? "未启用"] ?? deployment?.mcpStatus}
; + const toolSelectionChanged = selectedTools.slice().sort().join("|") !== selectedToolIDs.slice().sort().join("|"); + const toolState = (toolID: string) => { + const status = toolStatuses.find(item => item.toolID === toolID); + if (!status) return "未分配"; + return `${mcpStatusLabel[status.applyStatus] ?? status.applyStatus} · 第 ${status.deployVersion} 版`; + }; + return
setName(event.target.value)}/>{agent.id}
{agent.status === "online" ? "在线" : "离线"}
最后心跳:{lastSeen}状态:{agent.status}
LLM:{deployment?.llmProfile ? `${deployment.llmProfile} · ${deployment.llmStatus}` : "未部署"}Skills:{deployment ? `${deployment.skillApplied}/${deployment.skillTotal} 已应用 · ${deployment.skillStatus}` : "未选择"}工具:{mcpStatusLabel[deployment?.mcpStatus ?? "未启用"] ?? deployment?.mcpStatus}本机完全访问:默认启用{deployment?.llmFailure && LLM:{deployment.llmFailure}}{deployment?.skillFailure && Skill:{deployment.skillFailure}}{deployment?.mcpFailure && 工具:{deployment.mcpFailure}}

该 Agent 的 Skills

{skills.length === 0 ?

Skill 仓库为空。

:
{skills.map(skill => )}
}

本机预设工具(默认启用,无需配置)

Agent 装好就能操作它所在的电脑:文件与文件夹的查看/读取/写入/移动/删除、用系统默认程序打开网址或文件、查询当前时间与时区、查看进程、屏幕截图、读写剪贴板。这些工具由 Agent 本体提供,不需要总控下发,也不需要在目标电脑上安装任何东西。

需要组合操作时仍可使用 PowerShell 完全访问能力;读网页内容、点击页面等能力请用下方的 MCP 工具或插件。

该 Agent 的额外工具(MCP / 本机命令 / 内置)

此处选择的工具由总控下发,Agent 只在收到已认证快照后才会提供给模型调用。

{mcpTools.length === 0 ?

工具仓库为空,请先在“插件”页面新建。

:
{mcpTools.map(tool => )}
}
; } function AgentOperationalSections({detail}: {detail?: controlplane.AgentOperationalDetail}) { @@ -307,11 +387,25 @@ function AgentOperationalSections({detail}: {detail?: controlplane.AgentOperatio function executionLabel(execution: string) { if (execution === "full-access") return "完全访问 Agent"; + if (execution === "builtin") return "本机预设工具"; if (execution === "desktop-filesystem") return "旧版兼容 · 桌面文件工具"; + if (execution === "mcp") return "MCP / 插件工具"; if (execution === "llm") return "LLM"; return "内置演示执行器"; } +function toolsLabel(kind: string) { + if (kind === "builtin") return "内置工具"; + if (kind === "stdio") return "本机命令"; + return "HTTP MCP"; +} + +function toolLocation(tool: controlplane.MCPTool) { + if (tool.kind === "builtin") return "由 Agent 本机实现"; + if (tool.kind === "stdio") return [tool.command, ...(tool.args ?? [])].join(" ") || "未填写命令"; + return tool.endpoint || "未填写地址"; +} + function preview(value: string, fallback: string) { const compact = value.replace(/\s+/g, " ").trim(); return compact.length > 118 ? `${compact.slice(0, 118)}…` : compact || fallback; @@ -362,17 +456,20 @@ function MentionDispatch({agents, tasks, loading, onSubmit, onOpen}: {agents: co if (bounded.length !== accepted.length) setImageNotice("每项任务的图片原始总大小不能超过 12 MiB。"); void Promise.all(bounded.map(file => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve({id: crypto.randomUUID(), name: file.name || "粘贴的图片", dataURL: String(reader.result)}); reader.onerror = () => reject(new Error("无法读取图片")); reader.readAsDataURL(file); }))).then(added => setImages(current => [...current, ...added])); }; - const submit = async (event: FormEvent) => { - event.preventDefault(); - try { setResult(await onSubmit(message, timeoutSeconds, images)); setMessage(""); setImages([]); setImageNotice(""); } - catch { /* The application-level error banner contains the safe reason. */ } + const dispatch = async () => { + try { + setResult(await onSubmit(message, timeoutSeconds, images)); + setMessage(""); setImages([]); setImageNotice(""); setMentionQuery(undefined); + } catch { /* The application-level error banner contains the safe reason. */ } }; + const submit = (event: FormEvent) => { event.preventDefault(); void dispatch(); }; + const canSubmit = !loading && agents.length > 0 && Boolean(message.trim()); return
-

MULTI-AGENT @ ASSIGNMENT

批量指派

-

输入 @ 即会显示可选 Agent,可用方向键和回车选中;也可点击下方 Agent。每个非空行是一组独立任务:同一行多个 Agent 会收到相同任务;换行后可写不同任务。多条有效任务带图时,请在每行明确写“图片 1”等编号。

+

MULTI-AGENT @ ASSIGNMENT

批量指派

+

输入 @ 即会显示可选 Agent,可用方向键和回车选中;也可点击下方 Agent。按 Ctrl+Enter 立即下发,普通回车只换行。每个非空行是一组独立任务:同一行多个 Agent 会收到相同任务;换行后可写不同任务。多条有效任务带图时,请在每行明确写“图片 1”等编号。

{agents.map(agent => )}
{selectedAgents.length > 0 &&
当前已识别:{selectedAgents.map(agent => {agent.name})}
} -