mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(plans): Kanban boards in the Agents workspace
Live lifecycle board (grid<->board toggle) plus an assignee-swimlane plan board that groups follow-up re-runs of one goal into a single xN card. Custom right-side detail/goal panels with markdown output. Fixes plans being persisted under the per-run trace id so the board actually populates. Closes #385
This commit is contained in:
parent
d6001e3e6f
commit
c656aff349
@ -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<String> 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)
|
||||
|
||||
@ -23,10 +23,14 @@ public class PlanningController {
|
||||
|
||||
private final PlanningService planningService;
|
||||
|
||||
@Operation(summary = "获取 Agent 的计划列表")
|
||||
@Operation(summary = "获取计划列表(带 agentId 则按员工,否则跨员工取最近 N 条)")
|
||||
@GetMapping
|
||||
public R<List<PlanEntity>> listByAgent(@RequestParam String agentId) {
|
||||
return R.ok(planningService.listPlansByAgent(agentId));
|
||||
public R<List<PlanEntity>> 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 = "获取计划详情(含步骤)")
|
||||
|
||||
@ -21,6 +21,9 @@ public class PlanEntity {
|
||||
/** 关联的 Agent ID(字符串) */
|
||||
private String agentId;
|
||||
|
||||
/** 产生该计划的对话/运行 ID(可空,历史行为 null)。用于把计划绑定到具体运行、支持跨员工/协同分组。 */
|
||||
private String conversationId;
|
||||
|
||||
/** 任务目标 */
|
||||
private String goal;
|
||||
|
||||
|
||||
@ -34,8 +34,18 @@ public class PlanningService {
|
||||
*/
|
||||
@Transactional
|
||||
public PlanEntity createPlan(String agentId, String goal, List<String> steps) {
|
||||
return createPlan(agentId, null, goal, steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建执行计划,并绑定到产生它的对话/运行。
|
||||
* conversationId 可空(历史调用方),便于把计划归到某次运行,支撑跨员工/协同看板。
|
||||
*/
|
||||
@Transactional
|
||||
public PlanEntity createPlan(String agentId, String conversationId, String goal, List<String> 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<PlanEntity> listRecentPlans(int limit) {
|
||||
int capped = limit <= 0 ? 100 : Math.min(limit, 500);
|
||||
return planMapper.selectList(new LambdaQueryWrapper<PlanEntity>()
|
||||
.orderByDesc(PlanEntity::getCreateTime)
|
||||
.last("LIMIT " + capped));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划详情(含子计划)
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -66,10 +66,8 @@ init({ apiKey: 'your-channel-api-key', server: 'https://<your-deployment>' })
|
||||
|
||||
| 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-name>" skill: <user message>`
|
||||
- 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: <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 `[[<slug>]]` 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 [[<slug>]]: <user message>`
|
||||
- Chinese: `参考知识库页面 [[<slug>]]:<用户消息>`
|
||||
|
||||
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: <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**
|
||||
|
||||
@ -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-name>" 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: <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,选中后插入 `[[<slug>]]` token,发送时改写为指令文本,LLM 收到后调 `wiki_read_page(slug=...)` 读取该页面再做答。后端**不做任何 `[[` 解析**,webchat 走的是和主控台完全一样的 agent runtime。
|
||||
|
||||
指令文本格式(**精确**):
|
||||
|
||||
- 中文:`参考知识库页面 [[<slug>]]:<用户消息>`
|
||||
- 英文:`Reference the wiki page [[<slug>]]: <user message>`
|
||||
|
||||
多引用天然支持(并列写即可):
|
||||
|
||||
```
|
||||
参考知识库页面 [[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: <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 示例
|
||||
|
||||
**第一步:发首条消息**
|
||||
|
||||
@ -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}`),
|
||||
}
|
||||
|
||||
|
||||
274
mateclaw-ui/src/components/agents/GoalsPanel.vue
Normal file
274
mateclaw-ui/src/components/agents/GoalsPanel.vue
Normal file
@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<!--
|
||||
Active-goals panel — same right-anchored slide-in shell as PlanDetailPanel
|
||||
(backdrop blur + mc-surface-card + round close), replacing the default
|
||||
el-drawer chrome so both board popups share one design language.
|
||||
-->
|
||||
<Teleport to="body">
|
||||
<Transition name="pd-slide">
|
||||
<div v-if="open" class="pd-backdrop" @click.self="$emit('close')">
|
||||
<aside class="pd-panel mc-surface-card" role="dialog" aria-modal="true">
|
||||
<button class="pd-close" type="button" :aria-label="t('live.actions.close')" @click="$emit('close')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6 L18 18 M18 6 L6 18"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="gp-head">
|
||||
<h2 class="gp-head__title">{{ t('plans.activeGoals') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="pd-loading">{{ t('common.loading') }}</div>
|
||||
<el-empty v-else-if="!goals.length" :description="t('plans.noGoals')" />
|
||||
|
||||
<div v-else class="gp-list">
|
||||
<div v-for="goal in goals" :key="goal.id" class="gp-goal">
|
||||
<div class="gp-goal__title">{{ cleanGoal(goal.title) }}</div>
|
||||
<p v-if="showDesc(goal)" class="gp-goal__desc">{{ cleanGoal(goal.description) }}</p>
|
||||
|
||||
<div class="gp-goal__score" v-if="goal.completionScore != null">
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress__fill" :style="{ width: pct(goal) + '%' }"></div>
|
||||
</div>
|
||||
<span>{{ pct(goal) }}%</span>
|
||||
</div>
|
||||
|
||||
<ul v-if="goal.criteria?.length" class="gp-criteria">
|
||||
<li v-for="c in goal.criteria" :key="c.id" :class="{ 'is-passed': c.passed }">
|
||||
<span class="gp-check">
|
||||
<svg v-if="c.passed" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>
|
||||
</span>
|
||||
<span>{{ c.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="goal.progressSummary" class="gp-goal__gap">{{ goal.progressSummary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Goal } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
goals: Goal[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Strip the appended "[Follow-up guidance] ..." block so titles/descriptions
|
||||
// read as the original objective, not the internal re-prompt.
|
||||
function cleanGoal(text?: string | null): string {
|
||||
if (!text) return ''
|
||||
const i = text.indexOf('[Follow-up guidance]')
|
||||
return (i >= 0 ? text.slice(0, i) : text).trim()
|
||||
}
|
||||
|
||||
// The description often duplicates the title (both seeded from the same goal
|
||||
// text). Only show it when it adds something the title doesn't.
|
||||
function showDesc(goal: Goal): boolean {
|
||||
const title = cleanGoal(goal.title)
|
||||
const desc = cleanGoal(goal.description)
|
||||
return !!desc && desc !== title
|
||||
}
|
||||
|
||||
function pct(goal: Goal): number {
|
||||
return Math.round((goal.completionScore || 0) * 100)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== Shared right-panel shell (mirrors PlanDetailPanel) ===== */
|
||||
.pd-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(20, 14, 10, 0.42);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
html.dark .pd-backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.pd-slide-enter-active,
|
||||
.pd-slide-leave-active {
|
||||
transition: opacity 0.22s ease, backdrop-filter 0.22s ease, -webkit-backdrop-filter 0.22s ease;
|
||||
}
|
||||
.pd-slide-enter-active .pd-panel,
|
||||
.pd-slide-leave-active .pd-panel {
|
||||
transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
.pd-slide-enter-from,
|
||||
.pd-slide-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
}
|
||||
.pd-slide-enter-from .pd-panel,
|
||||
.pd-slide-leave-to .pd-panel {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.pd-panel {
|
||||
position: relative;
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 28px 28px 32px;
|
||||
border-radius: 24px 0 0 24px;
|
||||
box-shadow: -28px 0 80px -24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.pd-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.pd-close:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
.pd-loading {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.gp-head {
|
||||
margin: 2px 36px 20px 0;
|
||||
}
|
||||
.gp-head__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
/* ===== Goal cards ===== */
|
||||
.gp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.gp-goal {
|
||||
padding: 16px;
|
||||
background: var(--mc-bg-sunken);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: var(--mc-radius-lg);
|
||||
}
|
||||
.gp-goal__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--mc-text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.gp-goal__desc {
|
||||
margin: 7px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--mc-text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.gp-goal__score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 14px 0;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gp-progress {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
background: var(--mc-bg-muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
.gp-progress__fill {
|
||||
height: 100%;
|
||||
background: var(--mc-primary);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.gp-criteria {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.gp-criteria li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--mc-text-tertiary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.gp-criteria li.is-passed {
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.gp-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--mc-text-quaternary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.gp-criteria li.is-passed .gp-check {
|
||||
color: var(--mc-success);
|
||||
}
|
||||
.gp-goal__gap {
|
||||
margin: 12px 0 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--mc-border);
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.pd-panel {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
padding: 24px 20px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
587
mateclaw-ui/src/components/agents/PlanBoard.vue
Normal file
587
mateclaw-ui/src/components/agents/PlanBoard.vue
Normal file
@ -0,0 +1,587 @@
|
||||
<template>
|
||||
<!--
|
||||
Team plan board: every employee's recent plans laid out as assignee swimlanes
|
||||
(rows = employee, columns = plan status). Cross-agent by default — the picker
|
||||
is a filter, not a requirement. Click a plan to open its step detail. Read-only;
|
||||
status is execution-driven.
|
||||
-->
|
||||
<div class="plan-board">
|
||||
<div class="pb-toolbar mc-surface-card">
|
||||
<div class="pb-agent-select">
|
||||
<AgentPickerDialog
|
||||
block
|
||||
clearable
|
||||
:model-value="filterAgentId || null"
|
||||
:agents="agentStore.agents"
|
||||
:placeholder="t('plans.allEmployees')"
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pb-toolbar__spacer"></div>
|
||||
|
||||
<button
|
||||
class="pb-btn"
|
||||
:class="{ 'is-active': goalDrawerOpen }"
|
||||
@click="openGoalDrawer"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||
<span>{{ t('plans.goals') }}</span>
|
||||
<span v-if="activeGoals.length" class="pb-btn__badge">{{ activeGoals.length }}</span>
|
||||
</button>
|
||||
|
||||
<button class="pb-btn pb-btn--icon" :title="t('common.refresh')" :disabled="loading" @click="reload">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ 'pb-spin': loading }"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="pb-blank mc-surface-card">
|
||||
<span class="pb-hint">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!lanes.length" class="pb-blank mc-surface-card">
|
||||
<el-empty :description="t('plans.noPlansAll')" />
|
||||
</div>
|
||||
|
||||
<!-- Swimlanes -->
|
||||
<div v-else class="pb-swim mc-surface-card">
|
||||
<div class="pb-swim__head">
|
||||
<div class="pb-corner"></div>
|
||||
<div class="pb-cols">
|
||||
<div v-for="col in columns" :key="col.key" class="pb-colhead">
|
||||
<span class="pb-dot" :class="`is-${col.key}`"></span>
|
||||
<span>{{ col.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="lane in lanes" :key="lane.key" class="pb-lane">
|
||||
<div class="pb-lane__label">
|
||||
<div class="pb-avatar">
|
||||
<SkillIcon v-if="lane.icon" :value="lane.icon" :size="20" fallback="🤖" />
|
||||
<span v-else class="pb-avatar__letter">{{ laneLetter(lane.name) }}</span>
|
||||
</div>
|
||||
<div class="pb-lane__id">
|
||||
<div class="pb-lane__name">{{ lane.name }}</div>
|
||||
<div class="pb-lane__count">{{ lane.plans.length }} {{ t('plans.plans') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-cols">
|
||||
<div v-for="col in columns" :key="col.key" class="pb-cell">
|
||||
<article
|
||||
v-for="group in visibleGroups(lane, col.key)"
|
||||
:key="group.key"
|
||||
class="pb-card"
|
||||
@click="openDetail(group.latest)"
|
||||
>
|
||||
<div class="pb-card__goal">{{ group.goal }}</div>
|
||||
<div class="pb-card__foot">
|
||||
<div class="pb-progress">
|
||||
<div class="pb-progress__bar" :class="`is-${group.latest.status}`" :style="{ width: progressPct(group.latest) + '%' }"></div>
|
||||
</div>
|
||||
<span class="pb-card__steps">{{ group.latest.completedSteps }}/{{ group.latest.totalSteps }}</span>
|
||||
<span v-if="group.plans.length > 1" class="pb-card__runs" :title="t('plans.runs', { n: group.plans.length })">×{{ group.plans.length }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<button
|
||||
v-if="!expandedCells.has(cellKey(lane, col.key)) && hiddenCount(lane, col.key) > 0"
|
||||
class="pb-more"
|
||||
@click="toggleCell(lane, col.key)"
|
||||
>{{ t('plans.more', { n: hiddenCount(lane, col.key) }) }}</button>
|
||||
<button
|
||||
v-else-if="expandedCells.has(cellKey(lane, col.key)) && groupsIn(lane, col.key).length > GROUP_LIMIT"
|
||||
class="pb-more"
|
||||
@click="toggleCell(lane, col.key)"
|
||||
>{{ t('plans.collapse') }}</button>
|
||||
|
||||
<div v-if="!groupsIn(lane, col.key).length" class="pb-cell__empty">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan detail: custom right-side slide-in panel (matches focus-panel) -->
|
||||
<PlanDetailPanel
|
||||
:open="detailOpen"
|
||||
:plan="detailPlan"
|
||||
:loading="loadingDetail"
|
||||
:assignee-name="detailAssignee.name"
|
||||
:assignee-icon="detailAssignee.icon"
|
||||
@close="detailOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Active goals: custom right-side panel (matches plan detail) -->
|
||||
<GoalsPanel
|
||||
:open="goalDrawerOpen"
|
||||
:goals="activeGoals"
|
||||
:loading="loadingGoals"
|
||||
@close="goalDrawerOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAgentStore } from '@/stores/useAgentStore'
|
||||
import AgentPickerDialog from '@/components/common/AgentPickerDialog.vue'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import PlanDetailPanel from '@/components/agents/PlanDetailPanel.vue'
|
||||
import GoalsPanel from '@/components/agents/GoalsPanel.vue'
|
||||
import { planApi, goalApi, type Goal } from '@/api'
|
||||
import type { Plan } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const agentStore = useAgentStore()
|
||||
|
||||
type PlanStatus = 'pending' | 'running' | 'completed' | 'failed'
|
||||
interface Lane { key: string; name: string; icon?: string | null; plans: Plan[]; latest: number }
|
||||
/** A column card: one cleaned goal, with every plan that re-ran it (latest first). */
|
||||
interface PlanGroup { key: string; goal: string; plans: Plan[]; latest: Plan }
|
||||
|
||||
// Goal follow-up re-enters planning each turn, so a single objective can spawn
|
||||
// many near-identical plans. Collapse them per column into one card (×N badge)
|
||||
// instead of a wall of duplicates — closer to a "task with N runs" than N tasks.
|
||||
const GROUP_LIMIT = 6
|
||||
|
||||
const plans = ref<Plan[]>([])
|
||||
const loading = ref(false)
|
||||
const filterAgentId = ref<string>('')
|
||||
const expandedCells = ref<Set<string>>(new Set())
|
||||
|
||||
const detailOpen = ref(false)
|
||||
const detailPlan = ref<Plan | null>(null)
|
||||
const loadingDetail = ref(false)
|
||||
|
||||
const detailAssignee = computed<{ name: string; icon?: string | null }>(() => {
|
||||
const p = detailPlan.value
|
||||
if (!p) return { name: '' }
|
||||
const meta = agentById.value.get(String(p.agentId))
|
||||
return { name: meta?.name || t('plans.unknownAgent'), icon: meta?.icon }
|
||||
})
|
||||
|
||||
const goalDrawerOpen = ref(false)
|
||||
const loadingGoals = ref(false)
|
||||
const activeGoals = ref<Goal[]>([])
|
||||
|
||||
const columns = computed<{ key: PlanStatus; label: string }[]>(() => [
|
||||
{ key: 'pending', label: t('plans.col.pending') },
|
||||
{ key: 'running', label: t('plans.col.running') },
|
||||
{ key: 'completed', label: t('plans.col.completed') },
|
||||
{ key: 'failed', label: t('plans.col.failed') },
|
||||
])
|
||||
|
||||
const agentById = computed(() => {
|
||||
const m = new Map<string, { name: string; icon?: string | null }>()
|
||||
for (const a of agentStore.agents) m.set(String(a.id), { name: a.name, icon: a.icon })
|
||||
return m
|
||||
})
|
||||
|
||||
// Group plans into assignee lanes; only employees with at least one plan get a
|
||||
// lane. Optional picker narrows to a single employee. Lanes ordered by most
|
||||
// recent plan activity so the busy ones surface first.
|
||||
const lanes = computed<Lane[]>(() => {
|
||||
const src = filterAgentId.value
|
||||
? plans.value.filter((p) => String(p.agentId) === filterAgentId.value)
|
||||
: plans.value
|
||||
const map = new Map<string, Lane>()
|
||||
for (const p of src) {
|
||||
const key = String(p.agentId)
|
||||
let lane = map.get(key)
|
||||
if (!lane) {
|
||||
const meta = agentById.value.get(key)
|
||||
lane = { key, name: meta?.name || t('plans.unknownAgent'), icon: meta?.icon, plans: [], latest: 0 }
|
||||
map.set(key, lane)
|
||||
}
|
||||
lane.plans.push(p)
|
||||
const ts = p.createTime ? new Date(p.createTime).getTime() : 0
|
||||
if (ts > lane.latest) lane.latest = ts
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.latest - a.latest)
|
||||
})
|
||||
|
||||
function planTs(p: Plan): number {
|
||||
return p.createTime ? new Date(p.createTime).getTime() : 0
|
||||
}
|
||||
|
||||
// Drop the appended "[Follow-up guidance] ..." block so re-runs of one objective
|
||||
// share a title — and therefore a group.
|
||||
function cleanGoal(goal?: string): string {
|
||||
if (!goal) return ''
|
||||
const i = goal.indexOf('[Follow-up guidance]')
|
||||
return (i >= 0 ? goal.slice(0, i) : goal).trim()
|
||||
}
|
||||
|
||||
// Group a column's plans by cleaned goal, newest run first; groups ordered by
|
||||
// most-recent activity.
|
||||
function groupsIn(lane: Lane, status: PlanStatus): PlanGroup[] {
|
||||
const map = new Map<string, Plan[]>()
|
||||
for (const p of lane.plans) {
|
||||
if (p.status !== status) continue
|
||||
const key = cleanGoal(p.goal) || String(p.id)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(p)
|
||||
else map.set(key, [p])
|
||||
}
|
||||
const groups: PlanGroup[] = [...map.entries()].map(([key, ps]) => {
|
||||
const sorted = [...ps].sort((a, b) => planTs(b) - planTs(a))
|
||||
return { key, goal: cleanGoal(sorted[0].goal) || t('plans.untitled'), plans: sorted, latest: sorted[0] }
|
||||
})
|
||||
return groups.sort((a, b) => planTs(b.latest) - planTs(a.latest))
|
||||
}
|
||||
|
||||
function cellKey(lane: Lane, status: PlanStatus): string {
|
||||
return `${lane.key}:${status}`
|
||||
}
|
||||
|
||||
function visibleGroups(lane: Lane, status: PlanStatus): PlanGroup[] {
|
||||
const all = groupsIn(lane, status)
|
||||
return expandedCells.value.has(cellKey(lane, status)) ? all : all.slice(0, GROUP_LIMIT)
|
||||
}
|
||||
|
||||
function hiddenCount(lane: Lane, status: PlanStatus): number {
|
||||
return Math.max(0, groupsIn(lane, status).length - GROUP_LIMIT)
|
||||
}
|
||||
|
||||
function toggleCell(lane: Lane, status: PlanStatus) {
|
||||
const key = cellKey(lane, status)
|
||||
const next = new Set(expandedCells.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedCells.value = next
|
||||
}
|
||||
|
||||
function progressPct(plan: Plan): number {
|
||||
if (!plan.totalSteps) return 0
|
||||
return Math.round((plan.completedSteps / plan.totalSteps) * 100)
|
||||
}
|
||||
|
||||
function laneLetter(name: string): string {
|
||||
return (name || '?').trim().charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (!agentStore.agents.length) await agentStore.fetchAgents()
|
||||
const res: any = await planApi.listAll(200)
|
||||
plans.value = (res?.data ?? []) as Plan[]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterChange(value: string | number | null) {
|
||||
filterAgentId.value = value == null ? '' : String(value)
|
||||
}
|
||||
|
||||
async function openDetail(plan: Plan) {
|
||||
detailPlan.value = plan
|
||||
detailOpen.value = true
|
||||
loadingDetail.value = true
|
||||
try {
|
||||
const res: any = await planApi.get(String(plan.id))
|
||||
detailPlan.value = (res?.data ?? plan) as Plan
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openGoalDrawer() {
|
||||
goalDrawerOpen.value = true
|
||||
await loadGoals()
|
||||
}
|
||||
|
||||
async function loadGoals() {
|
||||
loadingGoals.value = true
|
||||
try {
|
||||
const res: any = await goalApi.list({ status: 'active', limit: 100 })
|
||||
const all = (res?.data ?? []) as Goal[]
|
||||
activeGoals.value = filterAgentId.value
|
||||
? all.filter((g) => String(g.agentId) === filterAgentId.value)
|
||||
: all
|
||||
} finally {
|
||||
loadingGoals.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.plan-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.pb-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.pb-agent-select {
|
||||
width: 220px;
|
||||
}
|
||||
.pb-toolbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: var(--mc-radius-md);
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.pb-btn:hover:not(:disabled) {
|
||||
border-color: var(--mc-border-strong);
|
||||
}
|
||||
.pb-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pb-btn.is-active {
|
||||
border-color: var(--mc-primary);
|
||||
color: var(--mc-primary);
|
||||
}
|
||||
.pb-btn--icon {
|
||||
padding: 8px 11px;
|
||||
}
|
||||
.pb-btn__badge {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
background: var(--mc-primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.pb-spin {
|
||||
animation: pb-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes pb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Blank */
|
||||
.pb-blank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
.pb-hint {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
/* Swimlanes */
|
||||
.pb-swim {
|
||||
padding: 8px 8px 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pb-swim__head,
|
||||
.pb-lane {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 760px;
|
||||
}
|
||||
.pb-corner,
|
||||
.pb-lane__label {
|
||||
width: 168px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pb-cols {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.pb-colhead {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
|
||||
/* Lane */
|
||||
.pb-lane {
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
padding: 12px 0;
|
||||
}
|
||||
.pb-lane__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.pb-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pb-avatar :deep(.skill-icon) { width: 100% !important; height: 100% !important; display: flex; align-items: center; justify-content: center; }
|
||||
.pb-avatar :deep(.skill-icon__img) { width: 62%; height: 62%; object-fit: contain; }
|
||||
.pb-avatar :deep(.skill-icon__glyph) { font-size: 18px; line-height: 1; }
|
||||
.pb-lane__id {
|
||||
min-width: 0;
|
||||
}
|
||||
.pb-lane__name {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pb-lane__count {
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Cell */
|
||||
.pb-cell {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--mc-bg-sunken);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: var(--mc-radius-md);
|
||||
}
|
||||
.pb-cell__empty {
|
||||
text-align: center;
|
||||
color: var(--mc-text-quaternary);
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Status dots + tags */
|
||||
.pb-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--mc-text-tertiary);
|
||||
}
|
||||
.pb-dot.is-pending { background: var(--mc-text-tertiary); }
|
||||
.pb-dot.is-running { background: var(--mc-primary); }
|
||||
.pb-dot.is-completed { background: var(--mc-success); }
|
||||
.pb-dot.is-failed { background: var(--mc-danger); }
|
||||
|
||||
/* Plan card */
|
||||
.pb-card {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: var(--mc-radius-md);
|
||||
padding: 10px 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.pb-card:hover {
|
||||
border-color: var(--mc-border-strong);
|
||||
box-shadow: var(--mc-shadow-soft);
|
||||
}
|
||||
.pb-card__goal {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
color: var(--mc-text-primary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pb-card__runs {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pb-more {
|
||||
margin-top: 2px;
|
||||
padding: 6px 8px;
|
||||
border: 1px dashed var(--mc-border);
|
||||
border-radius: var(--mc-radius-md);
|
||||
background: transparent;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 11.5px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.pb-more:hover {
|
||||
background: var(--mc-bg-hover);
|
||||
color: var(--mc-text-secondary);
|
||||
border-color: var(--mc-border-strong);
|
||||
}
|
||||
.pb-card__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.pb-card__steps {
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.pb-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
background: var(--mc-bg-muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pb-progress__bar {
|
||||
height: 100%;
|
||||
background: var(--mc-success);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.pb-progress__bar.is-running { background: var(--mc-primary); }
|
||||
.pb-progress__bar.is-pending { background: var(--mc-text-tertiary); }
|
||||
.pb-progress__bar.is-failed { background: var(--mc-danger); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pb-agent-select {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
444
mateclaw-ui/src/components/agents/PlanDetailPanel.vue
Normal file
444
mateclaw-ui/src/components/agents/PlanDetailPanel.vue
Normal file
@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<!--
|
||||
Plan (task) detail — a right-anchored slide-in panel matching the project's
|
||||
custom focus-panel language (backdrop blur + mc-surface-card + round close),
|
||||
NOT the default el-drawer chrome. Content is laid out like a kanban task
|
||||
card: assignee + status header, KPI tiles, output, and a step timeline.
|
||||
-->
|
||||
<Teleport to="body">
|
||||
<Transition name="pd-slide">
|
||||
<div v-if="open && plan" class="pd-backdrop" @click.self="$emit('close')">
|
||||
<aside class="pd-panel mc-surface-card" role="dialog" aria-modal="true">
|
||||
<button class="pd-close" type="button" :aria-label="t('live.actions.close')" @click="$emit('close')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6 L18 18 M18 6 L6 18"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Header: assignee + status -->
|
||||
<div class="pd-head">
|
||||
<div class="pd-assignee">
|
||||
<div class="pd-avatar">
|
||||
<SkillIcon v-if="assigneeIcon" :value="assigneeIcon" :size="20" fallback="🤖" />
|
||||
<span v-else class="pd-avatar__letter">{{ letter(assigneeName) }}</span>
|
||||
</div>
|
||||
<div class="pd-assignee__id">
|
||||
<div class="pd-assignee__name">{{ assigneeName || t('plans.unknownAgent') }}</div>
|
||||
<div class="pd-assignee__cap">{{ t('plans.assignee') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pd-status" :class="`is-${plan.status}`">{{ statusLabel(plan.status) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="pd-title">{{ cleanGoal(plan.goal) }}</h2>
|
||||
|
||||
<!-- KPI tiles -->
|
||||
<div class="pd-tiles">
|
||||
<div class="pd-tile">
|
||||
<div class="pd-tile__value">{{ plan.completedSteps }}/{{ plan.totalSteps }}</div>
|
||||
<div class="pd-tile__label">{{ t('plans.steps') }}</div>
|
||||
</div>
|
||||
<div class="pd-tile">
|
||||
<div class="pd-tile__value">{{ pct }}%</div>
|
||||
<div class="pd-tile__label">{{ t('plans.progress') }}</div>
|
||||
</div>
|
||||
<div class="pd-tile">
|
||||
<div class="pd-tile__value pd-tile__date">{{ shortDate(plan.createTime) }}</div>
|
||||
<div class="pd-tile__label">{{ t('plans.created') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output / summary -->
|
||||
<section v-if="plan.summary" class="pd-section">
|
||||
<div class="pd-section__title">{{ t('plans.output') }}</div>
|
||||
<div class="pd-output markdown-body" v-html="summaryHtml"></div>
|
||||
</section>
|
||||
|
||||
<!-- Steps timeline -->
|
||||
<section class="pd-section">
|
||||
<div class="pd-section__title">{{ t('plans.steps') }}</div>
|
||||
<div v-if="loading" class="pd-loading">{{ t('common.loading') }}</div>
|
||||
<ol v-else class="pd-timeline">
|
||||
<li
|
||||
v-for="step in (plan.steps ?? [])"
|
||||
:key="String(step.id)"
|
||||
class="pd-step"
|
||||
:class="{ 'is-open': expanded === String(step.id), 'has-result': !!step.result }"
|
||||
@click="toggle(step)"
|
||||
>
|
||||
<span class="pd-step__dot" :class="`is-${step.status}`"></span>
|
||||
<div class="pd-step__body">
|
||||
<div class="pd-step__title"><b>{{ step.stepIndex + 1 }}.</b> {{ step.description }}</div>
|
||||
<div v-if="step.result && expanded === String(step.id)" class="pd-step__result markdown-body" v-html="resultHtml"></div>
|
||||
<div v-else-if="step.result" class="pd-step__hint">{{ t('plans.viewResult') }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import { useStreamingMarkdown } from '@/composables/useStreamingMarkdown'
|
||||
import type { Plan, SubPlan } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
plan: Plan | null
|
||||
loading: boolean
|
||||
assigneeName?: string
|
||||
assigneeIcon?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const expanded = ref<string>('')
|
||||
|
||||
// Render the plan output and the expanded step result as markdown, reusing the
|
||||
// same renderer the chat uses. `streaming = false` → full-fidelity one-shot
|
||||
// render (code highlight + cache), since this content is already complete.
|
||||
const expandedResultText = computed(() => {
|
||||
if (!expanded.value) return ''
|
||||
return (props.plan?.steps ?? []).find((s) => String(s.id) === expanded.value)?.result ?? ''
|
||||
})
|
||||
const { html: summaryHtml } = useStreamingMarkdown(() => props.plan?.summary ?? '', () => false)
|
||||
const { html: resultHtml } = useStreamingMarkdown(() => expandedResultText.value, () => false)
|
||||
|
||||
const pct = computed(() => {
|
||||
const p = props.plan
|
||||
if (!p || !p.totalSteps) return 0
|
||||
return Math.round((p.completedSteps / p.totalSteps) * 100)
|
||||
})
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return t(`plans.col.${status}`, status)
|
||||
}
|
||||
|
||||
// The persisted goal can carry an appended "[Follow-up guidance] ..." block
|
||||
// (added when a goal follow-up re-enters planning). Strip it for display so the
|
||||
// title reads as the original task, not the internal re-prompt.
|
||||
function cleanGoal(goal: string): string {
|
||||
if (!goal) return ''
|
||||
const i = goal.indexOf('[Follow-up guidance]')
|
||||
return (i >= 0 ? goal.slice(0, i) : goal).trim()
|
||||
}
|
||||
|
||||
function letter(name?: string): string {
|
||||
return (name || '?').trim().charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function shortDate(ts?: string): string {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
function toggle(step: SubPlan) {
|
||||
if (!step.result) return
|
||||
expanded.value = expanded.value === String(step.id) ? '' : String(step.id)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
// Reset the expanded step whenever a different plan is shown.
|
||||
watch(() => props.plan?.id, () => { expanded.value = '' })
|
||||
watch(() => props.open, (open) => {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pd-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(20, 14, 10, 0.42);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
html.dark .pd-backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Slide-in from the right */
|
||||
.pd-slide-enter-active,
|
||||
.pd-slide-leave-active {
|
||||
transition: opacity 0.22s ease, backdrop-filter 0.22s ease, -webkit-backdrop-filter 0.22s ease;
|
||||
}
|
||||
.pd-slide-enter-active .pd-panel,
|
||||
.pd-slide-leave-active .pd-panel {
|
||||
transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
.pd-slide-enter-from,
|
||||
.pd-slide-leave-to {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
}
|
||||
.pd-slide-enter-from .pd-panel,
|
||||
.pd-slide-leave-to .pd-panel {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.pd-panel {
|
||||
position: relative;
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 28px 28px 32px;
|
||||
border-radius: 24px 0 0 24px;
|
||||
box-shadow: -28px 0 80px -24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pd-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.pd-close:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.pd-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 4px 36px 18px 0;
|
||||
}
|
||||
.pd-assignee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.pd-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 11px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.pd-avatar :deep(.skill-icon) { width: 100% !important; height: 100% !important; display: flex; align-items: center; justify-content: center; }
|
||||
.pd-avatar :deep(.skill-icon__img) { width: 62%; height: 62%; object-fit: contain; }
|
||||
.pd-avatar :deep(.skill-icon__glyph) { font-size: 20px; line-height: 1; }
|
||||
.pd-assignee__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pd-assignee__cap {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mc-text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pd-status {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 11px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.pd-status.is-running { background: var(--mc-primary-bg); color: var(--mc-primary); }
|
||||
.pd-status.is-completed { background: var(--mc-success); color: #fff; }
|
||||
.pd-status.is-failed { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
||||
|
||||
/* Title */
|
||||
.pd-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
/* KPI tiles */
|
||||
.pd-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.pd-tile {
|
||||
padding: 13px 10px 11px;
|
||||
background: var(--mc-bg-muted);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
html.dark .pd-tile {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.pd-tile__value {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--mc-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.pd-tile__date {
|
||||
font-size: 16px;
|
||||
}
|
||||
.pd-tile__label {
|
||||
font-size: 10.5px;
|
||||
color: var(--mc-text-tertiary);
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.pd-section {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.pd-section__title {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pd-output {
|
||||
padding: 14px 16px;
|
||||
background: var(--mc-bg-sunken);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: var(--mc-radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--mc-text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
.pd-output :deep(> :first-child) { margin-top: 0; }
|
||||
.pd-output :deep(> :last-child) { margin-bottom: 0; }
|
||||
.pd-loading {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.pd-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.pd-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.pd-step::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 16px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--mc-border-light);
|
||||
}
|
||||
.pd-step:last-child { padding-bottom: 0; }
|
||||
.pd-step:last-child::before { display: none; }
|
||||
.pd-step.has-result { cursor: pointer; }
|
||||
.pd-step__dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
background: var(--mc-text-tertiary);
|
||||
box-shadow: 0 0 0 3px var(--mc-bg-surface);
|
||||
}
|
||||
.pd-step__dot.is-pending { background: var(--mc-text-tertiary); }
|
||||
.pd-step__dot.is-running { background: var(--mc-primary); }
|
||||
.pd-step__dot.is-completed { background: var(--mc-success); }
|
||||
.pd-step__dot.is-failed { background: var(--mc-danger); }
|
||||
.pd-step__body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.pd-step__title {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--mc-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
.pd-step__result {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--mc-bg-sunken);
|
||||
border-radius: var(--mc-radius-md);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--mc-text-secondary);
|
||||
word-break: break-word;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pd-step__result :deep(> :first-child) { margin-top: 0; }
|
||||
.pd-step__result :deep(> :last-child) { margin-bottom: 0; }
|
||||
.pd-step__hint {
|
||||
margin-top: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--mc-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.pd-panel {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
padding: 24px 20px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
397
mateclaw-ui/src/components/live/LiveBoard.vue
Normal file
397
mateclaw-ui/src/components/live/LiveBoard.vue
Normal file
@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<!--
|
||||
Live lifecycle board: the same real-time snapshot the grid renders, laid out
|
||||
across run/goal lifecycle columns instead of a flat grid. Running cards carry
|
||||
a goal-completion overlay (goal linked by conversationId); terminal goals
|
||||
populate the Done / Unmet columns. Read-only — status is execution-driven.
|
||||
-->
|
||||
<div class="live-board">
|
||||
<section v-for="col in columns" :key="col.key" class="lb-col" :class="`is-${col.key}`">
|
||||
<header class="lb-col__head">
|
||||
<span class="lb-dot" :class="`is-${col.key}`"></span>
|
||||
<span class="lb-col__title">{{ col.label }}</span>
|
||||
<span class="lb-col__count">{{ col.items.length }}</span>
|
||||
<span v-if="col.key === 'running' && (summary?.queued ?? 0) > 0" class="lb-col__sub">
|
||||
{{ t('live.board.queuedHint', { n: summary!.queued }) }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="lb-col__body">
|
||||
<!-- Run cards (running / attention) -->
|
||||
<template v-if="col.kind === 'run'">
|
||||
<article
|
||||
v-for="run in (col.items as LiveRunCard[])"
|
||||
:key="run.conversationId"
|
||||
class="lb-card lb-card--run"
|
||||
:class="{ 'is-stuck': !!run.stuckReason, 'is-orphan': run.orphan && !run.stuckReason }"
|
||||
@click="$emit('open', run)"
|
||||
>
|
||||
<div class="lb-card__top">
|
||||
<div class="lb-avatar-wrap" :class="ringClass(run)">
|
||||
<div class="lb-avatar" :style="avatarBgStyle(run)">
|
||||
<SkillIcon v-if="run.agentIcon" :value="run.agentIcon" :size="22" fallback="🤖" />
|
||||
<span v-else class="lb-avatar__letter">{{ avatarLetter(run) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lb-card__id">
|
||||
<div class="lb-card__name">{{ run.agentName || t('live.unknownAgent') }}</div>
|
||||
<div class="lb-card__age">{{ formatAge(run.ageMs) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lb-card__saying">
|
||||
{{ humanSentence(run) }}
|
||||
<span v-if="run.runningToolName" class="lb-tool" :title="run.runningToolName">{{ run.runningToolName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Goal overlay: the conversation's goal becomes a visible bar -->
|
||||
<div v-if="goalFor(run)" class="lb-goal">
|
||||
<div class="lb-goal__bar">
|
||||
<div class="lb-goal__fill" :style="{ width: goalPct(goalFor(run)) + '%' }"></div>
|
||||
</div>
|
||||
<span class="lb-goal__pct">{{ goalPct(goalFor(run)) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="lb-card__foot">
|
||||
<div class="lb-subs" v-if="run.subagentCount > 0" :title="t('live.subagentsBadge', { n: run.subagentCount })">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
{{ run.subagentCount }}
|
||||
</div>
|
||||
<div class="lb-actions">
|
||||
<button class="lb-act" @click.stop="$emit('stop', run)">{{ t('live.actions.stop') }}</button>
|
||||
<button v-if="run.stuckReason" class="lb-act lb-act--strong" @click.stop="$emit('recycle', run)">{{ t('live.actions.endIt') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<!-- Goal cards (done / failed) -->
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="goal in (col.items as Goal[])"
|
||||
:key="goal.id"
|
||||
class="lb-card lb-card--goal"
|
||||
:class="`is-${col.key}`"
|
||||
>
|
||||
<div class="lb-card__name">{{ goal.title || t('live.board.noGoal') }}</div>
|
||||
<div v-if="goal.completionScore != null" class="lb-goal">
|
||||
<div class="lb-goal__bar">
|
||||
<div class="lb-goal__fill" :class="`is-${col.key}`" :style="{ width: goalPct(goal) + '%' }"></div>
|
||||
</div>
|
||||
<span class="lb-goal__pct">{{ goalPct(goal) }}%</span>
|
||||
</div>
|
||||
<p v-if="goal.progressSummary" class="lb-card__gap">{{ goal.progressSummary }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<div v-if="!col.items.length" class="lb-col__empty">{{ t('live.board.emptyCol') }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import { useLiveAgent } from '@/composables/useLiveAgent'
|
||||
import type { LiveRunCard, LiveSummary, Goal } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
runs: LiveRunCard[]
|
||||
summary: LiveSummary | null
|
||||
goalByConv: Record<string, Goal>
|
||||
doneGoals: Goal[]
|
||||
failedGoals: Goal[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open', run: LiveRunCard): void
|
||||
(e: 'stop', run: LiveRunCard): void
|
||||
(e: 'recycle', run: LiveRunCard): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { avatarLetter, avatarBgStyle, ringClass, humanSentence, formatAge } = useLiveAgent()
|
||||
|
||||
const runningRuns = computed(() => props.runs.filter((r) => !r.stuckReason && !r.orphan))
|
||||
const attentionRuns = computed(() =>
|
||||
props.runs
|
||||
.filter((r) => !!r.stuckReason || r.orphan)
|
||||
// Stuck (actionable) before orphan (merely unwatched).
|
||||
.sort((a, b) => (a.stuckReason ? 0 : 1) - (b.stuckReason ? 0 : 1)),
|
||||
)
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: 'running', kind: 'run', label: t('live.board.running'), items: runningRuns.value },
|
||||
{ key: 'attention', kind: 'run', label: t('live.board.attention'), items: attentionRuns.value },
|
||||
{ key: 'done', kind: 'goal', label: t('live.board.done'), items: props.doneGoals },
|
||||
{ key: 'failed', kind: 'goal', label: t('live.board.failed'), items: props.failedGoals },
|
||||
] as { key: string; kind: 'run' | 'goal'; label: string; items: LiveRunCard[] | Goal[] }[])
|
||||
|
||||
function goalFor(run: LiveRunCard): Goal | undefined {
|
||||
return props.goalByConv[run.conversationId]
|
||||
}
|
||||
|
||||
function goalPct(goal?: Goal): number {
|
||||
if (!goal || goal.completionScore == null) return 0
|
||||
return Math.round((goal.completionScore || 0) * 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.live-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.lb-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--mc-bg-sunken);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: var(--mc-radius-lg);
|
||||
min-height: 140px;
|
||||
}
|
||||
.lb-col__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-secondary);
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.lb-col__count {
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: var(--mc-radius-full);
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lb-col__sub {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
.lb-col__body {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 360px);
|
||||
}
|
||||
.lb-col__empty {
|
||||
text-align: center;
|
||||
color: var(--mc-text-quaternary);
|
||||
font-size: 12.5px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
/* Column accent dots */
|
||||
.lb-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lb-dot.is-running { background: hsl(155, 55%, 50%); }
|
||||
.lb-dot.is-attention { background: hsl(20, 80%, 55%); }
|
||||
.lb-dot.is-done { background: var(--mc-success); }
|
||||
.lb-dot.is-failed { background: var(--mc-danger); }
|
||||
|
||||
/* Cards */
|
||||
.lb-card {
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: var(--mc-radius-md);
|
||||
padding: 11px 12px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.lb-card--run {
|
||||
cursor: pointer;
|
||||
}
|
||||
.lb-card--run:hover {
|
||||
border-color: var(--mc-border-strong);
|
||||
box-shadow: var(--mc-shadow-soft);
|
||||
}
|
||||
.lb-card--run.is-stuck {
|
||||
border-color: hsla(20, 80%, 55%, 0.45);
|
||||
}
|
||||
.lb-card--run.is-orphan {
|
||||
border-color: hsla(265, 50%, 60%, 0.3);
|
||||
}
|
||||
|
||||
.lb-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.lb-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: 3px;
|
||||
margin: -3px;
|
||||
}
|
||||
.lb-avatar-wrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.lb-avatar-wrap.ring-healthy::before { border-color: hsla(155, 55%, 50%, 0.7); }
|
||||
.lb-avatar-wrap.ring-stuck::before { border-color: hsla(20, 80%, 55%, 0.8); }
|
||||
.lb-avatar-wrap.ring-orphan::before { border-color: hsla(265, 50%, 60%, 0.6); }
|
||||
.lb-avatar-wrap.ring-thinking::before { border-color: hsla(155, 55%, 55%, 0.5); }
|
||||
.lb-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.lb-avatar :deep(.skill-icon) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.lb-avatar :deep(.skill-icon__img) { width: 62%; height: 62%; object-fit: contain; }
|
||||
.lb-avatar :deep(.skill-icon__glyph) { font-size: 18px; line-height: 1; }
|
||||
.lb-card__id {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.lb-card__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lb-card__age {
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.lb-card__saying {
|
||||
margin-top: 9px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
.lb-tool {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--mc-radius-sm);
|
||||
background: var(--mc-accent-soft);
|
||||
color: var(--mc-accent);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* Goal overlay / goal cards */
|
||||
.lb-goal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.lb-goal__bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
background: var(--mc-bg-muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lb-goal__fill {
|
||||
height: 100%;
|
||||
background: var(--mc-primary);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.lb-goal__fill.is-done { background: var(--mc-success); }
|
||||
.lb-goal__fill.is-failed { background: var(--mc-danger); }
|
||||
.lb-goal__pct {
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lb-card__gap {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--mc-text-tertiary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.lb-card__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.lb-subs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lb-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.lb-act {
|
||||
padding: 4px 10px;
|
||||
font-size: 11.5px;
|
||||
border-radius: var(--mc-radius-full);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
background: transparent;
|
||||
color: var(--mc-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.lb-act:hover {
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-primary);
|
||||
border-color: var(--mc-border);
|
||||
}
|
||||
.lb-act--strong {
|
||||
border-color: hsla(20, 80%, 55%, 0.5);
|
||||
color: hsl(20, 75%, 45%);
|
||||
}
|
||||
.lb-act--strong:hover {
|
||||
background: hsla(20, 80%, 55%, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.live-board {
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -15,7 +15,7 @@
|
||||
<span>{{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showFilterRow" class="filter-row">
|
||||
<div v-if="showFilterRow && layout === 'grid'" class="filter-row">
|
||||
<button
|
||||
v-for="opt in filterOptions"
|
||||
:key="opt.key"
|
||||
@ -30,6 +30,27 @@
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<div class="layout-toggle" role="group">
|
||||
<button
|
||||
class="layout-seg"
|
||||
:class="{ 'is-active': layout === 'grid' }"
|
||||
:title="t('live.layout.gridHint')"
|
||||
@click="setLayout('grid')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
|
||||
<span>{{ t('live.layout.grid') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="layout-seg"
|
||||
:class="{ 'is-active': layout === 'board' }"
|
||||
:title="t('live.layout.boardHint')"
|
||||
@click="setLayout('board')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="6" height="18" rx="1"/><rect x="10" y="3" width="6" height="11" rx="1"/><rect x="17" y="3" width="4" height="7" rx="1"/></svg>
|
||||
<span>{{ t('live.layout.board') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
|
||||
class="chip-btn chip-btn-warm"
|
||||
@ -51,6 +72,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lifecycle board: runs + goals across status columns -->
|
||||
<LiveBoard
|
||||
v-else-if="layout === 'board'"
|
||||
:runs="snapshot?.runs ?? []"
|
||||
:summary="snapshot?.summary ?? null"
|
||||
:goal-by-conv="goalByConv"
|
||||
:done-goals="doneGoals"
|
||||
:failed-goals="failedGoals"
|
||||
@open="openDetail"
|
||||
@stop="confirmStop"
|
||||
@recycle="confirmRecycle"
|
||||
/>
|
||||
|
||||
<!-- Empty: nothing to see -->
|
||||
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
||||
<div class="empty-orb"></div>
|
||||
@ -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<LiveRunCard | null>(null)
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
let timer: ReturnType<typeof setInterval> | 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<Goal[]>([])
|
||||
const doneGoals = ref<Goal[]>([])
|
||||
const failedGoals = ref<Goal[]>([])
|
||||
|
||||
const goalByConv = computed<Record<string, Goal>>(() => {
|
||||
const map: Record<string, Goal> = {}
|
||||
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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '系统诊断',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,6 +30,11 @@
|
||||
:class="{ warn: liveStuck > 0 }"
|
||||
>{{ liveRunning }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-seg"
|
||||
:class="{ 'is-active': view === 'plans' }"
|
||||
@click="setView('plans')"
|
||||
>{{ t('agents.views.plans') }}</button>
|
||||
</div>
|
||||
<button class="btn-secondary" style="display:inline-flex;align-items:center;gap:6px;" @click="router.push('/agents/create')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@ -162,7 +167,10 @@
|
||||
</template>
|
||||
|
||||
<!-- Live: what the team is doing right now -->
|
||||
<LivePanel v-else />
|
||||
<LivePanel v-else-if="view === 'live'" />
|
||||
|
||||
<!-- Plans: the team's plans as a step board -->
|
||||
<PlanBoard v-else-if="view === 'plans'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -707,6 +715,7 @@ import type { Agent } from '@/types/index'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||
import LivePanel from '@/components/live/LivePanel.vue'
|
||||
import PlanBoard from '@/components/agents/PlanBoard.vue'
|
||||
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
|
||||
import {
|
||||
emptyProfile,
|
||||
@ -1133,16 +1142,19 @@ const filteredAgents = computed(() => {
|
||||
// Roster ↔ Live view switch — admin only. The running/stuck counts feed the
|
||||
// segmented control's pulse + badge so you know whether Live is worth a look.
|
||||
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
|
||||
const view = ref<'roster' | 'live'>(
|
||||
route.query.view === 'live' && isAdminRole.value ? 'live' : 'roster',
|
||||
type AgentView = 'roster' | 'live' | 'plans'
|
||||
const view = ref<AgentView>(
|
||||
isAdminRole.value && (route.query.view === 'live' || route.query.view === 'plans')
|
||||
? (route.query.view as AgentView)
|
||||
: 'roster',
|
||||
)
|
||||
const liveRunning = ref(0)
|
||||
const liveStuck = ref(0)
|
||||
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function setView(next: 'roster' | 'live') {
|
||||
function setView(next: AgentView) {
|
||||
view.value = next
|
||||
router.replace({ query: next === 'live' ? { view: 'live' } : {} })
|
||||
router.replace({ query: next === 'roster' ? {} : { view: next } })
|
||||
}
|
||||
|
||||
async function refreshLiveCounts() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user