mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(backstage): admin runtime console for live agent visibility + force-recycle
This commit is contained in:
parent
327a70dad4
commit
74edc09b2b
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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: '检查中...',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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; }
|
||||
|
||||
1036
mateclaw-ui/src/views/Backstage.vue
Normal file
1036
mateclaw-ui/src/views/Backstage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user