From b6808d4b733cd77360e51f5b1cb2c9896604c0e3 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Thu, 7 May 2026 18:06:55 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E4=BC=98=E5=8C=96=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=81=E4=BF=A1=E6=81=AF=E7=9A=84=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=92=8C=E6=A3=80=E7=B4=A2=EF=BC=8C=E4=BC=98=E5=8C=96chat=5Fpr?= =?UTF-8?q?ompt=E6=97=A0=E6=95=88=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=83=A8=E5=88=86=E7=BE=A4=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/components/layout/constants.ts | 3 +- dashboard/src/i18n/locales/en.json | 2 + dashboard/src/i18n/locales/ja.json | 2 + dashboard/src/i18n/locales/ko.json | 2 + dashboard/src/i18n/locales/zh.json | 2 + dashboard/src/lib/reasoning-process-api.ts | 67 +++ dashboard/src/router.tsx | 7 + dashboard/src/routes/monitor/index.tsx | 3 - .../src/routes/monitor/use-maisaka-monitor.ts | 215 +++++++++- dashboard/src/routes/reasoning-process.tsx | 380 ++++++++++++++++++ pytests/common_test/test_chat_config_utils.py | 89 ++++ .../test_maisaka_expression_selector.py | 44 ++ .../replyer/maisaka_expression_selector.py | 14 +- src/chat/replyer/maisaka_generator_base.py | 43 +- src/chat/utils/common_utils.py | 36 +- src/common/data_models/image_data_model.py | 8 +- src/common/utils/utils_config.py | 196 ++++++++- src/main.py | 26 +- src/maisaka/chat_loop_service.py | 46 +-- src/webui/routers/reasoning_process.py | 197 +++++++++ src/webui/routes.py | 2 + 21 files changed, 1219 insertions(+), 165 deletions(-) create mode 100644 dashboard/src/lib/reasoning-process-api.ts create mode 100644 dashboard/src/routes/reasoning-process.tsx create mode 100644 pytests/common_test/test_chat_config_utils.py create mode 100644 src/webui/routers/reasoning_process.py diff --git a/dashboard/src/components/layout/constants.ts b/dashboard/src/components/layout/constants.ts index c9b29347..5c69eb7e 100644 --- a/dashboard/src/components/layout/constants.ts +++ b/dashboard/src/components/layout/constants.ts @@ -1,4 +1,4 @@ -import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Settings, Sliders, Smile } from 'lucide-react' +import { Activity, Boxes, BrainCircuit, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Settings, Sliders, Smile } from 'lucide-react' import type { MenuSection } from './types' @@ -39,6 +39,7 @@ export const menuSections: MenuSection[] = [ title: 'sidebar.groups.system', items: [ { icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' }, + { icon: BrainCircuit, label: 'sidebar.menu.reasoningProcess', path: '/reasoning-process', searchDescription: 'search.items.reasoningProcessDesc' }, { icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' }, ], }, diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index a4e16283..a9a02389 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -40,6 +40,7 @@ "pluginConfig": "Plugin Management", "mcpSettings": "MCP Settings", "logViewer": "Log Viewer", + "reasoningProcess": "Reasoning Process", "maisakaMonitor": "MaiSaka Chat Monitor", "localChat": "Local Chat", "settings": "Settings" @@ -793,6 +794,7 @@ "pluginsDesc": "Browse and install plugins", "logs": "Log Viewer", "logsDesc": "View system logs", + "reasoningProcessDesc": "Browse Maisaka prompt reasoning logs", "settings": "Settings", "settingsDesc": "Configure system settings" } diff --git a/dashboard/src/i18n/locales/ja.json b/dashboard/src/i18n/locales/ja.json index d318efc9..5b05a2e7 100644 --- a/dashboard/src/i18n/locales/ja.json +++ b/dashboard/src/i18n/locales/ja.json @@ -40,6 +40,7 @@ "pluginConfig": "プラグイン管理", "mcpSettings": "MCP 設定", "logViewer": "ログビューア", + "reasoningProcess": "推論プロセス", "maisakaMonitor": "MaiSaka チャット監視", "localChat": "ローカルチャット", "settings": "設定" @@ -793,6 +794,7 @@ "pluginsDesc": "プラグインを閉覧してインストール", "logs": "ログビューア", "logsDesc": "システムログを表示", + "reasoningProcessDesc": "Maisaka prompt の推論ログを閲覧", "settings": "設定", "settingsDesc": "システム設定を構成" } diff --git a/dashboard/src/i18n/locales/ko.json b/dashboard/src/i18n/locales/ko.json index 3a4d9bb9..53e8ebb2 100644 --- a/dashboard/src/i18n/locales/ko.json +++ b/dashboard/src/i18n/locales/ko.json @@ -40,6 +40,7 @@ "pluginConfig": "플러그인 관리", "mcpSettings": "MCP 설정", "logViewer": "로그 뷰어", + "reasoningProcess": "추론 과정", "maisakaMonitor": "MaiSaka 채팅 모니터", "localChat": "로컬 채팅", "settings": "설정" @@ -793,6 +794,7 @@ "pluginsDesc": "플러그인 탐색 및 설치", "logs": "로그 뷰어", "logsDesc": "시스템 로그 보기", + "reasoningProcessDesc": "Maisaka prompt 추론 로그 보기", "settings": "설정", "settingsDesc": "시스템 설정 구성" } diff --git a/dashboard/src/i18n/locales/zh.json b/dashboard/src/i18n/locales/zh.json index 30084fc2..75074e94 100644 --- a/dashboard/src/i18n/locales/zh.json +++ b/dashboard/src/i18n/locales/zh.json @@ -40,6 +40,7 @@ "pluginConfig": "插件管理", "mcpSettings": "MCP 设置", "logViewer": "日志查看器", + "reasoningProcess": "推理过程", "maisakaMonitor": "麦麦观察", "localChat": "本地聊天室", "settings": "系统设置" @@ -793,6 +794,7 @@ "pluginsDesc": "浏览和安装插件", "logs": "日志查看器", "logsDesc": "查看系统日志", + "reasoningProcessDesc": "浏览 Maisaka prompt 推理记录", "settings": "系统设置", "settingsDesc": "配置系统参数" } diff --git a/dashboard/src/lib/reasoning-process-api.ts b/dashboard/src/lib/reasoning-process-api.ts new file mode 100644 index 00000000..ff8cc8fd --- /dev/null +++ b/dashboard/src/lib/reasoning-process-api.ts @@ -0,0 +1,67 @@ +import { parseResponse, throwIfError } from '@/lib/api-helpers' +import { resolveApiPath } from '@/lib/api-base' +import { fetchWithAuth } from '@/lib/fetch-with-auth' + +const API_BASE = '/api/webui/reasoning-process' + +export type ReasoningPromptFile = { + stage: string + session_id: string + stem: string + timestamp: number | null + text_path: string | null + html_path: string | null + size: number + modified_at: number +} + +export type ReasoningPromptListResponse = { + items: ReasoningPromptFile[] + total: number + page: number + page_size: number + stages: string[] + sessions: string[] +} + +export type ReasoningPromptContentResponse = { + path: string + content: string + size: number + modified_at: number +} + +export type ReasoningPromptListParams = { + stage?: string + session?: string + search?: string + page?: number + pageSize?: number +} + +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('search', params.search ?? '') + queryParams.set('page', String(params.page ?? 1)) + queryParams.set('page_size', String(params.pageSize ?? 50)) + + const response = await fetchWithAuth(`${API_BASE}/files?${queryParams}`, { cache: 'no-store' }) + return throwIfError(await parseResponse(response)) +} + +export async function getReasoningPromptFile( + path: string +): Promise { + const response = await fetchWithAuth(`${API_BASE}/file?path=${encodeURIComponent(path)}`, { + cache: 'no-store', + }) + return throwIfError(await parseResponse(response)) +} + +export async function getReasoningPromptHtmlUrl(path: string): Promise { + return resolveApiPath(`${API_BASE}/html?path=${encodeURIComponent(path)}`) +} diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx index 34cfc454..57d9a441 100644 --- a/dashboard/src/router.tsx +++ b/dashboard/src/router.tsx @@ -162,6 +162,12 @@ const logsRoute = createRoute({ component: lazyRouteComponent(() => import('./routes/logs'), 'LogViewerPage'), }) +const reasoningProcessRoute = createRoute({ + getParentRoute: () => protectedRoute, + path: '/reasoning-process', + component: lazyRouteComponent(() => import('./routes/reasoning-process'), 'ReasoningProcessPage'), +}) + // MaiSaka 聊天流监控路由 const plannerMonitorRoute = createRoute({ getParentRoute: () => protectedRoute, @@ -289,6 +295,7 @@ const routeTree = rootRoute.addChildren([ pluginMirrorsRoute, mcpSettingsRoute, logsRoute, + reasoningProcessRoute, plannerMonitorRoute, chatRoute, settingsRoute, diff --git a/dashboard/src/routes/monitor/index.tsx b/dashboard/src/routes/monitor/index.tsx index 6fd5a071..38053e6d 100644 --- a/dashboard/src/routes/monitor/index.tsx +++ b/dashboard/src/routes/monitor/index.tsx @@ -17,9 +17,6 @@ export function PlannerMonitorPage() { 麦麦观察 -

