diff --git a/dashboard/src/lib/maisaka-monitor-client.ts b/dashboard/src/lib/maisaka-monitor-client.ts index 40935278..4a6458af 100644 --- a/dashboard/src/lib/maisaka-monitor-client.ts +++ b/dashboard/src/lib/maisaka-monitor-client.ts @@ -33,6 +33,29 @@ export interface SessionStartEvent { timestamp: number } +export interface StageStatusEvent { + session_id: string + session_name?: string + stage: string + detail: string + round_text: string + agent_state: string + stage_started_at: number + updated_at: number + timestamp: number +} + +export interface StageRemovedEvent { + session_id: string + session_name?: string + timestamp: number +} + +export interface StageSnapshotEvent { + entries: StageStatusEvent[] + timestamp: number +} + export interface MessageIngestedEvent { session_id: string speaker_name: string @@ -41,6 +64,15 @@ export interface MessageIngestedEvent { timestamp: number } +export interface MessageSentEvent { + session_id: string + speaker_name: string + content: string + message_id: string + source_kind?: string + timestamp: number +} + export interface CycleStartEvent { session_id: string cycle_id: number @@ -143,9 +175,12 @@ export interface PlannerFinalizedEvent { request: MaisakaRequestBlock | null planner: MaisakaPlannerBlock | null tools: MaisakaFinalizedToolResult[] + interrupted?: boolean final_state: { time_records: Record agent_state: string + end_reason?: string + end_detail?: string } } @@ -154,6 +189,8 @@ export interface CycleEndEvent { cycle_id: number time_records: Record agent_state: string + end_reason?: string + end_detail?: string timestamp: number } @@ -181,7 +218,11 @@ export interface ReplierResponseEvent { export type MaisakaMonitorEvent = | { type: 'session.start'; data: SessionStartEvent } + | { type: 'stage.status'; data: StageStatusEvent } + | { type: 'stage.removed'; data: StageRemovedEvent } + | { type: 'stage.snapshot'; data: StageSnapshotEvent } | { type: 'message.ingested'; data: MessageIngestedEvent } + | { type: 'message.sent'; data: MessageSentEvent } | { type: 'cycle.start'; data: CycleStartEvent } | { type: 'timing_gate.result'; data: TimingGateResultEvent } | { type: 'planner.request'; data: PlannerRequestEvent } diff --git a/dashboard/src/lib/reasoning-process-api.ts b/dashboard/src/lib/reasoning-process-api.ts index ff8cc8fd..c565d398 100644 --- a/dashboard/src/lib/reasoning-process-api.ts +++ b/dashboard/src/lib/reasoning-process-api.ts @@ -22,6 +22,7 @@ export type ReasoningPromptListResponse = { page_size: number stages: string[] sessions: string[] + selected_session: string } export type ReasoningPromptContentResponse = { @@ -43,8 +44,8 @@ export async function listReasoningPromptFiles( params: ReasoningPromptListParams ): Promise { const queryParams = new URLSearchParams() - queryParams.set('stage', params.stage ?? 'all') - queryParams.set('session', params.session ?? 'all') + queryParams.set('stage', params.stage ?? 'planner') + queryParams.set('session', params.session ?? 'auto') queryParams.set('search', params.search ?? '') queryParams.set('page', String(params.page ?? 1)) queryParams.set('page_size', String(params.pageSize ?? 50)) diff --git a/dashboard/src/lib/system-api.ts b/dashboard/src/lib/system-api.ts index ee7dd758..9b880c6c 100644 --- a/dashboard/src/lib/system-api.ts +++ b/dashboard/src/lib/system-api.ts @@ -51,6 +51,50 @@ export interface DashboardVersionStatus { pypi_url: string } +export interface CacheDirectoryStats { + key: string + label: string + path: string + exists: boolean + file_count: number + total_size: number + db_records: number +} + +export interface DatabaseFileStats { + path: string + exists: boolean + size: number +} + +export interface DatabaseTableStats { + name: string + rows: number +} + +export interface DatabaseStorageStats { + files: DatabaseFileStats[] + tables: DatabaseTableStats[] + total_size: number +} + +export interface LocalCacheStats { + directories: CacheDirectoryStats[] + database: DatabaseStorageStats +} + +export interface LocalCacheCleanupResult { + success: boolean + message: string + target: 'images' | 'emoji' | 'log_files' | 'database_logs' + removed_files: number + removed_bytes: number + removed_records: number +} + +export type LocalCacheCleanupTarget = LocalCacheCleanupResult['target'] +export type LogCleanupTable = 'llm_usage' | 'tool_records' | 'mai_messages' + /** * 检查 WebUI 是否有 PyPI 新版本 */ @@ -70,3 +114,38 @@ export async function getDashboardVersionStatus( return await response.json() } + +export async function getLocalCacheStats(): Promise { + const response = await fetchWithAuth('/api/webui/system/local-cache', { + method: 'GET', + headers: getAuthHeaders(), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '获取本地缓存统计失败') + } + + return await response.json() +} + +export async function cleanupLocalCache( + target: LocalCacheCleanupTarget, + tables: LogCleanupTable[] = [] +): Promise { + const response = await fetchWithAuth('/api/webui/system/local-cache/cleanup', { + method: 'POST', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ target, tables }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || '清理本地缓存失败') + } + + return await response.json() +} diff --git a/dashboard/src/routes/monitor/maisaka-monitor.tsx b/dashboard/src/routes/monitor/maisaka-monitor.tsx index 53992389..77aefb86 100644 --- a/dashboard/src/routes/monitor/maisaka-monitor.tsx +++ b/dashboard/src/routes/monitor/maisaka-monitor.tsx @@ -6,6 +6,7 @@ */ import { Activity, + AlertCircle, ArrowRight, Bot, Brain, @@ -38,13 +39,14 @@ import type { CycleStartEvent, MaisakaToolCall, MessageIngestedEvent, + MessageSentEvent, PlannerFinalizedEvent, PlannerResponseEvent, ReplierResponseEvent, TimingGateResultEvent, ToolExecutionEvent, } from '@/lib/maisaka-monitor-client' -import type { SessionInfo, TimelineEntry } from './use-maisaka-monitor' +import type { SessionInfo, StageStatusInfo, TimelineEntry } from './use-maisaka-monitor' import { useMaisakaMonitor } from './use-maisaka-monitor' // ─── 工具函数 ────────────────────────────────────────────────── @@ -78,11 +80,13 @@ function formatRelativeTime(ts: number): string { function SessionSidebar({ sessions, + stageStatuses, selectedSession, onSelect, collapsed, }: { sessions: Map + stageStatuses: Map selectedSession: string | null onSelect: (id: string) => void collapsed: boolean @@ -110,31 +114,36 @@ function SessionSidebar({ return (
- {sortedSessions.map((session) => ( + {sortedSessions.map((session) => { + const status = stageStatuses.get(session.sessionId) + return ( - ))} + ) + })}
) } // ─── 单条时间线事件渲染 ────────────────────────────────────── +function StageStatusPanel({ status }: { status?: StageStatusInfo }) { + if (!status) { + return ( +
+ 当前聊天流暂无阶段状态 +
+ ) + } + + return ( +
+
+ + + {status.stage || '未知阶段'} + + {status.roundText && ( + + {status.roundText} + + )} + {status.agentState && ( + + {status.agentState} + + )} + + 更新于 {formatRelativeTime(status.updatedAt)} + +
+ {status.detail && ( +

{status.detail}

+ )} +
+ ) +} + function MessageIngestedCard({ data }: { data: MessageIngestedEvent }) { return (
@@ -172,6 +222,26 @@ function MessageIngestedCard({ data }: { data: MessageIngestedEvent }) { ) } +function MessageSentCard({ data }: { data: MessageSentEvent }) { + return ( +
+
+ +
+
+
+ {data.speaker_name || '麦麦'} + 已发送 + {formatTimestamp(data.timestamp)} +
+

+ {data.content || '[非文本消息]'} +

+
+
+ ) +} + function CycleStartCard({ data }: { data: CycleStartEvent }) { return (
@@ -201,7 +271,7 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) { const Icon = config.icon return ( -
+
@@ -246,6 +316,38 @@ function openPromptHtml(uri: string) { window.open(normalized, '_blank', 'noopener,noreferrer') } +function isPlannerInterrupted(data: PlannerFinalizedEvent) { + const content = data.planner?.content?.trim() ?? '' + return data.interrupted === true || ( + content.startsWith('Planner ') && + data.planner?.prompt_tokens === 0 && + data.planner?.completion_tokens === 0 && + data.planner?.tool_calls.length === 0 + ) +} + +function PlannerInterruptedCard({ data }: { data: PlannerFinalizedEvent }) { + const planner = data.planner + + return ( +
+
+ + Planner 被新消息打断 + + #{data.cycle_id} + + {planner && planner.duration_ms > 0 && ( + {formatMs(planner.duration_ms)} + )} +
+

+ {planner?.content || '收到新消息,已停止当前思考并准备重新决策。'} +

+
+ ) +} + function PlannerResponseCard({ data }: { data: PlannerResponseEvent }) { return (
@@ -330,11 +432,26 @@ function PlannerToolCallsBlock({ data }: { data: PlannerFinalizedEvent }) { duration_ms: 0, summary: '', })) + const isFinishTool = (toolName?: string) => toolName?.trim().toLowerCase() === 'finish' + const finishTools = displayTools.filter((tool) => isFinishTool(tool.tool_name)) + const regularTools = displayTools.filter((tool) => !isFinishTool(tool.tool_name)) if (displayTools.length <= 0) { return null } + if (regularTools.length <= 0 && finishTools.length > 0) { + return ( +
+
+ + 本轮思考暂时结束 + 等待新的消息。 +
+
+ ) + } + return ( @@ -342,11 +459,18 @@ function PlannerToolCallsBlock({ data }: { data: PlannerFinalizedEvent }) { Planner 工具调用 - {displayTools.length} 个 + {regularTools.length} 个
+ {finishTools.length > 0 && ( +
+ + 本轮思考暂时结束 + 等待新的消息。 +
+ )}
- {displayTools.map((tool, idx) => ( + {regularTools.map((tool, idx) => (
a + b, 0) return ( -
-
- -
-
- 循环结束 - - 总耗时 {formatMs(totalTime * 1000)} - - {Object.entries(data.time_records).map(([name, duration]) => ( - - {name}: {formatMs(duration * 1000)} - - ))} - - {data.agent_state} - +
+
+ +
+ + {getCycleEndReasonLabel(data)} + + #{data.cycle_id} + + {formatMs(totalTime * 1000)} + + {data.agent_state} + +
+
+

{getCycleEndReasonText(data)}

) } @@ -551,6 +707,8 @@ function TimelineEventRenderer({ switch (entry.type) { case 'message.ingested': return + case 'message.sent': + return case 'cycle.start': if (!showCycleMarkers) return null return @@ -559,6 +717,12 @@ function TimelineEventRenderer({ case 'planner.response': return case 'planner.finalized': + if (isPlannerInterrupted(entry.data as PlannerFinalizedEvent)) { + return + } + if ((entry.data as PlannerFinalizedEvent).timing_gate?.result?.action === 'no_reply') { + return null + } return (
@@ -583,6 +747,7 @@ export function MaisakaMonitor() { const { timeline, sessions, + stageStatuses, selectedSession, setSelectedSession, connected, @@ -629,7 +794,7 @@ export function MaisakaMonitor() { // 统计当前会话的各事件类型计数 const stats = { - messages: timeline.filter((e) => e.type === 'message.ingested').length, + messages: timeline.filter((e) => e.type === 'message.ingested' || e.type === 'message.sent').length, cycles: timeline.filter((e) => e.type === 'cycle.start').length, toolCalls: timeline.reduce((count, entry) => { if (entry.type === 'tool.execution') { @@ -641,6 +806,7 @@ export function MaisakaMonitor() { return count }, 0), } + const selectedStageStatus = selectedSession ? stageStatuses.get(selectedSession) : undefined return (
@@ -674,6 +840,7 @@ export function MaisakaMonitor() { {/* 时间线 */} + + ) : ( (() => { - const continuedTimingGateCycles = new Set() + const noReplyTimingGateCycles = new Set() return timeline.map((entry) => { if (entry.type === 'timing_gate.result') { const data = entry.data as TimingGateResultEvent - if (data.action === 'continue') { - continuedTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id)) + if (data.action === 'no_reply') { + noReplyTimingGateCycles.add(buildCycleKey(data.session_id, data.cycle_id)) } } if (entry.type === 'planner.response' || entry.type === 'planner.finalized') { const data = entry.data as PlannerResponseEvent | PlannerFinalizedEvent - if (!continuedTimingGateCycles.has(buildCycleKey(data.session_id, data.cycle_id))) { + const cycleKey = buildCycleKey(data.session_id, data.cycle_id) + if (entry.type === 'planner.finalized' && isPlannerInterrupted(data as PlannerFinalizedEvent)) { + const rendered = + if (!rendered) return null + return ( +
+ {rendered} +
+ ) + } + if (noReplyTimingGateCycles.has(cycleKey)) { return null } } @@ -784,9 +966,6 @@ export function MaisakaMonitor() { className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300" > {rendered} - {entry.type === 'cycle.end' && ( - - )}
) }) @@ -799,3 +978,4 @@ export function MaisakaMonitor() {
) } + diff --git a/dashboard/src/routes/monitor/use-maisaka-monitor.ts b/dashboard/src/routes/monitor/use-maisaka-monitor.ts index 64b07b88..93869139 100644 --- a/dashboard/src/routes/monitor/use-maisaka-monitor.ts +++ b/dashboard/src/routes/monitor/use-maisaka-monitor.ts @@ -35,6 +35,17 @@ export interface SessionInfo { eventCount: number } +export interface StageStatusInfo { + sessionId: string + sessionName?: string + stage: string + detail: string + roundText: string + agentState: string + stageStartedAt: number + updatedAt: number +} + /** 前端内存中最多恢复/展示的时间线条目数,避免一次渲染过多节点。 */ const MAX_TIMELINE_ENTRIES = 3000 /** IndexedDB 中最多持久化的时间线条目数。 */ @@ -78,6 +89,7 @@ function resolveSessionDisplayName({ let entryCounter = 0 let cachedTimeline: TimelineEntry[] = [] let cachedSessions: Map = new Map() +let cachedStageStatuses: Map = new Map() let cachedSelectedSession: string | null = null let cachedConnected = false let backgroundCollectionEnabled = false @@ -121,6 +133,23 @@ interface MaisakaMonitorDb extends DBSchema { } } +function toStageStatusInfo(raw: Record): StageStatusInfo | null { + const sessionId = typeof raw.session_id === 'string' ? raw.session_id : '' + if (!sessionId) { + return null + } + return { + sessionId, + sessionName: typeof raw.session_name === 'string' ? raw.session_name : undefined, + stage: typeof raw.stage === 'string' ? raw.stage : '', + detail: typeof raw.detail === 'string' ? raw.detail : '', + roundText: typeof raw.round_text === 'string' ? raw.round_text : '', + agentState: typeof raw.agent_state === 'string' ? raw.agent_state : '', + stageStartedAt: typeof raw.stage_started_at === 'number' ? raw.stage_started_at : Date.now() / 1000, + updatedAt: typeof raw.updated_at === 'number' ? raw.updated_at : Date.now() / 1000, + } +} + function notifyStoreListeners() { storeListeners.forEach((listener) => listener()) } @@ -359,15 +388,80 @@ function updateSessionInfo(event: MaisakaMonitorEvent, sessionId: string, timest cachedSessions = next } +function updateStageStatus(event: MaisakaMonitorEvent) { + const applyStatusIfFresh = (next: Map, status: StageStatusInfo) => { + const existing = next.get(status.sessionId) + if (existing && status.updatedAt < existing.updatedAt) { + return + } + next.set(status.sessionId, status) + } + + if (event.type === 'stage.snapshot') { + const rawEntries = (event.data as unknown as Record).entries + if (!Array.isArray(rawEntries)) { + return + } + const next = new Map(cachedStageStatuses) + for (const rawEntry of rawEntries) { + if (!rawEntry || typeof rawEntry !== 'object') { + continue + } + const status = toStageStatusInfo(rawEntry as Record) + if (status) { + applyStatusIfFresh(next, status) + } + } + cachedStageStatuses = next + return + } + + if (event.type === 'stage.status') { + const status = toStageStatusInfo(event.data as unknown as Record) + if (!status) { + return + } + const next = new Map(cachedStageStatuses) + applyStatusIfFresh(next, status) + cachedStageStatuses = next + return + } + + if (event.type === 'stage.removed') { + const dataRecord = event.data as unknown as Record + const sessionId = typeof dataRecord.session_id === 'string' ? dataRecord.session_id : '' + if (!sessionId) { + return + } + const next = new Map(cachedStageStatuses) + next.delete(sessionId) + cachedStageStatuses = next + } +} + function handleMonitorEvent(event: MaisakaMonitorEvent) { const dataRecord = event.data as unknown as Record const sessionId = dataRecord.session_id as string const timestamp = dataRecord.timestamp as number + if (event.type === 'stage.snapshot') { + updateStageStatus(event) + notifyStoreListeners() + return + } + if (!sessionId || typeof timestamp !== 'number') { return } + if (event.type === 'stage.status' || event.type === 'stage.removed') { + updateStageStatus(event) + updateSessionInfo(event, sessionId, timestamp) + schedulePersistMonitorSnapshot(undefined, sessionId) + notifyStoreListeners() + return + } + const entry: TimelineEntry = { id: `evt_${++entryCounter}_${Date.now()}`, type: event.type, @@ -435,6 +529,7 @@ function stopMonitorSubscriptionIfIdle() { export function useMaisakaMonitor() { const [timeline, setTimeline] = useState(cachedTimeline) const [sessions, setSessions] = useState>(new Map(cachedSessions)) + const [stageStatuses, setStageStatuses] = useState>(new Map(cachedStageStatuses)) const [selectedSession, setSelectedSessionState] = useState(cachedSelectedSession) const [connected, setConnected] = useState(cachedConnected) const [backgroundCollection, setBackgroundCollection] = useState(loadBackgroundCollectionPreference) @@ -445,6 +540,7 @@ export function useMaisakaMonitor() { const syncFromStore = () => { setTimeline(cachedTimeline) setSessions(new Map(cachedSessions)) + setStageStatuses(new Map(cachedStageStatuses)) setSelectedSessionState(cachedSelectedSession) setConnected(cachedConnected) setBackgroundCollection(backgroundCollectionEnabled) @@ -462,9 +558,11 @@ export function useMaisakaMonitor() { const clearTimeline = useCallback(() => { cachedTimeline = [] cachedSessions = new Map() + cachedStageStatuses = new Map() cachedSelectedSession = null setTimeline([]) setSessions(new Map()) + setStageStatuses(new Map()) setSelectedSessionState(null) pendingPersistEntries = [] pendingPersistSessionIds = new Set() @@ -504,6 +602,7 @@ export function useMaisakaMonitor() { timeline: filteredTimeline, allTimeline: timeline, sessions, + stageStatuses, selectedSession, setSelectedSession, connected, diff --git a/dashboard/src/routes/reasoning-process.tsx b/dashboard/src/routes/reasoning-process.tsx index 73ad1b50..b40c75e3 100644 --- a/dashboard/src/routes/reasoning-process.tsx +++ b/dashboard/src/routes/reasoning-process.tsx @@ -29,6 +29,7 @@ import { import { cn } from '@/lib/utils' const PAGE_SIZE = 50 +const AUTO_SESSION = 'auto' function formatTime(timestamp: number | null, modifiedAt: number): string { const value = timestamp ? timestamp : modifiedAt * 1000 @@ -51,8 +52,8 @@ export function ReasoningProcessPage() { const [items, setItems] = useState([]) const [stages, setStages] = useState([]) const [sessions, setSessions] = useState([]) - const [stage, setStage] = useState('all') - const [session, setSession] = useState('all') + const [stage, setStage] = useState('planner') + const [session, setSession] = useState(AUTO_SESSION) const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [refreshKey, setRefreshKey] = useState(0) @@ -85,6 +86,9 @@ export function ReasoningProcessPage() { setItems(data.items) setStages(data.stages) setSessions(data.sessions) + if (data.selected_session && data.selected_session !== session) { + setSession(data.selected_session) + } setTotal(data.total) setSelected((current) => { if ( @@ -185,7 +189,8 @@ export function ReasoningProcessPage() { onValueChange={(value) => resetToFirstPage(() => { setStage(value) - setSession('all') + setSession(AUTO_SESSION) + setSelected(null) }) } > @@ -193,7 +198,11 @@ export function ReasoningProcessPage() { - 全部阶段 + {!stages.includes(stage) && ( + + {stage} + + )} {stages.map((item) => ( {item} @@ -205,12 +214,15 @@ export function ReasoningProcessPage() {