diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index 3d9facfc..1b7ed0e4 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -284,7 +284,11 @@ public class PlanGenerationNode implements NodeAction { } String systemPrompt = accessor.systemPrompt(); - String agentId = state.value(MateClawStateKeys.TRACE_ID, "unknown"); + // Persist plans under the real agent id (the same key StepExecutionNode + // reads), NOT the per-run trace id — otherwise mate_plan.agent_id holds a + // random trace string and listByAgent never matches, leaving the Plan + // board permanently empty even after plans are generated. + String agentId = state.value(MateClawStateKeys.AGENT_ID, ""); String conversationId = accessor.conversationId(); log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal); @@ -410,7 +414,7 @@ public class PlanGenerationNode implements NodeAction { + "downgrading to single-step plan so tools can execute (goal: {})", goal.length() > 60 ? goal.substring(0, 60) + "..." : goal); List gatedSteps = List.of(goal); - var gatedPlan = planningService.createPlan(agentId, goal, gatedSteps); + var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps); events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps)); return PlanStateAccessor.output() .needsPlanning(true) @@ -451,7 +455,7 @@ public class PlanGenerationNode implements NodeAction { steps = List.of(goal); } - var plan = planningService.createPlan(agentId, goal, steps); + var plan = planningService.createPlan(agentId, conversationId, goal, steps); log.info("[PlanGeneration] Plan created: id={}, steps={} ({})", plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step"); @@ -495,7 +499,7 @@ public class PlanGenerationNode implements NodeAction { // answer. This preserves tool access on the failure path; the previous // "direct answer" fallback silently degraded tool-requiring tasks. try { - var plan = planningService.createPlan(agentId, goal, List.of(goal)); + var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal)); events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal))); return PlanStateAccessor.output() .needsPlanning(true) diff --git a/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java b/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java index aa921905..fc73da4f 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/controller/PlanningController.java @@ -23,10 +23,14 @@ public class PlanningController { private final PlanningService planningService; - @Operation(summary = "获取 Agent 的计划列表") + @Operation(summary = "获取计划列表(带 agentId 则按员工,否则跨员工取最近 N 条)") @GetMapping - public R> listByAgent(@RequestParam String agentId) { - return R.ok(planningService.listPlansByAgent(agentId)); + public R> list(@RequestParam(required = false) String agentId, + @RequestParam(required = false, defaultValue = "100") int limit) { + if (agentId != null && !agentId.isBlank()) { + return R.ok(planningService.listPlansByAgent(agentId)); + } + return R.ok(planningService.listRecentPlans(limit)); } @Operation(summary = "获取计划详情(含步骤)") diff --git a/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java b/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java index f9f56ef0..f02f0f29 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/model/PlanEntity.java @@ -21,6 +21,9 @@ public class PlanEntity { /** 关联的 Agent ID(字符串) */ private String agentId; + /** 产生该计划的对话/运行 ID(可空,历史行为 null)。用于把计划绑定到具体运行、支持跨员工/协同分组。 */ + private String conversationId; + /** 任务目标 */ private String goal; diff --git a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java index 80488171..689f8b0a 100644 --- a/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java +++ b/mateclaw-server/src/main/java/vip/mate/planning/service/PlanningService.java @@ -34,8 +34,18 @@ public class PlanningService { */ @Transactional public PlanEntity createPlan(String agentId, String goal, List steps) { + return createPlan(agentId, null, goal, steps); + } + + /** + * 创建执行计划,并绑定到产生它的对话/运行。 + * conversationId 可空(历史调用方),便于把计划归到某次运行,支撑跨员工/协同看板。 + */ + @Transactional + public PlanEntity createPlan(String agentId, String conversationId, String goal, List steps) { PlanEntity plan = new PlanEntity(); plan.setAgentId(agentId); + plan.setConversationId(conversationId); plan.setGoal(goal); plan.setStatus("running"); plan.setTotalSteps(steps.size()); @@ -111,6 +121,17 @@ public class PlanningService { .orderByDesc(PlanEntity::getCreateTime)); } + /** + * 跨员工获取最近的计划列表(用于团队/泳道看板)。 + * 按创建时间倒序,limit 兜底防止全表拉取。 + */ + public List listRecentPlans(int limit) { + int capped = limit <= 0 ? 100 : Math.min(limit, 500); + return planMapper.selectList(new LambdaQueryWrapper() + .orderByDesc(PlanEntity::getCreateTime) + .last("LIMIT " + capped)); + } + /** * 获取计划详情(含子计划) */ diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V155__plan_conversation_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V155__plan_conversation_id.sql new file mode 100644 index 00000000..f8a63666 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V155__plan_conversation_id.sql @@ -0,0 +1,11 @@ +-- V155: Link a plan to the conversation/run that produced it. +-- +-- mate_plan previously carried only agent_id, so a plan could not be tied to a +-- specific conversation or delegation run — every listByAgent query mixed all of +-- an agent's plans across all conversations, and a multi-agent collaboration +-- could not be reconstructed. conversation_id makes plans groupable by run and +-- is the foundation for the cross-agent / assignee-swimlane plan board. +-- +-- Nullable: legacy rows (and any plan created before this column existed) keep +-- a NULL conversation_id and simply don't participate in run-level grouping. +ALTER TABLE mate_plan ADD COLUMN IF NOT EXISTS conversation_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V155__plan_conversation_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V155__plan_conversation_id.sql new file mode 100644 index 00000000..084fecf5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V155__plan_conversation_id.sql @@ -0,0 +1,3 @@ +-- V155: Link a plan to the conversation/run that produced it (see H2 file for +-- context). KingbaseES (PostgreSQL-compatible) supports ADD COLUMN IF NOT EXISTS. +ALTER TABLE mate_plan ADD COLUMN IF NOT EXISTS conversation_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V155__plan_conversation_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V155__plan_conversation_id.sql new file mode 100644 index 00000000..2393ab4f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V155__plan_conversation_id.sql @@ -0,0 +1,15 @@ +-- V155: Link a plan to the conversation/run that produced it (see H2 file for +-- context). MySQL 8.0 doesn't support `ADD COLUMN IF NOT EXISTS`, so the +-- existence check goes through INFORMATION_SCHEMA + a prepared statement. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_plan' + AND COLUMN_NAME = 'conversation_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_plan ADD COLUMN conversation_id VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/main/resources/docs/en/webchat.md b/mateclaw-server/src/main/resources/docs/en/webchat.md index 3c134233..572f6fbb 100644 --- a/mateclaw-server/src/main/resources/docs/en/webchat.md +++ b/mateclaw-server/src/main/resources/docs/en/webchat.md @@ -66,10 +66,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://' }) | Method | Path | Auth | Purpose | |---|---|---|---| -| POST | `/stream` | API Key | SSE streaming chat (issues visitorToken); the body may include an optional `agentId` to override the channel's bound agent (must be in the same workspace as the channel) | +| POST | `/stream` | API Key | SSE streaming chat (issues visitorToken) | | GET | `/config` | API Key | Get channel config (title/placeholder/...) | -| GET | `/skills` | + visitorToken | List skills visible to this agent (for building your own slash picker UI) | -| GET | `/wiki/pages` | + visitorToken | List wiki pages visible to this agent (for building your own `[[slug]]` reference picker UI) | | POST | `/sessions` | API Key | Explicitly create an empty session thread | | GET | `/sessions` | + visitorToken | List sessions (excludes archived by default) | | GET | `/sessions/page` | + visitorToken | Paginated + keyword search | @@ -201,89 +199,6 @@ curl -X POST https://mate.example.com/api/v1/admin/webchat/revoked-visitor \ After revocation, all of that visitor's management endpoints return 401 (`/stream` is unaffected and can re-issue a fresh token). Revocation state is briefly cached, so under a multi-instance deployment it takes up to ~10 minutes to fully propagate. Un-revoke via `DELETE` on the same endpoint. -## Skill invocation (slash picker) - -The admin-console chat input shows a skill picker when you type `/`. This is a **pure frontend affordance** — selecting a skill rewrites the input box into a directive: - -- English: `Use the "" skill: ` -- Chinese: `使用「<技能名>」技能:<用户消息>` - -The directive goes out as a regular user message on `/stream`, and the LLM voluntarily calls the `load_skill` meta-tool when it sees it (see [skills.md](./skills.md#the-slash-menu)). The backend **does no `/` parsing**; webchat uses the exact same agent runtime as the admin console, so this path works out of the box for webchat callers. - -To build your own picker, first list the skills via the new endpoint: - -```bash -curl https://mate.example.com/api/v1/channels/webchat/skills?visitorId=v1 \ - -H "X-MC-Key: your-api-key" \ - -H "X-MC-Visitor-Token: " -# returns [{"id":..., "name":"news-summary", "nameZh":"News Summary", "description":"...", "icon":"..."}] -``` - -The `agentId` query parameter is optional (falls back to the channel's bound agent). Only skills **explicitly bound to the agent AND enabled** are returned, sorted by slug. The response carries display-level metadata only — **no** SKILL.md content, configJson, or security scan results (those stay admin-console-only). - -After a user picks a skill, construct the message: - -```bash -curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ - -H "X-MC-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{"visitorId":"v1","message":"Use the \"news-summary\" skill: summarize the top 3 AI stories today"}' -``` - -> Note: the directive text relies on the LLM "obeying" and calling `load_skill`. Under complex tasks it occasionally drifts; for production, bind the target skill to the agent and reinforce the system prompt in `AGENTS.md`. - -## Wiki knowledge-base reference (`[[slug]]` picker) - -The knowledge base has a picker parallel to the slash-skill one — using the **Obsidian / Wikipedia wikilink convention `[[slug]]`**. The user types `[[` in the input box to open a picker, selects a page, and a `[[]]` token is inserted. On submit the input is rewritten into a directive text, and the LLM calls `wiki_read_page(slug=...)` to read the referenced page before answering. The backend **does no `[[` parsing** — webchat uses the exact same agent runtime as the admin console. - -Directive text format (**exact**): - -- English: `Reference the wiki page [[]]: ` -- Chinese: `参考知识库页面 [[]]:<用户消息>` - -Multiple references are supported naturally (just list them): - -``` -Reference the wiki pages [[auth-design]], [[webchat-integration]]: how do these two work together? -``` - -To build your own picker, first list the pages via the new endpoint: - -```bash -curl "https://mate.example.com/api/v1/channels/webchat/wiki/pages?visitorId=v1" \ - -H "X-MC-Key: your-api-key" \ - -H "X-MC-Visitor-Token: " -# returns [{"kbId":1,"kbName":"MateClaw Docs","slug":"webchat-integration", -# "title":"WebChat Integration Guide","summary":"...","pageType":"source"}, ...] -``` - -Optional query parameters: - -| Parameter | Required | Notes | -|---|---|---| -| `visitorId` | yes | Visitor ID | -| `agentId` | no | Override the channel's bound agent; must be in the same workspace as the channel | -| `keyword` | no | Filter, matches `slug` OR `title` (LIKE) | - -Behavior: - -- **Scope**: KBs explicitly bound to the agent (`mate_agent_wiki_kb`); with no bindings, falls back to every KB in the workspace (mirrors the wiki-tool default) -- **Page filter**: excludes `pageType=synthesis` (LLM intermediate artifacts) -- **100-page cap**: when exceeded (and no `keyword`), returns `422` asking the caller to narrow with a keyword -- **Returned fields**: only `kbId / kbName / slug / title / summary / pageType`; **no** content, embedding, sourceRawIds, or outgoingLinks (admin-console-only) -- **Ordering**: by `slug` ascending - -After a user picks a page, construct the message (English directive example): - -```bash -curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ - -H "X-MC-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{"visitorId":"v1","message":"Reference the wiki page [[webchat-integration]]: summarize the integration flow"}' -``` - -> Note: `[[slug]]` is a convention hint for the LLM (documented in the `wiki_read_page` `@Tool` description), but the LLM can still drift under complex tasks. For production, reinforce the system prompt in `AGENTS.md`, or bind the target KB to a dedicated agent to narrow the retrieval space. - ## curl examples **Step 1: send the first message** diff --git a/mateclaw-server/src/main/resources/docs/zh/webchat.md b/mateclaw-server/src/main/resources/docs/zh/webchat.md index 35e732dd..955aaff2 100644 --- a/mateclaw-server/src/main/resources/docs/zh/webchat.md +++ b/mateclaw-server/src/main/resources/docs/zh/webchat.md @@ -66,10 +66,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://<你的部署地址>' }) | 方法 | 路径 | 鉴权 | 用途 | |---|---|---|---| -| POST | `/stream` | API Key | SSE 流式对话(签发 visitorToken);请求体可选传 `agentId` 覆盖渠道绑定的 agent(必须与渠道同 workspace) | +| POST | `/stream` | API Key | SSE 流式对话(签发 visitorToken) | | GET | `/config` | API Key | 拿渠道配置(title/placeholder/...) | -| GET | `/skills` | + visitorToken | 列出该 agent 绑定的可见技能(供下游自建 slash picker UI) | -| GET | `/wiki/pages` | + visitorToken | 列出该 agent 可见的 wiki 页面(供下游自建 `[[slug]]` 引用 picker UI) | | POST | `/sessions` | API Key | 显式创建空会话线程 | | GET | `/sessions` | + visitorToken | 列出会话(默认排除 archived) | | GET | `/sessions/page` | + visitorToken | 分页 + 关键词搜索 | @@ -206,89 +204,6 @@ curl -X POST https://mate.example.com/api/v1/admin/webchat/revoked-visitor \ 撤销后该 visitor 的所有管理端点调用返回 401(`/stream` 不受影响,可重新签发新 token)。撤销状态带短时缓存,多实例下最长约 10 分钟生效。取消撤销用 `DELETE` 同一端点。 -## 技能调用(slash picker) - -主控台前端在输入框键入 `/` 会弹出技能选择菜单。这是**纯前端 affordance**——选中后输入框被改写成指令文本: - -- 中文:`使用「<技能名>」技能:<用户消息>` -- 英文:`Use the "" skill: <用户消息>` - -指令文本作为普通 user message 发到 `/stream`,LLM 收到后调用 `load_skill` 元工具(详见 [skills.md](./skills.md#slash-菜单))。后端**不做 `/` 解析**,webchat 走的是和主控台完全一样的 agent runtime,所以这条路径对 webchat 调用方**开箱即用**。 - -下游集成方要自建 picker UI,先用新端点拿清单: - -```bash -curl https://mate.example.com/api/v1/channels/webchat/skills?visitorId=v1 \ - -H "X-MC-Key: your-api-key" \ - -H "X-MC-Visitor-Token: " -# 返回 [{"id":..., "name":"news-summary", "nameZh":"新闻摘要", "description":"...", "icon":"..."}] -``` - -可选 `agentId` 参数(默认回落到渠道绑定的 agent);只返回该 agent **显式绑定且 enabled** 的技能,按 slug 字母序排序。返回字段是展示级元数据,**不包含** SKILL.md 正文、configJson、安全扫描结果——这些只走管理控制台。 - -选中后构造消息: - -```bash -curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ - -H "X-MC-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{"visitorId":"v1","message":"使用「新闻摘要」技能:总结今天最重要的 3 条 AI 新闻"}' -``` - -> 注意:指令文本依赖 LLM "听话"调用 `load_skill`。复杂任务下偶发漂移,生产环境建议把目标技能**绑定**到 agent(`agentId` 对应的)并在 `AGENTS.md` 里强化系统提示。 - -## Wiki 知识库引用(`[[slug]]` picker) - -跟技能调用一样,wiki 知识库也可以通过 picker 显式指代——用 **Obsidian / Wikipedia 风格的 `[[slug]]` 链接语法**。用户在输入框敲 `[[` 触发 picker,选中后插入 `[[]]` token,发送时改写为指令文本,LLM 收到后调 `wiki_read_page(slug=...)` 读取该页面再做答。后端**不做任何 `[[` 解析**,webchat 走的是和主控台完全一样的 agent runtime。 - -指令文本格式(**精确**): - -- 中文:`参考知识库页面 [[]]:<用户消息>` -- 英文:`Reference the wiki page [[]]: ` - -多引用天然支持(并列写即可): - -``` -参考知识库页面 [[auth-design]]、[[webchat-integration]]:这两套怎么协同? -``` - -下游集成方要自建 picker UI,先用端点拿页面清单: - -```bash -curl "https://mate.example.com/api/v1/channels/webchat/wiki/pages?visitorId=v1" \ - -H "X-MC-Key: your-api-key" \ - -H "X-MC-Visitor-Token: " -# 返回 [{"kbId":1,"kbName":"MateClaw 文档","slug":"webchat-integration", -# "title":"WebChat 接入指南","summary":"...","pageType":"source"}, ...] -``` - -可选 query 参数: - -| 参数 | 必填 | 说明 | -|---|---|---| -| `visitorId` | 是 | 访客 ID | -| `agentId` | 否 | 显式指定 agent,缺省回落到渠道绑定;必须与渠道同 workspace | -| `keyword` | 否 | 关键词过滤,匹配 `slug` 或 `title`(LIKE) | - -行为约束: - -- **可见范围**:agent 显式绑定的 KB(`mate_agent_wiki_kb` 表);无绑定时回落到该 workspace 全部 KB(跟 wiki 工具的默认行为一致) -- **页面过滤**:排除 `pageType=synthesis`(LLM 中间产物,对终端访客无意义) -- **100 页上限**:超出时(且未传 `keyword`)返回 `422`,要求传 `keyword` 收窄 -- **返回字段**:仅 `kbId / kbName / slug / title / summary / pageType`;**不包含**正文、embedding、sourceRawIds、outgoingLinks(这些只走管理控制台) -- **排序**:按 `slug` 字母序 - -选中后构造消息(中文 directive 示例): - -```bash -curl -N -X POST https://mate.example.com/api/v1/channels/webchat/stream \ - -H "X-MC-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{"visitorId":"v1","message":"参考知识库页面 [[webchat-integration]]:总结这套接入流程"}' -``` - -> 注意:`[[slug]]` 是给 LLM 看的约定提示(`wiki_read_page` 的 `@Tool` description 里写明了),但 LLM 仍可能漂移——复杂任务下建议同时在 `AGENTS.md` 里强化提示,或把目标 KB **绑定**到专用 agent 收窄检索空间。 - ## curl 示例 **第一步:发首条消息** diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index c0f9f445..59348033 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -522,6 +522,8 @@ export const mcpApi = { // ==================== Plan ==================== export const planApi = { listByAgent: (agentId: string) => http.get(`/plans?agentId=${agentId}`), + /** Cross-agent recent plans for the team / swimlane board. */ + listAll: (limit = 100) => http.get('/plans', { params: { limit } }), get: (id: string | number) => http.get(`/plans/${id}`), } diff --git a/mateclaw-ui/src/components/agents/GoalsPanel.vue b/mateclaw-ui/src/components/agents/GoalsPanel.vue new file mode 100644 index 00000000..3ca5ec48 --- /dev/null +++ b/mateclaw-ui/src/components/agents/GoalsPanel.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/mateclaw-ui/src/components/agents/PlanBoard.vue b/mateclaw-ui/src/components/agents/PlanBoard.vue new file mode 100644 index 00000000..280ab3c1 --- /dev/null +++ b/mateclaw-ui/src/components/agents/PlanBoard.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/mateclaw-ui/src/components/agents/PlanDetailPanel.vue b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue new file mode 100644 index 00000000..574b9a80 --- /dev/null +++ b/mateclaw-ui/src/components/agents/PlanDetailPanel.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/mateclaw-ui/src/components/live/LiveBoard.vue b/mateclaw-ui/src/components/live/LiveBoard.vue new file mode 100644 index 00000000..e331b9bc --- /dev/null +++ b/mateclaw-ui/src/components/live/LiveBoard.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/mateclaw-ui/src/components/live/LivePanel.vue b/mateclaw-ui/src/components/live/LivePanel.vue index b2a840f4..366f31f1 100644 --- a/mateclaw-ui/src/components/live/LivePanel.vue +++ b/mateclaw-ui/src/components/live/LivePanel.vue @@ -15,7 +15,7 @@ {{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }} -
+
+
+ + +
+
+ + +
@@ -158,9 +192,10 @@ import { useI18n } from 'vue-i18n' import { mcToast } from '@/composables/useMcToast' import SkillIcon from '@/components/common/SkillIcon.vue' import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue' +import LiveBoard from '@/components/live/LiveBoard.vue' import { useLiveAgent } from '@/composables/useLiveAgent' import { mcConfirm } from '@/components/common/useConfirm' -import { liveApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard } from '@/api' +import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api' const { t } = useI18n() const { @@ -182,6 +217,42 @@ const detail = ref(null) const activeFilter = ref('all') let timer: ReturnType | null = null +// ===== Lifecycle board mode ===== +// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched +// lazily (only once the board is shown) and refreshed alongside the snapshot. +const layout = ref<'grid' | 'board'>('grid') +const goalsActive = ref([]) +const doneGoals = ref([]) +const failedGoals = ref([]) + +const goalByConv = computed>(() => { + const map: Record = {} + for (const g of goalsActive.value) map[g.conversationId] = g + return map +}) + +function setLayout(next: 'grid' | 'board') { + if (layout.value === next) return + layout.value = next + if (next === 'board') loadGoals() +} + +async function loadGoals() { + // Best-effort: the board still renders its run columns without goals. + try { + const [active, done, failed] = await Promise.all([ + goalApi.list({ status: 'active', limit: 100 }), + goalApi.list({ status: 'completed', limit: 50 }), + goalApi.list({ status: 'exhausted', limit: 50 }), + ]) + goalsActive.value = ((active as any)?.data ?? []) as Goal[] + doneGoals.value = ((done as any)?.data ?? []) as Goal[] + failedGoals.value = ((failed as any)?.data ?? []) as Goal[] + } catch { + /* leave whatever we had; columns degrade to empty */ + } +} + function isWorking(r: LiveRunCard): boolean { return !r.stuckReason && !r.orphan } @@ -347,6 +418,8 @@ async function refresh() { const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId) if (fresh) detail.value = fresh } + // Keep the board's goal columns fresh on the same cadence as the snapshot. + if (layout.value === 'board') loadGoals() } catch (e: any) { if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed')) } finally { @@ -469,6 +542,43 @@ onBeforeUnmount(() => { min-width: 0; } +/* ===== Grid / board layout toggle ===== */ +.layout-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 999px; + border: 1px solid var(--mc-border-light); + background: var(--mc-bg-muted); +} + +.layout-seg { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + border: none; + background: transparent; + color: var(--mc-text-tertiary); + font-size: 12.5px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease; +} + +.layout-seg:hover { + color: var(--mc-text-primary); +} + +.layout-seg.is-active { + background: var(--mc-bg-elevated); + color: var(--mc-text-primary); + box-shadow: var(--mc-shadow-soft); +} + /* ===== Filter chip row (kanban-inspired, soft) ===== */ .filter-row { display: flex; diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 76c8d08b..6a7b4ebf 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -416,6 +416,38 @@ export default { docs: { title: 'Docs', }, + plans: { + title: 'Plan Board', + subtitle: "Lay out an employee's plans and steps as a board by status", + selectAgent: 'Select an employee', + allEmployees: 'All employees', + unknownAgent: 'Unknown employee', + noPlansAll: 'No plans across the team yet. Once a plan-execute employee runs a multi-step task, it shows up here.', + detail: 'Plan detail', + assignee: 'Assignee', + progress: 'Progress', + created: 'Created', + output: 'Output', + runs: 'Ran {n}× for the same goal', + more: 'Show {n} more', + collapse: 'Collapse', + goals: 'Goals', + activeGoals: 'Active Goals', + noGoals: 'No active goals', + plans: 'Plans', + noPlans: 'No plans for this employee yet', + noAgent: 'Select an employee to begin', + selectPlan: 'Pick a plan on the left to view its step board', + untitled: 'Untitled plan', + steps: 'steps', + viewResult: 'Click to view result', + col: { + pending: 'To Do', + running: 'In Progress', + completed: 'Done', + failed: 'Failed', + }, + }, nav: { dashboard: 'Dashboard', chat: 'Chat', @@ -569,6 +601,22 @@ export default { errors: { loadFailed: 'Could not load runtime status.', }, + layout: { + grid: 'Grid', + board: 'Board', + gridHint: 'Grid view', + boardHint: 'Lifecycle board view', + }, + board: { + running: 'Running', + attention: 'Needs Attention', + done: 'Done', + failed: 'Unmet', + queuedHint: '{n} queued', + goalLabel: 'Goal', + emptyCol: 'Empty', + noGoal: 'No goal', + }, }, doctor: { title: 'System Diagnostics', @@ -1173,6 +1221,7 @@ export default { views: { roster: 'Roster', live: 'Live', + plans: 'Plans', }, templates: { title: 'Choose a Role', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index a0e75468..802bafa3 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -416,6 +416,38 @@ export default { docs: { title: '帮助文档', }, + plans: { + title: '计划看板', + subtitle: '把员工的计划与步骤按状态铺成看板', + selectAgent: '选择员工', + allEmployees: '全部员工', + unknownAgent: '未知员工', + noPlansAll: '团队暂无任何计划。让计划执行型员工跑一个多步任务后,这里就会出现。', + detail: '计划详情', + assignee: '受派员工', + progress: '进度', + created: '创建', + output: '产出', + runs: '同一目标运行 {n} 次', + more: '展开 {n} 项', + collapse: '收起', + goals: '目标', + activeGoals: '活跃目标', + noGoals: '暂无活跃目标', + plans: '计划', + noPlans: '该员工暂无计划', + noAgent: '请选择一个员工', + selectPlan: '从左侧选择一个计划,查看其步骤看板', + untitled: '未命名计划', + steps: '步骤', + viewResult: '点击查看结果', + col: { + pending: '待执行', + running: '执行中', + completed: '已完成', + failed: '失败', + }, + }, nav: { dashboard: '仪表盘', chat: '对话', @@ -1064,6 +1096,7 @@ export default { views: { roster: '花名册', live: '现场', + plans: '计划看板', }, templates: { title: '选择岗位', @@ -2104,6 +2137,22 @@ export default { errors: { loadFailed: '无法加载运行时状态。', }, + layout: { + grid: '网格', + board: '看板', + gridHint: '网格视图', + boardHint: '生命周期看板视图', + }, + board: { + running: '运行中', + attention: '需关注', + done: '已完成', + failed: '未达成', + queuedHint: '排队 {n}', + goalLabel: '目标', + emptyCol: '暂无', + noGoal: '未设目标', + }, }, doctor: { title: '系统诊断', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 57c115df..d525febc 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -723,6 +723,8 @@ export interface SubPlan { export interface Plan { id: string | number agentId: string + /** Conversation/run that produced the plan (may be absent on legacy rows). */ + conversationId?: string goal: string status: 'pending' | 'running' | 'completed' | 'failed' totalSteps: number diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index 12c78d26..c73398c6 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -30,6 +30,11 @@ :class="{ warn: liveStuck > 0 }" >{{ liveRunning }} +