- 实时追踪 MaiSaka 推理引擎的完整思考过程 -

diff --git a/dashboard/src/routes/monitor/use-maisaka-monitor.ts b/dashboard/src/routes/monitor/use-maisaka-monitor.ts index 591ce053..64b07b88 100644 --- a/dashboard/src/routes/monitor/use-maisaka-monitor.ts +++ b/dashboard/src/routes/monitor/use-maisaka-monitor.ts @@ -4,6 +4,7 @@ * 管理 WebSocket 订阅与事件流的状态。 */ import { useCallback, useEffect, useState } from 'react' +import { openDB, type DBSchema, type IDBPDatabase } from 'idb' import type { MaisakaMonitorEvent } from '@/lib/maisaka-monitor-client' import { maisakaMonitorClient } from '@/lib/maisaka-monitor-client' @@ -34,9 +35,14 @@ export interface SessionInfo { eventCount: number } -/** 最大保留的时间线条目数 */ -const MAX_TIMELINE_ENTRIES = 500 +/** 前端内存中最多恢复/展示的时间线条目数,避免一次渲染过多节点。 */ +const MAX_TIMELINE_ENTRIES = 3000 +/** IndexedDB 中最多持久化的时间线条目数。 */ +const MAX_PERSISTED_TIMELINE_ENTRIES = 10000 +const PERSIST_PRUNE_INTERVAL = 200 const BACKGROUND_COLLECTION_STORAGE_KEY = 'maisaka-monitor-background-collection' +const MONITOR_DB_NAME = 'maisaka-monitor-db' +const MONITOR_DB_VERSION = 1 function resolveSessionDisplayName({ fallbackName, @@ -81,6 +87,39 @@ let monitorSubscriptionStarted = false let monitorSubscriptionPromise: Promise | null = null let monitorUnsubscribe: (() => Promise) | null = null const storeListeners = new Set<() => void>() +let persistSnapshotTimer: ReturnType | null = null +let monitorDbPromise: Promise> | null = null +let persistedEntryCountSincePrune = 0 +let pendingPersistEntries: TimelineEntry[] = [] +let pendingPersistSessionIds = new Set() +let pendingPersistMeta = false + +interface PersistedTimelineEntry extends TimelineEntry { + persistedAt: number +} + +interface MonitorMetaRecord { + key: string + value: unknown +} + +interface MaisakaMonitorDb extends DBSchema { + timeline: { + key: string + value: PersistedTimelineEntry + indexes: { + 'by-timestamp': number + } + } + sessions: { + key: string + value: SessionInfo + } + meta: { + key: string + value: MonitorMetaRecord + } +} function notifyStoreListeners() { storeListeners.forEach((listener) => listener()) @@ -98,6 +137,163 @@ function loadBackgroundCollectionPreference() { return backgroundCollectionEnabled } +function getMonitorDb() { + if (typeof window === 'undefined' || !window.indexedDB) { + return null + } + + monitorDbPromise ??= openDB(MONITOR_DB_NAME, MONITOR_DB_VERSION, { + upgrade(db) { + const timelineStore = db.createObjectStore('timeline', { keyPath: 'id' }) + timelineStore.createIndex('by-timestamp', 'timestamp') + db.createObjectStore('sessions', { keyPath: 'sessionId' }) + db.createObjectStore('meta', { keyPath: 'key' }) + }, + }) + + return monitorDbPromise +} + +function toTimelineEntry(entry: PersistedTimelineEntry): TimelineEntry { + return { + id: entry.id, + type: entry.type, + data: entry.data, + timestamp: entry.timestamp, + sessionId: entry.sessionId, + } +} + +async function loadMonitorSnapshot() { + if (typeof window === 'undefined') { + return + } + + try { + const dbPromise = getMonitorDb() + if (!dbPromise) { + return + } + + const db = await dbPromise + const [timelineRecords, sessionRecords, selectedSessionMeta, entryCounterMeta] = await Promise.all([ + db.getAllFromIndex('timeline', 'by-timestamp'), + db.getAll('sessions'), + db.get('meta', 'selectedSession'), + db.get('meta', 'entryCounter'), + ]) + + cachedTimeline = timelineRecords + .slice(-MAX_TIMELINE_ENTRIES) + .map(toTimelineEntry) + cachedSessions = new Map(sessionRecords.map((session) => [session.sessionId, session])) + cachedSelectedSession = typeof selectedSessionMeta?.value === 'string' ? selectedSessionMeta.value : null + entryCounter = typeof entryCounterMeta?.value === 'number' ? entryCounterMeta.value : cachedTimeline.length + notifyStoreListeners() + } catch (error) { + console.warn('读取 MaiSaka 观察 IndexedDB 缓存失败,已忽略:', error) + } +} + +async function prunePersistedTimeline(db: IDBPDatabase) { + const keys = await db.getAllKeysFromIndex('timeline', 'by-timestamp') + const overflowCount = keys.length - MAX_PERSISTED_TIMELINE_ENTRIES + if (overflowCount <= 0) { + return + } + + const tx = db.transaction('timeline', 'readwrite') + for (const key of keys.slice(0, overflowCount)) { + await tx.store.delete(key) + } + await tx.done +} + +async function flushMonitorSnapshot() { + try { + const dbPromise = getMonitorDb() + if (!dbPromise) { + return + } + + const entries = pendingPersistEntries + const sessionIds = Array.from(pendingPersistSessionIds) + const shouldPersistMeta = pendingPersistMeta + pendingPersistEntries = [] + pendingPersistSessionIds = new Set() + pendingPersistMeta = false + + if (entries.length === 0 && sessionIds.length === 0 && !shouldPersistMeta) { + return + } + + const db = await dbPromise + const tx = db.transaction(['timeline', 'sessions', 'meta'], 'readwrite') + const persistedAt = Date.now() + for (const entry of entries) { + await tx.objectStore('timeline').put({ ...entry, persistedAt }) + } + for (const sessionId of sessionIds) { + const session = cachedSessions.get(sessionId) + if (session) { + await tx.objectStore('sessions').put(session) + } + } + await tx.objectStore('meta').put({ key: 'selectedSession', value: cachedSelectedSession }) + await tx.objectStore('meta').put({ key: 'entryCounter', value: entryCounter }) + await tx.done + + persistedEntryCountSincePrune += entries.length + if (persistedEntryCountSincePrune >= PERSIST_PRUNE_INTERVAL) { + persistedEntryCountSincePrune = 0 + await prunePersistedTimeline(db) + } + } catch (error) { + console.warn('保存 MaiSaka 观察 IndexedDB 缓存失败,已忽略:', error) + } +} + +async function clearPersistedMonitorSnapshot() { + try { + const dbPromise = getMonitorDb() + if (!dbPromise) { + return + } + const db = await dbPromise + const tx = db.transaction(['timeline', 'sessions', 'meta'], 'readwrite') + await Promise.all([ + tx.objectStore('timeline').clear(), + tx.objectStore('sessions').clear(), + tx.objectStore('meta').clear(), + ]) + await tx.done + } catch (error) { + console.warn('清空 MaiSaka 观察 IndexedDB 缓存失败,已忽略:', error) + } +} + +function schedulePersistMonitorSnapshot(entry?: TimelineEntry, sessionId?: string) { + if (typeof window === 'undefined') { + return + } + if (entry) { + pendingPersistEntries.push(entry) + } + if (sessionId) { + pendingPersistSessionIds.add(sessionId) + } + pendingPersistMeta = true + if (persistSnapshotTimer !== null) { + window.clearTimeout(persistSnapshotTimer) + } + persistSnapshotTimer = window.setTimeout(() => { + persistSnapshotTimer = null + void flushMonitorSnapshot() + }, 300) +} + +void loadMonitorSnapshot() + function shouldKeepMonitorActive() { return activeConsumerCount > 0 || backgroundCollectionEnabled } @@ -172,13 +368,14 @@ function handleMonitorEvent(event: MaisakaMonitorEvent) { return } - appendTimelineEntry({ + const entry: TimelineEntry = { id: `evt_${++entryCounter}_${Date.now()}`, type: event.type, data: event.data, timestamp, sessionId, - }) + } + appendTimelineEntry(entry) updateSessionInfo(event, sessionId, timestamp) @@ -186,6 +383,7 @@ function handleMonitorEvent(event: MaisakaMonitorEvent) { cachedSelectedSession = sessionId } + schedulePersistMonitorSnapshot(entry, sessionId) notifyStoreListeners() } @@ -263,13 +461,22 @@ export function useMaisakaMonitor() { const clearTimeline = useCallback(() => { cachedTimeline = [] + cachedSessions = new Map() + cachedSelectedSession = null setTimeline([]) + setSessions(new Map()) + setSelectedSessionState(null) + pendingPersistEntries = [] + pendingPersistSessionIds = new Set() + pendingPersistMeta = false + void clearPersistedMonitorSnapshot() notifyStoreListeners() }, []) const setSelectedSession = useCallback((sessionId: string | null) => { cachedSelectedSession = sessionId setSelectedSessionState(sessionId) + schedulePersistMonitorSnapshot() notifyStoreListeners() }, []) diff --git a/dashboard/src/routes/reasoning-process.tsx b/dashboard/src/routes/reasoning-process.tsx new file mode 100644 index 00000000..73ad1b50 --- /dev/null +++ b/dashboard/src/routes/reasoning-process.tsx @@ -0,0 +1,380 @@ +import { useEffect, useState } from 'react' +import { + Clock, + Code2, + FileCode2, + FileText, + RefreshCw, + Search, +} from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + getReasoningPromptFile, + getReasoningPromptHtmlUrl, + listReasoningPromptFiles, + type ReasoningPromptFile, +} from '@/lib/reasoning-process-api' +import { cn } from '@/lib/utils' + +const PAGE_SIZE = 50 + +function formatTime(timestamp: number | null, modifiedAt: number): string { + const value = timestamp ? timestamp : modifiedAt * 1000 + return new Date(value).toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +function formatSize(size: number): string { + if (size < 1024) return `${size} B` + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB` + return `${(size / 1024 / 1024).toFixed(1)} MB` +} + +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 [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [refreshKey, setRefreshKey] = useState(0) + const [total, setTotal] = useState(0) + const [selected, setSelected] = useState(null) + const [textContent, setTextContent] = useState('') + const [activePreview, setActivePreview] = useState<'text' | 'html'>('text') + const [htmlPreviewUrl, setHtmlPreviewUrl] = useState('') + const [loading, setLoading] = useState(false) + const [contentLoading, setContentLoading] = useState(false) + const [error, setError] = useState(null) + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + useEffect(() => { + let ignore = false + + async function loadFiles() { + setLoading(true) + setError(null) + try { + const data = await listReasoningPromptFiles({ + stage, + session, + search, + page, + pageSize: PAGE_SIZE, + }) + if (ignore) return + setItems(data.items) + setStages(data.stages) + setSessions(data.sessions) + setTotal(data.total) + setSelected((current) => { + if ( + current && + data.items.some( + (item) => + item.stem === current.stem && + item.stage === current.stage && + item.session_id === current.session_id + ) + ) { + return current + } + return data.items[0] ?? null + }) + } catch (err) { + if (!ignore) setError(err instanceof Error ? err.message : '加载推理过程失败') + } finally { + if (!ignore) setLoading(false) + } + } + + loadFiles() + return () => { + ignore = true + } + }, [page, refreshKey, search, session, stage]) + + useEffect(() => { + let ignore = false + + async function loadContent() { + if (!selected?.text_path) { + setTextContent('') + return + } + + setContentLoading(true) + try { + const data = await getReasoningPromptFile(selected.text_path) + if (!ignore) setTextContent(data.content) + } catch (err) { + if (!ignore) { + setTextContent(err instanceof Error ? err.message : '读取文本失败') + } + } finally { + if (!ignore) setContentLoading(false) + } + } + + async function loadHtmlPreviewUrl() { + if (!selected?.html_path) { + setHtmlPreviewUrl('') + return + } + const url = await getReasoningPromptHtmlUrl(selected.html_path) + if (!ignore) setHtmlPreviewUrl(url) + } + + if (selected?.html_path && !selected.text_path) { + setActivePreview('html') + } else { + setActivePreview('text') + } + loadContent() + loadHtmlPreviewUrl() + return () => { + ignore = true + } + }, [selected]) + + function resetToFirstPage(nextAction: () => void) { + nextAction() + setPage(1) + } + + return ( +
+
+
+

推理过程

+

浏览 logs/maisaka_prompt 下的 prompt 记录

+
+ +
+ +
+ + + + +
+ + resetToFirstPage(() => setSearch(event.target.value))} + className="pl-9" + placeholder="搜索阶段、会话或文件名" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ {total} 条记录 + + 第 {page} / {totalPages} 页 + +
+ +
+ {items.map((item) => { + const active = selected?.stage === item.stage && selected?.session_id === item.session_id && selected?.stem === item.stem + return ( + + ) + })} + {!loading && items.length === 0 && ( +
+ 没有找到推理过程记录 +
+ )} +
+
+
+ + +
+
+ +
+
+
+
+ {selected ? `${selected.stage}/${selected.session_id}/${selected.stem}` : '未选择记录'} +
+
+ {selected ? `${formatSize(selected.size)} · ${formatTime(selected.timestamp, selected.modified_at)}` : '从左侧列表选择一条记录'} +
+
+ {selected && ( +
+ {selected.text_path && ( + + + txt + + )} + {selected.html_path && ( + + + html + + )} +
+ )} +
+ + setActivePreview(value as 'text' | 'html')} + className="flex min-h-0 flex-1 flex-col" + > +
+ + + + 文本 + + + + HTML + + +
+ + + +
+                  {contentLoading ? '正在读取...' : textContent || '没有文本内容'}
+                
+
+
+ + + {selected?.html_path && htmlPreviewUrl ? ( +