AgentEvalTool/frontend/web/src/hooks/sessionReducer.ts
sinohqb a77cd83e6a v0.2.0-dev: 文件管理 + 页面布局统一 + 6 个 bug 修复
## 新增功能
- 文件管理模块:分类树 + 文件上传/下载/删除
- 文件上传支持拖拽(Dragger)+ 手动上传(customRequest 模式)

## 页面布局统一(参照评测执行页)
- 仪表盘/评测对象/评测场景/评测报告 全部改为全高 flex 布局
- 统一内联页头样式(h2 + 竖线分隔 + 描述)
- 表格撑满高度、overflow 处理
- 每页添加刷新按钮

## Bug 修复
- 分类树操作按钮 hover 不可见(CSS 规则缺失)
- 文件上传失败(multipart boundary 缺失)
- LLM API 响应 content blocks 数组格式支持(_extract_content_from_api_response)
- response_time_max_ms 被静默忽略(隐式规则传空 params)
- 空 messages 导致 IndexError 崩溃
- poll_reply 异常中止整个 run(缺 try/catch)
- engine finally 未关闭 session
- 3 个页面 UTC 时间戳解析偏差 8 小时

## 后端
- EvalEngine: poll_reply 异常保护、空 dialog 保护、session 关闭
- LLM API 响应解析支持 content-block-array 格式
- 隐式 response_time 规则正确传递 max_ms 参数

## 前端
- api.ts: 移除手动 Content-Type(让浏览器自动添加 boundary)
- Files.tsx: customRequest 替代 beforeUpload、布局优化
- index.css: 分类树 hover 规则
- Targets/Scenarios/Home/Reports: 全高布局改造
- 3 个页面时间戳改用 formatDateTime()(修复 UTC 偏差)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 15:25:22 +08:00

299 lines
9.8 KiB
TypeScript

