mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(live): speed up live status loading (#615)
This commit is contained in:
parent
bc2b4613f7
commit
ab5a0a651b
@ -65,14 +65,22 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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 v-if="initialLoadingCopy" class="live-loading-state" role="status" aria-live="polite">
|
||||
<span v-if="initialLoadingStage !== 'failed'" class="live-loading-spinner" aria-hidden="true"></span>
|
||||
<div class="live-loading-copy">
|
||||
<strong>{{ t(initialLoadingCopy.title) }}</strong>
|
||||
<span>{{ t(initialLoadingCopy.hint) }}</span>
|
||||
</div>
|
||||
<button v-if="initialLoadingStage === 'failed'" class="loading-retry" @click="retryInitialLoad">
|
||||
{{ t('live.loading.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="loadingState.stage === 'syncingTeams'" class="team-sync-note" role="status" aria-live="polite">
|
||||
<span class="team-sync-dot" aria-hidden="true"></span>
|
||||
<span>{{ t('live.loading.syncingTeams') }}</span>
|
||||
</div>
|
||||
<AgentRunGroups
|
||||
:groups="teamProjection.groups"
|
||||
:selected-run-id="selectedTeamRunId"
|
||||
@ -213,6 +221,7 @@ import { useLiveSnapshot } from '@/composables/useLiveSnapshot'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
|
||||
import { buildTeamRunRoute } from '@/components/team-run/teamRunPresentation'
|
||||
import { resolveLivePanelLoading, type LivePanelLoadingStage } from '@/utils/livePanelLoading'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@ -243,12 +252,36 @@ const routeHydrator = createAgentsLiveRouteHydrator({
|
||||
})
|
||||
const selectedTeamRunId = computed(() => selection.value.selectedRunId)
|
||||
const selectedTeamTaskId = computed(() => selection.value.selectedTaskId)
|
||||
const isInitialLoading = liveSnapshot.loading
|
||||
const loadingElapsedMs = ref(0)
|
||||
const hasRuntimeActivity = computed(() => (
|
||||
(snapshot.value?.runs.length ?? 0) > 0 || (snapshot.value?.subagents.length ?? 0) > 0
|
||||
))
|
||||
const loadingState = computed(() => resolveLivePanelLoading({
|
||||
hasSnapshot: snapshot.value != null,
|
||||
snapshotLoading: liveSnapshot.loading.value,
|
||||
teamLoading: teamGroups.loading.value,
|
||||
hasRuntimeActivity: hasRuntimeActivity.value,
|
||||
hasError: liveSnapshot.error.value != null,
|
||||
elapsedMs: loadingElapsedMs.value,
|
||||
}))
|
||||
const initialLoadingStage = computed<LivePanelLoadingStage>(() => (
|
||||
loadingState.value.blocksContent ? loadingState.value.stage : null
|
||||
))
|
||||
const initialLoadingCopy = computed(() => {
|
||||
switch (initialLoadingStage.value) {
|
||||
case 'connecting': return { title: 'live.loading.connecting', hint: 'live.loading.connectingHint' }
|
||||
case 'checking': return { title: 'live.loading.checking', hint: 'live.loading.checkingHint' }
|
||||
case 'slow': return { title: 'live.loading.slow', hint: 'live.loading.slowHint' }
|
||||
case 'failed': return { title: 'live.loading.failed', hint: 'live.loading.failedHint' }
|
||||
default: return null
|
||||
}
|
||||
})
|
||||
const autoRefresh = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const detail = ref<LiveRunCard | null>(null)
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let loadingClock: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ===== Lifecycle board mode =====
|
||||
// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched
|
||||
@ -458,6 +491,27 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
function stopLoadingClock() {
|
||||
if (!loadingClock) return
|
||||
clearInterval(loadingClock)
|
||||
loadingClock = null
|
||||
}
|
||||
|
||||
function startLoadingClock() {
|
||||
stopLoadingClock()
|
||||
const startedAt = Date.now()
|
||||
loadingElapsedMs.value = 0
|
||||
loadingClock = setInterval(() => {
|
||||
loadingElapsedMs.value = Date.now() - startedAt
|
||||
if (snapshot.value != null || !liveSnapshot.loading.value) stopLoadingClock()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function retryInitialLoad() {
|
||||
startLoadingClock()
|
||||
void refresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [liveRoute.value.view, liveRoute.value.runId, liveRoute.value.taskId] as const,
|
||||
() => routeHydrator.hydrate(liveRoute.value),
|
||||
@ -565,12 +619,14 @@ async function confirmSweep() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startLoadingClock()
|
||||
refresh()
|
||||
timer = setInterval(refresh, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
stopLoadingClock()
|
||||
liveSnapshot.close()
|
||||
teamGroups.close()
|
||||
})
|
||||
@ -583,6 +639,79 @@ onBeforeUnmount(() => {
|
||||
.other-live-title { margin: 0 0 10px; color: var(--mc-text-primary); font-size: 14px; letter-spacing: 0; }
|
||||
.team-runs-error { margin: -8px 0 12px; color: var(--mc-danger, #c13d3d); font-size: 12px; }
|
||||
|
||||
.live-loading-state {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
|
||||
.live-loading-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid color-mix(in srgb, var(--mc-text-tertiary) 25%, transparent);
|
||||
border-top-color: var(--mc-primary);
|
||||
border-radius: 50%;
|
||||
animation: live-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.live-loading-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.live-loading-copy strong {
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.live-loading-copy span {
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loading-retry {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--mc-border-light);
|
||||
border-radius: 6px;
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-sync-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
margin: -4px 0 14px;
|
||||
padding: 0 2px;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-sync-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--mc-primary);
|
||||
animation: chip-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Toolbar: live toggle + status filters + sweep ===== */
|
||||
.live-toolbar {
|
||||
display: flex;
|
||||
|
||||
@ -76,6 +76,20 @@ describe('projectAgentRunGroups', () => {
|
||||
describe('useAgentRunGroups hydration priority', () => {
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
|
||||
it('skips team-run pagination when the live snapshot is empty', async () => {
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage).mockResolvedValue({ data: { items: [run('running', [])], nextCursor: null } } as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
|
||||
await groups.refreshForSnapshot()
|
||||
|
||||
expect(teamApi.list).not.toHaveBeenCalled()
|
||||
expect(teamRunApi.listByTeamPage).not.toHaveBeenCalled()
|
||||
expect(groups.runs.value).toEqual([])
|
||||
expect(groups.loading.value).toBe(false)
|
||||
expect(groups.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an explicit route run when a later snapshot refresh completes first', async () => {
|
||||
const routeDetail = deferred<unknown>()
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
@ -170,7 +184,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
||||
vi.mocked(teamRunApi.listByTeamPage)
|
||||
.mockResolvedValueOnce({ data: { items: firstPage, nextCursor: 'cursor-2' } } as never)
|
||||
.mockResolvedValueOnce({ data: { items: [finalRun], nextCursor: null } } as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
|
||||
|
||||
await groups.refreshForSnapshot()
|
||||
|
||||
@ -189,7 +203,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
||||
vi.mocked(teamRunApi.listByTeamPage)
|
||||
.mockResolvedValueOnce({ data: { items: [run('running', [])], nextCursor: 'loop' } } as never)
|
||||
.mockResolvedValueOnce({ data: { items: [], nextCursor: 'loop' } } as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
|
||||
|
||||
await expect(groups.refreshForSnapshot()).rejects.toThrow('cursor')
|
||||
|
||||
@ -210,7 +224,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
||||
if (teamId === '11') return pending.promise as never
|
||||
return Promise.resolve({ data: { items: [], nextCursor: null } }) as never
|
||||
})
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
|
||||
|
||||
const first = groups.refreshForSnapshot()
|
||||
let settled = false
|
||||
@ -237,7 +251,7 @@ describe('useAgentRunGroups hydration priority', () => {
|
||||
const page = deferred<unknown>()
|
||||
vi.mocked(teamApi.list).mockResolvedValue({ data: [{ team: { id: '10' } }] } as never)
|
||||
vi.mocked(teamRunApi.listByTeamPage).mockReturnValue(page.promise as never)
|
||||
const groups = useAgentRunGroups(ref(snapshot([])))
|
||||
const groups = useAgentRunGroups(ref(snapshot([live('lead-conv')])))
|
||||
|
||||
const refresh = groups.refreshForSnapshot()
|
||||
await vi.waitFor(() => expect(teamRunApi.listByTeamPage).toHaveBeenCalledTimes(1))
|
||||
|
||||
@ -75,6 +75,28 @@ describe('useLiveSnapshot', () => {
|
||||
|
||||
history.resolve()
|
||||
})
|
||||
|
||||
it('re-enters loading when retrying before the first snapshot', async () => {
|
||||
const retry = deferred<unknown>()
|
||||
const live = useLiveSnapshot({
|
||||
load: vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockReturnValueOnce(retry.promise),
|
||||
refreshRuns: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
|
||||
await live.refresh()
|
||||
expect(live.loading.value).toBe(false)
|
||||
expect(live.error.value).toBeInstanceOf(Error)
|
||||
|
||||
const request = live.refresh()
|
||||
expect(live.loading.value).toBe(true)
|
||||
|
||||
retry.resolve({ data: snapshot('retry') })
|
||||
await request
|
||||
expect(live.loading.value).toBe(false)
|
||||
expect(live.snapshot.value?.runs[0].conversationId).toBe('retry')
|
||||
})
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
|
||||
@ -110,6 +110,10 @@ async function mapConcurrent<T, R>(
|
||||
return results
|
||||
}
|
||||
|
||||
function hasRuntimeActivity(snapshot: LiveSnapshot | null): boolean {
|
||||
return (snapshot?.runs?.length ?? 0) > 0 || (snapshot?.subagents?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
||||
const listedRuns = ref<TeamRun[]>([])
|
||||
const ensuredRun = ref<TeamRun | null>(null)
|
||||
@ -146,6 +150,11 @@ export function useAgentRunGroups(snapshot: Ref<LiveSnapshot | null>) {
|
||||
|
||||
async function runRefresh(request: number) {
|
||||
try {
|
||||
if (!hasRuntimeActivity(snapshot.value)) {
|
||||
listedRuns.value = []
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
const teamsResponse: any = await teamApi.list()
|
||||
const teams = teamsResponse?.data ?? []
|
||||
if (closed || request !== listSequence) return
|
||||
|
||||
@ -14,6 +14,7 @@ export function useLiveSnapshot({ load, refreshRuns }: Dependencies) {
|
||||
|
||||
async function refresh() {
|
||||
const request = ++sequence
|
||||
if (snapshot.value == null) loading.value = true
|
||||
try {
|
||||
const response = await load() as { data?: LiveSnapshot }
|
||||
if (request !== sequence) return false
|
||||
|
||||
@ -780,6 +780,18 @@ export default {
|
||||
otherSessions: 'Other live sessions',
|
||||
loadError: 'Team run context could not be refreshed.',
|
||||
},
|
||||
loading: {
|
||||
connecting: 'Connecting to Live...',
|
||||
connectingHint: 'Establishing the real-time status connection.',
|
||||
checking: 'Checking which employees are working...',
|
||||
checkingHint: 'Live is connected and reading the latest run status.',
|
||||
slow: 'Live data is still on its way...',
|
||||
slowHint: 'The service is responding more slowly than usual. Results will appear as soon as they arrive.',
|
||||
syncingTeams: 'Live is ready. Organizing team task details...',
|
||||
failed: 'Live could not be loaded',
|
||||
failedHint: 'The Live service did not return data. You can try again.',
|
||||
retry: 'Reload',
|
||||
},
|
||||
headline: {
|
||||
loading: 'Looking around...',
|
||||
allQuiet: 'All quiet. No one is working right now.',
|
||||
|
||||
@ -2436,6 +2436,18 @@ export default {
|
||||
otherSessions: '其他实时会话',
|
||||
loadError: '团队运行上下文刷新失败。',
|
||||
},
|
||||
loading: {
|
||||
connecting: '正在连接现场...',
|
||||
connectingHint: '正在建立实时状态连接。',
|
||||
checking: '正在检查正在工作的员工...',
|
||||
checkingHint: '现场服务已连接,正在读取最新运行状态。',
|
||||
slow: '现场数据仍在返回...',
|
||||
slowHint: '服务响应比平时慢,页面会在数据到达后立即展示。',
|
||||
syncingTeams: '现场已显示,正在整理团队任务信息...',
|
||||
failed: '暂时无法加载现场',
|
||||
failedHint: '现场服务没有返回数据,可以重新尝试。',
|
||||
retry: '重新加载',
|
||||
},
|
||||
headline: {
|
||||
loading: '正在看看...',
|
||||
allQuiet: '一片安静。当前没有员工在工作。',
|
||||
|
||||
34
mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts
Normal file
34
mateclaw-ui/src/utils/__tests__/agentsPageLoading.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { planAgentsPageLoads, shouldShowAgentsLiveBadge } from '../agentsPageLoading'
|
||||
|
||||
describe('planAgentsPageLoads', () => {
|
||||
it('keeps the live view free of roster and duplicate badge requests', () => {
|
||||
expect(planAgentsPageLoads({ view: 'live', isAdmin: true })).toEqual({
|
||||
loadRoster: false,
|
||||
loadAgentFormLookups: false,
|
||||
pollLiveBadge: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads roster data and live badge outside the live view for admins', () => {
|
||||
expect(planAgentsPageLoads({ view: 'roster', isAdmin: true })).toEqual({
|
||||
loadRoster: true,
|
||||
loadAgentFormLookups: true,
|
||||
pollLiveBadge: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not poll the admin live badge for ordinary users', () => {
|
||||
expect(planAgentsPageLoads({ view: 'roster', isAdmin: false })).toEqual({
|
||||
loadRoster: true,
|
||||
loadAgentFormLookups: true,
|
||||
pollLiveBadge: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the live badge only as an entry hint outside the live view', () => {
|
||||
expect(shouldShowAgentsLiveBadge({ view: 'live', running: 5 })).toBe(false)
|
||||
expect(shouldShowAgentsLiveBadge({ view: 'roster', running: 5 })).toBe(true)
|
||||
expect(shouldShowAgentsLiveBadge({ view: 'plans', running: 0 })).toBe(false)
|
||||
})
|
||||
})
|
||||
70
mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts
Normal file
70
mateclaw-ui/src/utils/__tests__/livePanelLoading.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveLivePanelLoading } from '@/utils/livePanelLoading'
|
||||
|
||||
describe('resolveLivePanelLoading', () => {
|
||||
it('starts by connecting to the live view', () => {
|
||||
expect(resolveLivePanelLoading({
|
||||
hasSnapshot: false,
|
||||
snapshotLoading: true,
|
||||
teamLoading: false,
|
||||
hasRuntimeActivity: false,
|
||||
hasError: false,
|
||||
elapsedMs: 200,
|
||||
})).toEqual({ stage: 'connecting', blocksContent: true })
|
||||
})
|
||||
|
||||
it('explains that it is checking active employees after the connection phase', () => {
|
||||
expect(resolveLivePanelLoading({
|
||||
hasSnapshot: false,
|
||||
snapshotLoading: true,
|
||||
teamLoading: false,
|
||||
hasRuntimeActivity: false,
|
||||
hasError: false,
|
||||
elapsedMs: 900,
|
||||
})).toEqual({ stage: 'checking', blocksContent: true })
|
||||
})
|
||||
|
||||
it('surfaces a slow response after two seconds', () => {
|
||||
expect(resolveLivePanelLoading({
|
||||
hasSnapshot: false,
|
||||
snapshotLoading: true,
|
||||
teamLoading: false,
|
||||
hasRuntimeActivity: false,
|
||||
hasError: false,
|
||||
elapsedMs: 2_100,
|
||||
})).toEqual({ stage: 'slow', blocksContent: true })
|
||||
})
|
||||
|
||||
it('shows snapshot content while team task context continues syncing', () => {
|
||||
expect(resolveLivePanelLoading({
|
||||
hasSnapshot: true,
|
||||
snapshotLoading: false,
|
||||
teamLoading: true,
|
||||
hasRuntimeActivity: true,
|
||||
hasError: false,
|
||||
elapsedMs: 300,
|
||||
})).toEqual({ stage: 'syncingTeams', blocksContent: false })
|
||||
})
|
||||
|
||||
it('does not show team syncing for an empty snapshot', () => {
|
||||
expect(resolveLivePanelLoading({
|
||||
hasSnapshot: true,
|
||||
snapshotLoading: false,
|
||||
teamLoading: true,
|
||||
hasRuntimeActivity: false,
|
||||
hasError: false,
|
||||
elapsedMs: 300,
|
||||
})).toEqual({ stage: null, blocksContent: false })
|
||||
})
|
||||
|
||||
it('turns a failed initial request into a retryable state', () => {
|
||||
expect(resolveLivePanelLoading({
|
||||
hasSnapshot: false,
|
||||
snapshotLoading: false,
|
||||
teamLoading: false,
|
||||
hasRuntimeActivity: false,
|
||||
hasError: true,
|
||||
elapsedMs: 300,
|
||||
})).toEqual({ stage: 'failed', blocksContent: true })
|
||||
})
|
||||
})
|
||||
24
mateclaw-ui/src/utils/agentsPageLoading.ts
Normal file
24
mateclaw-ui/src/utils/agentsPageLoading.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export type AgentsPageView = 'roster' | 'live' | 'plans'
|
||||
|
||||
export function planAgentsPageLoads(options: {
|
||||
view: AgentsPageView
|
||||
isAdmin: boolean
|
||||
}): {
|
||||
loadRoster: boolean
|
||||
loadAgentFormLookups: boolean
|
||||
pollLiveBadge: boolean
|
||||
} {
|
||||
const isLiveView = options.view === 'live'
|
||||
return {
|
||||
loadRoster: !isLiveView,
|
||||
loadAgentFormLookups: !isLiveView,
|
||||
pollLiveBadge: options.isAdmin && !isLiveView,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowAgentsLiveBadge(options: {
|
||||
view: AgentsPageView
|
||||
running: number
|
||||
}): boolean {
|
||||
return options.view !== 'live' && options.running > 0
|
||||
}
|
||||
36
mateclaw-ui/src/utils/livePanelLoading.ts
Normal file
36
mateclaw-ui/src/utils/livePanelLoading.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export type LivePanelLoadingStage = 'connecting' | 'checking' | 'slow' | 'syncingTeams' | 'failed' | null
|
||||
|
||||
interface LivePanelLoadingInput {
|
||||
hasSnapshot: boolean
|
||||
snapshotLoading: boolean
|
||||
teamLoading: boolean
|
||||
hasRuntimeActivity: boolean
|
||||
hasError: boolean
|
||||
elapsedMs: number
|
||||
}
|
||||
|
||||
export interface LivePanelLoadingState {
|
||||
stage: LivePanelLoadingStage
|
||||
blocksContent: boolean
|
||||
}
|
||||
|
||||
export function resolveLivePanelLoading(input: LivePanelLoadingInput): LivePanelLoadingState {
|
||||
if (!input.hasSnapshot) {
|
||||
if (input.hasError && !input.snapshotLoading) {
|
||||
return { stage: 'failed', blocksContent: true }
|
||||
}
|
||||
if (input.elapsedMs >= 2_000) {
|
||||
return { stage: 'slow', blocksContent: true }
|
||||
}
|
||||
if (input.elapsedMs >= 600) {
|
||||
return { stage: 'checking', blocksContent: true }
|
||||
}
|
||||
return { stage: 'connecting', blocksContent: true }
|
||||
}
|
||||
|
||||
if (input.teamLoading && input.hasRuntimeActivity) {
|
||||
return { stage: 'syncingTeams', blocksContent: false }
|
||||
}
|
||||
|
||||
return { stage: null, blocksContent: false }
|
||||
}
|
||||
@ -22,10 +22,10 @@
|
||||
:class="{ 'is-active': view === 'live' }"
|
||||
@click="setView('live')"
|
||||
>
|
||||
<span v-if="liveRunning > 0" class="seg-pulse"></span>
|
||||
<span v-if="showLiveBadge" class="seg-pulse"></span>
|
||||
{{ t('agents.views.live') }}
|
||||
<span
|
||||
v-if="liveRunning > 0"
|
||||
v-if="showLiveBadge"
|
||||
class="seg-count"
|
||||
:class="{ warn: liveStuck > 0 }"
|
||||
>{{ liveRunning }}</span>
|
||||
@ -741,19 +741,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
|
||||
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, liveApi, wikiApi } 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 { parseAgentsLiveRoute, type AgentsView } from '@/composables/agentsLiveRouteState'
|
||||
import PlanBoard from '@/components/agents/PlanBoard.vue'
|
||||
import AgentGuideEditor from './Agents/components/AgentGuideEditor.vue'
|
||||
import {
|
||||
emptyProfile,
|
||||
parsePrompt,
|
||||
@ -765,12 +760,18 @@ import {
|
||||
} from '@/utils/agentPromptProfile'
|
||||
import { agentIconColor } from '@/utils/agentIconColor'
|
||||
import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBindingSearch'
|
||||
import { planAgentsPageLoads, shouldShowAgentsLiveBadge } from '@/utils/agentsPageLoading'
|
||||
import { useSkillName } from '@/composables/useSkillName'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { resolveSkillName } = useSkillName()
|
||||
const SkillIcon = defineAsyncComponent(() => import('@/components/common/SkillIcon.vue'))
|
||||
const SkillIconPicker = defineAsyncComponent(() => import('@/components/common/SkillIconPicker.vue'))
|
||||
const LivePanel = defineAsyncComponent(() => import('@/components/live/LivePanel.vue'))
|
||||
const PlanBoard = defineAsyncComponent(() => import('@/components/agents/PlanBoard.vue'))
|
||||
const AgentGuideEditor = defineAsyncComponent(() => import('./Agents/components/AgentGuideEditor.vue'))
|
||||
const agents = ref<Agent[]>([])
|
||||
const searchText = ref('')
|
||||
const activeFilter = ref('all')
|
||||
@ -1196,7 +1197,14 @@ const view = ref<AgentView>(
|
||||
)
|
||||
const liveRunning = ref(0)
|
||||
const liveStuck = ref(0)
|
||||
const showLiveBadge = computed(() => shouldShowAgentsLiveBadge({
|
||||
view: view.value,
|
||||
running: liveRunning.value,
|
||||
}))
|
||||
let livePollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let agentsLoaded = false
|
||||
let modelOptionsLoaded = false
|
||||
let dshDiagnosticsLoaded = false
|
||||
|
||||
function setView(next: AgentView) {
|
||||
view.value = next
|
||||
@ -1226,31 +1234,59 @@ async function loadDshDiagnostics() {
|
||||
try {
|
||||
const res: any = await liveApi.dshDiagnostics()
|
||||
dshDiagnostics.value = res?.data ?? res
|
||||
dshDiagnosticsLoaded = true
|
||||
} catch {
|
||||
dshDiagnostics.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAgents()
|
||||
loadDshDiagnostics()
|
||||
// RFC-03 G1: load models once for the per-Agent override dropdown.
|
||||
// Failure is non-fatal — the dropdown just shows only "global default".
|
||||
loadAvailableModels()
|
||||
if (isAdminRole.value) {
|
||||
refreshLiveCounts()
|
||||
livePollTimer = setInterval(refreshLiveCounts, 10_000)
|
||||
}
|
||||
applyViewLoadPlan()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (livePollTimer) clearInterval(livePollTimer)
|
||||
stopLiveBadgePolling()
|
||||
})
|
||||
|
||||
watch(view, () => applyViewLoadPlan())
|
||||
|
||||
function applyViewLoadPlan() {
|
||||
const plan = planAgentsPageLoads({ view: view.value, isAdmin: isAdminRole.value })
|
||||
if (plan.loadRoster) ensureAgentsLoaded()
|
||||
if (plan.loadAgentFormLookups) ensureAgentFormLookupsLoaded()
|
||||
if (plan.pollLiveBadge) startLiveBadgePolling()
|
||||
else stopLiveBadgePolling()
|
||||
}
|
||||
|
||||
function startLiveBadgePolling() {
|
||||
if (livePollTimer) return
|
||||
refreshLiveCounts()
|
||||
livePollTimer = setInterval(refreshLiveCounts, 10_000)
|
||||
}
|
||||
|
||||
function stopLiveBadgePolling() {
|
||||
if (!livePollTimer) return
|
||||
clearInterval(livePollTimer)
|
||||
livePollTimer = null
|
||||
}
|
||||
|
||||
function ensureAgentsLoaded() {
|
||||
if (agentsLoaded) return
|
||||
loadAgents()
|
||||
}
|
||||
|
||||
function ensureAgentFormLookupsLoaded() {
|
||||
if (!dshDiagnosticsLoaded) loadDshDiagnostics()
|
||||
// RFC-03 G1: load models once for the per-Agent override dropdown.
|
||||
// Failure is non-fatal — the dropdown just shows only "global default".
|
||||
if (!modelOptionsLoaded) loadAvailableModels()
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
try {
|
||||
const res: any = await agentApi.list()
|
||||
agents.value = res.data || []
|
||||
agentsLoaded = true
|
||||
} catch {
|
||||
mcToast.error(t('agents.messages.loadFailed'))
|
||||
}
|
||||
@ -1265,6 +1301,7 @@ async function loadAvailableModels() {
|
||||
provider: m.provider,
|
||||
modelName: m.modelName,
|
||||
}))
|
||||
modelOptionsLoaded = true
|
||||
} catch {
|
||||
// Silent — the picker still works (empty list = only "default" option).
|
||||
}
|
||||
@ -1277,6 +1314,7 @@ function openCreateModal() {
|
||||
}
|
||||
|
||||
function openBlankCreateModal() {
|
||||
ensureAgentFormLookupsLoaded()
|
||||
showTemplateSelector.value = false
|
||||
editingAgent.value = null
|
||||
form.value = defaultForm()
|
||||
@ -1375,6 +1413,7 @@ async function applyTemplate(id: string) {
|
||||
}
|
||||
|
||||
async function openEditModal(agent: Agent) {
|
||||
ensureAgentFormLookupsLoaded()
|
||||
editingAgent.value = agent
|
||||
form.value = {
|
||||
name: agent.name,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user