diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java new file mode 100644 index 00000000..22fc5ef9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeAggregator.java @@ -0,0 +1,241 @@ +package vip.mate.agent.runtime; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.agent.AgentService; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.model.AgentEntity; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.channel.web.ChatStreamTracker.RunSnapshot; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry}) + * with agent metadata so the admin Backstage UI can render one card per + * working agent without making the frontend traverse three independent + * services. + * + *

The "stuck" verdict is computed here rather than persisted on + * {@link RunSnapshot} so the thresholds can be tuned at runtime without + * touching every producer. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentRuntimeAggregator { + + /** + * Idle threshold: a run with no events for this long while NOT inside a + * tool call is treated as wedged. Aligns with the upstream cli reference + * (5 × 30s heartbeat cycles) so pre-token latency does not false-alarm. + */ + private static final long STUCK_IDLE_MS = 150_000L; + + /** + * In-tool threshold: a run with a {@code runningToolName} but no events + * for this long. Looser than idle because slow tool calls (LLM-backed + * tools, long-running shell commands) routinely sit silent for minutes. + */ + private static final long STUCK_TOOL_MS = 600_000L; + + /** + * Hard cap regardless of activity. A run older than this is suspicious + * even when the bytes are flowing: the user has likely walked away and + * the model is in a feedback loop. + */ + private static final long STUCK_HARD_CAP_MS = 1_800_000L; + + private final ChatStreamTracker streamTracker; + private final SubagentRegistry subagentRegistry; + private final AgentService agentService; + + /** One in-flight run, enriched with agent label and stuck verdict. */ + public record RunCard( + String conversationId, + Long agentId, + String agentName, + String agentIcon, + String username, + String currentPhase, + String runningToolName, + String waitingReason, + boolean done, + boolean stopRequested, + boolean firstTokenReceived, + int subscriberCount, + int queueLen, + long ageMs, + long msSinceLastEvent, + String stuckReason, + boolean orphan, + int subagentCount + ) {} + + /** One sub-agent under a parent run, ready for tree rendering. */ + public record SubagentCard( + String subagentId, + String parentConversationId, + String childConversationId, + Long agentId, + String agentName, + String agentIcon, + String goal, + String status, + String currentPhase, + String lastTool, + int toolCount, + long ageMs + ) {} + + /** Top-level summary used to drive the breathing sidebar dot. */ + public record Summary( + int running, + int stuck, + int orphan, + int queued, + int subagentsActive + ) {} + + /** Full snapshot envelope returned to the admin UI. */ + public record RuntimeSnapshot( + Summary summary, + List runs, + List subagents, + long timestamp + ) {} + + public RuntimeSnapshot snapshot() { + List rawRuns = streamTracker.getAllSnapshot(); + Set agentIds = rawRuns.stream() + .map(RunSnapshot::agentId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + for (var rec : subagentRegistry.allActive()) { + if (rec.agentId() != null) agentIds.add(rec.agentId()); + } + Map agentInfo = resolveAgents(agentIds); + + Map subagentCountByParent = new HashMap<>(); + for (var rec : subagentRegistry.allActive()) { + String parent = rec.parentConversationId(); + if (parent != null) { + subagentCountByParent.merge(parent, 1L, Long::sum); + } + } + + List cards = new ArrayList<>(rawRuns.size()); + int stuckCount = 0; + int orphanCount = 0; + int queuedTotal = 0; + int runningCount = 0; + for (RunSnapshot s : rawRuns) { + if (s.done()) continue; + runningCount++; + String stuckReason = computeStuckReason(s); + boolean orphan = s.subscriberCount() == 0; + if (stuckReason != null) stuckCount++; + if (orphan) orphanCount++; + queuedTotal += s.queueLen(); + int subCount = subagentCountByParent.getOrDefault(s.conversationId(), 0L).intValue(); + AgentEntity ag = s.agentId() == null ? null : agentInfo.get(s.agentId()); + cards.add(new RunCard( + s.conversationId(), + s.agentId(), + ag == null ? null : ag.getName(), + ag == null ? null : ag.getIcon(), + s.username(), + s.currentPhase(), + s.runningToolName(), + s.waitingReason(), + s.done(), + s.stopRequested(), + s.firstTokenReceived(), + s.subscriberCount(), + s.queueLen(), + s.ageMs(), + s.msSinceLastEvent(), + stuckReason, + orphan, + subCount + )); + } + // Sort: stuck first (loudest first), then orphan, then by lastEventAt asc + cards.sort((a, b) -> { + int aStuck = a.stuckReason() != null ? 1 : 0; + int bStuck = b.stuckReason() != null ? 1 : 0; + if (aStuck != bStuck) return bStuck - aStuck; + int aOrph = a.orphan() ? 1 : 0; + int bOrph = b.orphan() ? 1 : 0; + if (aOrph != bOrph) return bOrph - aOrph; + return Long.compare(b.msSinceLastEvent(), a.msSinceLastEvent()); + }); + + List subCards = subagentRegistry.allActive().stream() + .map(rec -> { + long now = System.currentTimeMillis(); + AgentEntity ag = rec.agentId() == null ? null : agentInfo.get(rec.agentId()); + return new SubagentCard( + rec.subagentId(), + rec.parentConversationId(), + rec.childConversationId(), + rec.agentId(), + ag == null ? null : ag.getName(), + ag == null ? null : ag.getIcon(), + rec.goal(), + rec.status() != null ? rec.status().get() : null, + rec.currentPhase() != null ? rec.currentPhase().get() : null, + rec.lastTool() != null ? rec.lastTool().get() : null, + rec.toolCount() != null ? rec.toolCount().get() : 0, + now - rec.startedAt() + ); + }) + .toList(); + + Summary summary = new Summary( + runningCount, + stuckCount, + orphanCount, + queuedTotal, + subCards.size() + ); + + return new RuntimeSnapshot(summary, cards, subCards, System.currentTimeMillis()); + } + + /** + * Returns null when the run looks healthy. The returned tag is a stable + * machine-readable code (not a translated label) so the frontend can + * decide presentation: {@code idle_silent} / {@code tool_silent} / + * {@code hard_cap}. + */ + private String computeStuckReason(RunSnapshot s) { + if (s.ageMs() > STUCK_HARD_CAP_MS) return "hard_cap"; + boolean inTool = s.runningToolName() != null && !s.runningToolName().isBlank(); + long since = s.msSinceLastEvent(); + if (inTool && since > STUCK_TOOL_MS) return "tool_silent"; + if (!inTool && since > STUCK_IDLE_MS) return "idle_silent"; + return null; + } + + private Map resolveAgents(Set ids) { + Map out = new LinkedHashMap<>(); + for (Long id : ids) { + if (id == null) continue; + try { + AgentEntity a = agentService.getAgent(id); + if (a != null) out.put(id, a); + } catch (Exception e) { + log.debug("agent lookup failed for id={}: {}", id, e.getMessage()); + } + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java new file mode 100644 index 00000000..15b9597a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java @@ -0,0 +1,124 @@ +package vip.mate.agent.runtime; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.web.bind.annotation.*; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Admin-only Backstage surface: the global view of every in-flight agent + * turn plus the controls to friendly-stop, force-recycle, or sweep stuck + * runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation + * owner-scoped — this controller is intentionally cross-tenant for the + * operator role. + */ +@Slf4j +@Tag(name = "Agent Runtime (Backstage)") +@RestController +@RequestMapping("/api/v1/admin/agent-runtime") +@RequiredArgsConstructor +public class AgentRuntimeController { + + private final AgentRuntimeAggregator aggregator; + private final ChatStreamTracker streamTracker; + private final SubagentRegistry subagentRegistry; + private final AuditEventService auditEventService; + + @Operation(summary = "Snapshot of every in-flight agent turn") + @GetMapping("/snapshot") + public R snapshot(Authentication auth) { + requireAdmin(auth); + return R.ok(aggregator.snapshot()); + } + + @Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint") + @PostMapping("/runs/{conversationId}/stop") + public R> stopFriendly(@PathVariable String conversationId, + Authentication auth) { + requireAdmin(auth); + boolean ok = streamTracker.requestStop(conversationId); + recordAudit(auth, "agent-runtime.stop", conversationId, Map.of("result", ok)); + return R.ok(Map.of("stopped", ok)); + } + + @Operation(summary = "Force recycle — dispose flux + drop RunState; use after friendly stop ignored") + @PostMapping("/runs/{conversationId}/recycle") + public R> recycle(@PathVariable String conversationId, + Authentication auth) { + requireAdmin(auth); + boolean ok = streamTracker.forceRecycle(conversationId); + recordAudit(auth, "agent-runtime.recycle", conversationId, Map.of("result", ok)); + return R.ok(Map.of("recycled", ok)); + } + + @Operation(summary = "Interrupt one sub-agent (admin override of ownership check)") + @PostMapping("/subagents/{subagentId}/interrupt") + public R> interruptSubagent(@PathVariable String subagentId, + Authentication auth) { + requireAdmin(auth); + boolean ok = subagentRegistry.interrupt(subagentId); + recordAudit(auth, "agent-runtime.subagent.interrupt", subagentId, Map.of("result", ok)); + return R.ok(Map.of("interrupted", ok)); + } + + /** + * Bulk recycle every run that the aggregator currently flags as stuck. + * Returns the conversationIds that were touched so the caller can render + * a confirmation toast without re-fetching. + */ + @Operation(summary = "Recycle every run currently flagged as stuck") + @PostMapping("/sweep") + public R> sweep(Authentication auth) { + requireAdmin(auth); + AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot(); + List ids = snap.runs().stream() + .filter(r -> r.stuckReason() != null) + .map(AgentRuntimeAggregator.RunCard::conversationId) + .toList(); + int recycled = 0; + for (String cid : ids) { + if (streamTracker.forceRecycle(cid)) recycled++; + } + recordAudit(auth, "agent-runtime.sweep", "all", + Map.of("targets", ids, "recycled", recycled)); + return R.ok(Map.of("recycled", recycled, "ids", ids)); + } + + private void requireAdmin(Authentication auth) { + if (auth == null) { + throw new MateClawException(401, "authentication required"); + } + boolean isAdmin = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch("ROLE_ADMIN"::equals); + if (!isAdmin) { + throw new MateClawException(403, "admin role required"); + } + } + + private void recordAudit(Authentication auth, String action, + String resourceId, Map detail) { + try { + String username = auth != null ? auth.getName() : "anonymous"; + Map payload = new LinkedHashMap<>(); + payload.put("by", username); + payload.putAll(detail); + auditEventService.record(action, "agent-runtime", resourceId, resourceId, + new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload)); + } catch (Exception e) { + log.warn("audit serialization failed for {}: {}", action, e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index f54713c5..2aa7652f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -225,6 +225,8 @@ public class ChatController { final String decision = isApprovalCommand ? "approved" : "denied"; streamTracker.register(conversationId); + Long approvalAgentId = parseLongOrNull(pending.getAgentId()); + streamTracker.bindRunMeta(conversationId, approvalAgentId, username); registerEmitterCallbacks(emitter, conversationId); streamTracker.attach(conversationId, emitter); AtomicBoolean approvalEmitterDone = new AtomicBoolean(false); @@ -445,6 +447,7 @@ public class ChatController { // ---- 正常请求:注册流状态并附着首个订阅者 ---- streamTracker.register(conversationId); + streamTracker.bindRunMeta(conversationId, agentId, username); registerEmitterCallbacks(emitter, conversationId); streamTracker.attach(conversationId, emitter); @@ -1769,4 +1772,9 @@ public class ChatController { } } } + + private static Long parseLongOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { return Long.parseLong(s.trim()); } catch (NumberFormatException e) { return null; } + } } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index ebb58184..f9e6c9bf 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -183,6 +183,70 @@ export const activityApi = { http.get('/activity/feed', { params }), } +// ==================== Backstage (admin runtime view) ==================== +export interface BackstageRunCard { + conversationId: string + agentId: number | null + agentName: string | null + agentIcon: string | null + username: string | null + currentPhase: string | null + runningToolName: string | null + waitingReason: string | null + done: boolean + stopRequested: boolean + firstTokenReceived: boolean + subscriberCount: number + queueLen: number + ageMs: number + msSinceLastEvent: number + /** null when healthy; otherwise: 'idle_silent' | 'tool_silent' | 'hard_cap' */ + stuckReason: string | null + orphan: boolean + subagentCount: number +} + +export interface BackstageSubagentCard { + subagentId: string + parentConversationId: string | null + childConversationId: string | null + agentId: number | null + agentName: string | null + agentIcon: string | null + goal: string | null + status: string | null + currentPhase: string | null + lastTool: string | null + toolCount: number + ageMs: number +} + +export interface BackstageSummary { + running: number + stuck: number + orphan: number + queued: number + subagentsActive: number +} + +export interface BackstageSnapshot { + summary: BackstageSummary + runs: BackstageRunCard[] + subagents: BackstageSubagentCard[] + timestamp: number +} + +export const backstageApi = { + snapshot: () => http.get<{ data: BackstageSnapshot }>('/admin/agent-runtime/snapshot'), + stop: (conversationId: string) => + http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`), + recycle: (conversationId: string) => + http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/recycle`), + interruptSubagent: (subagentId: string) => + http.post(`/admin/agent-runtime/subagents/${encodeURIComponent(subagentId)}/interrupt`), + sweep: () => http.post('/admin/agent-runtime/sweep'), +} + // ==================== ACP Endpoints (RFC-090 Phase 7) ==================== export const acpApi = { list: () => http.get('/acp/endpoints'), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c598cadd..4744e15c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -289,6 +289,8 @@ export default { acpEndpoints: 'ACP Endpoints', settingsGroup: 'Settings', agents: 'Agents', + backstage: 'Backstage', + backstageTooltip: 'See what your agents are doing right now', security: 'Security', tokenUsage: 'Token Usage', cronJobs: 'Cron Jobs', @@ -304,6 +306,96 @@ export default { roleUser: 'User', roleAdmin: 'Admin', }, + backstage: { + kicker: 'Backstage', + title: 'See what your agents are doing', + attention: 'Someone needs your attention', + unknownAgent: 'Agent', + aTool: 'a tool', + orphan: 'no one watching', + orphanHint: 'The browser tab closed but this agent is still running.', + subagentsBadge: '{n} helping', + headline: { + loading: 'Looking around...', + allQuiet: 'All quiet. No agents are working right now.', + working: 'Your {n} agents are at work.', + someoneNeedsAttention: '{n} need a look from you.', + workingAlone: '{running} working — {orphan} with no one listening.', + }, + saying: { + thinking: 'Thinking...', + replying: 'Writing the reply...', + planning: 'Planning the next steps...', + summarizing: 'Wrapping up...', + usingTool: 'Using {tool}...', + usingSomething: 'Using a tool...', + awaitingApproval: 'Waiting for your approval.', + toolSilent: 'Stuck on {tool}, no progress.', + idleSilent: 'Quiet for too long.', + hardCap: 'Has been at this far too long.', + }, + callout: { + toolSilent: '{tool} has been silent for {time}. It looks stuck.', + idleSilent: 'No sign of activity for {time}.', + hardCap: 'Running for {time} — well past normal.', + }, + dotTitle: { + healthy: 'Working normally', + stuck: 'Looks stuck', + orphan: 'Running with no audience', + }, + detail: { + who: 'Who', + doing: 'What it\'s doing', + runningFor: 'Running for', + lastHeard: 'Last heard from', + ago: 'ago', + audience: 'Audience', + noOneListening: 'No one watching the chat right now.', + peopleListening: '{n} listening', + helpers: 'Helpers', + }, + actions: { + live: 'Live', + paused: 'Paused', + pauseRefresh: 'Pause auto-refresh', + resumeRefresh: 'Resume auto-refresh', + tidyUp: 'Tidy up', + stop: 'Stop', + stopHint: 'Ask it to wind down at the next checkpoint.', + endIt: 'End it', + endHint: 'Force it to stop now.', + }, + confirm: { + stopTitle: 'Stop this agent?', + stopBody: 'Ask {name} to wind down. The current step will finish first.', + endTitle: 'Force {name} to stop now?', + endBody: 'This will stop the agent immediately. Any work in progress will be lost. The user will see "stopped by admin".', + subTitle: 'Stop this helper?', + subBody: 'Stop {name}. The parent agent will keep running.', + sweepTitle: 'Tidy up stuck agents?', + sweepBody: '{n} agent(s) look stuck. Stop all of them now?', + }, + toast: { + stopped: 'Asked it to stop.', + ended: 'Stopped.', + subStopped: 'Helper stopped.', + swept: 'Tidied up {n} agent(s).', + }, + empty: { + allQuiet: 'All quiet.', + hint: 'No agents are working right now. This page will light up when one is.', + }, + time: { + justNow: 'just now', + seconds: '{n}s', + minutes: '{n}m {s}s', + hours: '{n}h {m}m', + }, + errors: { + loadFailed: 'Could not load runtime status.', + }, + }, doctor: { title: 'System Diagnostics', checking: 'Checking...', @@ -763,6 +855,10 @@ export default { title: 'Agent Management', desc: 'Create, edit, and manage your AI agents', newAgent: 'New Agent', + live: { + atWork: '{n} at work — see backstage', + needsAttention: '{n} need attention — see backstage', + }, templates: { title: 'Choose a Template', desc: 'Start with a pre-configured agent, or create from scratch.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 453543e2..80191c23 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -289,6 +289,8 @@ export default { acpEndpoints: 'ACP 端点', settingsGroup: '设置', agents: '智能体', + backstage: '后台', + backstageTooltip: '看看你的智能体此刻在做什么', security: '安全', tokenUsage: 'Token 统计', cronJobs: '定时任务', @@ -765,6 +767,10 @@ export default { title: '智能体管理', desc: '创建、编辑和管理你的 AI 智能体', newAgent: '新建智能体', + live: { + atWork: '{n} 个在干活 · 看现场', + needsAttention: '{n} 个需要看看 · 去现场', + }, templates: { title: '选择模板', desc: '选择预配置的 Agent 模板快速开始,或从空白创建。', @@ -1480,6 +1486,96 @@ export default { testFailed: '连接失败,请检查配置', }, }, + backstage: { + kicker: '后台', + title: '看看你的智能体在做什么', + attention: '有几个需要你看看', + unknownAgent: '智能体', + aTool: '某个工具', + orphan: '没人在听', + orphanHint: '聊天窗口已关闭,但这个智能体仍在运行。', + subagentsBadge: '{n} 个帮手', + headline: { + loading: '正在看看...', + allQuiet: '一片安静。当前没有智能体在工作。', + working: '你的 {n} 个智能体正在干活。', + someoneNeedsAttention: '有 {n} 个需要你看看。', + workingAlone: '{running} 个在干活 — 其中 {orphan} 个没人在听。', + }, + saying: { + thinking: '正在思考...', + replying: '正在写回复...', + planning: '正在规划下一步...', + summarizing: '正在收尾...', + usingTool: '正在使用 {tool}...', + usingSomething: '正在使用某个工具...', + awaitingApproval: '在等你审批。', + toolSilent: '卡在 {tool},没有进展。', + idleSilent: '安静太久了。', + hardCap: '已经跑了太久。', + }, + callout: { + toolSilent: '{tool} 已经 {time} 没有动静了,看起来是卡住了。', + idleSilent: '{time} 没有任何活动迹象。', + hardCap: '已经跑了 {time},明显超出正常范围。', + }, + dotTitle: { + healthy: '工作正常', + stuck: '看起来卡住了', + orphan: '在没人看的情况下运行', + }, + detail: { + who: '谁', + doing: '在做什么', + runningFor: '已运行', + lastHeard: '最后一次有动静', + ago: '前', + audience: '观众', + noOneListening: '当前没有人在看这个对话。', + peopleListening: '{n} 人在看', + helpers: '帮手', + }, + actions: { + live: '实时', + paused: '已暂停', + pauseRefresh: '暂停自动刷新', + resumeRefresh: '恢复自动刷新', + tidyUp: '清理一下', + stop: '停下', + stopHint: '让它在下个检查点停下来。', + endIt: '强制结束', + endHint: '立即强制停止。', + }, + confirm: { + stopTitle: '让这个智能体停下吗?', + stopBody: '让 {name} 收尾。它会先把当前的步骤做完。', + endTitle: '强制 {name} 立即停止?', + endBody: '这会立即终止智能体,正在进行的工作会丢失。用户会看到「已被管理员停止」。', + subTitle: '停下这个帮手?', + subBody: '停止 {name}。父智能体会继续运行。', + sweepTitle: '清理卡住的智能体?', + sweepBody: '有 {n} 个智能体看起来卡住了,全部停掉吗?', + }, + toast: { + stopped: '已要求停止。', + ended: '已停止。', + subStopped: '帮手已停止。', + swept: '已清理 {n} 个智能体。', + }, + empty: { + allQuiet: '一片安静。', + hint: '当前没有智能体在工作。一旦有,这里就会亮起来。', + }, + time: { + justNow: '刚刚', + seconds: '{n} 秒', + minutes: '{n} 分 {s} 秒', + hours: '{n} 时 {m} 分', + }, + errors: { + loadFailed: '无法加载运行时状态。', + }, + }, doctor: { title: '系统诊断', checking: '检查中...', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 31eab3a7..9bba9cfc 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -27,6 +27,12 @@ const router = createRouter({ component: () => import('@/views/Agents.vue'), meta: { title: 'Agents' }, }, + { + path: 'backstage', + name: 'Backstage', + component: () => import('@/views/Backstage.vue'), + meta: { title: 'Backstage', requireAdmin: true }, + }, { path: 'wiki', name: 'Wiki', diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index d8b5e5b8..b6a72740 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -8,12 +8,31 @@

{{ t('agents.title') }}

{{ t('agents.desc') }}

- +
+ + + + {{ backstageStuck > 0 + ? t('agents.live.needsAttention', { n: backstageStuck }) + : t('agents.live.atWork', { n: backstageRunning }) }} + + + + + + +
@@ -349,12 +368,12 @@ + + diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index b799e509..865ed8b9 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -46,12 +46,17 @@ :key="item.path" :to="item.path" class="nav-item" - :class="{ active: isNavItemActive(item) }" - :title="effectiveCollapsed ? item.label : ''" + :class="{ active: isNavItemActive(item), 'has-attention': item.path === '/backstage' && backstageAlertActive }" + :title="effectiveCollapsed ? (item.tooltip || item.label) : (item.tooltip || '')" @click="onNavClick" > {{ item.label }} +
@@ -168,7 +173,7 @@ import { useI18n } from 'vue-i18n' import { useThemeStore } from '@/stores/useThemeStore' import { version as appVersion } from '../../../package.json' import type { ThemeMode } from '@/stores/useThemeStore' -import { http, settingsApi, setupApi } from '@/api/index' +import { http, settingsApi, setupApi, backstageApi } from '@/api/index' import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue' import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue' import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue' @@ -205,6 +210,25 @@ async function fetchHealthStatus() { } } +// Live attention signal for the Backstage sidebar entry. Admins only — +// non-admin users never poll the runtime endpoint and never see the dot. +const backstageStuckCount = ref(0) +let backstagePollTimer: ReturnType | null = null + +const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin') +const backstageAlertActive = computed(() => isAdminRole.value && backstageStuckCount.value > 0) + +async function refreshBackstageBadge() { + if (!isAdminRole.value) return + try { + const res: any = await backstageApi.snapshot() + const data = res?.data ?? res + backstageStuckCount.value = data?.summary?.stuck ?? 0 + } catch { + // Silent: stale value is preferable to a flapping indicator. + } +} + // 移动端状态 const isMobile = ref(false) const mobileMenuOpen = ref(false) @@ -250,11 +274,18 @@ onMounted(async () => { // Fetch initial health status for sidebar indicator fetchHealthStatus() + + // Backstage attention dot — poll every 15s for admins. + if (isAdminRole.value) { + refreshBackstageBadge() + backstagePollTimer = setInterval(refreshBackstageBadge, 15_000) + } }) onBeforeUnmount(() => { mobileQuery?.removeEventListener('change', handleMobileChange) mediumQuery?.removeEventListener('change', handleMediumChange) + if (backstagePollTimer) clearInterval(backstagePollTimer) }) function onNavClick() { @@ -312,6 +343,12 @@ const navGroups = computed(() => [ label: t('nav.agents'), icon: ``, }, + ...(isAdminRole.value ? [{ + path: '/backstage', + label: t('nav.backstage'), + tooltip: t('nav.backstageTooltip'), + icon: ``, + }] : []), { path: '/wiki', label: t('nav.wiki'), @@ -631,6 +668,44 @@ watch(() => workspaceStore.currentWorkspaceId, () => { .nav-icon { display: flex; align-items: center; flex-shrink: 0; } .nav-label { overflow: hidden; text-overflow: ellipsis; } +/* Backstage attention dot — appears only when stuck > 0 for an admin. */ +.nav-attention-dot { + position: absolute; + right: 14px; + top: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + background: hsl(20, 80%, 55%); + transform: translateY(-50%); + box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.6); + animation: nav-attention-pulse 2.4s ease-in-out infinite; +} + +.nav-item.has-attention { + color: hsl(20, 75%, 50%); +} + +@keyframes nav-attention-pulse { + 0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: translateY(-50%) scale(1); } + 50% { box-shadow: 0 0 0 6px hsla(20, 80%, 55%, 0); transform: translateY(-50%) scale(1.15); } +} + +.sidebar.collapsed .nav-attention-dot { + right: 8px; + top: 8px; + transform: none; +} + +.sidebar.collapsed .nav-attention-dot { + animation-name: nav-attention-pulse-collapsed; +} + +@keyframes nav-attention-pulse-collapsed { + 0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: scale(1); } + 50% { box-shadow: 0 0 0 5px hsla(20, 80%, 55%, 0); transform: scale(1.2); } +} + /* 底部 */ .sidebar-footer { border-top: 1px solid var(--mc-border-light);