/**
* Pure reducer for evaluation-run session state.
*
* Extracted from `useRunSession` so the event-handling logic is:
* - a pure function (no refs, no API calls, no WebSocket manipulation)
* - trivially unit-testable
* - free of the stale-closure hazards that bit the previous
* `useCallback` + `useState` implementation.
*
* Side effects (closing the WebSocket, refreshing the Run object) are
* handled by the hook via a `useEffect` that watches `completed`.
*/
import type { CaseSnapshot } from '../api'
import type {
CaseError,
CaseState,
RunError,
RunProgress,
RuleResultState,
TurnState,
} from './useRunSession'
export interface SessionState {
cases: CaseState[]
progress: RunProgress | null
scenarioSnapshot: Record<string, CaseSnapshot>
isLive: boolean
completed: boolean
finalStatus: 'completed' | 'failed' | 'cancelled' | null
errorInfo: RunError | null
}
export const initialSessionState: SessionState = {
cases: [],
progress: null,
scenarioSnapshot: {},
isLive: false,
completed: false,
finalStatus: null,
errorInfo: null,
}
export type SessionAction =
| { type: 'RESET' }
| { type: 'SET_LIVE'; live: boolean }
| { type: 'SET_SNAPSHOT'; snapshot: Record<string, CaseSnapshot> }
| { type: 'LOAD_HISTORY'; cases: CaseState[]; progress: RunProgress; snapshot: Record<string, CaseSnapshot> }
| { type: 'WS_EVENT'; event: WsEvent }
| { type: 'FINALIZE_FROM_RUN'; status: string; summary: any }
| { type: 'SET_RUN'; run: { id: string } | null }
/**
* Shape of a WebSocket event payload emitted by the backend. Typed loosely
* because the backend sends a different shape per event name.
*/
export interface WsEvent {
event: string
[key: string]: any
}
// ── helpers ──────────────────────────────────────────────────────────────
function findOrCreateCase(cases: CaseState[], caseId: string, isDynamic = false): {
cases: CaseState[]
index: number
} {
const idx = cases.findIndex((c) => c.caseId === caseId)
if (idx !== -1) return { cases, index: idx }
const newCase: CaseState = {
caseId,
status: 'waiting',
turns: [],
ruleResults: [],
errors: [],
isDynamic,
}
return { cases: [...cases, newCase], index: cases.length }
}
function updateCase(cases: CaseState[], index: number, patch: Partial<CaseState>): CaseState[] {
const next = [...cases]
next[index] = { ...next[index], ...patch }
return next
}
function upsertTurn(cases: CaseState[], caseIndex: number, turn: TurnState): CaseState[] {
const cs = cases[caseIndex]
const turns = [...cs.turns]
const existing = turns.findIndex((t) => t.roundIndex === turn.roundIndex)
if (existing >= 0) turns[existing] = turn
else turns.push(turn)
return updateCase(cases, caseIndex, { turns, status: 'running' })
}
// ── reducer ──────────────────────────────────────────────────────────────
export function sessionReducer(state: SessionState, action: SessionAction): SessionState {
switch (action.type) {
case 'RESET':
return initialSessionState
case 'SET_LIVE':
return { ...state, isLive: action.live }
case 'SET_SNAPSHOT':
return { ...state, scenarioSnapshot: action.snapshot }
case 'LOAD_HISTORY':
return {
...state,
cases: action.cases,
progress: action.progress,
scenarioSnapshot: action.snapshot,
}
case 'SET_RUN':
// The Run object itself lives in a separate useState; this action
// only exists to piggy-back a reset of transient session state when
// the user selects a different run.
return state
case 'FINALIZE_FROM_RUN': {
const { status, summary } = action
if (status === 'completed') {
return { ...state, completed: true, isLive: false, finalStatus: 'completed', errorInfo: null }
}
if (status === 'failed') {
const err = summary?.error
let final: 'failed' | 'cancelled' = 'failed'
let errInfo: RunError | null = null
if (err && typeof err === 'object' && err.code === 'cancelled_by_user') {
final = 'cancelled'
errInfo = err
} else if (typeof err === 'string') {
if (err === 'cancelled by user') {
final = 'cancelled'
errInfo = { code: 'cancelled_by_user', message: '评测已手动停止' }
} else {
errInfo = { message: err }
}
} else if (err && typeof err === 'object') {
errInfo = err
}
return { ...state, completed: true, isLive: false, finalStatus: final, errorInfo: errInfo }
}
return state
}
case 'WS_EVENT': {
const ev = action.event
switch (ev.event) {
case 'case_start': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id)
return {
...state,
cases: updateCase(cases, index, { status: 'running' }),
progress: {
done: state.progress?.done ?? 0,
total: ev.total ?? state.progress?.total ?? 0,
currentIndex: ev.index ?? 0,
},
}
}
case 'case_end': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id)
return {
...state,
cases: updateCase(cases, index, { status: 'done', passed: !!ev.passed }),
progress: {
done: (state.progress?.done ?? 0) + 1,
total: ev.total ?? state.progress?.total ?? 0,
currentIndex: ev.index ?? state.progress?.currentIndex ?? 0,
},
}
}
case 'messages_generated': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id, true)
return {
...state,
cases: updateCase(cases, index, {
generatedMessages: ev.messages,
isDynamic: true,
}),
}
}
case 'turn_start': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id)
const turn: TurnState = {
roundIndex: ev.round,
message: ev.message,
pending: true,
}
return { ...state, cases: upsertTurn(cases, index, turn) }
}
case 'turn_end': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id)
const existing = cases[index].turns.findIndex((t) => t.roundIndex === ev.round)
const merged: TurnState = {
roundIndex: ev.round,
message: existing >= 0 ? cases[index].turns[existing].message : '',
reply: ev.reply_text,
latency_ms: ev.latency_ms,
pending: false,
}
return { ...state, cases: upsertTurn(cases, index, merged) }
}
case 'turn_error': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id ?? 'unknown')
const cs = cases[index]
const errors: CaseError[] = [...cs.errors, { round: ev.round, message: ev.error ?? '未知错误' }]
const turns = [...cs.turns]
const existing = turns.findIndex((t) => t.roundIndex === ev.round)
if (existing >= 0) {
turns[existing] = { ...turns[existing], pending: false, error: ev.error }
}
return { ...state, cases: updateCase(cases, index, { errors, turns }) }
}
case 'rule_result': {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id)
const cs = cases[index]
const ruleResults: RuleResultState[] = [
...cs.ruleResults,
{
rule_type: ev.rule_type,
passed: !!ev.passed,
score: ev.score,
reason: ev.reason,
},
]
return { ...state, cases: updateCase(cases, index, { ruleResults }) }
}
case 'error': {
if (ev.case_id) {
const { cases, index } = findOrCreateCase(state.cases, ev.case_id)
const cs = cases[index]
const errors: CaseError[] = [...cs.errors, { message: ev.error ?? '未知错误' }]
return { ...state, cases: updateCase(cases, index, { errors }) }
}
return {
...state,
errorInfo: { message: ev.error ?? '未知错误' },
}
}
case 'run_completed': {
const status: string = ev.status ?? 'completed'
const reason: string | undefined = ev.reason
const errorPayload = ev.error
let final: 'completed' | 'failed' | 'cancelled' = 'completed'
let errInfo: RunError | null = null
if (status === 'completed') {
final = 'completed'
} else if (reason === 'cancelled') {
final = 'cancelled'
} else {
final = 'failed'
}
if (typeof errorPayload === 'string') {
errInfo = { message: errorPayload }
} else if (errorPayload && typeof errorPayload === 'object') {
errInfo = { code: errorPayload.code, message: errorPayload.message ?? '评测失败' }
}
// Mark any still-running cases as done so the UI doesn't show
// spinners forever after the run ends.
const cases = state.cases.map((c) =>
c.status === 'running' ? { ...c, status: 'done' as const } : c,
)
return {
...state,
cases,
completed: true,
isLive: false,
finalStatus: final,
errorInfo: errInfo,
}
}
default:
return state
}
}
default:
return state
}
}