fix:优化聊天流信息的展示和检索,优化chat_prompt无效的问题,优化部分群定义问题
This commit is contained in:
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "システム設定を構成"
|
||||
}
|
||||
|
||||
@@ -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": "시스템 설정 구성"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"pluginConfig": "插件管理",
|
||||
"mcpSettings": "MCP 设置",
|
||||
"logViewer": "日志查看器",
|
||||
"reasoningProcess": "推理过程",
|
||||
"maisakaMonitor": "麦麦观察",
|
||||
"localChat": "本地聊天室",
|
||||
"settings": "系统设置"
|
||||
@@ -793,6 +794,7 @@
|
||||
"pluginsDesc": "浏览和安装插件",
|
||||
"logs": "日志查看器",
|
||||
"logsDesc": "查看系统日志",
|
||||
"reasoningProcessDesc": "浏览 Maisaka prompt 推理记录",
|
||||
"settings": "系统设置",
|
||||
"settingsDesc": "配置系统参数"
|
||||
}
|
||||
|
||||
67
dashboard/src/lib/reasoning-process-api.ts
Normal file
67
dashboard/src/lib/reasoning-process-api.ts
Normal 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)}`)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
}, [])
|
||||
|
||||
|
||||
380
dashboard/src/routes/reasoning-process.tsx
Normal file
380
dashboard/src/routes/reasoning-process.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user