fix:优化聊天流信息的展示和检索,优化chat_prompt无效的问题,优化部分群定义问题

This commit is contained in:
SengokuCola
2026-05-07 18:06:55 +08:00
parent 93cef02d92
commit b6808d4b73
21 changed files with 1219 additions and 165 deletions

View File

@@ -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' },
],
},

View File

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

View File

@@ -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": "システム設定を構成"
}

View File

@@ -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": "시스템 설정 구성"
}

View File

@@ -40,6 +40,7 @@
"pluginConfig": "插件管理",
"mcpSettings": "MCP 设置",
"logViewer": "日志查看器",
"reasoningProcess": "推理过程",
"maisakaMonitor": "麦麦观察",
"localChat": "本地聊天室",
"settings": "系统设置"
@@ -793,6 +794,7 @@
"pluginsDesc": "浏览和安装插件",
"logs": "日志查看器",
"logsDesc": "查看系统日志",
"reasoningProcessDesc": "浏览 Maisaka prompt 推理记录",
"settings": "系统设置",
"settingsDesc": "配置系统参数"
}

View File

@@ -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<ReasoningPromptListResponse> {
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<ReasoningPromptListResponse>(response))
}
export async function getReasoningPromptFile(
path: string
): Promise<ReasoningPromptContentResponse> {
const response = await fetchWithAuth(`${API_BASE}/file?path=${encodeURIComponent(path)}`, {
cache: 'no-store',
})
return throwIfError(await parseResponse<ReasoningPromptContentResponse>(response))
}
export async function getReasoningPromptHtmlUrl(path: string): Promise<string> {
return resolveApiPath(`${API_BASE}/html?path=${encodeURIComponent(path)}`)
}

View File

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

View File

@@ -17,9 +17,6 @@ export function PlannerMonitorPage() {
<Activity className="h-6 w-6 sm:h-7 sm:w-7" />
</h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
MaiSaka
</p>
</div>
</div>

View File

@@ -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<void> | null = null
let monitorUnsubscribe: (() => Promise<void>) | null = null
const storeListeners = new Set<() => void>()
let persistSnapshotTimer: ReturnType<typeof setTimeout> | null = null
let monitorDbPromise: Promise<IDBPDatabase<MaisakaMonitorDb>> | null = null
let persistedEntryCountSincePrune = 0
let pendingPersistEntries: TimelineEntry[] = []
let pendingPersistSessionIds = new Set<string>()
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<MaisakaMonitorDb>(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<MaisakaMonitorDb>) {
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()
}, [])

View File

@@ -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<ReasoningPromptFile[]>([])
const [stages, setStages] = useState<string[]>([])
const [sessions, setSessions] = useState<string[]>([])
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<ReasoningPromptFile | null>(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<string | null>(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 (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden p-3 lg:p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-normal text-foreground"></h1>
<p className="text-sm text-muted-foreground"> logs/maisaka_prompt prompt </p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setRefreshKey((current) => current + 1)}
disabled={loading}
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
<div className="grid flex-shrink-0 grid-cols-1 gap-2 md:grid-cols-[180px_240px_1fr]">
<Select
value={stage}
onValueChange={(value) =>
resetToFirstPage(() => {
setStage(value)
setSession('all')
})
}
>
<SelectTrigger>
<SelectValue placeholder="阶段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{stages.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={session}
onValueChange={(value) => resetToFirstPage(() => setSession(value))}
>
<SelectTrigger>
<SelectValue placeholder="会话" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{sessions.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => resetToFirstPage(() => setSearch(event.target.value))}
className="pl-9"
placeholder="搜索阶段、会话或文件名"
/>
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-[360px_1fr]">
<div className="flex min-h-0 flex-col overflow-hidden rounded-md border bg-background">
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b px-3 text-sm text-muted-foreground">
<span>{total} </span>
<span>
{page} / {totalPages}
</span>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-1 p-2">
{items.map((item) => {
const active = selected?.stage === item.stage && selected?.session_id === item.session_id && selected?.stem === item.stem
return (
<button
key={`${item.stage}/${item.session_id}/${item.stem}`}
type="button"
onClick={() => setSelected(item)}
className={cn(
'flex w-full flex-col gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors',
active
? 'border-primary bg-primary/10 text-foreground'
: 'border-transparent hover:border-border hover:bg-muted/60'
)}
>
<div className="flex items-center justify-between gap-2">
<Badge variant="secondary" className="max-w-[150px] truncate">
{item.stage}
</Badge>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
{formatTime(item.timestamp, item.modified_at)}
</span>
</div>
<div className="truncate font-medium">{item.session_id}</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="truncate">{item.stem}</span>
<span className="shrink-0">{formatSize(item.size)}</span>
</div>
</button>
)
})}
{!loading && items.length === 0 && (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
<div className="flex h-12 flex-shrink-0 items-center justify-between border-t px-3">
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
</Button>
</div>
</div>
<div className="flex min-h-0 flex-col overflow-hidden rounded-md border bg-background">
<div className="flex min-h-14 flex-shrink-0 flex-col gap-1 border-b px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{selected ? `${selected.stage}/${selected.session_id}/${selected.stem}` : '未选择记录'}
</div>
<div className="text-xs text-muted-foreground">
{selected ? `${formatSize(selected.size)} · ${formatTime(selected.timestamp, selected.modified_at)}` : '从左侧列表选择一条记录'}
</div>
</div>
{selected && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{selected.text_path && (
<span className="inline-flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
txt
</span>
)}
{selected.html_path && (
<span className="inline-flex items-center gap-1">
<FileCode2 className="h-3.5 w-3.5" />
html
</span>
)}
</div>
)}
</div>
<Tabs
value={activePreview}
onValueChange={(value) => setActivePreview(value as 'text' | 'html')}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex flex-shrink-0 border-b px-3 py-2">
<TabsList>
<TabsTrigger value="text" disabled={!selected?.text_path}>
<FileText className="mr-1 h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="html" disabled={!selected?.html_path}>
<Code2 className="mr-1 h-4 w-4" />
HTML
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="text" className="m-0 min-h-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<pre className="min-h-full whitespace-pre-wrap break-words p-4 font-mono text-xs leading-5 text-foreground">
{contentLoading ? '正在读取...' : textContent || '没有文本内容'}
</pre>
</ScrollArea>
</TabsContent>
<TabsContent value="html" className="m-0 min-h-0 flex-1 overflow-hidden">
{selected?.html_path && htmlPreviewUrl ? (
<iframe
title="推理过程 HTML 预览"
src={htmlPreviewUrl}
sandbox=""
className="h-full w-full border-0 bg-white"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
HTML
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
)
}