diff --git a/backend/agenteval/intelligent_eval/lifecycle.py b/backend/agenteval/intelligent_eval/lifecycle.py index d3d9ba2..056036d 100644 --- a/backend/agenteval/intelligent_eval/lifecycle.py +++ b/backend/agenteval/intelligent_eval/lifecycle.py @@ -218,6 +218,12 @@ def list_evals(session: Session) -> list[IntelligentEval]: return IntelligentEvalRepository(session).list_all() +def list_evals_page(session: Session, offset: int, limit: int) -> tuple[list[IntelligentEval], int]: + """Return one page of evals (newest first) plus the total count.""" + repo = IntelligentEvalRepository(session) + return repo.list_page(offset, limit), repo.count() + + def _get_session_or_raise(repo: IntelligentEvalSessionRepository, session_id: str) -> IntelligentEvalSession: obj = repo.get(session_id) if obj is None: diff --git a/backend/agenteval/intelligent_eval/repository.py b/backend/agenteval/intelligent_eval/repository.py index 5b86bfe..69ff4ce 100644 --- a/backend/agenteval/intelligent_eval/repository.py +++ b/backend/agenteval/intelligent_eval/repository.py @@ -101,6 +101,20 @@ class IntelligentEvalRepository: statement = select(IntelligentEvalDB).order_by(IntelligentEvalDB.created_at.desc()) return [self._from_db(r) for r in self.session.exec(statement).all()] + def list_page(self, offset: int, limit: int) -> list[IntelligentEval]: + """Return one page of evals (created_at desc) with the same ordering as ``list_all``.""" + statement = ( + select(IntelligentEvalDB) + .order_by(IntelligentEvalDB.created_at.desc()) + .offset(offset) + .limit(limit) + ) + return [self._from_db(r) for r in self.session.exec(statement).all()] + + def count(self) -> int: + """Total number of evaluations (for pagination metadata).""" + return self.session.exec(select(func.count()).select_from(IntelligentEvalDB)).one() + def get(self, eval_id: str) -> Optional[IntelligentEval]: db = self.session.get(IntelligentEvalDB, eval_id) return self._from_db(db) if db else None diff --git a/backend/agenteval/web/routers/intelligent_evals.py b/backend/agenteval/web/routers/intelligent_evals.py index 4efe9ad..5f1ac14 100644 --- a/backend/agenteval/web/routers/intelligent_evals.py +++ b/backend/agenteval/web/routers/intelligent_evals.py @@ -94,10 +94,29 @@ async def create_eval(request: CreateEvalRequest, session: Session = Depends(get @router.get("") -async def list_evals(session: Session = Depends(get_db)) -> dict: - evals = lifecycle.list_evals(session) +async def list_evals( + page: int | None = None, + page_size: int = 20, + session: Session = Depends(get_db), +) -> dict: + """List intelligent evaluations, optionally paginated. + + 不传 ``page`` 时返回全部(向后兼容);传 ``page``(从 1 起)时按 + ``created_at`` 倒序分页,返回 ``total`` 供前端服务端分页。 + """ reader = IntelligentEvalReadModel(session) - return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]} + if page is None: + evals = lifecycle.list_evals(session) + return {"intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)]} + page_size = max(1, min(page_size, 100)) + offset = (max(1, page) - 1) * page_size + evals, total = lifecycle.list_evals_page(session, offset, page_size) + return { + "intelligent_evals": [item.model_dump(mode="json") for item in reader.list_items(evals)], + "total": total, + "page": page, + "page_size": page_size, + } @router.get("/tasks") diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 8a36cd0..c4362e0 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -785,7 +785,11 @@ export interface TaskQueueList { } export const intelligentEvalsApi = { - list: () => api.get<{ intelligent_evals: IntelligentEval[] }>('/intelligent-evals'), + list: (params?: { page?: number; page_size?: number }) => + api.get<{ intelligent_evals: IntelligentEval[]; total?: number; page?: number; page_size?: number }>( + '/intelligent-evals', + { params }, + ), listTasks: (params?: { status?: TaskQueueStatus; limit?: number }) => api.get('/intelligent-evals/tasks', { params }), get: (id: string) => api.get(`/intelligent-evals/${id}`), diff --git a/frontend/web/src/pages/IntelligentEvals.tsx b/frontend/web/src/pages/IntelligentEvals.tsx index be20885..3477706 100644 --- a/frontend/web/src/pages/IntelligentEvals.tsx +++ b/frontend/web/src/pages/IntelligentEvals.tsx @@ -38,10 +38,12 @@ export default function IntelligentEvalsPage() { const [selectedId, setSelectedId] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [taskQueueOpen, setTaskQueueOpen] = useState(false) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) const [submitting, setSubmitting] = useState(false) const [form] = Form.useForm() - const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(selectedId) + const { list, detail, reloadList, reloadDetail } = useIntelligentEvalRead(selectedId, undefined, page, pageSize) const evals = list.value const loading = list.phase === 'loading' const { data: targets } = useResource( @@ -173,7 +175,18 @@ export default function IntelligentEvalsPage() { loading={loading} dataSource={evals ?? []} columns={columns} - pagination={(evals?.length ?? 0) > 20 ? { pageSize: 20, showTotal: (t) => `共 ${t} 个` } : false} + pagination={{ + current: page, + pageSize, + total: list.total, + showSizeChanger: true, + pageSizeOptions: [10, 20, 50], + showTotal: (t) => `共 ${t} 个`, + onChange: (p, ps) => { + setPage(p) + setPageSize(ps) + }, + }} locale={{ emptyText: }} /> diff --git a/frontend/web/src/read/intelligentEval.test.ts b/frontend/web/src/read/intelligentEval.test.ts index b354cb7..efc798e 100644 --- a/frontend/web/src/read/intelligentEval.test.ts +++ b/frontend/web/src/read/intelligentEval.test.ts @@ -12,20 +12,25 @@ describe('intelligent evaluation read state', () => { const loading = intelligentEvalReadReducer(initialIntelligentEvalReadState, { type: 'list_requested' }) expect(loading.list.phase).toBe('loading') - const ready = intelligentEvalReadReducer(loading, { type: 'list_succeeded', value: [evaluation] }) - expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null }) + const ready = intelligentEvalReadReducer(loading, { + type: 'list_succeeded', + value: [evaluation], + total: 42, + }) + expect(ready.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 42 }) }) it('keeps the last list snapshot during silent refresh and failure', () => { const ready: IntelligentEvalReadState = { ...initialIntelligentEvalReadState, - list: { phase: 'ready', value: [evaluation], error: null }, + list: { phase: 'ready', value: [evaluation], error: null, total: 1 }, } const refreshing = intelligentEvalReadReducer(ready, { type: 'list_requested', silent: true }) expect(refreshing.list.phase).toBe('refreshing') + expect(refreshing.list.total).toBe(1) const afterFailure = intelligentEvalReadReducer(refreshing, { type: 'list_failed', error: '网络错误' }) - expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null }) + expect(afterFailure.list).toEqual({ phase: 'ready', value: [evaluation], error: null, total: 1 }) }) it('surfaces an initial detail failure without an existing snapshot', () => { diff --git a/frontend/web/src/read/intelligentEval.ts b/frontend/web/src/read/intelligentEval.ts index 08595aa..9e6966f 100644 --- a/frontend/web/src/read/intelligentEval.ts +++ b/frontend/web/src/read/intelligentEval.ts @@ -9,7 +9,7 @@ export interface ReadSlot { } export interface IntelligentEvalReadState { - list: ReadSlot + list: ReadSlot & { total: number } detail: ReadSlot & { selectedId: string | null requestId: number @@ -18,7 +18,7 @@ export interface IntelligentEvalReadState { export type IntelligentEvalReadAction = | { type: 'list_requested'; silent?: boolean } - | { type: 'list_succeeded'; value: IntelligentEval[] } + | { type: 'list_succeeded'; value: IntelligentEval[]; total: number } | { type: 'list_failed'; error: string } | { type: 'detail_cleared'; requestId: number } | { type: 'detail_requested'; id: string; requestId: number; silent?: boolean } @@ -26,21 +26,28 @@ export type IntelligentEvalReadAction = | { type: 'detail_failed'; id: string; requestId: number; error: string } export interface IntelligentEvalReadAdapter { - list: () => Promise + list: (page?: number, pageSize?: number) => Promise<{ items: IntelligentEval[]; total: number }> get: (id: string) => Promise } export const intelligentEvalReadAdapter: IntelligentEvalReadAdapter = { - list: () => intelligentEvalsApi.list().then((response) => response.data.intelligent_evals), + list: (page, pageSize) => + intelligentEvalsApi + .list(page != null ? { page, page_size: pageSize ?? 20 } : undefined) + .then((response) => ({ + items: response.data.intelligent_evals, + // 兼容不分页响应:无 total 时以本页长度为总数 + total: response.data.total ?? response.data.intelligent_evals.length, + })), get: (id) => intelligentEvalsApi.get(id).then((response) => response.data), } export const initialIntelligentEvalReadState: IntelligentEvalReadState = { - list: { phase: 'idle', value: [], error: null }, + list: { phase: 'idle', value: [], error: null, total: 0 }, detail: { phase: 'idle', value: null, error: null, selectedId: null, requestId: 0 }, } -function requestPhase(slot: ReadSlot, silent: boolean | undefined): ReadSlot { +function requestPhase>(slot: S, silent: boolean | undefined): S { return { ...slot, phase: silent && slot.phase === 'ready' ? 'refreshing' : 'loading', error: null } } @@ -52,7 +59,7 @@ export function intelligentEvalReadReducer( case 'list_requested': return { ...state, list: requestPhase(state.list, action.silent) } case 'list_succeeded': - return { ...state, list: { phase: 'ready', value: action.value, error: null } } + return { ...state, list: { phase: 'ready', value: action.value, error: null, total: action.total } } case 'list_failed': return { ...state, diff --git a/frontend/web/src/read/useIntelligentEvalRead.test.tsx b/frontend/web/src/read/useIntelligentEvalRead.test.tsx index 09b2b4a..87ed795 100644 --- a/frontend/web/src/read/useIntelligentEvalRead.test.tsx +++ b/frontend/web/src/read/useIntelligentEvalRead.test.tsx @@ -28,19 +28,20 @@ describe('useIntelligentEvalRead', () => { it('refreshes active evaluations every five seconds and stops at terminal state', async () => { vi.useFakeTimers() const adapter: IntelligentEvalReadAdapter = { - list: vi.fn().mockResolvedValue([evaluation]), + list: vi.fn().mockResolvedValue({ items: [evaluation], total: 1 }), get: vi.fn().mockResolvedValue(evaluation), } - renderHook(() => useIntelligentEvalRead(null, adapter)) + const { result } = renderHook(() => useIntelligentEvalRead(null, adapter)) await settle() expect(adapter.list).toHaveBeenCalledTimes(1) + expect(result.current.list.total).toBe(1) await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) await settle() expect(adapter.list).toHaveBeenCalledTimes(2) const terminal = { ...evaluation, status: 'completed' } as IntelligentEval - vi.mocked(adapter.list).mockResolvedValue([terminal]) + vi.mocked(adapter.list).mockResolvedValue({ items: [terminal], total: 1 }) await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) await settle() await act(async () => { await vi.advanceTimersByTimeAsync(10000) }) @@ -51,7 +52,7 @@ describe('useIntelligentEvalRead', () => { vi.useFakeTimers() const adapter: IntelligentEvalReadAdapter = { list: vi.fn() - .mockResolvedValueOnce([evaluation]) + .mockResolvedValueOnce({ items: [evaluation], total: 1 }) .mockRejectedValueOnce(new Error('网络错误')), get: vi.fn().mockResolvedValue(evaluation), } @@ -65,12 +66,29 @@ describe('useIntelligentEvalRead', () => { expect(result.current.list.error).toBeNull() }) + it('passes page/pageSize to the adapter and reloads on change', async () => { + const adapter: IntelligentEvalReadAdapter = { + list: vi.fn().mockResolvedValue({ items: [], total: 0 }), + get: vi.fn().mockResolvedValue(evaluation), + } + const { rerender } = renderHook( + ({ page }) => useIntelligentEvalRead(null, adapter, page, 20), + { initialProps: { page: 1 } }, + ) + await settle() + expect(adapter.list).toHaveBeenCalledWith(1, 20) + + rerender({ page: 2 }) + await settle() + expect(adapter.list).toHaveBeenCalledWith(2, 20) + }) + it('clears the old detail and ignores a response for the previous selection', async () => { const first = deferred() const second = deferred() const secondEvaluation = { ...evaluation, id: 'eval-2', name: '评估二' } as IntelligentEval const adapter: IntelligentEvalReadAdapter = { - list: vi.fn().mockResolvedValue([]), + list: vi.fn().mockResolvedValue({ items: [], total: 0 }), get: vi.fn((id: string) => (id === 'eval-1' ? first.promise : second.promise)), } const { result, rerender } = renderHook( @@ -91,7 +109,7 @@ describe('useIntelligentEvalRead', () => { it('surfaces failure for a newly selected detail instead of retaining the old snapshot', async () => { const adapter: IntelligentEvalReadAdapter = { - list: vi.fn().mockResolvedValue([]), + list: vi.fn().mockResolvedValue({ items: [], total: 0 }), get: vi.fn() .mockResolvedValueOnce(evaluation) .mockRejectedValueOnce(new Error('不存在')), diff --git a/frontend/web/src/read/useIntelligentEvalRead.ts b/frontend/web/src/read/useIntelligentEvalRead.ts index 19bf80d..0020e29 100644 --- a/frontend/web/src/read/useIntelligentEvalRead.ts +++ b/frontend/web/src/read/useIntelligentEvalRead.ts @@ -21,6 +21,8 @@ function isActive(status: string | undefined): boolean { export function useIntelligentEvalRead( selectedId: string | null, adapter: IntelligentEvalReadAdapter = intelligentEvalReadAdapter, + page: number = 1, + pageSize: number = 20, ): IntelligentEvalReadState & { reloadList: () => Promise; reloadDetail: () => Promise } { const [state, dispatch] = useReducer(intelligentEvalReadReducer, initialIntelligentEvalReadState) const detailRequestId = useRef(0) @@ -28,12 +30,12 @@ export function useIntelligentEvalRead( const loadList = useCallback(async (silent = false) => { dispatch({ type: 'list_requested', silent }) try { - const value = await adapter.list() - dispatch({ type: 'list_succeeded', value }) + const { items, total } = await adapter.list(page, pageSize) + dispatch({ type: 'list_succeeded', value: items, total }) } catch (error) { dispatch({ type: 'list_failed', error: errorMessage(error) }) } - }, [adapter]) + }, [adapter, page, pageSize]) const loadDetail = useCallback(async (id: string, silent = false) => { const requestId = ++detailRequestId.current diff --git a/tests/integration/test_intelligent_eval_task_queue_api.py b/tests/integration/test_intelligent_eval_task_queue_api.py index f8f9d2f..136b920 100644 --- a/tests/integration/test_intelligent_eval_task_queue_api.py +++ b/tests/integration/test_intelligent_eval_task_queue_api.py @@ -274,3 +274,63 @@ def test_list_tasks_not_shadowed_by_eval_id(client: TestClient): response = client.get("/api/intelligent-evals/tasks") assert response.status_code == 200 assert "tasks" in response.json() + + +def _make_eval(db_session: Session, name: str, days_ago: int) -> IntelligentEvalDB: + from agenteval.storage.db import utc_now + + ev = IntelligentEvalDB( + name=name, + target_id="target1", + status=IntelligentEvalStatus.COMPLETED.value, + created_at=utc_now() - timedelta(days=days_ago), + started_at=utc_now() - timedelta(days=days_ago), + ) + db_session.add(ev) + return ev + + +def test_list_evals_backward_compatible_no_page(client: TestClient, db_session: Session): + """Without page param the endpoint returns everything (no total key).""" + _make_eval(db_session, "A", days_ago=3) + _make_eval(db_session, "B", days_ago=2) + db_session.commit() + + response = client.get("/api/intelligent-evals") + assert response.status_code == 200 + data = response.json() + assert len(data["intelligent_evals"]) == 2 + assert "total" not in data + + +def test_list_evals_pagination(client: TestClient, db_session: Session): + """page/page_size returns one slice (newest first) plus total.""" + _make_eval(db_session, "oldest", days_ago=3) + _make_eval(db_session, "middle", days_ago=2) + _make_eval(db_session, "newest", days_ago=1) + db_session.commit() + + response = client.get("/api/intelligent-evals?page=1&page_size=2") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 3 + assert data["page"] == 1 + assert data["page_size"] == 2 + names = [e["name"] for e in data["intelligent_evals"]] + assert names == ["newest", "middle"] # created_at desc + + response = client.get("/api/intelligent-evals?page=2&page_size=2") + names = [e["name"] for e in response.json()["intelligent_evals"]] + assert names == ["oldest"] + + +def test_list_evals_page_size_clamped(client: TestClient, db_session: Session): + """page_size is clamped to [1, 100].""" + for i in range(5): + _make_eval(db_session, f"eval{i}", days_ago=10 - i) + db_session.commit() + + response = client.get("/api/intelligent-evals?page=1&page_size=0") + data = response.json() + assert data["page_size"] == 1 + assert len(data["intelligent_evals"]) == 1