mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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 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();
|
String conversationId = accessor.conversationId();
|
||||||
|
|
||||||
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
|
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: {})",
|
+ "downgrading to single-step plan so tools can execute (goal: {})",
|
||||||
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
|
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
|
||||||
List<String> gatedSteps = List.of(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));
|
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.needsPlanning(true)
|
.needsPlanning(true)
|
||||||
@ -451,7 +455,7 @@ public class PlanGenerationNode implements NodeAction {
|
|||||||
steps = List.of(goal);
|
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={} ({})",
|
log.info("[PlanGeneration] Plan created: id={}, steps={} ({})",
|
||||||
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step");
|
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
|
// answer. This preserves tool access on the failure path; the previous
|
||||||
// "direct answer" fallback silently degraded tool-requiring tasks.
|
// "direct answer" fallback silently degraded tool-requiring tasks.
|
||||||
try {
|
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)));
|
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
|
||||||
return PlanStateAccessor.output()
|
return PlanStateAccessor.output()
|
||||||
.needsPlanning(true)
|
.needsPlanning(true)
|
||||||
|
|||||||
@ -23,10 +23,14 @@ public class PlanningController {
|
|||||||
|
|
||||||
private final PlanningService planningService;
|
private final PlanningService planningService;
|
||||||
|
|
||||||
@Operation(summary = "获取 Agent 的计划列表")
|
@Operation(summary = "获取计划列表(带 agentId 则按员工,否则跨员工取最近 N 条)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public R<List<PlanEntity>> listByAgent(@RequestParam String agentId) {
|
public R<List<PlanEntity>> list(@RequestParam(required = false) String agentId,
|
||||||
return R.ok(planningService.listPlansByAgent(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 = "获取计划详情(含步骤)")
|
@Operation(summary = "获取计划详情(含步骤)")
|
||||||
|
|||||||
@ -21,6 +21,9 @@ public class PlanEntity {
|
|||||||
/** 关联的 Agent ID(字符串) */
|
/** 关联的 Agent ID(字符串) */
|
||||||
private String agentId;
|
private String agentId;
|
||||||
|
|
||||||
|
/** 产生该计划的对话/运行 ID(可空,历史行为 null)。用于把计划绑定到具体运行、支持跨员工/协同分组。 */
|
||||||
|
private String conversationId;
|
||||||
|
|
||||||
/** 任务目标 */
|
/** 任务目标 */
|
||||||
private String goal;
|
private String goal;
|
||||||
|
|
||||||
|
|||||||
@ -34,8 +34,18 @@ public class PlanningService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public PlanEntity createPlan(String agentId, String goal, List<String> steps) {
|
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();
|
PlanEntity plan = new PlanEntity();
|
||||||
plan.setAgentId(agentId);
|
plan.setAgentId(agentId);
|
||||||
|
plan.setConversationId(conversationId);
|
||||||
plan.setGoal(goal);
|
plan.setGoal(goal);
|
||||||
plan.setStatus("running");
|
plan.setStatus("running");
|
||||||
plan.setTotalSteps(steps.size());
|
plan.setTotalSteps(steps.size());
|
||||||
@ -111,6 +121,17 @@ public class PlanningService {
|
|||||||
.orderByDesc(PlanEntity::getCreateTime));
|
.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 |
|
| 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 | `/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 |
|
| POST | `/sessions` | API Key | Explicitly create an empty session thread |
|
||||||
| GET | `/sessions` | + visitorToken | List sessions (excludes archived by default) |
|
| GET | `/sessions` | + visitorToken | List sessions (excludes archived by default) |
|
||||||
| GET | `/sessions/page` | + visitorToken | Paginated + keyword search |
|
| 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.
|
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
|
## curl examples
|
||||||
|
|
||||||
**Step 1: send the first message**
|
**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 | `/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 | 显式创建空会话线程 |
|
| POST | `/sessions` | API Key | 显式创建空会话线程 |
|
||||||
| GET | `/sessions` | + visitorToken | 列出会话(默认排除 archived) |
|
| GET | `/sessions` | + visitorToken | 列出会话(默认排除 archived) |
|
||||||
| GET | `/sessions/page` | + visitorToken | 分页 + 关键词搜索 |
|
| 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` 同一端点。
|
撤销后该 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 示例
|
## curl 示例
|
||||||
|
|
||||||
**第一步:发首条消息**
|
**第一步:发首条消息**
|
||||||
|
|||||||
@ -522,6 +522,8 @@ export const mcpApi = {
|
|||||||
// ==================== Plan ====================
|
// ==================== Plan ====================
|
||||||
export const planApi = {
|
export const planApi = {
|
||||||
listByAgent: (agentId: string) => http.get(`/plans?agentId=${agentId}`),
|
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}`),
|
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>
|
<span>{{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="showFilterRow" class="filter-row">
|
<div v-if="showFilterRow && layout === 'grid'" class="filter-row">
|
||||||
<button
|
<button
|
||||||
v-for="opt in filterOptions"
|
v-for="opt in filterOptions"
|
||||||
:key="opt.key"
|
:key="opt.key"
|
||||||
@ -30,6 +30,27 @@
|
|||||||
|
|
||||||
<div class="toolbar-spacer"></div>
|
<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
|
<button
|
||||||
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
|
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
|
||||||
class="chip-btn chip-btn-warm"
|
class="chip-btn chip-btn-warm"
|
||||||
@ -51,6 +72,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Empty: nothing to see -->
|
||||||
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
||||||
<div class="empty-orb"></div>
|
<div class="empty-orb"></div>
|
||||||
@ -158,9 +192,10 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
|
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
|
||||||
|
import LiveBoard from '@/components/live/LiveBoard.vue'
|
||||||
import { useLiveAgent } from '@/composables/useLiveAgent'
|
import { useLiveAgent } from '@/composables/useLiveAgent'
|
||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
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 { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
@ -182,6 +217,42 @@ const detail = ref<LiveRunCard | null>(null)
|
|||||||
const activeFilter = ref<FilterKey>('all')
|
const activeFilter = ref<FilterKey>('all')
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
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 {
|
function isWorking(r: LiveRunCard): boolean {
|
||||||
return !r.stuckReason && !r.orphan
|
return !r.stuckReason && !r.orphan
|
||||||
}
|
}
|
||||||
@ -347,6 +418,8 @@ async function refresh() {
|
|||||||
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
|
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
|
||||||
if (fresh) detail.value = fresh
|
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) {
|
} catch (e: any) {
|
||||||
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
|
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
@ -469,6 +542,43 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 0;
|
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 chip row (kanban-inspired, soft) ===== */
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -416,6 +416,38 @@ export default {
|
|||||||
docs: {
|
docs: {
|
||||||
title: '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: {
|
nav: {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
@ -569,6 +601,22 @@ export default {
|
|||||||
errors: {
|
errors: {
|
||||||
loadFailed: 'Could not load runtime status.',
|
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: {
|
doctor: {
|
||||||
title: 'System Diagnostics',
|
title: 'System Diagnostics',
|
||||||
@ -1173,6 +1221,7 @@ export default {
|
|||||||
views: {
|
views: {
|
||||||
roster: 'Roster',
|
roster: 'Roster',
|
||||||
live: 'Live',
|
live: 'Live',
|
||||||
|
plans: 'Plans',
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
title: 'Choose a Role',
|
title: 'Choose a Role',
|
||||||
|
|||||||
@ -416,6 +416,38 @@ export default {
|
|||||||
docs: {
|
docs: {
|
||||||
title: '帮助文档',
|
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: {
|
nav: {
|
||||||
dashboard: '仪表盘',
|
dashboard: '仪表盘',
|
||||||
chat: '对话',
|
chat: '对话',
|
||||||
@ -1064,6 +1096,7 @@ export default {
|
|||||||
views: {
|
views: {
|
||||||
roster: '花名册',
|
roster: '花名册',
|
||||||
live: '现场',
|
live: '现场',
|
||||||
|
plans: '计划看板',
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
title: '选择岗位',
|
title: '选择岗位',
|
||||||
@ -2104,6 +2137,22 @@ export default {
|
|||||||
errors: {
|
errors: {
|
||||||
loadFailed: '无法加载运行时状态。',
|
loadFailed: '无法加载运行时状态。',
|
||||||
},
|
},
|
||||||
|
layout: {
|
||||||
|
grid: '网格',
|
||||||
|
board: '看板',
|
||||||
|
gridHint: '网格视图',
|
||||||
|
boardHint: '生命周期看板视图',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
running: '运行中',
|
||||||
|
attention: '需关注',
|
||||||
|
done: '已完成',
|
||||||
|
failed: '未达成',
|
||||||
|
queuedHint: '排队 {n}',
|
||||||
|
goalLabel: '目标',
|
||||||
|
emptyCol: '暂无',
|
||||||
|
noGoal: '未设目标',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
doctor: {
|
doctor: {
|
||||||
title: '系统诊断',
|
title: '系统诊断',
|
||||||
|
|||||||
@ -723,6 +723,8 @@ export interface SubPlan {
|
|||||||
export interface Plan {
|
export interface Plan {
|
||||||
id: string | number
|
id: string | number
|
||||||
agentId: string
|
agentId: string
|
||||||
|
/** Conversation/run that produced the plan (may be absent on legacy rows). */
|
||||||
|
conversationId?: string
|
||||||
goal: string
|
goal: string
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
totalSteps: number
|
totalSteps: number
|
||||||
|
|||||||
@ -30,6 +30,11 @@
|
|||||||
:class="{ warn: liveStuck > 0 }"
|
:class="{ warn: liveStuck > 0 }"
|
||||||
>{{ liveRunning }}</span>
|
>{{ liveRunning }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="view-seg"
|
||||||
|
:class="{ 'is-active': view === 'plans' }"
|
||||||
|
@click="setView('plans')"
|
||||||
|
>{{ t('agents.views.plans') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-secondary" style="display:inline-flex;align-items:center;gap:6px;" @click="router.push('/agents/create')">
|
<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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -162,7 +167,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Live: what the team is doing right now -->
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -707,6 +715,7 @@ import type { Agent } from '@/types/index'
|
|||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||||
import LivePanel from '@/components/live/LivePanel.vue'
|
import LivePanel from '@/components/live/LivePanel.vue'
|
||||||
|
import PlanBoard from '@/components/agents/PlanBoard.vue'
|
||||||
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
|
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
|
||||||
import {
|
import {
|
||||||
emptyProfile,
|
emptyProfile,
|
||||||
@ -1133,16 +1142,19 @@ const filteredAgents = computed(() => {
|
|||||||
// Roster ↔ Live view switch — admin only. The running/stuck counts feed the
|
// 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.
|
// segmented control's pulse + badge so you know whether Live is worth a look.
|
||||||
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
|
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
|
||||||
const view = ref<'roster' | 'live'>(
|
type AgentView = 'roster' | 'live' | 'plans'
|
||||||
route.query.view === 'live' && isAdminRole.value ? 'live' : 'roster',
|
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 liveRunning = ref(0)
|
||||||
const liveStuck = ref(0)
|
const liveStuck = ref(0)
|
||||||
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function setView(next: 'roster' | 'live') {
|
function setView(next: AgentView) {
|
||||||
view.value = next
|
view.value = next
|
||||||
router.replace({ query: next === 'live' ? { view: 'live' } : {} })
|
router.replace({ query: next === 'roster' ? {} : { view: next } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLiveCounts() {
|
async function refreshLiveCounts() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user