feat(frontend): detail auto-refresh, session list, pinned AI-assistant tab
Some checks failed
CI / test (push) Failing after 40s

- Detail page silently polls every 5s while planning/executing/pending_approval
- Detail page shows per-session progress (persona/status/goal/turns), polled while executing
- /openclaw tab pinned by default like the dashboard (tabStore now .tsx)
This commit is contained in:
sinohqb 2026-08-05 14:01:26 +08:00
parent 9cdbc41808
commit 6cc2efafb6
4 changed files with 71 additions and 17 deletions

View File

@ -74,6 +74,8 @@ const routeConfigs: RouteConfig[] = [
const componentMap: Record<string, () => ReactNode> = {} const componentMap: Record<string, () => ReactNode> = {}
routeConfigs.forEach((r) => { componentMap[r.path] = r.component }) routeConfigs.forEach((r) => { componentMap[r.path] = r.component })
const PINNED_TAB_PATHS = new Set(['/', '/openclaw'])
const menuItems: MenuProps['items'] = [ const menuItems: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '仪表盘' }, { key: '/', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/openclaw', icon: <RobotOutlined />, label: 'AI 助手' }, { key: '/openclaw', icon: <RobotOutlined />, label: 'AI 助手' },
@ -147,7 +149,7 @@ function App() {
key: config.path, key: config.path,
title: config.name, title: config.name,
icon: config.icon, icon: config.icon,
closable: config.path !== '/', closable: !PINNED_TAB_PATHS.has(config.path),
} }
openTab(tab) openTab(tab)
} else { } else {
@ -163,7 +165,7 @@ function App() {
key: config.path, key: config.path,
title: config.name, title: config.name,
icon: config.icon, icon: config.icon,
closable: config.path !== '/', closable: !PINNED_TAB_PATHS.has(config.path),
} }
openTab(tab) openTab(tab)
navigate(key) navigate(key)

View File

