mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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";
|
final String decision = isApprovalCommand ? "approved" : "denied";
|
||||||
|
|
||||||
streamTracker.register(conversationId);
|
streamTracker.register(conversationId);
|
||||||
|
Long approvalAgentId = parseLongOrNull(pending.getAgentId());
|
||||||
|
streamTracker.bindRunMeta(conversationId, approvalAgentId, username);
|
||||||
registerEmitterCallbacks(emitter, conversationId);
|
registerEmitterCallbacks(emitter, conversationId);
|
||||||
streamTracker.attach(conversationId, emitter);
|
streamTracker.attach(conversationId, emitter);
|
||||||
AtomicBoolean approvalEmitterDone = new AtomicBoolean(false);
|
AtomicBoolean approvalEmitterDone = new AtomicBoolean(false);
|
||||||
@ -445,6 +447,7 @@ public class ChatController {
|
|||||||
|
|
||||||
// ---- 正常请求:注册流状态并附着首个订阅者 ----
|
// ---- 正常请求:注册流状态并附着首个订阅者 ----
|
||||||
streamTracker.register(conversationId);
|
streamTracker.register(conversationId);
|
||||||
|
streamTracker.bindRunMeta(conversationId, agentId, username);
|
||||||
registerEmitterCallbacks(emitter, conversationId);
|
registerEmitterCallbacks(emitter, conversationId);
|
||||||
streamTracker.attach(conversationId, emitter);
|
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 }),
|
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) ====================
|
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================
|
||||||
export const acpApi = {
|
export const acpApi = {
|
||||||
list: () => http.get('/acp/endpoints'),
|
list: () => http.get('/acp/endpoints'),
|
||||||
|
|||||||
@ -289,6 +289,8 @@ export default {
|
|||||||
acpEndpoints: 'ACP Endpoints',
|
acpEndpoints: 'ACP Endpoints',
|
||||||
settingsGroup: 'Settings',
|
settingsGroup: 'Settings',
|
||||||
agents: 'Agents',
|
agents: 'Agents',
|
||||||
|
backstage: 'Backstage',
|
||||||
|
backstageTooltip: 'See what your agents are doing right now',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
tokenUsage: 'Token Usage',
|
tokenUsage: 'Token Usage',
|
||||||
cronJobs: 'Cron Jobs',
|
cronJobs: 'Cron Jobs',
|
||||||
@ -304,6 +306,96 @@ export default {
|
|||||||
roleUser: 'User',
|
roleUser: 'User',
|
||||||
roleAdmin: 'Admin',
|
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: {
|
doctor: {
|
||||||
title: 'System Diagnostics',
|
title: 'System Diagnostics',
|
||||||
checking: 'Checking...',
|
checking: 'Checking...',
|
||||||
@ -763,6 +855,10 @@ export default {
|
|||||||
title: 'Agent Management',
|
title: 'Agent Management',
|
||||||
desc: 'Create, edit, and manage your AI agents',
|
desc: 'Create, edit, and manage your AI agents',
|
||||||
newAgent: 'New Agent',
|
newAgent: 'New Agent',
|
||||||
|
live: {
|
||||||
|
atWork: '{n} at work — see backstage',
|
||||||
|
needsAttention: '{n} need attention — see backstage',
|
||||||
|
},
|
||||||
templates: {
|
templates: {
|
||||||
title: 'Choose a Template',
|
title: 'Choose a Template',
|
||||||
desc: 'Start with a pre-configured agent, or create from scratch.',
|
desc: 'Start with a pre-configured agent, or create from scratch.',
|
||||||
|
|||||||
@ -289,6 +289,8 @@ export default {
|
|||||||
acpEndpoints: 'ACP 端点',
|
acpEndpoints: 'ACP 端点',
|
||||||
settingsGroup: '设置',
|
settingsGroup: '设置',
|
||||||
agents: '智能体',
|
agents: '智能体',
|
||||||
|
backstage: '后台',
|
||||||
|
backstageTooltip: '看看你的智能体此刻在做什么',
|
||||||
security: '安全',
|
security: '安全',
|
||||||
tokenUsage: 'Token 统计',
|
tokenUsage: 'Token 统计',
|
||||||
cronJobs: '定时任务',
|
cronJobs: '定时任务',
|
||||||
@ -765,6 +767,10 @@ export default {
|
|||||||
title: '智能体管理',
|
title: '智能体管理',
|
||||||
desc: '创建、编辑和管理你的 AI 智能体',
|
desc: '创建、编辑和管理你的 AI 智能体',
|
||||||
newAgent: '新建智能体',
|
newAgent: '新建智能体',
|
||||||
|
live: {
|
||||||
|
atWork: '{n} 个在干活 · 看现场',
|
||||||
|
needsAttention: '{n} 个需要看看 · 去现场',
|
||||||
|
},
|
||||||
templates: {
|
templates: {
|
||||||
title: '选择模板',
|
title: '选择模板',
|
||||||
desc: '选择预配置的 Agent 模板快速开始,或从空白创建。',
|
desc: '选择预配置的 Agent 模板快速开始,或从空白创建。',
|
||||||
@ -1480,6 +1486,96 @@ export default {
|
|||||||
testFailed: '连接失败,请检查配置',
|
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: {
|
doctor: {
|
||||||
title: '系统诊断',
|
title: '系统诊断',
|
||||||
checking: '检查中...',
|
checking: '检查中...',
|
||||||
|
|||||||
@ -27,6 +27,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/Agents.vue'),
|
component: () => import('@/views/Agents.vue'),
|
||||||
meta: { title: 'Agents' },
|
meta: { title: 'Agents' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'backstage',
|
||||||
|
name: 'Backstage',
|
||||||
|
component: () => import('@/views/Backstage.vue'),
|
||||||
|
meta: { title: 'Backstage', requireAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'wiki',
|
path: 'wiki',
|
||||||
name: 'Wiki',
|
name: 'Wiki',
|
||||||
|
|||||||
@ -8,12 +8,31 @@
|
|||||||
<h1 class="mc-page-title">{{ t('agents.title') }}</h1>
|
<h1 class="mc-page-title">{{ t('agents.title') }}</h1>
|
||||||
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
|
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" @click="openCreateModal">
|
<div class="header-right">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<router-link
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
v-if="isAdminRole && backstageRunning > 0"
|
||||||
</svg>
|
to="/backstage"
|
||||||
{{ t('agents.newAgent') }}
|
class="live-pill"
|
||||||
</button>
|
: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>
|
||||||
|
|
||||||
<div class="agents-toolbar mc-surface-card">
|
<div class="agents-toolbar mc-surface-card">
|
||||||
@ -349,12 +368,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { mcConfirm } from '@/components/common/useConfirm'
|
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 type { Agent } from '@/types/index'
|
||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||||
@ -433,11 +452,37 @@ const filteredAgents = computed(() => {
|
|||||||
return list
|
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(() => {
|
onMounted(() => {
|
||||||
loadAgents()
|
loadAgents()
|
||||||
// RFC-03 G1: load models once for the per-Agent override dropdown.
|
// RFC-03 G1: load models once for the per-Agent override dropdown.
|
||||||
// Failure is non-fatal — the dropdown just shows only "global default".
|
// Failure is non-fatal — the dropdown just shows only "global default".
|
||||||
loadAvailableModels()
|
loadAvailableModels()
|
||||||
|
if (isAdminRole.value) {
|
||||||
|
refreshBackstagePill()
|
||||||
|
backstagePollTimer = setInterval(refreshBackstagePill, 10_000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (backstagePollTimer) clearInterval(backstagePollTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
@ -664,6 +709,94 @@ async function toggleAgent(agent: Agent) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.agents-page { gap: 18px; }
|
.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 { 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:hover { background: var(--mc-primary-hover); }
|
||||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
.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"
|
:key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: isNavItemActive(item) }"
|
:class="{ active: isNavItemActive(item), 'has-attention': item.path === '/backstage' && backstageAlertActive }"
|
||||||
:title="effectiveCollapsed ? item.label : ''"
|
:title="effectiveCollapsed ? (item.tooltip || item.label) : (item.tooltip || '')"
|
||||||
@click="onNavClick"
|
@click="onNavClick"
|
||||||
>
|
>
|
||||||
<span class="nav-icon" v-html="item.icon"></span>
|
<span class="nav-icon" v-html="item.icon"></span>
|
||||||
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</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>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -168,7 +173,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useThemeStore } from '@/stores/useThemeStore'
|
import { useThemeStore } from '@/stores/useThemeStore'
|
||||||
import { version as appVersion } from '../../../package.json'
|
import { version as appVersion } from '../../../package.json'
|
||||||
import type { ThemeMode } from '@/stores/useThemeStore'
|
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 OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
|
||||||
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
|
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
|
||||||
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.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 isMobile = ref(false)
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
@ -250,11 +274,18 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Fetch initial health status for sidebar indicator
|
// Fetch initial health status for sidebar indicator
|
||||||
fetchHealthStatus()
|
fetchHealthStatus()
|
||||||
|
|
||||||
|
// Backstage attention dot — poll every 15s for admins.
|
||||||
|
if (isAdminRole.value) {
|
||||||
|
refreshBackstageBadge()
|
||||||
|
backstagePollTimer = setInterval(refreshBackstageBadge, 15_000)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
mobileQuery?.removeEventListener('change', handleMobileChange)
|
||||||
mediumQuery?.removeEventListener('change', handleMediumChange)
|
mediumQuery?.removeEventListener('change', handleMediumChange)
|
||||||
|
if (backstagePollTimer) clearInterval(backstagePollTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onNavClick() {
|
function onNavClick() {
|
||||||
@ -312,6 +343,12 @@ const navGroups = computed(() => [
|
|||||||
label: t('nav.agents'),
|
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>`,
|
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',
|
path: '/wiki',
|
||||||
label: t('nav.wiki'),
|
label: t('nav.wiki'),
|
||||||
@ -631,6 +668,44 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
|||||||
.nav-icon { display: flex; align-items: center; flex-shrink: 0; }
|
.nav-icon { display: flex; align-items: center; flex-shrink: 0; }
|
||||||
.nav-label { overflow: hidden; text-overflow: ellipsis; }
|
.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 {
|
.sidebar-footer {
|
||||||
border-top: 1px solid var(--mc-border-light);
|
border-top: 1px solid var(--mc-border-light);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user