feat(backstage): admin runtime console for live agent visibility + force-recycle

This commit is contained in:
matevip 2026-05-03 17:15:17 +08:00
parent 327a70dad4
commit 74edc09b2b
10 changed files with 1890 additions and 11 deletions

View File

@ -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.
*
* <p>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<RunCard> runs,
List<SubagentCard> subagents,
long timestamp
) {}
public RuntimeSnapshot snapshot() {
List<RunSnapshot> rawRuns = streamTracker.getAllSnapshot();
Set<Long> 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<Long, AgentEntity> agentInfo = resolveAgents(agentIds);
Map<String, Long> subagentCountByParent = new HashMap<>();
for (var rec : subagentRegistry.allActive()) {
String parent = rec.parentConversationId();
if (parent != null) {
subagentCountByParent.merge(parent, 1L, Long::sum);
}
}
List<RunCard> 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<SubagentCard> 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<Long, AgentEntity> resolveAgents(Set<Long> ids) {
Map<Long, AgentEntity> 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;
}
}

View File

@ -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<AgentRuntimeAggregator.RuntimeSnapshot> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> sweep(Authentication auth) {
requireAdmin(auth);
AgentRuntimeAggregator.RuntimeSnapshot snap = aggregator.snapshot();
List<String> 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<String, Object> detail) {
try {
String username = auth != null ? auth.getName() : "anonymous";
Map<String, Object> 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());
}
}
}

View File

@ -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; }
}
}

View File

@ -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'),

View File

@ -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.',

View File

@ -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: '检查中...',

View File

@ -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',

View File

@ -8,12 +8,31 @@
<h1 class="mc-page-title">{{ t('agents.title') }}</h1>
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('agents.newAgent') }}
</button>
<div class="header-right">
<router-link
v-if="isAdminRole && backstageRunning > 0"
to="/backstage"
class="live-pill"
:class="{ 'live-pill--alert': backstageStuck > 0 }"
:title="t('backstage.attention')"
>
<span class="live-pill-dot"></span>
<span class="live-pill-text">
{{ backstageStuck > 0
? t('agents.live.needsAttention', { n: backstageStuck })
: t('agents.live.atWork', { n: backstageRunning }) }}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</router-link>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('agents.newAgent') }}
</button>
</div>
</div>
<div class="agents-toolbar mc-surface-card">
@ -349,12 +368,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { mcConfirm } from '@/components/common/useConfirm'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi } from '@/api/index'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, backstageApi } from '@/api/index'
import type { Agent } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
@ -433,11 +452,37 @@ const filteredAgents = computed(() => {
return list
})
// Live signal for the "see what's running" header pill admin only.
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
const backstageRunning = ref(0)
const backstageStuck = ref(0)
let backstagePollTimer: ReturnType<typeof setInterval> | null = null
async function refreshBackstagePill() {
if (!isAdminRole.value) return
try {
const res: any = await backstageApi.snapshot()
const data = res?.data ?? res
backstageRunning.value = data?.summary?.running ?? 0
backstageStuck.value = data?.summary?.stuck ?? 0
} catch {
// Silent stale value is preferable to a flapping number.
}
}
onMounted(() => {
loadAgents()
// RFC-03 G1: load models once for the per-Agent override dropdown.
// Failure is non-fatal the dropdown just shows only "global default".
loadAvailableModels()
if (isAdminRole.value) {
refreshBackstagePill()
backstagePollTimer = setInterval(refreshBackstagePill, 10_000)
}
})
onBeforeUnmount(() => {
if (backstagePollTimer) clearInterval(backstagePollTimer)
})
async function loadAgents() {
@ -664,6 +709,94 @@ async function toggleAgent(agent: Agent) {
<style scoped>
.agents-page { gap: 18px; }
/* ===== Backstage live pill in page header ===== */
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.live-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--mc-border-light);
color: var(--mc-text-secondary);
font-size: 12.5px;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.18s ease;
backdrop-filter: blur(8px);
}
html.dark .live-pill {
background: rgba(255, 255, 255, 0.04);
}
.live-pill:hover {
border-color: var(--mc-border);
color: var(--mc-text-primary);
transform: translateY(-1px);
}
.live-pill-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: hsl(155, 55%, 50%);
position: relative;
animation: live-pill-pulse 2.4s ease-in-out infinite;
}
.live-pill-dot::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: hsla(155, 55%, 50%, 0.3);
animation: live-pill-halo 2.4s ease-in-out infinite;
}
@keyframes live-pill-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.85); }
}
@keyframes live-pill-halo {
0%, 100% { opacity: 0; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.6); }
}
.live-pill--alert {
background: linear-gradient(135deg, hsla(28, 90%, 60%, 0.12), hsla(20, 90%, 55%, 0.16));
border-color: hsla(20, 80%, 55%, 0.32);
color: hsl(20, 70%, 40%);
}
.live-pill--alert:hover {
background: linear-gradient(135deg, hsla(28, 90%, 60%, 0.2), hsla(20, 90%, 55%, 0.25));
color: hsl(20, 75%, 35%);
}
.live-pill--alert .live-pill-dot {
background: hsl(20, 80%, 55%);
animation-duration: 4s;
}
.live-pill--alert .live-pill-dot::before {
background: hsla(20, 80%, 55%, 0.32);
animation-duration: 4s;
}
.live-pill-text {
letter-spacing: -0.005em;
white-space: nowrap;
}
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.15s; box-shadow: var(--mc-shadow-soft); }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }

File diff suppressed because it is too large Load Diff

View File

@ -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"
>
<span class="nav-icon" v-html="item.icon"></span>
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</span>
<span
v-if="item.path === '/backstage' && backstageAlertActive"
class="nav-attention-dot"
:title="t('backstage.attention')"
></span>
</router-link>
</div>
</template>
@ -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<typeof setInterval> | 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: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
},
...(isAdminRole.value ? [{
path: '/backstage',
label: t('nav.backstage'),
tooltip: t('nav.backstageTooltip'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`,
}] : []),
{
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);