mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(ui): fold the live runtime view into the Employees page
This commit is contained in:
parent
c320aab1ba
commit
82594878a0
@ -19,7 +19,7 @@ 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
|
||||
* with agent metadata so the admin Live view can render one card per
|
||||
* working agent without making the frontend traverse three independent
|
||||
* services.
|
||||
*
|
||||
|
||||
@ -21,14 +21,14 @@ import java.util.Map;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
/**
|
||||
* Admin-only Backstage surface: the global view of every in-flight agent
|
||||
* Admin-only live runtime 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)")
|
||||
@Tag(name = "Agent Runtime (Live)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/agent-runtime")
|
||||
@RequiredArgsConstructor
|
||||
|
||||
@ -1563,7 +1563,7 @@ public class ChatStreamTracker {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Runtime snapshot surface (admin Backstage) =====
|
||||
// ===== Runtime snapshot surface (admin Live view) =====
|
||||
|
||||
/**
|
||||
* Bind the resolved agent + owner to the active run so the runtime
|
||||
@ -1603,7 +1603,7 @@ public class ChatStreamTracker {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Snapshot every active run. Used by the admin Backstage to render the
|
||||
* Snapshot every active run. Used by the admin Live view to render the
|
||||
* global "what are my agents doing right now" view. Returned list is a
|
||||
* defensive copy — callers may freely sort / filter it.
|
||||
*/
|
||||
@ -1640,7 +1640,7 @@ public class ChatStreamTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a wedged run to terminate. Used by the admin Backstage's
|
||||
* Force a wedged run to terminate. Used by the admin Live view's
|
||||
* "End it" action when the friendly stop has been observed not to take
|
||||
* effect (model wedged in a tool call beyond the timeout). Sequence
|
||||
* matches what {@link #onShutdown()} does for individual runs.
|
||||
|
||||
@ -229,8 +229,8 @@ export const activityApi = {
|
||||
http.get('/activity/feed', { params }),
|
||||
}
|
||||
|
||||
// ==================== Backstage (admin runtime view) ====================
|
||||
export interface BackstageRunCard {
|
||||
// ==================== Live (admin runtime view) ====================
|
||||
export interface LiveRunCard {
|
||||
conversationId: string
|
||||
agentId: number | null
|
||||
agentName: string | null
|
||||
@ -252,7 +252,7 @@ export interface BackstageRunCard {
|
||||
subagentCount: number
|
||||
}
|
||||
|
||||
export interface BackstageSubagentCard {
|
||||
export interface LiveSubagentCard {
|
||||
subagentId: string
|
||||
parentConversationId: string | null
|
||||
childConversationId: string | null
|
||||
@ -267,7 +267,7 @@ export interface BackstageSubagentCard {
|
||||
ageMs: number
|
||||
}
|
||||
|
||||
export interface BackstageSummary {
|
||||
export interface LiveSummary {
|
||||
running: number
|
||||
stuck: number
|
||||
orphan: number
|
||||
@ -275,15 +275,15 @@ export interface BackstageSummary {
|
||||
subagentsActive: number
|
||||
}
|
||||
|
||||
export interface BackstageSnapshot {
|
||||
summary: BackstageSummary
|
||||
runs: BackstageRunCard[]
|
||||
subagents: BackstageSubagentCard[]
|
||||
export interface LiveSnapshot {
|
||||
summary: LiveSummary
|
||||
runs: LiveRunCard[]
|
||||
subagents: LiveSubagentCard[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export const backstageApi = {
|
||||
snapshot: () => http.get<{ data: BackstageSnapshot }>('/admin/agent-runtime/snapshot'),
|
||||
export const liveApi = {
|
||||
snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'),
|
||||
stop: (conversationId: string) =>
|
||||
http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`),
|
||||
recycle: (conversationId: string) =>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<button
|
||||
class="focus-close"
|
||||
type="button"
|
||||
:aria-label="t('backstage.actions.close')"
|
||||
:aria-label="t('live.actions.close')"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@ -28,7 +28,7 @@
|
||||
<span v-else class="agent-avatar-letter focus-avatar-letter">{{ avatarLetter(run) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="focus-title">{{ run.agentName || t('backstage.unknownAgent') }}</div>
|
||||
<div class="focus-title">{{ run.agentName || t('live.unknownAgent') }}</div>
|
||||
<div class="focus-subtitle" v-if="run.username">@{{ run.username }}</div>
|
||||
</div>
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
</svg>
|
||||
<div class="ring-label">
|
||||
<div class="ring-value">{{ formatAge(run.ageMs) }}</div>
|
||||
<div class="ring-caption">{{ t('backstage.detail.runningFor') }}</div>
|
||||
<div class="ring-caption">{{ t('live.detail.runningFor') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -73,27 +73,27 @@
|
||||
<div class="focus-tiles">
|
||||
<div class="focus-tile">
|
||||
<div class="focus-tile-value">{{ formatAge(run.msSinceLastEvent) }}</div>
|
||||
<div class="focus-tile-label">{{ t('backstage.detail.lastHeard') }}</div>
|
||||
<div class="focus-tile-label">{{ t('live.detail.lastHeard') }}</div>
|
||||
</div>
|
||||
<div class="focus-tile">
|
||||
<div
|
||||
class="focus-tile-value"
|
||||
:class="{ 'focus-tile-warn': run.subscriberCount === 0 }"
|
||||
>{{ run.subscriberCount }}</div>
|
||||
<div class="focus-tile-label">{{ t('backstage.detail.audience') }}</div>
|
||||
<div class="focus-tile-label">{{ t('live.detail.audience') }}</div>
|
||||
</div>
|
||||
<div class="focus-tile">
|
||||
<div
|
||||
class="focus-tile-value focus-tile-id"
|
||||
:title="run.conversationId"
|
||||
>#{{ shortId(run.conversationId) }}</div>
|
||||
<div class="focus-tile-label">{{ t('backstage.detail.session') }}</div>
|
||||
<div class="focus-tile-label">{{ t('live.detail.session') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Helpers (subagents) -->
|
||||
<div v-if="subagents.length > 0" class="focus-section">
|
||||
<div class="focus-section-title">{{ t('backstage.detail.helpers') }}</div>
|
||||
<div class="focus-section-title">{{ t('live.detail.helpers') }}</div>
|
||||
<div v-for="sub in subagents" :key="sub.subagentId" class="focus-sub-row">
|
||||
<div class="focus-sub-icon" :style="avatarBgStyle(sub)">
|
||||
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="20" fallback="🤖" />
|
||||
@ -106,17 +106,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="focus-sub-stop" type="button" @click="$emit('interrupt-sub', sub)">
|
||||
{{ t('backstage.actions.stop') }}
|
||||
{{ t('live.actions.stop') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="focus-actions">
|
||||
<button class="focus-btn focus-btn-soft" type="button" @click="$emit('stop', run)">
|
||||
{{ t('backstage.actions.stop') }}
|
||||
{{ t('live.actions.stop') }}
|
||||
</button>
|
||||
<button class="focus-btn focus-btn-strong" type="button" @click="$emit('recycle', run)">
|
||||
{{ t('backstage.actions.endIt') }}
|
||||
{{ t('live.actions.endIt') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,20 +129,20 @@
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import { useBackstageAgent } from '@/composables/useBackstageAgent'
|
||||
import type { BackstageRunCard, BackstageSubagentCard } from '@/api'
|
||||
import { useLiveAgent } from '@/composables/useLiveAgent'
|
||||
import type { LiveRunCard, LiveSubagentCard } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
run: BackstageRunCard | null
|
||||
subagents: BackstageSubagentCard[]
|
||||
run: LiveRunCard | null
|
||||
subagents: LiveSubagentCard[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
stop: [run: BackstageRunCard]
|
||||
recycle: [run: BackstageRunCard]
|
||||
'interrupt-sub': [sub: BackstageSubagentCard]
|
||||
stop: [run: LiveRunCard]
|
||||
recycle: [run: LiveRunCard]
|
||||
'interrupt-sub': [sub: LiveSubagentCard]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -154,7 +154,7 @@ const {
|
||||
humanSentence,
|
||||
stuckCallout,
|
||||
formatAge,
|
||||
} = useBackstageAgent()
|
||||
} = useLiveAgent()
|
||||
|
||||
// SVG ring geometry: r=54 gives a circumference of ~339.292.
|
||||
// Map the agent's age over a 5-minute window to a stroke-dashoffset so the
|
||||
@ -1,164 +1,147 @@
|
||||
<template>
|
||||
<div class="mc-page-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner backstage-page">
|
||||
<!-- Header: one sentence, no jargon -->
|
||||
<div class="mc-page-header">
|
||||
<div class="header-lead">
|
||||
<div class="mc-page-kicker">{{ t('backstage.kicker') }}</div>
|
||||
<h1 class="mc-page-title">{{ t('backstage.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ headlineMessage }}</p>
|
||||
<div v-if="statusSegments.length" class="head-meta">
|
||||
<template v-for="(seg, i) in statusSegments" :key="seg.key">
|
||||
<span class="head-meta-sep" v-if="i > 0">·</span>
|
||||
<span class="head-meta-seg" :class="seg.tone">{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="chip-btn"
|
||||
:class="{ 'is-paused': !autoRefresh }"
|
||||
:title="autoRefresh ? t('backstage.actions.pauseRefresh') : t('backstage.actions.resumeRefresh')"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
<span class="chip-pulse" v-if="autoRefresh"></span>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<span>{{ autoRefresh ? t('backstage.actions.live') : t('backstage.actions.paused') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
|
||||
class="chip-btn chip-btn-warm"
|
||||
@click="confirmSweep"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M19 6l-1.2 14a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
</svg>
|
||||
{{ t('backstage.actions.tidyUp') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="live-panel">
|
||||
<!-- Toolbar: live toggle (left) + status filters + sweep (right) -->
|
||||
<div class="live-toolbar">
|
||||
<button
|
||||
class="chip-btn"
|
||||
:class="{ 'is-paused': !autoRefresh }"
|
||||
:title="autoRefresh ? t('live.actions.pauseRefresh') : t('live.actions.resumeRefresh')"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
<span class="chip-pulse" v-if="autoRefresh"></span>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<span>{{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isInitialLoading" class="cards-grid">
|
||||
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
|
||||
<el-skeleton :rows="2" animated />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showFilterRow" class="filter-row">
|
||||
<button
|
||||
v-for="opt in filterOptions"
|
||||
:key="opt.key"
|
||||
class="filter-chip"
|
||||
:class="{ 'is-active': activeFilter === opt.key, [`tone-${opt.tone}`]: true }"
|
||||
@click="activeFilter = opt.key"
|
||||
>
|
||||
<span class="filter-chip-label">{{ opt.label }}</span>
|
||||
<span class="filter-chip-count">{{ opt.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty: nothing to see -->
|
||||
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
||||
<div class="empty-orb"></div>
|
||||
<div class="empty-line">{{ t('backstage.empty.allQuiet') }}</div>
|
||||
<div class="empty-hint">{{ t('backstage.empty.hint') }}</div>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<!-- Active runs: filter row (when there's variety) + cards grid -->
|
||||
<template v-else>
|
||||
<div v-if="showFilterRow" class="filter-row">
|
||||
<button
|
||||
v-for="opt in filterOptions"
|
||||
:key="opt.key"
|
||||
class="filter-chip"
|
||||
:class="{ 'is-active': activeFilter === opt.key, [`tone-${opt.tone}`]: true }"
|
||||
@click="activeFilter = opt.key"
|
||||
>
|
||||
<span class="filter-chip-label">{{ opt.label }}</span>
|
||||
<span class="filter-chip-count">{{ opt.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
|
||||
class="chip-btn chip-btn-warm"
|
||||
@click="confirmSweep"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M19 6l-1.2 14a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
</svg>
|
||||
{{ t('live.actions.tidyUp') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid">
|
||||
<article
|
||||
v-for="run in visibleRuns"
|
||||
:key="run.conversationId"
|
||||
class="agent-card mc-surface-card"
|
||||
:class="cardClass(run)"
|
||||
@click="openDetail(run)"
|
||||
>
|
||||
<!-- Top: avatar (with status ring) + name -->
|
||||
<div class="agent-card-top">
|
||||
<div class="agent-avatar-wrap" :class="ringClass(run)" :title="dotTitle(run)">
|
||||
<div class="agent-avatar" :style="avatarBgStyle(run)">
|
||||
<SkillIcon
|
||||
v-if="run.agentIcon"
|
||||
:value="run.agentIcon"
|
||||
:size="34"
|
||||
fallback="🤖"
|
||||
/>
|
||||
<span v-else class="agent-avatar-letter">{{ avatarLetter(run) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-id">
|
||||
<div class="agent-name">{{ run.agentName || t('backstage.unknownAgent') }}</div>
|
||||
<div class="agent-owner" v-if="run.username">@{{ run.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status sentence + (when present) the tool chip standing
|
||||
on its own so a long tool name doesn't get ellipsis-eaten. -->
|
||||
<div class="agent-saying-row">
|
||||
<span class="agent-saying">{{ humanSentence(run) }}</span>
|
||||
<span v-if="run.runningToolName" class="tool-chip" :title="run.runningToolName">
|
||||
{{ run.runningToolName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta: just the time + orphan hint (id moved to detail) -->
|
||||
<div class="agent-meta-row">
|
||||
<span class="meta-time">{{ formatAge(run.ageMs) }}</span>
|
||||
<span v-if="run.orphan && !run.stuckReason" class="meta-pill meta-pill-orphan" :title="t('backstage.orphanHint')">
|
||||
{{ t('backstage.orphan') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="agent-bar" v-if="showBar(run)">
|
||||
<div class="bar-fill" :style="progressFillStyle(run)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Foot: subagent stack (left) + action hierarchy (right) -->
|
||||
<div class="agent-card-foot">
|
||||
<div class="subagent-stack" v-if="childrenOf(run).length > 0">
|
||||
<div
|
||||
v-for="sub in childrenOf(run).slice(0, 3)"
|
||||
:key="sub.subagentId"
|
||||
class="subagent-chip"
|
||||
:style="avatarBgStyle(sub)"
|
||||
:title="sub.agentName || sub.subagentId"
|
||||
>
|
||||
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="14" fallback="🤖" />
|
||||
<span v-else class="sub-chip-letter">{{ avatarLetter(sub) }}</span>
|
||||
</div>
|
||||
<div v-if="childrenOf(run).length > 3" class="subagent-chip subagent-overflow">
|
||||
+{{ childrenOf(run).length - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot-actions">
|
||||
<button
|
||||
class="card-action card-action-soft"
|
||||
@click.stop="confirmStop(run)"
|
||||
:title="t('backstage.actions.stopHint')"
|
||||
>{{ t('backstage.actions.stop') }}</button>
|
||||
<button
|
||||
v-if="run.stuckReason"
|
||||
class="card-action card-action-strong"
|
||||
@click.stop="confirmRecycle(run)"
|
||||
:title="t('backstage.actions.endHint')"
|
||||
>{{ t('backstage.actions.endIt') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Loading -->
|
||||
<div v-if="isInitialLoading" class="cards-grid">
|
||||
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
|
||||
<el-skeleton :rows="2" animated />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty: nothing to see -->
|
||||
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
||||
<div class="empty-orb"></div>
|
||||
<div class="empty-line">{{ t('live.empty.allQuiet') }}</div>
|
||||
<div class="empty-hint">{{ t('live.empty.hint') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Active runs -->
|
||||
<div v-else class="cards-grid">
|
||||
<article
|
||||
v-for="run in visibleRuns"
|
||||
:key="run.conversationId"
|
||||
class="agent-card mc-surface-card"
|
||||
:class="cardClass(run)"
|
||||
@click="openDetail(run)"
|
||||
>
|
||||
<!-- Top: avatar (with status ring) + name -->
|
||||
<div class="agent-card-top">
|
||||
<div class="agent-avatar-wrap" :class="ringClass(run)" :title="dotTitle(run)">
|
||||
<div class="agent-avatar" :style="avatarBgStyle(run)">
|
||||
<SkillIcon
|
||||
v-if="run.agentIcon"
|
||||
:value="run.agentIcon"
|
||||
:size="34"
|
||||
fallback="🤖"
|
||||
/>
|
||||
<span v-else class="agent-avatar-letter">{{ avatarLetter(run) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-id">
|
||||
<div class="agent-name">{{ run.agentName || t('live.unknownAgent') }}</div>
|
||||
<div class="agent-owner" v-if="run.username">@{{ run.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status sentence + (when present) the tool chip standing
|
||||
on its own so a long tool name doesn't get ellipsis-eaten. -->
|
||||
<div class="agent-saying-row">
|
||||
<span class="agent-saying">{{ humanSentence(run) }}</span>
|
||||
<span v-if="run.runningToolName" class="tool-chip" :title="run.runningToolName">
|
||||
{{ run.runningToolName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta: just the time + orphan hint (id moved to detail) -->
|
||||
<div class="agent-meta-row">
|
||||
<span class="meta-time">{{ formatAge(run.ageMs) }}</span>
|
||||
<span v-if="run.orphan && !run.stuckReason" class="meta-pill meta-pill-orphan" :title="t('live.orphanHint')">
|
||||
{{ t('live.orphan') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="agent-bar" v-if="showBar(run)">
|
||||
<div class="bar-fill" :style="progressFillStyle(run)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Foot: subagent stack (left) + action hierarchy (right) -->
|
||||
<div class="agent-card-foot">
|
||||
<div class="subagent-stack" v-if="childrenOf(run).length > 0">
|
||||
<div
|
||||
v-for="sub in childrenOf(run).slice(0, 3)"
|
||||
:key="sub.subagentId"
|
||||
class="subagent-chip"
|
||||
:style="avatarBgStyle(sub)"
|
||||
:title="sub.agentName || sub.subagentId"
|
||||
>
|
||||
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="14" fallback="🤖" />
|
||||
<span v-else class="sub-chip-letter">{{ avatarLetter(sub) }}</span>
|
||||
</div>
|
||||
<div v-if="childrenOf(run).length > 3" class="subagent-chip subagent-overflow">
|
||||
+{{ childrenOf(run).length - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot-actions">
|
||||
<button
|
||||
class="card-action card-action-soft"
|
||||
@click.stop="confirmStop(run)"
|
||||
:title="t('live.actions.stopHint')"
|
||||
>{{ t('live.actions.stop') }}</button>
|
||||
<button
|
||||
v-if="run.stuckReason"
|
||||
class="card-action card-action-strong"
|
||||
@click.stop="confirmRecycle(run)"
|
||||
:title="t('live.actions.endHint')"
|
||||
>{{ t('live.actions.endIt') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackstageFocusPanel
|
||||
<LiveFocusPanel
|
||||
:open="drawerOpen"
|
||||
:run="detail"
|
||||
:subagents="detail ? childrenOf(detail) : []"
|
||||
@ -174,10 +157,10 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import BackstageFocusPanel from '@/components/backstage/BackstageFocusPanel.vue'
|
||||
import { useBackstageAgent } from '@/composables/useBackstageAgent'
|
||||
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
|
||||
import { useLiveAgent } from '@/composables/useLiveAgent'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { backstageApi, type BackstageSnapshot, type BackstageRunCard, type BackstageSubagentCard } from '@/api'
|
||||
import { liveApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
@ -187,50 +170,25 @@ const {
|
||||
dotTitle,
|
||||
humanSentence,
|
||||
formatAge,
|
||||
} = useBackstageAgent()
|
||||
} = useLiveAgent()
|
||||
|
||||
type FilterKey = 'all' | 'working' | 'attention' | 'quiet'
|
||||
|
||||
const snapshot = ref<BackstageSnapshot | null>(null)
|
||||
const snapshot = ref<LiveSnapshot | null>(null)
|
||||
const isInitialLoading = ref(true)
|
||||
const autoRefresh = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const detail = ref<BackstageRunCard | null>(null)
|
||||
const detail = ref<LiveRunCard | null>(null)
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const headlineMessage = computed(() => {
|
||||
if (!snapshot.value) return t('backstage.headline.loading')
|
||||
const s = snapshot.value.summary
|
||||
if (s.running === 0) return t('backstage.headline.allQuiet')
|
||||
if (s.stuck > 0) return t('backstage.headline.someoneNeedsAttention', { n: s.stuck })
|
||||
if (s.orphan > 0) return t('backstage.headline.workingAlone', { running: s.running, orphan: s.orphan })
|
||||
return t('backstage.headline.working', { n: s.running })
|
||||
})
|
||||
|
||||
/**
|
||||
* Runbook-style monospace status line. Each segment is a small fact, joined
|
||||
* with a `·` separator. Tone hints colour subtly (warn / accent / muted).
|
||||
* Hidden when nothing is running — empty state already says it best.
|
||||
*/
|
||||
const statusSegments = computed<{ key: string; text: string; tone: 'muted' | 'accent' | 'warn' }[]>(() => {
|
||||
const s = snapshot.value?.summary
|
||||
if (!s || s.running === 0) return []
|
||||
const segs: { key: string; text: string; tone: 'muted' | 'accent' | 'warn' }[] = []
|
||||
segs.push({ key: 'running', text: t('backstage.status.running', { n: s.running }), tone: 'accent' })
|
||||
if (s.stuck > 0) segs.push({ key: 'stuck', text: t('backstage.status.stuck', { n: s.stuck }), tone: 'warn' })
|
||||
if (s.orphan > 0) segs.push({ key: 'orphan', text: t('backstage.status.orphan', { n: s.orphan }), tone: 'muted' })
|
||||
if (s.subagentsActive > 0) segs.push({ key: 'helpers', text: t('backstage.status.helpers', { n: s.subagentsActive }), tone: 'muted' })
|
||||
return segs
|
||||
})
|
||||
|
||||
function isWorking(r: BackstageRunCard): boolean {
|
||||
function isWorking(r: LiveRunCard): boolean {
|
||||
return !r.stuckReason && !r.orphan
|
||||
}
|
||||
function isAttention(r: BackstageRunCard): boolean {
|
||||
function isAttention(r: LiveRunCard): boolean {
|
||||
return !!r.stuckReason
|
||||
}
|
||||
function isQuiet(r: BackstageRunCard): boolean {
|
||||
function isQuiet(r: LiveRunCard): boolean {
|
||||
return r.orphan && !r.stuckReason
|
||||
}
|
||||
|
||||
@ -245,11 +203,11 @@ const filterOptions = computed(() => {
|
||||
// Always show All; only show secondary chips that have at least one match —
|
||||
// an empty chip is dead pixels.
|
||||
const opts: { key: FilterKey; label: string; count: number; tone: string }[] = [
|
||||
{ key: 'all', label: t('backstage.filters.all'), count: counts.all, tone: 'neutral' },
|
||||
{ key: 'all', label: t('live.filters.all'), count: counts.all, tone: 'neutral' },
|
||||
]
|
||||
if (counts.working > 0) opts.push({ key: 'working', label: t('backstage.filters.working'), count: counts.working, tone: 'good' })
|
||||
if (counts.attention > 0) opts.push({ key: 'attention', label: t('backstage.filters.attention'), count: counts.attention, tone: 'warn' })
|
||||
if (counts.quiet > 0) opts.push({ key: 'quiet', label: t('backstage.filters.quiet'), count: counts.quiet, tone: 'muted' })
|
||||
if (counts.working > 0) opts.push({ key: 'working', label: t('live.filters.working'), count: counts.working, tone: 'good' })
|
||||
if (counts.attention > 0) opts.push({ key: 'attention', label: t('live.filters.attention'), count: counts.attention, tone: 'warn' })
|
||||
if (counts.quiet > 0) opts.push({ key: 'quiet', label: t('live.filters.quiet'), count: counts.quiet, tone: 'muted' })
|
||||
return opts
|
||||
})
|
||||
|
||||
@ -272,13 +230,13 @@ const showFilterRow = computed(() => {
|
||||
* Without this, runs are listed in whatever order the snapshot returned
|
||||
* them, and the one card you actually need to look at can be 4 rows down.
|
||||
*/
|
||||
function tierOf(r: BackstageRunCard): number {
|
||||
function tierOf(r: LiveRunCard): number {
|
||||
if (r.stuckReason) return 0
|
||||
if (r.orphan) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
const visibleRuns = computed<BackstageRunCard[]>(() => {
|
||||
const visibleRuns = computed<LiveRunCard[]>(() => {
|
||||
const runs = snapshot.value?.runs ?? []
|
||||
const filtered = (() => {
|
||||
switch (activeFilter.value) {
|
||||
@ -297,7 +255,7 @@ const visibleRuns = computed<BackstageRunCard[]>(() => {
|
||||
})
|
||||
|
||||
// If the filter the user picked no longer matches any rows (e.g., the stuck
|
||||
// run resolved itself between refreshes), drop back to All so the page
|
||||
// run resolved itself between refreshes), drop back to All so the panel
|
||||
// doesn't go inexplicably empty.
|
||||
watch(filterOptions, opts => {
|
||||
if (!opts.some(o => o.key === activeFilter.value)) {
|
||||
@ -305,7 +263,7 @@ watch(filterOptions, opts => {
|
||||
}
|
||||
})
|
||||
|
||||
function cardClass(run: BackstageRunCard) {
|
||||
function cardClass(run: LiveRunCard) {
|
||||
return {
|
||||
'is-stuck': !!run.stuckReason,
|
||||
'is-orphan': run.orphan && !run.stuckReason,
|
||||
@ -318,21 +276,21 @@ function cardClass(run: BackstageRunCard) {
|
||||
* starts to matter. Under 30s is "barely started" — rendering 1px of bar
|
||||
* adds visual noise without informing the user.
|
||||
*/
|
||||
function showBar(run: BackstageRunCard): boolean {
|
||||
function showBar(run: LiveRunCard): boolean {
|
||||
return run.ageMs > 30_000
|
||||
}
|
||||
|
||||
function progressFillStyle(run: BackstageRunCard) {
|
||||
function progressFillStyle(run: LiveRunCard) {
|
||||
// Map age to 0..100% over the 5-minute window. Beyond that we just stay full.
|
||||
const pct = Math.min(100, ((run.ageMs - 30_000) / 270_000) * 100)
|
||||
return { width: `${Math.max(4, pct)}%` }
|
||||
}
|
||||
|
||||
function childrenOf(run: BackstageRunCard): BackstageSubagentCard[] {
|
||||
function childrenOf(run: LiveRunCard): LiveSubagentCard[] {
|
||||
return snapshot.value?.subagents.filter(s => s.parentConversationId === run.conversationId) ?? []
|
||||
}
|
||||
|
||||
function openDetail(run: BackstageRunCard) {
|
||||
function openDetail(run: LiveRunCard) {
|
||||
// Clicking the same card while the panel is open closes it — toggle.
|
||||
// Otherwise swap content and (re)open. The focus panel handles its own
|
||||
// enter/leave animations, so all we manage here is the open boolean and
|
||||
@ -351,14 +309,14 @@ function closeDetail() {
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res: any = await backstageApi.snapshot()
|
||||
snapshot.value = (res?.data ?? res) as BackstageSnapshot
|
||||
const res: any = await liveApi.snapshot()
|
||||
snapshot.value = (res?.data ?? res) as LiveSnapshot
|
||||
if (detail.value && snapshot.value) {
|
||||
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
|
||||
if (fresh) detail.value = fresh
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (isInitialLoading.value) mcToast.error(e?.message || t('backstage.errors.loadFailed'))
|
||||
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||
} finally {
|
||||
isInitialLoading.value = false
|
||||
}
|
||||
@ -375,78 +333,78 @@ function toggleAutoRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmStop(run: BackstageRunCard) {
|
||||
async function confirmStop(run: LiveRunCard) {
|
||||
const ok = await mcConfirm({
|
||||
title: t('backstage.confirm.stopTitle'),
|
||||
message: t('backstage.confirm.stopBody', { name: run.agentName || t('backstage.unknownAgent') }),
|
||||
confirmText: t('backstage.actions.stop'),
|
||||
title: t('live.confirm.stopTitle'),
|
||||
message: t('live.confirm.stopBody', { name: run.agentName || t('live.unknownAgent') }),
|
||||
confirmText: t('live.actions.stop'),
|
||||
cancelText: t('common.cancel'),
|
||||
tone: 'primary',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await backstageApi.stop(run.conversationId)
|
||||
mcToast.success(t('backstage.toast.stopped'))
|
||||
await liveApi.stop(run.conversationId)
|
||||
mcToast.success(t('live.toast.stopped'))
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
|
||||
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRecycle(run: BackstageRunCard) {
|
||||
async function confirmRecycle(run: LiveRunCard) {
|
||||
const ok = await mcConfirm({
|
||||
title: t('backstage.confirm.endTitle', { name: run.agentName || t('backstage.unknownAgent') }),
|
||||
message: t('backstage.confirm.endBody', { name: run.agentName || t('backstage.unknownAgent') }),
|
||||
confirmText: t('backstage.actions.endIt'),
|
||||
title: t('live.confirm.endTitle', { name: run.agentName || t('live.unknownAgent') }),
|
||||
message: t('live.confirm.endBody', { name: run.agentName || t('live.unknownAgent') }),
|
||||
confirmText: t('live.actions.endIt'),
|
||||
cancelText: t('common.cancel'),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await backstageApi.recycle(run.conversationId)
|
||||
mcToast.success(t('backstage.toast.ended'))
|
||||
await liveApi.recycle(run.conversationId)
|
||||
mcToast.success(t('live.toast.ended'))
|
||||
drawerOpen.value = false
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
|
||||
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInterruptSub(sub: BackstageSubagentCard) {
|
||||
async function confirmInterruptSub(sub: LiveSubagentCard) {
|
||||
const ok = await mcConfirm({
|
||||
title: t('backstage.confirm.subTitle'),
|
||||
message: t('backstage.confirm.subBody', { name: sub.agentName || sub.subagentId }),
|
||||
confirmText: t('backstage.actions.stop'),
|
||||
title: t('live.confirm.subTitle'),
|
||||
message: t('live.confirm.subBody', { name: sub.agentName || sub.subagentId }),
|
||||
confirmText: t('live.actions.stop'),
|
||||
cancelText: t('common.cancel'),
|
||||
tone: 'primary',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await backstageApi.interruptSubagent(sub.subagentId)
|
||||
mcToast.success(t('backstage.toast.subStopped'))
|
||||
await liveApi.interruptSubagent(sub.subagentId)
|
||||
mcToast.success(t('live.toast.subStopped'))
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
|
||||
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSweep() {
|
||||
const stuckCount = snapshot.value?.summary.stuck ?? 0
|
||||
const ok = await mcConfirm({
|
||||
title: t('backstage.confirm.sweepTitle'),
|
||||
message: t('backstage.confirm.sweepBody', { n: stuckCount }),
|
||||
confirmText: t('backstage.actions.tidyUp'),
|
||||
title: t('live.confirm.sweepTitle'),
|
||||
message: t('live.confirm.sweepBody', { n: stuckCount }),
|
||||
confirmText: t('live.actions.tidyUp'),
|
||||
cancelText: t('common.cancel'),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
const res: any = await backstageApi.sweep()
|
||||
const res: any = await liveApi.sweep()
|
||||
const recycled = res?.data?.recycled ?? 0
|
||||
mcToast.success(t('backstage.toast.swept', { n: recycled }))
|
||||
mcToast.success(t('live.toast.swept', { n: recycled }))
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
|
||||
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@ -461,57 +419,22 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backstage-page {
|
||||
.live-panel {
|
||||
--card-radius: 24px;
|
||||
}
|
||||
|
||||
.header-lead {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===== Runbook-style monospace status line ===== */
|
||||
.head-meta {
|
||||
/* ===== Toolbar: live toggle + status filters + sweep ===== */
|
||||
.live-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.head-meta-sep {
|
||||
color: var(--mc-border-strong);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html.dark .head-meta-sep {
|
||||
/* Border-strong reads too pale in dark mode for a punctuation glyph. */
|
||||
color: var(--mc-text-tertiary);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.head-meta-seg.muted {
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
.head-meta-seg.accent {
|
||||
color: hsl(155, 50%, 38%);
|
||||
}
|
||||
|
||||
html.dark .head-meta-seg.accent {
|
||||
color: hsl(155, 55%, 62%);
|
||||
}
|
||||
|
||||
.head-meta-seg.warn {
|
||||
color: hsl(20, 75%, 42%);
|
||||
}
|
||||
|
||||
html.dark .head-meta-seg.warn {
|
||||
color: hsl(28, 80%, 70%);
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ===== Filter chip row (kanban-inspired, soft) ===== */
|
||||
@ -519,7 +442,6 @@ html.dark .head-meta-seg.warn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
@ -611,12 +533,6 @@ html.dark .filter-chip.is-active.tone-good {
|
||||
}
|
||||
|
||||
/* ===== Header chips ===== */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -1028,10 +944,9 @@ html.dark .agent-card.is-stuck .tool-chip {
|
||||
}
|
||||
|
||||
/*
|
||||
* Subagent stack — overlapping circular avatars, like Linear's assignee
|
||||
* column. Up to 3 visible; the rest collapse into a "+N" chip.
|
||||
* Replaces the old "{n} 个帮手" pill: subagents are good news, they
|
||||
* deserve faces, not a count.
|
||||
* Subagent stack — overlapping circular avatars, like an assignee column.
|
||||
* Up to 3 visible; the rest collapse into a "+N" chip. Subagents are good
|
||||
* news, they deserve faces, not a count.
|
||||
*/
|
||||
.subagent-stack {
|
||||
display: inline-flex;
|
||||
@ -1155,5 +1070,4 @@ html.dark .card-action-strong {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Shared display helpers for the Backstage page and its child components.
|
||||
* Shared display helpers for the Live runtime view and its child components.
|
||||
* Pure formatting and classification — no API calls, no shared state.
|
||||
*
|
||||
* The parent (Backstage.vue) and the focus panel both render agent cards
|
||||
* with avatars, status rings, human sentences, and elapsed-time strings;
|
||||
* keeping these in one place avoids drift between the two surfaces when
|
||||
* a status string or ring rule changes.
|
||||
* The live panel and the focus panel both render agent cards with avatars,
|
||||
* status rings, human sentences, and elapsed-time strings; keeping these in
|
||||
* one place avoids drift between the two surfaces when a status string or
|
||||
* ring rule changes.
|
||||
*/
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { BackstageRunCard, BackstageSubagentCard } from '@/api'
|
||||
import type { LiveRunCard, LiveSubagentCard } from '@/api'
|
||||
|
||||
type AnyRun = BackstageRunCard | BackstageSubagentCard
|
||||
type AnyRun = LiveRunCard | LiveSubagentCard
|
||||
|
||||
export function useBackstageAgent() {
|
||||
export function useLiveAgent() {
|
||||
const { t } = useI18n()
|
||||
|
||||
function avatarLetter(run: AnyRun): string {
|
||||
@ -47,7 +47,7 @@ export function useBackstageAgent() {
|
||||
* to healthy — which is what you'd want for a helper that's still around.
|
||||
*/
|
||||
function ringClass(run: AnyRun): Record<string, boolean> {
|
||||
const r = run as BackstageRunCard
|
||||
const r = run as LiveRunCard
|
||||
if (r.stuckReason) return { 'ring-stuck': true }
|
||||
if (r.orphan) return { 'ring-orphan': true }
|
||||
if (r.firstTokenReceived === false && r.ageMs < 30_000) return { 'ring-thinking': true }
|
||||
@ -55,10 +55,10 @@ export function useBackstageAgent() {
|
||||
}
|
||||
|
||||
function dotTitle(run: AnyRun): string {
|
||||
const r = run as BackstageRunCard
|
||||
if (r.stuckReason) return t('backstage.dotTitle.stuck')
|
||||
if (r.orphan) return t('backstage.dotTitle.orphan')
|
||||
return t('backstage.dotTitle.healthy')
|
||||
const r = run as LiveRunCard
|
||||
if (r.stuckReason) return t('live.dotTitle.stuck')
|
||||
if (r.orphan) return t('live.dotTitle.orphan')
|
||||
return t('live.dotTitle.healthy')
|
||||
}
|
||||
|
||||
/**
|
||||
@ -69,33 +69,33 @@ export function useBackstageAgent() {
|
||||
* stuckCallout (used in the focus panel) is the one place that still
|
||||
* mentions the tool name, because there it lives in a long-form alert.
|
||||
*/
|
||||
function humanSentence(run: BackstageRunCard): string {
|
||||
if (run.stuckReason === 'tool_silent') return t('backstage.saying.toolSilentBare')
|
||||
if (run.stuckReason === 'idle_silent') return t('backstage.saying.idleSilent')
|
||||
if (run.stuckReason === 'hard_cap') return t('backstage.saying.hardCap')
|
||||
if (run.currentPhase === 'awaiting_approval') return t('backstage.saying.awaitingApproval')
|
||||
if (run.runningToolName) return t('backstage.saying.usingToolBare')
|
||||
if (run.currentPhase === 'executing_tool') return t('backstage.saying.usingSomething')
|
||||
if (run.currentPhase === 'summarizing') return t('backstage.saying.wrappingUp')
|
||||
if (run.currentPhase === 'planning') return t('backstage.saying.planning')
|
||||
if (!run.firstTokenReceived) return t('backstage.saying.thinking')
|
||||
return t('backstage.saying.replying')
|
||||
function humanSentence(run: LiveRunCard): string {
|
||||
if (run.stuckReason === 'tool_silent') return t('live.saying.toolSilentBare')
|
||||
if (run.stuckReason === 'idle_silent') return t('live.saying.idleSilent')
|
||||
if (run.stuckReason === 'hard_cap') return t('live.saying.hardCap')
|
||||
if (run.currentPhase === 'awaiting_approval') return t('live.saying.awaitingApproval')
|
||||
if (run.runningToolName) return t('live.saying.usingToolBare')
|
||||
if (run.currentPhase === 'executing_tool') return t('live.saying.usingSomething')
|
||||
if (run.currentPhase === 'summarizing') return t('live.saying.wrappingUp')
|
||||
if (run.currentPhase === 'planning') return t('live.saying.planning')
|
||||
if (!run.firstTokenReceived) return t('live.saying.thinking')
|
||||
return t('live.saying.replying')
|
||||
}
|
||||
|
||||
function formatAge(ms: number): string {
|
||||
if (ms < 1500) return t('backstage.time.justNow')
|
||||
if (ms < 1500) return t('live.time.justNow')
|
||||
const sec = Math.floor(ms / 1000)
|
||||
if (sec < 60) return t('backstage.time.seconds', { n: sec })
|
||||
if (sec < 60) return t('live.time.seconds', { n: sec })
|
||||
const min = Math.floor(sec / 60)
|
||||
if (min < 60) return t('backstage.time.minutes', { n: min, s: sec % 60 })
|
||||
if (min < 60) return t('live.time.minutes', { n: min, s: sec % 60 })
|
||||
const hr = Math.floor(min / 60)
|
||||
return t('backstage.time.hours', { n: hr, m: min % 60 })
|
||||
return t('live.time.hours', { n: hr, m: min % 60 })
|
||||
}
|
||||
|
||||
function stuckCallout(run: BackstageRunCard): string {
|
||||
if (run.stuckReason === 'tool_silent') return t('backstage.callout.toolSilent', { tool: run.runningToolName || t('backstage.aTool'), time: formatAge(run.msSinceLastEvent) })
|
||||
if (run.stuckReason === 'idle_silent') return t('backstage.callout.idleSilent', { time: formatAge(run.msSinceLastEvent) })
|
||||
return t('backstage.callout.hardCap', { time: formatAge(run.ageMs) })
|
||||
function stuckCallout(run: LiveRunCard): string {
|
||||
if (run.stuckReason === 'tool_silent') return t('live.callout.toolSilent', { tool: run.runningToolName || t('live.aTool'), time: formatAge(run.msSinceLastEvent) })
|
||||
if (run.stuckReason === 'idle_silent') return t('live.callout.idleSilent', { time: formatAge(run.msSinceLastEvent) })
|
||||
return t('live.callout.hardCap', { time: formatAge(run.ageMs) })
|
||||
}
|
||||
|
||||
return {
|
||||
@ -381,9 +381,7 @@ export default {
|
||||
activity: 'Activity',
|
||||
acpEndpoints: 'ACP Endpoints',
|
||||
settingsGroup: 'Settings',
|
||||
agents: 'Digital Employees',
|
||||
backstage: 'Backstage',
|
||||
backstageTooltip: 'See what your employees are doing right now',
|
||||
agents: 'Employees',
|
||||
security: 'Security',
|
||||
tokenUsage: 'Token Usage',
|
||||
cronJobs: 'Cron Jobs',
|
||||
@ -400,15 +398,15 @@ export default {
|
||||
appearance: 'Appearance & Language',
|
||||
roleUser: 'User',
|
||||
roleAdmin: 'Admin',
|
||||
shortcutAgents: 'Agents',
|
||||
shortcutAgents: 'Employees',
|
||||
shortcutNew: 'New',
|
||||
shortcutsHint: 'Ctrl+K {agents} | Ctrl+N {newAction}',
|
||||
},
|
||||
notifications: {
|
||||
pendingApprovals: '{n} tool call(s) pending approval',
|
||||
},
|
||||
backstage: {
|
||||
kicker: 'Backstage',
|
||||
live: {
|
||||
kicker: 'Live',
|
||||
title: 'See what your employees are doing',
|
||||
attention: 'Someone needs your attention',
|
||||
unknownAgent: 'Employee',
|
||||
@ -1026,12 +1024,12 @@ export default {
|
||||
},
|
||||
agents: {
|
||||
kicker: 'Employee Studio',
|
||||
title: 'Digital Employees',
|
||||
desc: 'Hire, train, and manage your digital employees',
|
||||
title: 'Employees',
|
||||
desc: 'Hire, train, and manage your employees',
|
||||
newAgent: 'Hire Employee',
|
||||
live: {
|
||||
atWork: '{n} at work — see backstage',
|
||||
needsAttention: '{n} need attention — see backstage',
|
||||
views: {
|
||||
roster: 'Roster',
|
||||
live: 'Live',
|
||||
},
|
||||
templates: {
|
||||
title: 'Choose a Role',
|
||||
|
||||
@ -381,9 +381,7 @@ export default {
|
||||
activity: '活动记录',
|
||||
acpEndpoints: 'ACP 端点',
|
||||
settingsGroup: '设置',
|
||||
agents: '数字员工',
|
||||
backstage: '后台',
|
||||
backstageTooltip: '看看你的数字员工此刻在做什么',
|
||||
agents: '员工',
|
||||
security: '安全',
|
||||
tokenUsage: 'Token 统计',
|
||||
cronJobs: '定时任务',
|
||||
@ -400,7 +398,7 @@ export default {
|
||||
appearance: '外观与语言',
|
||||
roleUser: '用户',
|
||||
roleAdmin: '管理员',
|
||||
shortcutAgents: '智能体',
|
||||
shortcutAgents: '员工',
|
||||
shortcutNew: '新建',
|
||||
shortcutsHint: 'Ctrl+K {agents} | Ctrl+N {newAction}',
|
||||
},
|
||||
@ -918,12 +916,12 @@ export default {
|
||||
},
|
||||
agents: {
|
||||
kicker: '员工工作室',
|
||||
title: '数字员工',
|
||||
desc: '招募、培训和管理你的数字员工',
|
||||
title: '员工',
|
||||
desc: '招募、培训和管理你的员工',
|
||||
newAgent: '新员工',
|
||||
live: {
|
||||
atWork: '{n} 个在干活 · 看现场',
|
||||
needsAttention: '{n} 个需要看看 · 去现场',
|
||||
views: {
|
||||
roster: '花名册',
|
||||
live: '现场',
|
||||
},
|
||||
templates: {
|
||||
title: '选择岗位',
|
||||
@ -1720,8 +1718,8 @@ export default {
|
||||
notifications: {
|
||||
pendingApprovals: '{n} 个工具调用等待审批',
|
||||
},
|
||||
backstage: {
|
||||
kicker: '后台',
|
||||
live: {
|
||||
kicker: '现场',
|
||||
title: '看看你的数字员工在做什么',
|
||||
attention: '有几个需要你看看',
|
||||
unknownAgent: '员工',
|
||||
|
||||
@ -40,10 +40,10 @@ const router = createRouter({
|
||||
meta: { title: 'Agents', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
// Live runtime view folded into the Agents page as a sub-view.
|
||||
// Kept as a redirect so old links / bookmarks still resolve.
|
||||
path: 'backstage',
|
||||
name: 'Backstage',
|
||||
component: () => import('@/views/Backstage.vue'),
|
||||
meta: { title: 'Backstage', requireAdmin: true },
|
||||
redirect: { path: '/agents', query: { view: 'live' } },
|
||||
},
|
||||
{
|
||||
path: 'wiki',
|
||||
|
||||
@ -9,23 +9,28 @@
|
||||
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
|
||||
</div>
|
||||
<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>
|
||||
<!-- Roster / Live view switch — one team, two states. Admin only.
|
||||
Sits on the header line, level with the New Employee button. -->
|
||||
<div v-if="isAdminRole" class="view-switch">
|
||||
<button
|
||||
class="view-seg"
|
||||
:class="{ 'is-active': view === 'roster' }"
|
||||
@click="setView('roster')"
|
||||
>{{ t('agents.views.roster') }}</button>
|
||||
<button
|
||||
class="view-seg"
|
||||
:class="{ 'is-active': view === 'live' }"
|
||||
@click="setView('live')"
|
||||
>
|
||||
<span v-if="liveRunning > 0" class="seg-pulse"></span>
|
||||
{{ t('agents.views.live') }}
|
||||
<span
|
||||
v-if="liveRunning > 0"
|
||||
class="seg-count"
|
||||
:class="{ warn: liveStuck > 0 }"
|
||||
>{{ liveRunning }}</span>
|
||||
</button>
|
||||
</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"/>
|
||||
@ -35,6 +40,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roster: the team -->
|
||||
<template v-if="view === 'roster'">
|
||||
<div class="agents-toolbar mc-surface-card">
|
||||
<div class="filter-bar">
|
||||
<div class="search-box">
|
||||
@ -123,6 +130,10 @@
|
||||
<p>{{ t('agents.emptyDesc') }}</p>
|
||||
<button class="btn-primary" @click="openCreateModal">{{ t('agents.newAgent') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Live: what the team is doing right now -->
|
||||
<LivePanel v-else />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -486,14 +497,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, backstageApi } from '@/api/index'
|
||||
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi } from '@/api/index'
|
||||
import type { Agent } from '@/types/index'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||
import LivePanel from '@/components/live/LivePanel.vue'
|
||||
import {
|
||||
emptyProfile,
|
||||
parsePrompt,
|
||||
@ -508,6 +520,7 @@ import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBin
|
||||
import { useSkillName } from '@/composables/useSkillName'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { resolveSkillName } = useSkillName()
|
||||
const agents = ref<Agent[]>([])
|
||||
@ -715,19 +728,28 @@ const filteredAgents = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// Live signal for the "see what's running" header pill — admin only.
|
||||
// Roster ↔ Live view switch — admin only. The running/stuck counts feed the
|
||||
// segmented control's pulse + badge so you know whether Live is worth a look.
|
||||
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
|
||||
const backstageRunning = ref(0)
|
||||
const backstageStuck = ref(0)
|
||||
let backstagePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
const view = ref<'roster' | 'live'>(
|
||||
route.query.view === 'live' && isAdminRole.value ? 'live' : 'roster',
|
||||
)
|
||||
const liveRunning = ref(0)
|
||||
const liveStuck = ref(0)
|
||||
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshBackstagePill() {
|
||||
function setView(next: 'roster' | 'live') {
|
||||
view.value = next
|
||||
router.replace({ query: next === 'live' ? { view: 'live' } : {} })
|
||||
}
|
||||
|
||||
async function refreshLiveCounts() {
|
||||
if (!isAdminRole.value) return
|
||||
try {
|
||||
const res: any = await backstageApi.snapshot()
|
||||
const res: any = await liveApi.snapshot()
|
||||
const data = res?.data ?? res
|
||||
backstageRunning.value = data?.summary?.running ?? 0
|
||||
backstageStuck.value = data?.summary?.stuck ?? 0
|
||||
liveRunning.value = data?.summary?.running ?? 0
|
||||
liveStuck.value = data?.summary?.stuck ?? 0
|
||||
} catch {
|
||||
// Silent — stale value is preferable to a flapping number.
|
||||
}
|
||||
@ -739,13 +761,13 @@ onMounted(() => {
|
||||
// Failure is non-fatal — the dropdown just shows only "global default".
|
||||
loadAvailableModels()
|
||||
if (isAdminRole.value) {
|
||||
refreshBackstagePill()
|
||||
backstagePollTimer = setInterval(refreshBackstagePill, 10_000)
|
||||
refreshLiveCounts()
|
||||
livePollTimer = setInterval(refreshLiveCounts, 10_000)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (backstagePollTimer) clearInterval(backstagePollTimer)
|
||||
if (livePollTimer) clearInterval(livePollTimer)
|
||||
})
|
||||
|
||||
async function loadAgents() {
|
||||
@ -977,92 +999,87 @@ 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 {
|
||||
/* ===== Roster / Live segmented switch ===== */
|
||||
/* Lives in the header row, level with the New Employee button. */
|
||||
.view-switch {
|
||||
display: inline-flex;
|
||||
background: var(--mc-bg-sunken);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.view-seg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px 8px 12px;
|
||||
padding: 6px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mc-text-secondary);
|
||||
font-size: 12.5px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
html.dark .live-pill {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.live-pill:hover {
|
||||
border-color: var(--mc-border);
|
||||
.view-seg:hover {
|
||||
color: var(--mc-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.live-pill-dot {
|
||||
.view-seg.is-active {
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Pulsing dot — the Live segment is alive when runs are in flight. */
|
||||
.seg-pulse {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: hsl(155, 55%, 50%);
|
||||
position: relative;
|
||||
animation: live-pill-pulse 2.4s ease-in-out infinite;
|
||||
background: hsl(140, 55%, 48%);
|
||||
animation: seg-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 seg-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsla(140, 55%, 50%, 0.5); }
|
||||
50% { box-shadow: 0 0 0 5px hsla(140, 55%, 50%, 0); }
|
||||
}
|
||||
|
||||
@keyframes live-pill-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(0.85); }
|
||||
.seg-count {
|
||||
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes live-pill-halo {
|
||||
0%, 100% { opacity: 0; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.6); }
|
||||
.view-seg.is-active .seg-count {
|
||||
background: var(--mc-accent-soft);
|
||||
color: var(--mc-accent);
|
||||
}
|
||||
|
||||
.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%);
|
||||
/* Stuck runs turn the badge warm — you should look without switching. */
|
||||
.seg-count.warn {
|
||||
background: hsla(20, 90%, 55%, 0.18);
|
||||
color: hsl(20, 75%, 42%);
|
||||
}
|
||||
|
||||
.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;
|
||||
html.dark .seg-count.warn {
|
||||
color: hsl(28, 80%, 70%);
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
</button>
|
||||
|
||||
<div class="agent-selector">
|
||||
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
|
||||
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="$t('chat.selectAgent')">
|
||||
<span class="agent-select-trigger__icon" :style="{ color: agentIconColor(currentAgent?.icon) }"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🤖'" /></span>
|
||||
<span v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
|
||||
<svg v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
@ -994,23 +994,21 @@ const eligibleModels = computed(() => {
|
||||
// Global shortcuts (Ctrl+K agents, Ctrl+N new chat) live in MainLayout so they
|
||||
// work from any page; this view reacts to the dispatched event when mounted.
|
||||
function handleChatShortcut(e: Event) {
|
||||
const action = (e as CustomEvent).detail as 'newChat' | 'selectAgent' | undefined
|
||||
const action = (e as CustomEvent).detail as 'newChat' | undefined
|
||||
if (action === 'newChat') {
|
||||
newConversation()
|
||||
nextTick(() => chatInputRef.value?.focus?.())
|
||||
} else if (action === 'selectAgent') {
|
||||
agentDropdownOpen.value = !agentDropdownOpen.value
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-page hand-off from MainLayout's global shortcuts: read once on mount
|
||||
// (before loadAgents triggers syncRouteState, which would wipe the action key)
|
||||
// and apply after agents are loaded so the dropdown actually has something to show.
|
||||
let pendingRouteAction: 'newChat' | 'selectAgent' | '' = ''
|
||||
let pendingRouteAction: 'newChat' | '' = ''
|
||||
|
||||
function captureRouteAction() {
|
||||
const action = route.query.action
|
||||
if (action === 'newChat' || action === 'selectAgent') {
|
||||
if (action === 'newChat') {
|
||||
pendingRouteAction = action
|
||||
}
|
||||
}
|
||||
@ -1021,8 +1019,6 @@ function applyPendingRouteAction() {
|
||||
if (action === 'newChat') {
|
||||
newConversation()
|
||||
nextTick(() => chatInputRef.value?.focus?.())
|
||||
} else if (action === 'selectAgent') {
|
||||
agentDropdownOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -53,11 +53,11 @@
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
<NavBadge
|
||||
v-if="item.path === '/backstage'"
|
||||
:dot="backstageAlertActive"
|
||||
v-if="item.path === '/agents' && isAdminRole"
|
||||
:dot="liveAlertActive"
|
||||
tone="warning"
|
||||
:collapsed="effectiveCollapsed"
|
||||
:title="t('backstage.attention')"
|
||||
:title="t('live.attention')"
|
||||
/>
|
||||
<NavBadge
|
||||
v-else-if="item.path === '/security' && isAdminRole"
|
||||
@ -229,12 +229,12 @@ async function fetchHealthStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar attention signals — admin-only. Both `/backstage` (stuck agents)
|
||||
// and `/security` (pending approvals) read from a shared 15s poller so
|
||||
// multiple consumers don't multiply HTTP traffic.
|
||||
// Sidebar attention signals — admin-only. Both `/agents` (stuck agents in the
|
||||
// Live view) and `/security` (pending approvals) read from a shared 15s poller
|
||||
// so multiple consumers don't multiply HTTP traffic.
|
||||
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
|
||||
const { stuckAgents, pendingApprovals } = useNotificationCenter()
|
||||
const backstageAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
|
||||
const liveAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
|
||||
|
||||
// 移动端状态
|
||||
const isMobile = ref(false)
|
||||
@ -258,17 +258,20 @@ function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
}
|
||||
}
|
||||
|
||||
type ChatShortcutAction = 'newChat' | 'selectAgent'
|
||||
|
||||
const shortcutsHintText = computed(() =>
|
||||
`Ctrl+K ${t('nav.shortcutAgents')} | Ctrl+N ${t('nav.shortcutNew')}`,
|
||||
)
|
||||
|
||||
function fireChatShortcut(action: ChatShortcutAction) {
|
||||
function openAgentsMenu() {
|
||||
if (!workspaceStore.can('manage:agents' as never)) return
|
||||
if (route.path !== '/agents') router.push('/agents')
|
||||
}
|
||||
|
||||
function fireNewChatShortcut() {
|
||||
if (route.path === '/chat') {
|
||||
window.dispatchEvent(new CustomEvent('mc:chat-shortcut', { detail: action }))
|
||||
window.dispatchEvent(new CustomEvent('mc:chat-shortcut', { detail: 'newChat' }))
|
||||
} else {
|
||||
router.push({ path: '/chat', query: { action } })
|
||||
router.push({ path: '/chat', query: { action: 'newChat' } })
|
||||
}
|
||||
}
|
||||
|
||||
@ -290,7 +293,11 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
// still want to let the chat input handle native paste / undo unblocked.
|
||||
if (key === 'n' && isEditableTarget(e.target)) return
|
||||
e.preventDefault()
|
||||
fireChatShortcut(key === 'k' ? 'selectAgent' : 'newChat')
|
||||
if (key === 'k') {
|
||||
openAgentsMenu()
|
||||
} else {
|
||||
fireNewChatShortcut()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@ -318,7 +325,7 @@ onMounted(async () => {
|
||||
|
||||
// Fetch initial health status for sidebar indicator
|
||||
fetchHealthStatus()
|
||||
// Sidebar attention counts (backstage / security) are driven by
|
||||
// Sidebar attention counts (live / security) are driven by
|
||||
// useNotificationCenter — it polls when admins are mounted.
|
||||
})
|
||||
|
||||
@ -420,13 +427,6 @@ const navGroups = computed(() => [
|
||||
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>`,
|
||||
requiredCapability: 'manage:agents',
|
||||
},
|
||||
{
|
||||
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>`,
|
||||
globalAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/wiki',
|
||||
label: t('nav.wiki'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user