@ -1,12 +1,12 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Alert, Button, Card, Descriptions, Empty, Input, Modal, Popconfirm, Space, Spin, Tag, message, Alert, Button, Card, Descriptions, Empty, Input, Modal, Popconfirm, Space, Spin, Tag, message,
} from 'antd' } from 'antd'
import { ArrowLeftOutlined, FileTextOutlined, StopOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, FileTextOutlined, StopOutlined } from '@ant-design/icons'
import { intelligentEvalsApi, type IntelligentEval } from '../../api' import { intelligentEvalsApi, type IntelligentEval, type IntelligentEvalSession } from '../../api'
import { colors } from '../../tokens' import { colors } from '../../tokens'
import { formatDateTime, shortDateTime } from '../../utils/date' import { formatDateTime, shortDateTime } from '../../utils/date'
import { EVAL_STATUS } from './status' import { EVAL_STATUS, SESSION_STATUS } from './status'
const sectionCard: React.CSSProperties = { marginBottom: 16 } const sectionCard: React.CSSProperties = { marginBottom: 16 }
@ -71,7 +71,21 @@ export default function EvalDetail({ ev, targetName, onBack, onOpenReport, onCha
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [rejectOpen, setRejectOpen] = useState(false) const [rejectOpen, setRejectOpen] = useState(false)
const [feedback, setFeedback] = useState('') const [feedback, setFeedback] = useState('')
const [sessions, setSessions] = useState<IntelligentEvalSession[] | null>(null)
const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' } const meta = EVAL_STATUS[ev.status] ?? { label: ev.status, color: 'default' }
const showSessions = ev.status === 'executing' || ev.status === 'completed'
useEffect(() => {
if (!showSessions) { setSessions(null); return }
let cancelled = false
const load = () => intelligentEvalsApi.listSessions(ev.id)
.then((res) => { if (!cancelled) setSessions(res.data.sessions) })
.catch(() => undefined)
void load()
if (ev.status !== 'executing') return () => { cancelled = true }
const timer = setInterval(load, 5000)
return () => { cancelled = true; clearInterval(timer) }
}, [ev.id, ev.status, showSessions])
const runAction = async (fn: () => Promise<unknown>, okMsg: string) => { const runAction = async (fn: () => Promise<unknown>, okMsg: string) => {
setBusy(true) setBusy(true)
@ -177,15 +191,40 @@ export default function EvalDetail({ ev, targetName, onBack, onOpenReport, onCha
</Card> </Card>
)} )}
{ev.status === 'executing' && ( {showSessions && (
<Card size="small" title="执行进度" style={sectionCard}> <Card size="small" title={`会话进度(${ev.completed_sessions}/${ev.session_count}`} style={sectionCard}>
<div style={{ fontSize: 13, color: colors.textSecondary, marginBottom: 8 }}> {sessions === null && <Spin size="small" />}
{ev.completed_sessions} / {ev.session_count} {sessions !== null && sessions.length === 0 && (
{ev.plan?.estimated_sessions ? ` / 预估 ${ev.plan.estimated_sessions}` : ''} <div style={{ fontSize: 13, color: colors.textSecondary }}>
</div> OpenClaw
<div style={{ fontSize: 12, color: colors.textMuted }}> </div>
OpenClaw {shortDateTime(ev.updated_at)} )}
</div> {sessions?.map((s) => {
const sMeta = SESSION_STATUS[s.status]
return (
<div
key={s.id}
style={{
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
padding: '6px 0', borderBottom: `1px solid ${colors.border}`, fontSize: 13,
}}
>
<b style={{ color: colors.text }}>{personaLabel(s.persona)}</b>
<Tag color={sMeta.color} style={{ margin: 0 }}>{sMeta.label}</Tag>
{s.dimension && <Tag style={{ margin: 0 }}>{s.dimension}</Tag>}
<span style={{ color: colors.textSecondary, flex: 1, minWidth: 120 }}>{s.goal}</span>
<span style={{ color: colors.textMuted }}>{s.turn_count} </span>
{s.created_at && (
<span style={{ color: colors.textMuted }}>{shortDateTime(s.created_at)}</span>
)}
</div>
)
})}
{ev.status === 'executing' && (
<div style={{ fontSize: 12, color: colors.textMuted, marginTop: 8 }}>
OpenClaw 5
</div>
)}
</Card> </Card>
)} )}

View File

@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Button, Drawer, Empty, Form, Input, InputNumber, Select, Space, Table, Tag, message, Button, Drawer, Empty, Form, Input, InputNumber, Select, Space, Table, Tag, message,
} from 'antd' } from 'antd'
@ -45,11 +45,20 @@ export default function IntelligentEvalsPage() {
{ tabPath: '/intelligent-evals' }, { tabPath: '/intelligent-evals' },
) )
const { data: selected } = useResource( const { data: selected, reload: reloadSelected } = useResource(
() => (selectedId ? intelligentEvalsApi.get(selectedId).then((r) => r.data) : Promise.resolve(null)), () => (selectedId ? intelligentEvalsApi.get(selectedId).then((r) => r.data) : Promise.resolve(null)),
{ deps: [selectedId, detailTick] }, { deps: [selectedId, detailTick] },
) )
const pollActive = view === 'detail' && selected != null
&& (selected.status === 'planning' || selected.status === 'executing' || selected.status === 'pending_approval')
useEffect(() => {
if (!pollActive) return
const timer = setInterval(() => void reloadSelected(true), 5000)
return () => clearInterval(timer)
}, [pollActive, reloadSelected])
const targetName = (id: string) => const targetName = (id: string) =>
targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8) targets?.find((t) => t.id === id)?.name ?? id.slice(0, 8)

View File

@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { RobotOutlined } from '@ant-design/icons'
export interface TabItem { export interface TabItem {
key: string key: string
@ -16,7 +17,10 @@ interface TabStore {
} }
export const useTabStore = create<TabStore>((set, get) => ({ export const useTabStore = create<TabStore>((set, get) => ({
tabs: [{ key: '/', title: '仪表盘', closable: false }], tabs: [
{ key: '/', title: '仪表盘', closable: false },
{ key: '/openclaw', title: 'AI 助手', icon: <RobotOutlined />, closable: false },
],
activeKey: '/', activeKey: '/',
openTab: (tab) => { openTab: (tab) => {