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

@@ -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>