perf:优化麦麦观察体验,优化推理检索体验

This commit is contained in:
SengokuCola
2026-05-07 20:15:14 +08:00
parent 2a7722f84e
commit 827cdbd441
23 changed files with 1206 additions and 376 deletions

View File

@@ -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
@@ -181,7 +213,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 }

View File

@@ -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<ReasoningPromptListResponse> {
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))

View File

@@ -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' | '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<LocalCacheStats> {
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<LocalCacheCleanupResult> {
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()
}

View File

@@ -38,13 +38,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 +79,13 @@ function formatRelativeTime(ts: number): string {
function SessionSidebar({
sessions,
stageStatuses,
selectedSession,
onSelect,
collapsed,
}: {
sessions: Map<string, SessionInfo>
stageStatuses: Map<string, StageStatusInfo>
selectedSession: string | null
onSelect: (id: string) => void
collapsed: boolean
@@ -110,31 +113,36 @@ function SessionSidebar({
return (
<div className={cn('flex flex-col gap-1', collapsed ? 'items-center p-2' : 'p-2')}>
{sortedSessions.map((session) => (
{sortedSessions.map((session) => {
const status = stageStatuses.get(session.sessionId)
return (
<button
key={session.sessionId}
onClick={() => onSelect(session.sessionId)}
title={session.sessionName}
className={cn(
'rounded-lg text-left text-sm transition-colors',
'max-w-full overflow-hidden rounded-lg text-left text-sm transition-colors',
'hover:bg-accent/50',
collapsed
? 'flex h-10 w-10 items-center justify-center p-0'
: 'flex w-full flex-col items-start gap-0.5 px-2.5 py-2',
: 'flex w-full min-w-0 flex-col items-start gap-0.5 px-2.5 py-2',
selectedSession === session.sessionId && 'bg-accent text-accent-foreground',
)}
>
<div className={cn('flex w-full items-center', collapsed ? 'justify-center' : 'justify-between gap-2')}>
<div className={cn('flex min-w-0 items-center gap-2', !collapsed && 'flex-1')}>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-semibold text-primary">
<div className={cn('flex w-full min-w-0 items-center', collapsed ? 'justify-center' : 'justify-between gap-2')}>
<div className={cn('flex min-w-0 items-center gap-2 overflow-hidden', !collapsed && 'flex-1')}>
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-semibold text-primary">
{getSessionInitial(session)}
{status && (
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-background" />
)}
</span>
{false && session.isGroupChat !== undefined && (
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">
{session.isGroupChat ? '群' : '私'}
</Badge>
)}
{!collapsed && <span className="min-w-0 flex-1 truncate font-medium" title={session.sessionName}>
{!collapsed && <span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-medium" title={session.sessionName}>
{session.sessionName}
</span>}
</div>
@@ -142,17 +150,58 @@ function SessionSidebar({
{session.eventCount}
</Badge>}
</div>
{!collapsed && <span className="text-xs text-muted-foreground">
{formatRelativeTime(session.lastActivity)}
</span>}
{!collapsed && (
<div className="flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden text-xs text-muted-foreground">
<span className="shrink-0">{formatRelativeTime(session.lastActivity)}</span>
{status && <span className="min-w-0 truncate text-primary">{status.stage}</span>}
</div>
)}
</button>
))}
)
})}
</div>
)
}
// ─── 单条时间线事件渲染 ──────────────────────────────────────
function StageStatusPanel({ status }: { status?: StageStatusInfo }) {
if (!status) {
return (
<div className="mb-3 rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
</div>
)
}
return (
<div className="mb-3 rounded-md border bg-background px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default" className="gap-1">
<Activity className="h-3 w-3" />
{status.stage || '未知阶段'}
</Badge>
{status.roundText && (
<Badge variant="secondary" className="text-[10px]">
{status.roundText}
</Badge>
)}
{status.agentState && (
<Badge variant={status.agentState === 'running' ? 'default' : 'outline'} className="text-[10px]">
{status.agentState}
</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{formatRelativeTime(status.updatedAt)}
</span>
</div>
{status.detail && (
<p className="mt-1 text-sm text-muted-foreground">{status.detail}</p>
)}
</div>
)
}
function MessageIngestedCard({ data }: { data: MessageIngestedEvent }) {
return (
<div className="flex items-start gap-3">
@@ -172,6 +221,26 @@ function MessageIngestedCard({ data }: { data: MessageIngestedEvent }) {
)
}
function MessageSentCard({ data }: { data: MessageSentEvent }) {
return (
<div className="flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2">
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="mb-1 flex items-center gap-2">
<span className="font-medium text-sm">{data.speaker_name || '麦麦'}</span>
<Badge variant="outline" className="text-[10px]"></Badge>
<span className="text-xs text-muted-foreground">{formatTimestamp(data.timestamp)}</span>
</div>
<p className="text-sm text-foreground/80 whitespace-pre-wrap wrap-break-word leading-relaxed">
{data.content || '[非文本消息]'}
</p>
</div>
</div>
)
}
function CycleStartCard({ data }: { data: CycleStartEvent }) {
return (
<div className="flex items-center gap-3">
@@ -201,7 +270,7 @@ function TimingGateCard({ data }: { data: TimingGateResultEvent }) {
const Icon = config.icon
return (
<div className="flex items-start gap-3">
<div className="flex items-start gap-3 rounded-md border bg-background px-3 py-2 shadow-sm">
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-amber-500/15 text-amber-500">
<Timer className="h-3.5 w-3.5" />
</div>
@@ -330,11 +399,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 (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
<span className="font-medium"></span>
<span className="text-muted-foreground"></span>
</div>
</div>
)
}
return (
<Card className="border-l-4 border-l-teal-500/60">
<CardHeader className="py-3 px-4 space-y-2">
@@ -342,11 +426,18 @@ function PlannerToolCallsBlock({ data }: { data: PlannerFinalizedEvent }) {
<Wrench className="h-4 w-4 text-teal-500" />
<CardTitle className="text-sm font-medium">Planner </CardTitle>
<Badge variant="secondary" className="ml-auto text-[10px]">
{displayTools.length}
{regularTools.length}
</Badge>
</div>
{finishTools.length > 0 && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-2.5 py-1.5 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<span className="font-medium"></span>
<span className="text-muted-foreground"></span>
</div>
)}
<div className="space-y-2">
{displayTools.map((tool, idx) => (
{regularTools.map((tool, idx) => (
<div
key={`${tool.tool_call_id || tool.tool_name}-${idx}`}
className="rounded-md border bg-muted/40 px-2.5 py-2 text-xs"
@@ -413,20 +504,15 @@ function ToolExecutionCard({ data }: { data: ToolExecutionEvent }) {
function CycleEndCard({ data }: { data: CycleEndEvent }) {
const totalTime = Object.values(data.time_records).reduce((a, b) => a + b, 0)
return (
<div className="flex items-center gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-500/15 text-slate-500">
<CircleDot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground"></span>
<div className="my-1 flex items-center gap-3">
<Separator className="flex-1" />
<div className="flex items-center gap-2 rounded-full border bg-background px-3 py-1">
<CircleDot className="h-3.5 w-3.5 text-slate-500" />
<span className="text-xs text-muted-foreground"></span>
<Badge variant="outline" className="text-[10px]">
{formatMs(totalTime * 1000)}
#{data.cycle_id}
</Badge>
{Object.entries(data.time_records).map(([name, duration]) => (
<span key={name} className="text-[10px] text-muted-foreground">
{name}: {formatMs(duration * 1000)}
</span>
))}
<span className="text-[10px] text-muted-foreground">{formatMs(totalTime * 1000)}</span>
<Badge
variant={data.agent_state === 'running' ? 'default' : 'secondary'}
className="text-[10px]"
@@ -434,6 +520,7 @@ function CycleEndCard({ data }: { data: CycleEndEvent }) {
{data.agent_state}
</Badge>
</div>
<Separator className="flex-1" />
</div>
)
}
@@ -551,6 +638,8 @@ function TimelineEventRenderer({
switch (entry.type) {
case 'message.ingested':
return <MessageIngestedCard data={entry.data as MessageIngestedEvent} />
case 'message.sent':
return <MessageSentCard data={entry.data as MessageSentEvent} />
case 'cycle.start':
if (!showCycleMarkers) return null
return <CycleStartCard data={entry.data as CycleStartEvent} />
@@ -559,6 +648,9 @@ function TimelineEventRenderer({
case 'planner.response':
return <PlannerResponseCard data={entry.data as PlannerResponseEvent} />
case 'planner.finalized':
if ((entry.data as PlannerFinalizedEvent).timing_gate?.result?.action !== 'continue') {
return null
}
return (
<div className="space-y-2">
<PlannerFinalizedCard data={entry.data as PlannerFinalizedEvent} />
@@ -583,6 +675,7 @@ export function MaisakaMonitor() {
const {
timeline,
sessions,
stageStatuses,
selectedSession,
setSelectedSession,
connected,
@@ -629,7 +722,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 +734,7 @@ export function MaisakaMonitor() {
return count
}, 0),
}
const selectedStageStatus = selectedSession ? stageStatuses.get(selectedSession) : undefined
return (
<div className="flex h-[calc(100vh-180px)] gap-4">
@@ -674,6 +768,7 @@ export function MaisakaMonitor() {
<ScrollArea className="flex-1">
<SessionSidebar
sessions={sessions}
stageStatuses={stageStatuses}
selectedSession={selectedSession}
onSelect={setSelectedSession}
collapsed={sidebarCollapsed}
@@ -742,6 +837,8 @@ export function MaisakaMonitor() {
</div>
{/* 时间线 */}
<StageStatusPanel status={selectedStageStatus} />
<Card className="flex-1 overflow-hidden">
<ScrollArea
className="h-full"
@@ -760,18 +857,22 @@ export function MaisakaMonitor() {
) : (
(() => {
const continuedTimingGateCycles = new Set<string>()
const stoppedTimingGateCycles = new Set<string>()
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))
} else {
stoppedTimingGateCycles.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 (stoppedTimingGateCycles.has(cycleKey) || !continuedTimingGateCycles.has(cycleKey)) {
return null
}
}
@@ -784,9 +885,6 @@ export function MaisakaMonitor() {
className="animate-in fade-in-0 slide-in-from-bottom-2 duration-300"
>
{rendered}
{entry.type === 'cycle.end' && (
<Separator className="mt-3" />
)}
</div>
)
})

View File

@@ -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<string, SessionInfo> = new Map()
let cachedStageStatuses: Map<string, StageStatusInfo> = 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<string, unknown>): 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,72 @@ function updateSessionInfo(event: MaisakaMonitorEvent, sessionId: string, timest
cachedSessions = next
}
function updateStageStatus(event: MaisakaMonitorEvent) {
if (event.type === 'stage.snapshot') {
const rawEntries = (event.data as unknown as Record<string, unknown>).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<string, unknown>)
if (status) {
next.set(status.sessionId, status)
}
}
cachedStageStatuses = next
return
}
if (event.type === 'stage.status') {
const status = toStageStatusInfo(event.data as unknown as Record<string, unknown>)
if (!status) {
return
}
const next = new Map(cachedStageStatuses)
next.set(status.sessionId, status)
cachedStageStatuses = next
return
}
if (event.type === 'stage.removed') {
const dataRecord = event.data as unknown as Record<string, unknown>
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<string, unknown>
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 +521,7 @@ function stopMonitorSubscriptionIfIdle() {
export function useMaisakaMonitor() {
const [timeline, setTimeline] = useState<TimelineEntry[]>(cachedTimeline)
const [sessions, setSessions] = useState<Map<string, SessionInfo>>(new Map(cachedSessions))
const [stageStatuses, setStageStatuses] = useState<Map<string, StageStatusInfo>>(new Map(cachedStageStatuses))
const [selectedSession, setSelectedSessionState] = useState<string | null>(cachedSelectedSession)
const [connected, setConnected] = useState(cachedConnected)
const [backgroundCollection, setBackgroundCollection] = useState(loadBackgroundCollectionPreference)
@@ -445,6 +532,7 @@ export function useMaisakaMonitor() {
const syncFromStore = () => {
setTimeline(cachedTimeline)
setSessions(new Map(cachedSessions))
setStageStatuses(new Map(cachedStageStatuses))
setSelectedSessionState(cachedSelectedSession)
setConnected(cachedConnected)
setBackgroundCollection(backgroundCollectionEnabled)
@@ -462,9 +550,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 +594,7 @@ export function useMaisakaMonitor() {
timeline: filteredTimeline,
allTimeline: timeline,
sessions,
stageStatuses,
selectedSession,
setSelectedSession,
connected,

View File

@@ -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<ReasoningPromptFile[]>([])
const [stages, setStages] = useState<string[]>([])
const [sessions, setSessions] = useState<string[]>([])
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() {
<SelectValue placeholder="阶段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{!stages.includes(stage) && (
<SelectItem value={stage}>
{stage}
</SelectItem>
)}
{stages.map((item) => (
<SelectItem key={item} value={item}>
{item}
@@ -205,12 +214,15 @@ export function ReasoningProcessPage() {
<Select
value={session}
onValueChange={(value) => resetToFirstPage(() => setSession(value))}
disabled={sessions.length === 0 && loading}
>
<SelectTrigger>
<SelectValue placeholder="会话" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{session === AUTO_SESSION && (
<SelectItem value={AUTO_SESSION}></SelectItem>
)}
{sessions.map((item) => (
<SelectItem key={item} value={item}>
{item}

View File

@@ -0,0 +1,309 @@
import { Database, HardDrive, Image, RefreshCw, Sparkles, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/hooks/use-toast'
import {
cleanupLocalCache,
getLocalCacheStats,
type CacheDirectoryStats,
type LocalCacheStats,
type LogCleanupTable,
} from '@/lib/system-api'
const LOG_CLEANUP_OPTIONS: Array<{
table: LogCleanupTable
label: string
description: string
}> = [
{ table: 'llm_usage', label: 'llm_usage', description: '记录 LLM 调用统计信息' },
{ table: 'tool_records', label: 'tool_records', description: '记录工具使用记录' },
{ table: 'mai_messages', label: 'mai_messages', description: '清理收到的消息' },
]
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** unitIndex
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
function CacheIcon({ cacheKey }: { cacheKey: string }) {
if (cacheKey === 'images') {
return <Image className="h-4 w-4 text-primary" />
}
if (cacheKey === 'emoji' || cacheKey === 'emoji_thumbnails') {
return <Sparkles className="h-4 w-4 text-primary" />
}
return <HardDrive className="h-4 w-4 text-primary" />
}
function DirectoryCard({
item,
cleanupDisabled,
onCleanup,
}: {
item: CacheDirectoryStats
cleanupDisabled: boolean
onCleanup: (target: 'images' | 'emoji') => void
}) {
const cleanupTarget = item.key === 'images' ? 'images' : item.key === 'emoji' ? 'emoji' : null
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex items-center gap-2">
<CacheIcon cacheKey={item.key} />
<h4 className="font-semibold">{item.label}</h4>
</div>
<p className="break-all text-xs text-muted-foreground">{item.path}</p>
</div>
{cleanupTarget && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" disabled={cleanupDisabled}>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{item.label}</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => onCleanup(cleanupTarget)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-lg font-semibold">{item.file_count}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-lg font-semibold">{formatBytes(item.total_size)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-lg font-semibold">{item.db_records}</div>
</div>
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-lg font-semibold">{item.exists ? '存在' : '未创建'}</div>
</div>
</div>
</div>
)
}
export function LocalCacheTab() {
const { toast } = useToast()
const [stats, setStats] = useState<LocalCacheStats | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [cleanupTarget, setCleanupTarget] = useState<string | null>(null)
const [selectedLogTables, setSelectedLogTables] = useState<LogCleanupTable[]>([])
const tableRows = useMemo(() => {
const rows = new Map<string, number>()
for (const table of stats?.database.tables ?? []) {
rows.set(table.name, table.rows)
}
return rows
}, [stats?.database.tables])
const selectedLogRows = selectedLogTables.reduce((total, table) => total + (tableRows.get(table) ?? 0), 0)
const refreshStats = useCallback(async () => {
setIsLoading(true)
try {
setStats(await getLocalCacheStats())
} catch (error) {
toast({
title: '获取本地缓存失败',
description: error instanceof Error ? error.message : '请稍后重试',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [toast])
const handleDirectoryCleanup = async (target: 'images' | 'emoji') => {
setCleanupTarget(target)
try {
const result = await cleanupLocalCache(target)
await refreshStats()
toast({
title: result.message,
description: `删除 ${result.removed_files} 个文件,释放 ${formatBytes(result.removed_bytes)},移除 ${result.removed_records} 条记录。`,
})
} catch (error) {
toast({
title: '清理失败',
description: error instanceof Error ? error.message : '请稍后重试',
variant: 'destructive',
})
} finally {
setCleanupTarget(null)
}
}
const handleLogCleanup = async () => {
setCleanupTarget('logs')
try {
const result = await cleanupLocalCache('logs', selectedLogTables)
setSelectedLogTables([])
await refreshStats()
toast({
title: result.message,
description: `已清理 ${result.removed_records} 条日志记录。`,
})
} catch (error) {
toast({
title: '日志清理失败',
description: error instanceof Error ? error.message : '请稍后重试',
variant: 'destructive',
})
} finally {
setCleanupTarget(null)
}
}
const toggleLogTable = (table: LogCleanupTable, checked: boolean) => {
setSelectedLogTables((current) => {
if (checked) {
return current.includes(table) ? current : [...current, table]
}
return current.filter((item) => item !== table)
})
}
useEffect(() => {
void refreshStats()
}, [refreshStats])
return (
<div className="space-y-4 sm:space-y-6">
<div className="rounded-lg border bg-card p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<HardDrive className="h-5 w-5" />
</h3>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
data
</p>
</div>
<Button variant="outline" onClick={refreshStats} disabled={isLoading} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="grid gap-4">
{(stats?.directories ?? []).map((item) => (
<DirectoryCard
key={item.key}
item={item}
cleanupDisabled={cleanupTarget !== null || isLoading}
onCleanup={handleDirectoryCleanup}
/>
))}
</div>
<div className="rounded-lg border bg-card p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<Database className="h-5 w-5" />
</h3>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="gap-2" disabled={cleanupTarget !== null || isLoading}>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{formatBytes(stats?.database.total_size ?? 0)}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
{LOG_CLEANUP_OPTIONS.map((option) => {
const rows = tableRows.get(option.table) ?? 0
const checked = selectedLogTables.includes(option.table)
const checkboxId = `log-cleanup-${option.table}`
return (
<label
key={option.table}
htmlFor={checkboxId}
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-muted/50"
>
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={(value) => toggleLogTable(option.table, value === true)}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{option.label}</span>
<span className="block text-xs text-muted-foreground">{option.description}</span>
<span className="mt-1 block text-xs text-muted-foreground"> {rows} </span>
</span>
</label>
)
})}
</div>
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
{selectedLogTables.length} {selectedLogRows}
</div>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleLogCleanup} disabled={selectedLogTables.length === 0}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Info, Palette, Settings, Shield } from 'lucide-react'
import { HardDrive, Info, Palette, Settings, Shield } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -6,6 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { AboutTab } from './AboutTab'
import { AppearanceTab } from './AppearanceTab'
import { LocalCacheTab } from './LocalCacheTab'
import { OtherTab } from './OtherTab'
import { SecurityTab } from './SecurityTab'
@@ -23,7 +24,7 @@ export function SettingsPage() {
{/* 标签页 */}
<Tabs defaultValue="appearance" className="w-full">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 gap-0.5 sm:gap-1 h-auto p-1">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-5 gap-0.5 sm:gap-1 h-auto p-1">
<TabsTrigger value="appearance" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Palette className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span>{t('settings.tabs.appearance')}</span>
@@ -32,6 +33,10 @@ export function SettingsPage() {
<Shield className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span>{t('settings.tabs.security')}</span>
</TabsTrigger>
<TabsTrigger value="local-cache" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<HardDrive className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="other" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span>{t('settings.tabs.other')}</span>
@@ -51,6 +56,10 @@ export function SettingsPage() {
<SecurityTab />
</TabsContent>
<TabsContent value="local-cache" className="mt-0">
<LocalCacheTab />
</TabsContent>
<TabsContent value="other" className="mt-0">
<OtherTab />
</TabsContent>