From ad38c706597904bd2d5687ff31e8e65b4b619e95 Mon Sep 17 00:00:00 2001 From: Jorben Date: Thu, 5 Mar 2026 15:35:11 +0800 Subject: [PATCH 1/3] feat(retry): add model switch for task/page retry flows --- package.json | 2 +- .../application/workers/ConverterWorker.ts | 22 ++- .../workers/__tests__/ConverterWorker.test.ts | 14 +- .../infrastructure/services/CloudService.ts | 28 ++- .../services/__tests__/CloudService.test.ts | 31 ++++ src/main/ipc/__tests__/handlers.test.ts | 1 + .../handlers/__tests__/cloud.handler.test.ts | 13 +- .../handlers/__tests__/task.handler.test.ts | 141 ++++++++++++++- .../__tests__/taskDetail.handler.test.ts | 45 +++++ src/main/ipc/handlers/cloud.handler.ts | 9 +- src/main/ipc/handlers/task.handler.ts | 145 +++++++++++++++ src/main/ipc/handlers/taskDetail.handler.ts | 52 +++++- src/preload/electron.d.ts | 7 +- src/preload/index.ts | 12 +- src/renderer/contexts/CloudContext.tsx | 12 +- .../contexts/CloudContextDefinition.ts | 5 +- .../contexts/__tests__/CloudContext.test.tsx | 4 + src/renderer/electron.d.ts | 23 ++- src/renderer/locales/en-US/cloud-preview.json | 8 + src/renderer/locales/en-US/list.json | 12 ++ src/renderer/locales/en-US/settings.json | 5 + src/renderer/locales/zh-CN/cloud-preview.json | 8 + src/renderer/locales/zh-CN/list.json | 12 ++ src/renderer/locales/zh-CN/settings.json | 5 + src/renderer/pages/CloudPreview.tsx | 104 ++++++++--- src/renderer/pages/List.tsx | 137 ++++++++++++-- src/renderer/pages/Preview.tsx | 168 ++++++++++++++---- .../pages/__tests__/CloudPreview.test.tsx | 66 ++++++- src/renderer/pages/__tests__/List.test.tsx | 13 +- src/renderer/pages/__tests__/Preview.test.tsx | 50 +++++- src/shared/ipc/channels.ts | 1 + tests/setup.renderer.ts | 2 + 32 files changed, 1048 insertions(+), 109 deletions(-) diff --git a/package.json b/package.json index e3f80ee..26617f7 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "migrate:reset": "prisma migrate reset --schema=./src/core/infrastructure/db/schema.prisma", "generate": "prisma generate --schema=./src/core/infrastructure/db/schema.prisma", "logo": "electron-icon-builder --input=./src/renderer/assets/logo.png --output=./public", - "test": "vitest", + "test": "vitest run", "test:unit": "vitest run --config vitest.config.ts", "test:renderer": "vitest run --config vitest.config.renderer.ts", "test:watch": "vitest watch", diff --git a/src/core/application/workers/ConverterWorker.ts b/src/core/application/workers/ConverterWorker.ts index 1061838..05cb659 100644 --- a/src/core/application/workers/ConverterWorker.ts +++ b/src/core/application/workers/ConverterWorker.ts @@ -370,7 +370,14 @@ export class ConverterWorker extends WorkerBase { // Step 2: Check task is not cancelled const task = await tx.task.findUnique({ where: { id: page.task }, - select: { status: true, pages: true, completed_count: true, failed_count: true }, + select: { + status: true, + pages: true, + completed_count: true, + failed_count: true, + provider: true, + model: true, + }, }); if (!task) { @@ -393,6 +400,8 @@ export class ConverterWorker extends WorkerBase { completed_at: new Date(), worker_id: null, // Release worker error: null, + provider: task.provider, + model: task.model, }, }); @@ -475,7 +484,14 @@ export class ConverterWorker extends WorkerBase { // Step 2: Check task is not cancelled const task = await tx.task.findUnique({ where: { id: page.task }, - select: { status: true, pages: true, completed_count: true, failed_count: true }, + select: { + status: true, + pages: true, + completed_count: true, + failed_count: true, + provider: true, + model: true, + }, }); if (!task) { @@ -494,6 +510,8 @@ export class ConverterWorker extends WorkerBase { error: errorMessage, completed_at: new Date(), worker_id: null, // Release worker + provider: task.provider, + model: task.model, }, }); diff --git a/src/core/application/workers/__tests__/ConverterWorker.test.ts b/src/core/application/workers/__tests__/ConverterWorker.test.ts index 04ef0ca..64e27eb 100644 --- a/src/core/application/workers/__tests__/ConverterWorker.test.ts +++ b/src/core/application/workers/__tests__/ConverterWorker.test.ts @@ -585,6 +585,8 @@ describe('ConverterWorker', () => { }; it('should update page status to COMPLETED', async () => { + let pageUpdateData: any; + vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => { const tx = { taskDetail: { @@ -592,7 +594,9 @@ describe('ConverterWorker', () => { worker_id: worker.getWorkerId(), status: PageStatus.PROCESSING, }), - update: vi.fn(), + update: vi.fn().mockImplementation((params: any) => { + pageUpdateData = params.data; + }), }, task: { findUnique: vi.fn().mockResolvedValue({ @@ -600,6 +604,8 @@ describe('ConverterWorker', () => { pages: 10, completed_count: 5, failed_count: 0, + provider: 1, + model: 'gpt-4o', }), update: vi.fn().mockResolvedValue({ completed_count: 6, @@ -618,6 +624,8 @@ describe('ConverterWorker', () => { await (worker as any).completePageSuccess(mockPage, mockResult); expect(prisma.$transaction).toHaveBeenCalled(); + expect(pageUpdateData.provider).toBe(1); + expect(pageUpdateData.model).toBe('gpt-4o'); }); it('should skip if page already completed (idempotency)', async () => { @@ -823,6 +831,8 @@ describe('ConverterWorker', () => { pages: 10, completed_count: 5, failed_count: 1, + provider: 9, + model: 'claude-3-7-sonnet', }), update: vi.fn().mockResolvedValue({ failed_count: 2 }), }, @@ -839,6 +849,8 @@ describe('ConverterWorker', () => { expect(pageUpdateData.status).toBe(PageStatus.FAILED); expect(pageUpdateData.error).toBe('Test error'); + expect(pageUpdateData.provider).toBe(9); + expect(pageUpdateData.model).toBe('claude-3-7-sonnet'); }); it('should set task to FAILED when all pages failed', async () => { diff --git a/src/core/infrastructure/services/CloudService.ts b/src/core/infrastructure/services/CloudService.ts index 414b7aa..8c4c75e 100644 --- a/src/core/infrastructure/services/CloudService.ts +++ b/src/core/infrastructure/services/CloudService.ts @@ -12,6 +12,7 @@ import type { CloudCancelTaskResponse, CloudRetryPageResponse, CloudApiPagination, + CloudModelTier, PaymentCheckoutApiResponse, PaymentCheckoutStatusApiResponse, PaymentHistoryApiItem, @@ -433,15 +434,23 @@ class CloudService { /** * Retry an entire task (creates a new task) */ - public async retryTask(id: string): Promise<{ + public async retryTask(id: string, model?: CloudModelTier): Promise<{ success: boolean; data?: CreateTaskResponse; error?: string; }> { try { - const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/retry`, { - method: 'POST', - }); + const hasModelOverride = typeof model === 'string' && model.length > 0; + const res = await authManager.fetchWithAuth( + `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/retry`, + hasModelOverride + ? { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model }), + } + : { method: 'POST' } + ); if (!res.ok) { const errorBody = await res.json().catch(() => null); @@ -469,15 +478,22 @@ class CloudService { /** * Retry a single page */ - public async retryPage(taskId: string, pageNumber: number): Promise<{ + public async retryPage(taskId: string, pageNumber: number, model?: CloudModelTier): Promise<{ success: boolean; data?: CloudRetryPageResponse; error?: string; }> { try { + const hasModelOverride = typeof model === 'string' && model.length > 0; const res = await authManager.fetchWithAuth( `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(taskId)}/pages/${encodeURIComponent(String(pageNumber))}/retry`, - { method: 'POST' }, + hasModelOverride + ? { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model }), + } + : { method: 'POST' }, ); if (!res.ok) { diff --git a/src/core/infrastructure/services/__tests__/CloudService.test.ts b/src/core/infrastructure/services/__tests__/CloudService.test.ts index a30d0e6..baa805b 100644 --- a/src/core/infrastructure/services/__tests__/CloudService.test.ts +++ b/src/core/infrastructure/services/__tests__/CloudService.test.ts @@ -205,6 +205,37 @@ describe('CloudService', () => { }) }) + it('retryTask/retryPage send model override payload when model is provided', async () => { + const cloudService = (await import('../CloudService.js')).default + + mockAuthManager.fetchWithAuth + .mockResolvedValueOnce(makeJsonResponse(200, { success: true, data: { task_id: 'task-2', events_url: '/events' } })) + .mockResolvedValueOnce(makeJsonResponse(200, { success: true, data: { task_id: 'task-1', page: 3, status: 'queued' } })) + + await cloudService.retryTask('task-1', 'pro') + await cloudService.retryPage('task-1', 3, 'ultra') + + const retryTaskCall = mockAuthManager.fetchWithAuth.mock.calls[0] + expect(retryTaskCall[0]).toContain('/api/v1/tasks/task-1/retry') + expect(retryTaskCall[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'pro' }), + }) + ) + + const retryPageCall = mockAuthManager.fetchWithAuth.mock.calls[1] + expect(retryPageCall[0]).toContain('/api/v1/tasks/task-1/pages/3/retry') + expect(retryPageCall[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'ultra' }), + }) + ) + }) + it('cancelTask/retryTask/retryPage/deleteTask return API errors when non-OK', async () => { const cloudService = (await import('../CloudService.js')).default mockAuthManager.fetchWithAuth diff --git a/src/main/ipc/__tests__/handlers.test.ts b/src/main/ipc/__tests__/handlers.test.ts index 1a82d74..e1beda8 100644 --- a/src/main/ipc/__tests__/handlers.test.ts +++ b/src/main/ipc/__tests__/handlers.test.ts @@ -139,6 +139,7 @@ vi.mock('../../../shared/ipc/channels.js', () => ({ GET_ALL: 'task:getAll', GET_BY_ID: 'task:getById', UPDATE: 'task:update', + RETRY: 'task:retry', DELETE: 'task:delete', HAS_RUNNING: 'task:hasRunningTasks', }, diff --git a/src/main/ipc/handlers/__tests__/cloud.handler.test.ts b/src/main/ipc/handlers/__tests__/cloud.handler.test.ts index a35fc62..98029bd 100644 --- a/src/main/ipc/handlers/__tests__/cloud.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/cloud.handler.test.ts @@ -170,10 +170,21 @@ describe('cloud.handler', () => { expect(mockCloudService.getTasks).toHaveBeenCalledWith(2, 20) expect(mockCloudService.getTaskById).toHaveBeenCalledWith('t1') - expect(mockCloudService.retryTask).toHaveBeenCalledWith('t1') + expect(mockCloudService.retryTask).toHaveBeenCalledWith('t1', undefined) expect(mockCloudService.deleteTask).toHaveBeenCalledWith('t1') }) + it('passes model override for retryTask and retryPage', async () => { + mockCloudService.retryTask.mockResolvedValueOnce({ success: true, data: { task_id: 'new' } }) + mockCloudService.retryPage.mockResolvedValueOnce({ success: true, data: { task_id: 't1', page: 1, status: 1 } }) + + await handlers.get('cloud:retryTask')!({}, { id: 't1', model: 'pro' }) + await handlers.get('cloud:retryPage')!({}, { taskId: 't1', pageNumber: 1, model: 'ultra' }) + + expect(mockCloudService.retryTask).toHaveBeenCalledWith('t1', 'pro') + expect(mockCloudService.retryPage).toHaveBeenCalledWith('t1', 1, 'ultra') + }) + describe('cloud:downloadPdf', () => { it('returns service error when download fails', async () => { mockCloudService.downloadPdf.mockResolvedValueOnce({ success: false, error: 'bad' }) diff --git a/src/main/ipc/handlers/__tests__/task.handler.test.ts b/src/main/ipc/handlers/__tests__/task.handler.test.ts index 8c9f472..bc63353 100644 --- a/src/main/ipc/handlers/__tests__/task.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/task.handler.test.ts @@ -19,9 +19,20 @@ const mockEventBus = { } const mockPrisma = { + $transaction: vi.fn(), task: { count: vi.fn() - } + }, + taskDetail: { + count: vi.fn(), + updateMany: vi.fn(), + }, + provider: { + findUnique: vi.fn(), + }, + model: { + findUnique: vi.fn(), + }, } const mockIpcMain = { @@ -64,7 +75,8 @@ vi.mock('../../../../shared/types/TaskStatus.js', () => ({ READY_TO_MERGE: 4, MERGING: 5, COMPLETED: 6, - CANCELLED: 7 + CANCELLED: 7, + PARTIAL_FAILED: 8, } })) @@ -75,6 +87,7 @@ vi.mock('../../../../shared/ipc/channels.js', () => ({ GET_ALL: 'task:getAll', GET_BY_ID: 'task:getById', UPDATE: 'task:update', + RETRY: 'task:retry', DELETE: 'task:delete', HAS_RUNNING: 'task:hasRunningTasks' } @@ -290,6 +303,130 @@ describe('Task Handler', () => { }) }) + describe('task:retry', () => { + it('should retry task with model override', async () => { + const taskDetailUpdateMany = vi.fn().mockResolvedValue({ count: 3 }) + const taskUpdate = vi.fn().mockResolvedValue({ + id: 'task-1', + status: 3, + provider: 2, + model: 'gpt-4.1', + }) + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 0, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + update: taskUpdate, + }, + taskDetail: { + count: vi.fn().mockResolvedValue(3), + updateMany: taskDetailUpdateMany, + }, + provider: { + findUnique: vi.fn().mockResolvedValue({ id: 2, name: 'Anthropic', status: 0 }), + }, + model: { + findUnique: vi.fn().mockResolvedValue({ id: 'gpt-4.1', name: 'GPT-4.1', provider: 2 }), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1', providerId: 2, modelId: 'gpt-4.1' }) + + expect(result.success).toBe(true) + expect(taskDetailUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { task: 'task-1' }, + data: expect.objectContaining({ + provider: 2, + model: 'gpt-4.1', + }), + }) + ) + expect(taskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'task-1' }, + data: expect.objectContaining({ + provider: 2, + model: 'gpt-4.1', + status: 3, + }), + }) + ) + expect(mockEventBus.emitTaskEvent).toHaveBeenCalledWith( + 'task:status_changed', + expect.objectContaining({ + taskId: 'task-1', + task: { status: 3 }, + }) + ) + }) + + it('should return error when taskId is missing', async () => { + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: '' }) + + expect(result).toEqual({ + success: false, + error: 'Task ID is required' + }) + }) + + it('should return error when model override params are incomplete', async () => { + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1', providerId: 1 }) + + expect(result).toEqual({ + success: false, + error: 'providerId and modelId must be provided together' + }) + }) + + it('should reject non-retryable task status', async () => { + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 3, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + }, + taskDetail: { + count: vi.fn(), + updateMany: vi.fn(), + }, + provider: { + findUnique: vi.fn(), + }, + model: { + findUnique: vi.fn(), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1' }) + + expect(result).toEqual({ + success: false, + error: 'Task is not retryable' + }) + }) + }) + describe('task:delete', () => { it('should delete task and files and emit event', async () => { mockFileLogic.deleteTaskFiles.mockResolvedValue(undefined) diff --git a/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts b/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts index 986c9dc..b482381 100644 --- a/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts @@ -287,6 +287,51 @@ describe('TaskDetail Handler', () => { error: 'Task is cancelled, cannot retry' }) }) + + it('should retry page with model override', async () => { + const mockPage = { id: 1, task: 'task-1', status: -1, provider: 1, model: 'gpt-4o' } // FAILED + const mockTask = { id: 'task-1', status: 6, progress: 90, pages: 10 } + const updatedPage = { ...mockPage, status: 0, provider: 2, model: 'claude-3-7-sonnet' } + const updatedTask = { ...mockTask, status: 3 } // PROCESSING + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + taskDetail: { + findUnique: vi.fn().mockResolvedValue(mockPage), + update: vi.fn().mockResolvedValue(updatedPage) + }, + task: { + findUnique: vi.fn().mockResolvedValue(mockTask), + update: vi.fn().mockResolvedValue(updatedTask) + }, + provider: { + findUnique: vi.fn().mockResolvedValue({ id: 2, status: 0 }), + }, + model: { + findUnique: vi.fn().mockResolvedValue({ id: 'claude-3-7-sonnet', provider: 2 }), + }, + } + return callback(tx) + }) + + const handler = handlers.get('taskDetail:retry') + const result = await handler!({}, { pageId: 1, providerId: 2, modelId: 'claude-3-7-sonnet' }) + + expect(result).toEqual({ + success: true, + data: updatedPage + }) + }) + + it('should return error when model override params are incomplete', async () => { + const handler = handlers.get('taskDetail:retry') + const result = await handler!({}, { pageId: 1, providerId: 2 }) + + expect(result).toEqual({ + success: false, + error: 'providerId and modelId must be provided together' + }) + }) }) describe('taskDetail:retryFailed', () => { diff --git a/src/main/ipc/handlers/cloud.handler.ts b/src/main/ipc/handlers/cloud.handler.ts index b1d62b1..bd94c6b 100644 --- a/src/main/ipc/handlers/cloud.handler.ts +++ b/src/main/ipc/handlers/cloud.handler.ts @@ -110,9 +110,10 @@ export function registerCloudHandlers() { /** * Retry task */ - ipcMain.handle('cloud:retryTask', async (_, id: string) => { + ipcMain.handle('cloud:retryTask', async (_, params: string | { id: string; model?: 'lite' | 'pro' | 'ultra' }) => { try { - return await cloudService.retryTask(id); + const payload = typeof params === 'string' ? { id: params } : params; + return await cloudService.retryTask(payload.id, payload.model); } catch (error) { console.error('[IPC] cloud:retryTask error:', error); return { @@ -140,9 +141,9 @@ export function registerCloudHandlers() { /** * Retry single page */ - ipcMain.handle('cloud:retryPage', async (_, params: { taskId: string; pageNumber: number }) => { + ipcMain.handle('cloud:retryPage', async (_, params: { taskId: string; pageNumber: number; model?: 'lite' | 'pro' | 'ultra' }) => { try { - return await cloudService.retryPage(params.taskId, params.pageNumber); + return await cloudService.retryPage(params.taskId, params.pageNumber, params.model); } catch (error) { console.error('[IPC] cloud:retryPage error:', error); return { diff --git a/src/main/ipc/handlers/task.handler.ts b/src/main/ipc/handlers/task.handler.ts index 81bde7c..5dce4a1 100644 --- a/src/main/ipc/handlers/task.handler.ts +++ b/src/main/ipc/handlers/task.handler.ts @@ -5,6 +5,7 @@ import fileLogic from "../../../core/infrastructure/services/FileService.js"; import { eventBus, TaskEventType } from '../../../core/shared/events/EventBus.js'; import { prisma } from '../../../core/infrastructure/db/index.js'; import { TaskStatus } from '../../../shared/types/TaskStatus.js'; +import { PageStatus } from '../../../shared/types/PageStatus.js'; import { IPC_CHANNELS } from "../../../shared/ipc/channels.js"; import type { IpcResponse } from "../../../shared/ipc/responses.js"; @@ -118,6 +119,150 @@ export function registerTaskHandlers() { } ); + /** + * Retry task with optional model override + */ + ipcMain.handle( + IPC_CHANNELS.TASK.RETRY, + async ( + _, + params: string | { taskId: string; providerId?: number; modelId?: string } + ): Promise => { + try { + const payload = typeof params === 'string' ? { taskId: params } : params; + const taskId = payload?.taskId; + + if (!taskId) { + return { success: false, error: "Task ID is required" }; + } + + const hasProviderOverride = payload?.providerId !== undefined; + const hasModelOverride = payload?.modelId !== undefined; + const hasAnyModelOverride = hasProviderOverride || hasModelOverride; + + if (hasAnyModelOverride && (!hasProviderOverride || !hasModelOverride)) { + return { success: false, error: "providerId and modelId must be provided together" }; + } + + const updatedTask = await prisma.$transaction(async (tx) => { + const task = await tx.task.findUnique({ + where: { id: taskId }, + }); + + if (!task) { + throw new Error("Task not found"); + } + + const retryableStatuses = [ + TaskStatus.FAILED, + TaskStatus.COMPLETED, + TaskStatus.CANCELLED, + TaskStatus.PARTIAL_FAILED, + ]; + + if (!retryableStatuses.includes(task.status)) { + throw new Error("Task is not retryable"); + } + + let targetProvider = task.provider; + let targetModel = task.model; + let targetModelName = task.model_name; + + if (hasAnyModelOverride) { + const providerId = payload.providerId as number; + const modelId = payload.modelId as string; + + const provider = await tx.provider.findUnique({ + where: { id: providerId }, + select: { id: true, name: true, status: true }, + }); + + if (!provider || provider.status !== 0) { + throw new Error("Provider not found or disabled"); + } + + const model = await tx.model.findUnique({ + where: { + id_provider: { + id: modelId, + provider: providerId, + }, + }, + select: { id: true, name: true, provider: true }, + }); + + if (!model) { + throw new Error("Model not found for provider"); + } + + targetProvider = providerId; + targetModel = modelId; + targetModelName = `${model.name} | ${provider.name}`; + } + + const detailCount = await tx.taskDetail.count({ + where: { task: taskId }, + }); + + if (detailCount > 0) { + await tx.taskDetail.updateMany({ + where: { task: taskId }, + data: { + status: PageStatus.PENDING, + retry_count: 0, + error: null, + worker_id: null, + started_at: null, + completed_at: null, + input_tokens: 0, + output_tokens: 0, + conversion_time: 0, + content: "", + provider: targetProvider, + model: targetModel, + }, + }); + } + + return await tx.task.update({ + where: { id: taskId }, + data: { + provider: targetProvider, + model: targetModel, + model_name: targetModelName, + status: detailCount > 0 ? TaskStatus.PROCESSING : TaskStatus.PENDING, + progress: 0, + completed_count: 0, + failed_count: 0, + error: null, + merged_path: null, + worker_id: null, + }, + }); + }, { + isolationLevel: 'Serializable', + }); + + eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, { + taskId, + task: updatedTask, + timestamp: Date.now(), + }); + + eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, { + taskId, + task: { status: updatedTask.status }, + timestamp: Date.now(), + }); + + return { success: true, data: updatedTask }; + } catch (error: any) { + console.error("[IPC] task:retry error:", error); + return { success: false, error: error.message }; + } + } + ); + /** * Delete task */ diff --git a/src/main/ipc/handlers/taskDetail.handler.ts b/src/main/ipc/handlers/taskDetail.handler.ts index 1f7bb77..d7ec18f 100644 --- a/src/main/ipc/handlers/taskDetail.handler.ts +++ b/src/main/ipc/handlers/taskDetail.handler.ts @@ -79,12 +79,26 @@ export function registerTaskDetailHandlers() { */ ipcMain.handle( IPC_CHANNELS.TASK_DETAIL.RETRY, - async (_, pageId: number): Promise => { + async ( + _, + params: number | { pageId: number; providerId?: number; modelId?: string } + ): Promise => { try { + const payload = typeof params === 'number' ? { pageId: params } : params; + const pageId = payload?.pageId; + if (!pageId) { return { success: false, error: "Page ID is required" }; } + const hasProviderOverride = payload?.providerId !== undefined; + const hasModelOverride = payload?.modelId !== undefined; + const hasAnyModelOverride = hasProviderOverride || hasModelOverride; + + if (hasAnyModelOverride && (!hasProviderOverride || !hasModelOverride)) { + return { success: false, error: "providerId and modelId must be provided together" }; + } + const result = await prisma.$transaction(async (tx) => { // Step 1: Find the page const page = await tx.taskDetail.findUnique({ @@ -113,6 +127,40 @@ export function registerTaskDetailHandlers() { throw new Error("Task is cancelled, cannot retry"); } + let targetProvider = page.provider; + let targetModel = page.model; + + if (hasAnyModelOverride) { + const providerId = payload.providerId as number; + const modelId = payload.modelId as string; + + const provider = await tx.provider.findUnique({ + where: { id: providerId }, + select: { id: true, status: true }, + }); + + if (!provider || provider.status !== 0) { + throw new Error("Provider not found or disabled"); + } + + const model = await tx.model.findUnique({ + where: { + id_provider: { + id: modelId, + provider: providerId, + }, + }, + select: { id: true }, + }); + + if (!model) { + throw new Error("Model not found for provider"); + } + + targetProvider = providerId; + targetModel = modelId; + } + // Step 4: Update page status const updatedPage = await tx.taskDetail.update({ where: { id: pageId }, @@ -127,6 +175,8 @@ export function registerTaskDetailHandlers() { output_tokens: 0, conversion_time: 0, content: "", + provider: targetProvider, + model: targetModel, }, }); diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index 0bf72e4..5f90bca 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -84,13 +84,14 @@ interface WindowAPI { getAll: (params: { page: number; pageSize: number }) => Promise; getById: (id: string) => Promise; update: (id: string, data: any) => Promise; + retry: (params: { taskId: string; providerId?: number; modelId?: string }) => Promise; delete: (id: string) => Promise; hasRunningTasks: () => Promise; }; taskDetail: { getByPage: (taskId: string, page: number) => Promise; getAllByTask: (taskId: string) => Promise; - retry: (pageId: number) => Promise; + retry: (params: number | { pageId: number; providerId?: number; modelId?: string }) => Promise; retryFailed: (taskId: string) => Promise; }; file: { @@ -117,9 +118,9 @@ interface WindowAPI { getTaskById: (id: string) => Promise; getTaskPages: (params: { taskId: string; page?: number; pageSize?: number }) => Promise; cancelTask: (id: string) => Promise; - retryTask: (id: string) => Promise; + retryTask: (params: string | { id: string; model?: 'lite' | 'pro' | 'ultra' }) => Promise; deleteTask: (id: string) => Promise; - retryPage: (params: { taskId: string; pageNumber: number }) => Promise; + retryPage: (params: { taskId: string; pageNumber: number; model?: 'lite' | 'pro' | 'ultra' }) => Promise; getTaskResult: (id: string) => Promise; downloadPdf: (id: string) => Promise; getPageImage: (params: { taskId: string; pageNumber: number }) => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index 8dcc0ec..7643f3b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,6 +40,8 @@ contextBridge.exposeInMainWorld("api", { getById: (id: string) => ipcRenderer.invoke("task:getById", id), update: (id: string, data: any) => ipcRenderer.invoke("task:update", id, data), + retry: (params: { taskId: string; providerId?: number; modelId?: string }) => + ipcRenderer.invoke("task:retry", params), delete: (id: string) => ipcRenderer.invoke("task:delete", id), hasRunningTasks: () => ipcRenderer.invoke("task:hasRunningTasks"), }, @@ -50,8 +52,8 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("taskDetail:getByPage", taskId, page), getAllByTask: (taskId: string) => ipcRenderer.invoke("taskDetail:getAllByTask", taskId), - retry: (pageId: number) => - ipcRenderer.invoke("taskDetail:retry", pageId), + retry: (params: number | { pageId: number; providerId?: number; modelId?: string }) => + ipcRenderer.invoke("taskDetail:retry", params), retryFailed: (taskId: string) => ipcRenderer.invoke("taskDetail:retryFailed", taskId), }, @@ -99,11 +101,11 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("cloud:getTaskPages", params), cancelTask: (id: string) => ipcRenderer.invoke("cloud:cancelTask", id), - retryTask: (id: string) => - ipcRenderer.invoke("cloud:retryTask", id), + retryTask: (params: string | { id: string; model?: 'lite' | 'pro' | 'ultra' }) => + ipcRenderer.invoke("cloud:retryTask", params), deleteTask: (id: string) => ipcRenderer.invoke("cloud:deleteTask", id), - retryPage: (params: { taskId: string; pageNumber: number }) => + retryPage: (params: { taskId: string; pageNumber: number; model?: 'lite' | 'pro' | 'ultra' }) => ipcRenderer.invoke("cloud:retryPage", params), getTaskResult: (id: string) => ipcRenderer.invoke("cloud:getTaskResult", id), diff --git a/src/renderer/contexts/CloudContext.tsx b/src/renderer/contexts/CloudContext.tsx index f9f1fe7..887c4a0 100644 --- a/src/renderer/contexts/CloudContext.tsx +++ b/src/renderer/contexts/CloudContext.tsx @@ -8,7 +8,7 @@ import { CloudFileInput, CheckoutStatus, } from './CloudContextDefinition'; -import type { AuthState, DeviceFlowStatus } from '../../shared/types/cloud-api'; +import type { AuthState, CloudModelTier, DeviceFlowStatus } from '../../shared/types/cloud-api'; interface CloudProviderProps { children: ReactNode; @@ -227,9 +227,12 @@ export const CloudProvider: React.FC = ({ children }) => { } }, [isAuthenticated, refreshCredits]); - const retryTask = useCallback(async (id: string) => { + const retryTask = useCallback(async (id: string, model?: CloudModelTier) => { if (!isAuthenticated) return { success: false, error: 'User not signed in' }; try { + if (model) { + return await window.api.cloud.retryTask({ id, model }); + } return await window.api.cloud.retryTask(id); } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -245,9 +248,12 @@ export const CloudProvider: React.FC = ({ children }) => { } }, [isAuthenticated]); - const retryPage = useCallback(async (taskId: string, pageNumber: number) => { + const retryPage = useCallback(async (taskId: string, pageNumber: number, model?: CloudModelTier) => { if (!isAuthenticated) return { success: false, error: 'User not signed in' }; try { + if (model) { + return await window.api.cloud.retryPage({ taskId, pageNumber, model }); + } return await window.api.cloud.retryPage({ taskId, pageNumber }); } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; diff --git a/src/renderer/contexts/CloudContextDefinition.ts b/src/renderer/contexts/CloudContextDefinition.ts index 6466746..5b9fe6c 100644 --- a/src/renderer/contexts/CloudContextDefinition.ts +++ b/src/renderer/contexts/CloudContextDefinition.ts @@ -7,6 +7,7 @@ import type { CloudTaskResult, PaymentProviderStatus, PaymentStatus, + CloudModelTier, } from '../../shared/types/cloud-api'; export interface UserProfile { @@ -120,9 +121,9 @@ export interface CloudContextType { error?: string; }>; cancelTask: (id: string) => Promise<{ success: boolean; error?: string }>; - retryTask: (id: string) => Promise<{ success: boolean; data?: { task_id: string }; error?: string }>; + retryTask: (id: string, model?: CloudModelTier) => Promise<{ success: boolean; data?: { task_id: string }; error?: string }>; deleteTask: (id: string) => Promise<{ success: boolean; data?: { id: string; message: string }; error?: string }>; - retryPage: (taskId: string, pageNumber: number) => Promise<{ success: boolean; error?: string }>; + retryPage: (taskId: string, pageNumber: number, model?: CloudModelTier) => Promise<{ success: boolean; error?: string }>; getTaskResult: (id: string) => Promise<{ success: boolean; data?: CloudTaskResult; error?: string }>; downloadResult: (id: string) => Promise<{ success: boolean; error?: string }>; createCheckout: (amountUsd: number) => Promise<{ success: boolean; data?: CheckoutSession; error?: string }>; diff --git a/src/renderer/contexts/__tests__/CloudContext.test.tsx b/src/renderer/contexts/__tests__/CloudContext.test.tsx index 6f3e4aa..8a8ef27 100644 --- a/src/renderer/contexts/__tests__/CloudContext.test.tsx +++ b/src/renderer/contexts/__tests__/CloudContext.test.tsx @@ -432,8 +432,10 @@ describe('CloudContext', () => { await latestContext!.getTaskPages('t1', 1, 2) await latestContext!.cancelTask('t1') await latestContext!.retryTask('t1') + await latestContext!.retryTask('t1', 'pro') await latestContext!.deleteTask('t1') await latestContext!.retryPage('t1', 1) + await latestContext!.retryPage('t1', 2, 'ultra') await latestContext!.getTaskResult('t1') await latestContext!.downloadResult('t1') @@ -442,8 +444,10 @@ describe('CloudContext', () => { expect(window.api.cloud.getTaskPages).toHaveBeenCalledWith({ taskId: 't1', page: 1, pageSize: 2 }) expect(window.api.cloud.cancelTask).toHaveBeenCalledWith('t1') expect(window.api.cloud.retryTask).toHaveBeenCalledWith('t1') + expect(window.api.cloud.retryTask).toHaveBeenCalledWith({ id: 't1', model: 'pro' }) expect(window.api.cloud.deleteTask).toHaveBeenCalledWith('t1') expect(window.api.cloud.retryPage).toHaveBeenCalledWith({ taskId: 't1', pageNumber: 1 }) + expect(window.api.cloud.retryPage).toHaveBeenCalledWith({ taskId: 't1', pageNumber: 2, model: 'ultra' }) expect(window.api.cloud.getTaskResult).toHaveBeenCalledWith('t1') expect(window.api.cloud.downloadPdf).toHaveBeenCalledWith('t1') }) diff --git a/src/renderer/electron.d.ts b/src/renderer/electron.d.ts index 35684bd..8452b62 100644 --- a/src/renderer/electron.d.ts +++ b/src/renderer/electron.d.ts @@ -132,6 +132,18 @@ interface UpdateTaskDTO { [key: string]: any; } +interface RetryTaskDTO { + taskId: string; + providerId?: number; + modelId?: string; +} + +interface RetryTaskDetailDTO { + pageId: number; + providerId?: number; + modelId?: string; +} + interface TaskListResponse { list: Task[]; total: number; @@ -207,6 +219,7 @@ interface ElectronAPI { }) => Promise>; getById: (id: string) => Promise>; update: (id: string, data: UpdateTaskDTO) => Promise>; + retry: (params: RetryTaskDTO) => Promise>; delete: (id: string) => Promise>; hasRunningTasks: () => Promise>; }; @@ -214,7 +227,7 @@ interface ElectronAPI { taskDetail: { getByPage: (taskId: string, page: number) => Promise>; getAllByTask: (taskId: string) => Promise>; - retry: (pageId: number) => Promise>; + retry: (params: number | RetryTaskDetailDTO) => Promise>; retryFailed: (taskId: string) => Promise>; }; @@ -266,9 +279,13 @@ interface ElectronAPI { getTaskById: (id: string) => Promise>; getTaskPages: (params: { taskId: string; page?: number; pageSize?: number }) => Promise>; cancelTask: (id: string) => Promise>; - retryTask: (id: string) => Promise>; + retryTask: ( + params: string | { id: string; model?: import('../shared/types/cloud-api').CloudModelTier } + ) => Promise>; deleteTask: (id: string) => Promise>; - retryPage: (params: { taskId: string; pageNumber: number }) => Promise>; + retryPage: ( + params: { taskId: string; pageNumber: number; model?: import('../shared/types/cloud-api').CloudModelTier } + ) => Promise>; getTaskResult: (id: string) => Promise>; downloadPdf: (id: string) => Promise>; getPageImage: (params: { taskId: string; pageNumber: number }) => Promise>; diff --git a/src/renderer/locales/en-US/cloud-preview.json b/src/renderer/locales/en-US/cloud-preview.json index 38a9a3a..a4bb406 100644 --- a/src/renderer/locales/en-US/cloud-preview.json +++ b/src/renderer/locales/en-US/cloud-preview.json @@ -13,6 +13,9 @@ "cancel_task": "Cancel", "confirm_retry": "Retry Task", "confirm_retry_content": "This will create a new task. Are you sure?", + "confirm_retry_with_model": "Retry Task (Choose Model)", + "confirm_page_retry_with_model": "Retry Current Page (Choose Model)", + "select_retry_model": "Select retry model", "retry_success": "Retry started", "retry_failed": "Retry failed", "retry_all": "Retry All", @@ -40,6 +43,11 @@ "copy_image_tooltip": "Copy current page image", "copy_image_success": "Image copied", "copy_image_failed": "Failed to copy image", + "retry_model": { + "lite": "Fit Lite (~10 credits/page)", + "pro": "Fit Pro (~20 credits/page)", + "ultra": "Fit Ultra (~60 credits/page)" + }, "page_status": { "pending": "Pending", "processing": "Processing", diff --git a/src/renderer/locales/en-US/list.json b/src/renderer/locales/en-US/list.json index 1030e15..949c229 100644 --- a/src/renderer/locales/en-US/list.json +++ b/src/renderer/locales/en-US/list.json @@ -39,6 +39,18 @@ "action_success": "{{action}} successful", "action_failed": "{{action}} failed" }, + "retry": { + "confirm_with_model": "Retry Task (Choose Model)", + "confirm_cloud_with_model": "Retry Cloud Task (Choose Model)", + "select_model": "Select retry model", + "load_models_failed": "Failed to load model list", + "no_models_available": "No available models. Please configure models in settings first.", + "model": { + "lite": "Fit Lite", + "pro": "Fit Pro", + "ultra": "Fit Ultra" + } + }, "task_type": { "cloud": "Cloud Task", "local": "Local Task" diff --git a/src/renderer/locales/en-US/settings.json b/src/renderer/locales/en-US/settings.json index b5aae34..7b90b26 100644 --- a/src/renderer/locales/en-US/settings.json +++ b/src/renderer/locales/en-US/settings.json @@ -53,12 +53,17 @@ "cancel_failed": "Cancel failed", "confirm_retry": "Confirm Retry", "confirm_retry_content": "Are you sure you want to retry this task?", + "confirm_retry_with_model": "Retry Task (Choose Model)", + "confirm_page_retry_with_model": "Retry Current Page (Choose Model)", + "select_retry_model": "Select retry model", "confirm_retry_failed": "Confirm Retry Failed Pages", "confirm_retry_failed_content": "Are you sure you want to retry all failed pages?", "retry_success": "Task has been added to retry queue", "retry_failed_success": "{{count}} failed pages have been added to retry queue", "page_retry_success": "Page has been added to retry queue", "page_retry_failed": "Retry failed", + "load_models_failed": "Failed to load model list", + "no_models_available": "No available models. Please configure models in settings first.", "fetch_task_failed": "Failed to fetch task info", "fetch_page_failed": "Failed to fetch page details", "task_deleted": "Task has been deleted", diff --git a/src/renderer/locales/zh-CN/cloud-preview.json b/src/renderer/locales/zh-CN/cloud-preview.json index 4da316c..150c6c5 100644 --- a/src/renderer/locales/zh-CN/cloud-preview.json +++ b/src/renderer/locales/zh-CN/cloud-preview.json @@ -13,6 +13,9 @@ "cancel_task": "取消", "confirm_retry": "重试任务", "confirm_retry_content": "这将创建一个新任务,确定继续吗?", + "confirm_retry_with_model": "重试任务(可切换模型)", + "confirm_page_retry_with_model": "重试当前页(可切换模型)", + "select_retry_model": "选择重试模型", "retry_success": "重试已开始", "retry_failed": "重试失败", "retry_all": "全部重试", @@ -40,6 +43,11 @@ "copy_image_tooltip": "复制当前页图片", "copy_image_success": "图片已复制", "copy_image_failed": "复制图片失败", + "retry_model": { + "lite": "Fit Lite(约10积分/页)", + "pro": "Fit Pro(约20积分/页)", + "ultra": "Fit Ultra(约60积分/页)" + }, "page_status": { "pending": "待处理", "processing": "处理中", diff --git a/src/renderer/locales/zh-CN/list.json b/src/renderer/locales/zh-CN/list.json index fbee5f2..6ab567a 100644 --- a/src/renderer/locales/zh-CN/list.json +++ b/src/renderer/locales/zh-CN/list.json @@ -39,6 +39,18 @@ "action_success": "{{action}}成功", "action_failed": "{{action}}失败" }, + "retry": { + "confirm_with_model": "重试任务(可切换模型)", + "confirm_cloud_with_model": "重试云端任务(可切换模型)", + "select_model": "选择重试模型", + "load_models_failed": "加载模型列表失败", + "no_models_available": "暂无可用模型,请先在设置中配置模型", + "model": { + "lite": "Fit Lite", + "pro": "Fit Pro", + "ultra": "Fit Ultra" + } + }, "task_type": { "cloud": "云端任务", "local": "本地任务" diff --git a/src/renderer/locales/zh-CN/settings.json b/src/renderer/locales/zh-CN/settings.json index c3879e0..f38ecac 100644 --- a/src/renderer/locales/zh-CN/settings.json +++ b/src/renderer/locales/zh-CN/settings.json @@ -53,12 +53,17 @@ "cancel_failed": "取消失败", "confirm_retry": "确认重试", "confirm_retry_content": "确定要重试此任务吗?", + "confirm_retry_with_model": "重试任务(可切换模型)", + "confirm_page_retry_with_model": "重试当前页(可切换模型)", + "select_retry_model": "选择重试模型", "confirm_retry_failed": "确认重试失败页", "confirm_retry_failed_content": "确定要重试所有失败的页面吗?", "retry_success": "任务已加入重试队列", "retry_failed_success": "{{count}} 个失败页面已加入重试队列", "page_retry_success": "页面已加入重试队列", "page_retry_failed": "重试失败", + "load_models_failed": "加载模型列表失败", + "no_models_available": "暂无可用模型,请先在设置中配置模型", "fetch_task_failed": "获取任务信息失败", "fetch_page_failed": "获取页面详情失败", "task_deleted": "任务已被删除", diff --git a/src/renderer/pages/CloudPreview.tsx b/src/renderer/pages/CloudPreview.tsx index e80f56c..6306fa4 100644 --- a/src/renderer/pages/CloudPreview.tsx +++ b/src/renderer/pages/CloudPreview.tsx @@ -19,6 +19,7 @@ import { Dropdown, Pagination, Progress, + Select, Space, Spin, Splitter, @@ -32,6 +33,7 @@ import { useTranslation } from "react-i18next"; import MarkdownPreview from "../components/MarkdownPreview"; import { CloudContext } from "../contexts/CloudContextDefinition"; import type { + CloudModelTier, CloudTaskResponse, CloudTaskPageResponse, CloudSSEEvent, @@ -43,6 +45,8 @@ const dedupeAndSortPages = (pageItems: CloudTaskPageResponse[]): CloudTaskPageRe Array.from(new Map(pageItems.map((page) => [page.page, page])).values()) .sort((a, b) => a.page - b.page); +const CLOUD_MODEL_TIERS: CloudModelTier[] = ['lite', 'pro', 'ultra']; + const CloudPreview: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -383,13 +387,32 @@ const CloudPreview: React.FC = () => { const handleRetryTask = async () => { if (!id || !cloudContext) return; + const options = CLOUD_MODEL_TIERS.map((tier) => ({ + value: tier, + label: t(`retry_model.${tier}`), + })); + const taskTier = (task?.model_tier || 'lite') as CloudModelTier; + let selectedModel: CloudModelTier = CLOUD_MODEL_TIERS.includes(taskTier) ? taskTier : 'lite'; + modal.confirm({ - title: t('confirm_retry'), - content: t('confirm_retry_content'), + title: t('confirm_retry_with_model'), + content: ( +
+
{t('select_retry_model')}
+ { + selectedModel = value; + }} + /> +
+ ), + okText: tCommon('common.confirm'), + cancelText: tCommon('common.cancel'), + onOk: async () => { + setRetrying(true); + try { + const result = await cloudContext.retryPage(id, currentPage, selectedModel); + if (result.success) { + message.success(t('page_retry_success')); + // Update page status locally + setPages(prev => { + const idx = prev.findIndex(p => p.page === currentPage); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], status: 1 }; + return updated; + } + return prev; + }); + } else { + message.error(result.error || t('page_retry_failed')); } - return prev; - }); - } else { - message.error(result.error || t('page_retry_failed')); - } - } catch { - message.error(t('page_retry_failed')); - } finally { - setRetrying(false); - } + } catch { + message.error(t('page_retry_failed')); + } finally { + setRetrying(false); + } + }, + }); }; // Retry all failed pages @@ -671,8 +721,8 @@ const CloudPreview: React.FC = () => { }); } - // Retry all: status === 0 (failed) - if (status === 0) { + // Retry all: status === 0 (failed) or status === 6 (completed) + if (status === 0 || status === 6) { menuItems.push({ key: 'retry_all', icon: , diff --git a/src/renderer/pages/List.tsx b/src/renderer/pages/List.tsx index 3717e91..b7db5ca 100644 --- a/src/renderer/pages/List.tsx +++ b/src/renderer/pages/List.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback, useRef, useContext } from "react"; -import { Progress, Space, Table, Tooltip, Typography, Tag, App } from "antd"; +import { Progress, Space, Table, Tooltip, Typography, Tag, App, Select } from "antd"; import { FilePdfTwoTone, FileImageTwoTone, @@ -14,9 +14,15 @@ import { useTranslation } from "react-i18next"; import { Task } from "../../shared/types/Task"; import { CloudContext } from "../contexts/CloudContextDefinition"; import { mapCloudTasksToTasks, type CloudTask } from "../utils/cloudTaskMapper"; -import type { CloudSSEEvent } from "../../shared/types/cloud-api"; +import type { CloudModelTier, CloudSSEEvent } from "../../shared/types/cloud-api"; const { Text } = Typography; +const CLOUD_MODEL_TIERS: CloudModelTier[] = ['lite', 'pro', 'ultra']; + +interface LocalModelOption { + value: string; + label: string; +} const List: React.FC = () => { const { message, modal } = App.useApp(); @@ -41,6 +47,41 @@ const List: React.FC = () => { // Max items to fetch for unified sorting and local pagination const MAX_FETCH_ITEMS = 100; + const buildLocalModelValue = (modelId: string, providerId: number) => `${modelId}@${providerId}`; + + const parseLocalModelValue = (value: string) => { + const [modelId, providerIdStr] = value.split('@'); + return { + modelId, + providerId: Number(providerIdStr), + }; + }; + + const loadLocalModelOptions = useCallback(async (): Promise => { + const result = await window.api.model.getAll(); + if (!result.success || !result.data) { + throw new Error(result.error || t('retry.load_models_failed')); + } + + return result.data.flatMap((group) => + group.models.map((model) => ({ + value: buildLocalModelValue(model.id, group.provider), + label: `${model.name} | ${group.providerName}`, + })) + ); + }, [t]); + + const parseCloudModelTier = (record: Task | CloudTask): CloudModelTier => { + const explicitTier = (record as any)?.model_tier as string | undefined; + if (explicitTier && CLOUD_MODEL_TIERS.includes(explicitTier as CloudModelTier)) { + return explicitTier as CloudModelTier; + } + + const modelName = (record.model_name || '').toLowerCase(); + if (modelName.includes('ultra')) return 'ultra'; + if (modelName.includes('pro')) return 'pro'; + return 'lite'; + }; const fetchTasks = useCallback(async (page = 1, pageSize = 10) => { setLoading(true); @@ -377,9 +418,67 @@ const List: React.FC = () => { }); }; - // 重试任务 - const handleRetryTask = (id: string) => { - handleUpdateTaskStatus(id, 1, t('actions.retry')); + // 本地任务重试(支持切换模型) + const handleRetryTask = async (task: Task | CloudTask) => { + if (!task.id) return; + + try { + const modelOptions = await loadLocalModelOptions(); + if (modelOptions.length === 0) { + message.error(t('retry.no_models_available')); + return; + } + + const defaultModelValue = ( + task.model && + task.provider !== undefined && + task.provider >= 0 + ) ? buildLocalModelValue(task.model, task.provider) : modelOptions[0].value; + + let selectedModelValue = modelOptions.some((opt) => opt.value === defaultModelValue) + ? defaultModelValue + : modelOptions[0].value; + + modal.confirm({ + title: t('retry.confirm_with_model'), + content: ( +
+
{t('retry.select_model')}
+ ({ + value: tier, + label: t(`retry.model.${tier}`), + }))} + defaultValue={selectedModel} + onChange={(value: CloudModelTier) => { + selectedModel = value; + }} + /> +
+ ), okText: t('confirmations.ok'), cancelText: t('confirmations.cancel'), onOk: async () => { try { - const result = await cloudContext.retryTask(id); + const result = await cloudContext.retryTask(task.id as string, selectedModel); if (result.success) { message.success(t('messages.action_success', { action: t('actions.retry') })); fetchTasks(pagination.current, pagination.pageSize); @@ -638,7 +755,7 @@ const List: React.FC = () => { record.id && (isCloud ? handleCloudRetryTask(record.id) : handleRetryTask(record.id))} + onClick={() => record.id && (isCloud ? handleCloudRetryTask(record) : handleRetryTask(record))} > {t('actions.retry')} diff --git a/src/renderer/pages/Preview.tsx b/src/renderer/pages/Preview.tsx index 096cf74..529a8f6 100644 --- a/src/renderer/pages/Preview.tsx +++ b/src/renderer/pages/Preview.tsx @@ -18,6 +18,7 @@ import { Dropdown, Pagination, Progress, + Select, Space, Spin, Splitter, @@ -66,6 +67,11 @@ interface TaskDetailEvent { timestamp: number; } +interface LocalModelOption { + value: string; + label: string; +} + const { Text } = Typography; const Preview: React.FC = () => { @@ -84,6 +90,30 @@ const Preview: React.FC = () => { const [retrying, setRetrying] = useState(false); const [retryingFailed, setRetryingFailed] = useState(false); + const buildModelValue = (modelId: string, providerId: number) => `${modelId}@${providerId}`; + + const parseModelValue = (value: string) => { + const [modelId, providerIdStr] = value.split('@'); + return { + modelId, + providerId: Number(providerIdStr), + }; + }; + + const loadLocalModelOptions = useCallback(async (): Promise => { + const result = await window.api.model.getAll(); + if (!result.success || !result.data) { + throw new Error(result.error || t('preview.load_models_failed')); + } + + return result.data.flatMap((group) => + group.models.map((model) => ({ + value: buildModelValue(model.id, group.provider), + label: `${model.name} | ${group.providerName}`, + })) + ); + }, [t]); + // 获取任务元数据 const fetchTask = useCallback(async () => { if (!id) return; @@ -284,28 +314,60 @@ const Preview: React.FC = () => { // 重试任务(全部重试) const handleRetryTask = async () => { - if (!id) return; + if (!id || !task) return; - modal.confirm({ - title: t('preview.confirm_retry'), - content: t('preview.confirm_retry_content'), - okText: tCommon('common.confirm'), - cancelText: tCommon('common.cancel'), - onOk: async () => { - try { - const result = await window.api.task.update(id, { status: 1 }); // PENDING = 1 + try { + const modelOptions = await loadLocalModelOptions(); + if (modelOptions.length === 0) { + message.error(t('preview.no_models_available')); + return; + } - if (result.success) { - message.success(t('preview.retry_success')); - } else { - message.error(result.error || t('preview.retry_failed')); + const defaultModelValue = buildModelValue(task.model || '', task.provider || 0); + let selectedModelValue = modelOptions.some((opt) => opt.value === defaultModelValue) + ? defaultModelValue + : modelOptions[0].value; + + modal.confirm({ + title: t('preview.confirm_retry_with_model'), + content: ( +
+
{t('preview.select_retry_model')}
+ { + selectedModelValue = value; + }} + /> +
+ ), + okText: tCommon('common.confirm'), + cancelText: tCommon('common.cancel'), + onOk: async () => { + setRetrying(true); + try { + const { modelId, providerId } = parseModelValue(selectedModelValue); + const shouldOverride = selectedModelValue !== defaultModelValue; + const result = await window.api.taskDetail.retry( + shouldOverride + ? { pageId: taskDetail.id, providerId, modelId } + : { pageId: taskDetail.id } + ); + + if (result.success) { + message.success(t('preview.page_retry_success')); + fetchPageDetail(currentPage); + } else { + message.error(result.error || t('preview.page_retry_failed')); + } + } catch (error) { + console.error('Failed to retry page:', error); + message.error(t('preview.page_retry_failed')); + } finally { + setRetrying(false); + } + } + }); } catch (error) { - console.error('Failed to retry page:', error); - message.error(t('preview.page_retry_failed')); - } finally { - setRetrying(false); + message.error(error instanceof Error ? error.message : t('preview.load_models_failed')); } }; @@ -524,8 +626,8 @@ const Preview: React.FC = () => { }); } - // 全部重试: status === 0 - if (status === 0) { + // 全部重试: status === 0 || status === 6 + if (status === 0 || status === 6) { menuItems.push({ key: 'retry_all', icon: , diff --git a/src/renderer/pages/__tests__/CloudPreview.test.tsx b/src/renderer/pages/__tests__/CloudPreview.test.tsx index a35395d..d858aa1 100644 --- a/src/renderer/pages/__tests__/CloudPreview.test.tsx +++ b/src/renderer/pages/__tests__/CloudPreview.test.tsx @@ -1054,7 +1054,7 @@ describe('CloudPreview', () => { fireEvent.click(retryBtn as HTMLElement) await waitFor(() => { - expect(ctx.retryPage).toHaveBeenCalledWith('task-1', 1) + expect(ctx.retryPage).toHaveBeenCalledWith('task-1', 1, 'lite') }) }) @@ -1207,6 +1207,70 @@ describe('CloudPreview', () => { }) }) + it('shows retry all in More Actions for completed task and retries task', async () => { + const ctx = buildContextValue({ + getTaskById: vi.fn().mockResolvedValue({ + success: true, + data: { + id: 'task-1', + file_type: 'pdf', + file_name: 'done.pdf', + status: 6, + status_name: 'COMPLETED', + page_count: 2, + pages_completed: 2, + pages_failed: 0, + pdf_url: '/done.pdf', + credits_estimated: 10, + credits_consumed: 5, + created_at: '2026-03-03T00:00:00.000Z', + model_tier: 'lite', + }, + }), + getTaskPages: vi.fn().mockResolvedValue({ + success: true, + data: [ + { + page: 1, + status: 2, + status_name: 'COMPLETED', + markdown: 'page-1', + width_mm: 210, + height_mm: 297, + image_url: 'https://cdn.example.com/p1.png', + }, + { + page: 2, + status: 2, + status_name: 'COMPLETED', + markdown: 'page-2', + width_mm: 210, + height_mm: 297, + image_url: 'https://cdn.example.com/p2.png', + }, + ], + }), + }) + + renderPage(ctx) + + await waitFor(() => { + expect(screen.getByText('More Actions')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('More Actions')) + + await waitFor(() => { + expect(screen.getByText('Retry All')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Retry All')) + + await waitFor(() => { + expect(ctx.retryTask).toHaveBeenCalledWith('task-1', 'lite') + }) + }) + it('handles download markdown action', async () => { const ctx = buildContextValue({ getTaskById: vi.fn().mockResolvedValue({ diff --git a/src/renderer/pages/__tests__/List.test.tsx b/src/renderer/pages/__tests__/List.test.tsx index c83f7cb..70521b5 100644 --- a/src/renderer/pages/__tests__/List.test.tsx +++ b/src/renderer/pages/__tests__/List.test.tsx @@ -39,6 +39,14 @@ vi.mock('react-i18next', () => ({ 'actions.cancel': 'Cancel', 'actions.retry': 'Retry', 'actions.delete': 'Delete', + 'retry.confirm_with_model': 'Retry Task (Choose Model)', + 'retry.confirm_cloud_with_model': 'Retry Cloud Task (Choose Model)', + 'retry.select_model': 'Select retry model', + 'retry.load_models_failed': 'Failed to load model list', + 'retry.no_models_available': 'No available models', + 'retry.model.lite': 'Fit Lite', + 'retry.model.pro': 'Fit Pro', + 'retry.model.ultra': 'Fit Ultra', 'task_type.cloud': 'Cloud Task' } return translations[key] || key @@ -53,7 +61,9 @@ vi.mock('react-i18next', () => ({ const mockCloudContext = { user: { id: '', email: '', fullName: null, imageUrl: '', isLoaded: true, isSignedIn: false }, isAuthenticated: false, - getTasks: vi.fn().mockResolvedValue({ success: false, error: 'Not authenticated' }) + getTasks: vi.fn().mockResolvedValue({ success: false, error: 'Not authenticated' }), + cancelTask: vi.fn().mockResolvedValue({ success: true }), + retryTask: vi.fn().mockResolvedValue({ success: true }), } vi.mock('../../contexts/CloudContextDefinition', () => ({ @@ -290,6 +300,7 @@ describe('List', () => { expect(deleteButtons.length).toBeGreaterThan(0) }) }) + }) describe('Pagination', () => { diff --git a/src/renderer/pages/__tests__/Preview.test.tsx b/src/renderer/pages/__tests__/Preview.test.tsx index 6a5cf72..341fd03 100644 --- a/src/renderer/pages/__tests__/Preview.test.tsx +++ b/src/renderer/pages/__tests__/Preview.test.tsx @@ -109,6 +109,8 @@ describe('Preview', () => { filename: 'document.pdf', type: 'pdf', pages: 5, + provider: 1, + model: 'gpt-4o', model_name: 'GPT-4o', progress: 60, status: 3, // PROCESSING @@ -130,9 +132,11 @@ describe('Preview', () => { } const mockTaskDetail = { - id: 'detail-1', + id: 1, taskId: 'task-1', page: 1, + provider: 1, + model: 'gpt-4o', status: 2, // COMPLETED content: '# Page 1 Content\n\nThis is the content.', imagePath: 'C:\\images\\page1.png', @@ -183,7 +187,21 @@ describe('Preview', () => { vi.mocked(window.api.taskDetail.retry).mockResolvedValue({ success: true, - data: { id: 'detail-1' } + data: { id: 1 } + }) + + vi.mocked(window.api.model.getAll).mockResolvedValue({ + success: true, + data: [ + { + provider: 1, + providerName: 'OpenAI', + models: [ + { id: 'gpt-4o', name: 'GPT-4o', provider: 1 }, + { id: 'gpt-4.1', name: 'GPT-4.1', provider: 1 }, + ], + }, + ], }) // Setup event listener mocks @@ -531,6 +549,7 @@ describe('Preview', () => { }) it('should call retry API when clicking regenerate', async () => { + const { useAppSpy } = mockUseApp() render( @@ -554,8 +573,10 @@ describe('Preview', () => { fireEvent.click(regenerateButton) await waitFor(() => { - expect(window.api.taskDetail.retry).toHaveBeenCalledWith('detail-1') + expect(window.api.taskDetail.retry).toHaveBeenCalledWith({ pageId: 1 }) }) + + useAppSpy.mockRestore() }) }) @@ -698,6 +719,29 @@ describe('Preview', () => { expect(screen.queryByText('Retry Failed')).not.toBeInTheDocument() expect(screen.queryByText('Retry All')).not.toBeInTheDocument() }) + + it('should show retry all in More Actions menu for completed tasks', async () => { + vi.mocked(window.api.task.getById).mockResolvedValue({ + success: true, + data: mockCompletedTask + }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('More Actions')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('More Actions')) + + await waitFor(() => { + expect(screen.getByText('Retry All')).toBeInTheDocument() + }) + }) }) describe('Delete', () => { diff --git a/src/shared/ipc/channels.ts b/src/shared/ipc/channels.ts index 6cbe18f..228c13a 100644 --- a/src/shared/ipc/channels.ts +++ b/src/shared/ipc/channels.ts @@ -31,6 +31,7 @@ export const IPC_CHANNELS = { GET_ALL: 'task:getAll', GET_BY_ID: 'task:getById', UPDATE: 'task:update', + RETRY: 'task:retry', DELETE: 'task:delete', HAS_RUNNING: 'task:hasRunningTasks', }, diff --git a/tests/setup.renderer.ts b/tests/setup.renderer.ts index dd20323..5f17b88 100644 --- a/tests/setup.renderer.ts +++ b/tests/setup.renderer.ts @@ -25,6 +25,7 @@ const mockWindowApi = { getAll: vi.fn(), getById: vi.fn(), update: vi.fn(), + retry: vi.fn(), delete: vi.fn(), hasRunningTasks: vi.fn(), }, @@ -208,6 +209,7 @@ beforeEach(() => { mockWindowApi.file.selectDialog.mockResolvedValue({ success: true, data: { canceled: true, filePaths: [] } }) mockWindowApi.file.copyImageToClipboard.mockResolvedValue({ success: true, data: { copied: true } }) mockWindowApi.task.create.mockResolvedValue({ success: true, data: [] }) + mockWindowApi.task.retry.mockResolvedValue({ success: true, data: {} }) mockWindowApi.cloud.getTasks.mockResolvedValue({ success: true, data: [], pagination: { page: 1, page_size: 10, total: 0, total_pages: 0 } }) mockWindowApi.cloud.getTaskPages.mockResolvedValue({ success: true, data: [], pagination: { page: 1, page_size: 10, total: 0, total_pages: 0 } }) From 127d8ef8af9ecfccafda8a065f615d53d636d63f Mon Sep 17 00:00:00 2001 From: Jorben Date: Thu, 5 Mar 2026 15:42:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(renderer):=20=F0=9F=90=9B=20resolve=20t?= =?UTF-8?q?ypecheck=20errors=20in=20retry=20model=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/pages/List.tsx | 14 ++++++++++++-- src/renderer/pages/Preview.tsx | 12 +++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/renderer/pages/List.tsx b/src/renderer/pages/List.tsx index b7db5ca..880903e 100644 --- a/src/renderer/pages/List.tsx +++ b/src/renderer/pages/List.tsx @@ -24,6 +24,15 @@ interface LocalModelOption { label: string; } +interface LocalModelGroup { + provider: number; + providerName: string; + models: Array<{ + id: string; + name: string; + }>; +} + const List: React.FC = () => { const { message, modal } = App.useApp(); const { t } = useTranslation('list'); @@ -63,7 +72,8 @@ const List: React.FC = () => { throw new Error(result.error || t('retry.load_models_failed')); } - return result.data.flatMap((group) => + const modelGroups = result.data as LocalModelGroup[]; + return modelGroups.flatMap((group) => group.models.map((model) => ({ value: buildLocalModelValue(model.id, group.provider), label: `${model.name} | ${group.providerName}`, @@ -460,7 +470,7 @@ const List: React.FC = () => { const { modelId, providerId } = parseLocalModelValue(selectedModelValue); try { const result = await window.api.task.retry({ - id: task.id as string, + taskId: task.id as string, providerId, modelId, }); diff --git a/src/renderer/pages/Preview.tsx b/src/renderer/pages/Preview.tsx index 529a8f6..bb4ae54 100644 --- a/src/renderer/pages/Preview.tsx +++ b/src/renderer/pages/Preview.tsx @@ -72,6 +72,15 @@ interface LocalModelOption { label: string; } +interface LocalModelGroup { + provider: number; + providerName: string; + models: Array<{ + id: string; + name: string; + }>; +} + const { Text } = Typography; const Preview: React.FC = () => { @@ -106,7 +115,8 @@ const Preview: React.FC = () => { throw new Error(result.error || t('preview.load_models_failed')); } - return result.data.flatMap((group) => + const modelGroups = result.data as LocalModelGroup[]; + return modelGroups.flatMap((group) => group.models.map((model) => ({ value: buildModelValue(model.id, group.provider), label: `${model.name} | ${group.providerName}`, From 3199ab0cc2769a3020e29e117d851a3d839c542e Mon Sep 17 00:00:00 2001 From: Jorben Date: Thu, 5 Mar 2026 16:37:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(retry):=20=F0=9F=90=9B=20harden=20model?= =?UTF-8?q?=20parsing=20and=20review=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handlers/__tests__/task.handler.test.ts | 212 ++++++++++++++++++ src/renderer/pages/List.tsx | 24 +- src/renderer/pages/Preview.tsx | 44 +++- .../pages/__tests__/CloudPreview.test.tsx | 57 +++++ src/renderer/pages/__tests__/List.test.tsx | 197 ++++++++++++---- src/renderer/pages/__tests__/Preview.test.tsx | 72 ++++++ 6 files changed, 549 insertions(+), 57 deletions(-) diff --git a/src/main/ipc/handlers/__tests__/task.handler.test.ts b/src/main/ipc/handlers/__tests__/task.handler.test.ts index bc63353..5b46a43 100644 --- a/src/main/ipc/handlers/__tests__/task.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/task.handler.test.ts @@ -304,6 +304,51 @@ describe('Task Handler', () => { }) describe('task:retry', () => { + it('should retry task with legacy string payload', async () => { + const taskUpdate = vi.fn().mockResolvedValue({ + id: 'task-1', + status: 3, + provider: 1, + model: 'gpt-4o', + }) + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 0, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + update: taskUpdate, + }, + taskDetail: { + count: vi.fn().mockResolvedValue(2), + updateMany: vi.fn().mockResolvedValue({ count: 2 }), + }, + provider: { + findUnique: vi.fn(), + }, + model: { + findUnique: vi.fn(), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, 'task-1') + + expect(result.success).toBe(true) + expect(taskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'task-1' }, + }) + ) + }) + it('should retry task with model override', async () => { const taskDetailUpdateMany = vi.fn().mockResolvedValue({ count: 3 }) const taskUpdate = vi.fn().mockResolvedValue({ @@ -371,6 +416,62 @@ describe('Task Handler', () => { ) }) + it('should set task to pending when task has no details', async () => { + const taskDetailUpdateMany = vi.fn().mockResolvedValue({ count: 0 }) + const taskUpdate = vi.fn().mockResolvedValue({ + id: 'task-1', + status: 1, + provider: 1, + model: 'gpt-4o', + }) + + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 0, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + update: taskUpdate, + }, + taskDetail: { + count: vi.fn().mockResolvedValue(0), + updateMany: taskDetailUpdateMany, + }, + provider: { + findUnique: vi.fn(), + }, + model: { + findUnique: vi.fn(), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1' }) + + expect(result.success).toBe(true) + expect(taskDetailUpdateMany).not.toHaveBeenCalled() + expect(taskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 1, + }), + }) + ) + expect(mockEventBus.emitTaskEvent).toHaveBeenCalledWith( + 'task:status_changed', + expect.objectContaining({ + taskId: 'task-1', + task: { status: 1 }, + }) + ) + }) + it('should return error when taskId is missing', async () => { const handler = handlers.get('task:retry') const result = await handler!({}, { taskId: '' }) @@ -425,6 +526,117 @@ describe('Task Handler', () => { error: 'Task is not retryable' }) }) + + it('should return error when override provider is not found', async () => { + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 0, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + update: vi.fn(), + }, + taskDetail: { + count: vi.fn().mockResolvedValue(1), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + provider: { + findUnique: vi.fn().mockResolvedValue(null), + }, + model: { + findUnique: vi.fn(), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1', providerId: 2, modelId: 'gpt-4.1' }) + + expect(result).toEqual({ + success: false, + error: 'Provider not found or disabled' + }) + expect(mockEventBus.emitTaskEvent).not.toHaveBeenCalled() + }) + + it('should return error when override provider is disabled', async () => { + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 0, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + update: vi.fn(), + }, + taskDetail: { + count: vi.fn().mockResolvedValue(1), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + provider: { + findUnique: vi.fn().mockResolvedValue({ id: 2, name: 'Disabled', status: 1 }), + }, + model: { + findUnique: vi.fn(), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1', providerId: 2, modelId: 'gpt-4.1' }) + + expect(result).toEqual({ + success: false, + error: 'Provider not found or disabled' + }) + expect(mockEventBus.emitTaskEvent).not.toHaveBeenCalled() + }) + + it('should return error when override model is not found', async () => { + mockPrisma.$transaction.mockImplementation(async (callback: any) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ + id: 'task-1', + status: 0, + provider: 1, + model: 'gpt-4o', + model_name: 'GPT-4o | OpenAI', + }), + update: vi.fn(), + }, + taskDetail: { + count: vi.fn().mockResolvedValue(1), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + provider: { + findUnique: vi.fn().mockResolvedValue({ id: 2, name: 'Anthropic', status: 0 }), + }, + model: { + findUnique: vi.fn().mockResolvedValue(null), + }, + } + return callback(tx) + }) + + const handler = handlers.get('task:retry') + const result = await handler!({}, { taskId: 'task-1', providerId: 2, modelId: 'gpt-4.1' }) + + expect(result).toEqual({ + success: false, + error: 'Model not found for provider' + }) + expect(mockEventBus.emitTaskEvent).not.toHaveBeenCalled() + }) }) describe('task:delete', () => { diff --git a/src/renderer/pages/List.tsx b/src/renderer/pages/List.tsx index 880903e..84aaf01 100644 --- a/src/renderer/pages/List.tsx +++ b/src/renderer/pages/List.tsx @@ -58,11 +58,21 @@ const List: React.FC = () => { const MAX_FETCH_ITEMS = 100; const buildLocalModelValue = (modelId: string, providerId: number) => `${modelId}@${providerId}`; - const parseLocalModelValue = (value: string) => { - const [modelId, providerIdStr] = value.split('@'); + const parseLocalModelValue = (value: string): { modelId: string; providerId: number } | null => { + const separatorIndex = value.lastIndexOf('@'); + if (separatorIndex <= 0 || separatorIndex === value.length - 1) { + return null; + } + + const modelId = value.slice(0, separatorIndex); + const providerId = Number(value.slice(separatorIndex + 1)); + if (!modelId || !Number.isInteger(providerId)) { + return null; + } + return { modelId, - providerId: Number(providerIdStr), + providerId, }; }; @@ -467,7 +477,13 @@ const List: React.FC = () => { okText: t('confirmations.ok'), cancelText: t('confirmations.cancel'), onOk: async () => { - const { modelId, providerId } = parseLocalModelValue(selectedModelValue); + const parsedModel = parseLocalModelValue(selectedModelValue); + if (!parsedModel) { + message.error(t('messages.action_failed', { action: t('actions.retry') })); + return; + } + + const { modelId, providerId } = parsedModel; try { const result = await window.api.task.retry({ taskId: task.id as string, diff --git a/src/renderer/pages/Preview.tsx b/src/renderer/pages/Preview.tsx index bb4ae54..1d0f10b 100644 --- a/src/renderer/pages/Preview.tsx +++ b/src/renderer/pages/Preview.tsx @@ -101,11 +101,21 @@ const Preview: React.FC = () => { const buildModelValue = (modelId: string, providerId: number) => `${modelId}@${providerId}`; - const parseModelValue = (value: string) => { - const [modelId, providerIdStr] = value.split('@'); + const parseModelValue = (value: string): { modelId: string; providerId: number } | null => { + const separatorIndex = value.lastIndexOf('@'); + if (separatorIndex <= 0 || separatorIndex === value.length - 1) { + return null; + } + + const modelId = value.slice(0, separatorIndex); + const providerId = Number(value.slice(separatorIndex + 1)); + if (!modelId || !Number.isInteger(providerId)) { + return null; + } + return { modelId, - providerId: Number(providerIdStr), + providerId, }; }; @@ -357,11 +367,20 @@ const Preview: React.FC = () => { cancelText: tCommon('common.cancel'), onOk: async () => { try { - const { modelId, providerId } = parseModelValue(selectedModelValue); const shouldOverride = selectedModelValue !== defaultModelValue; + let overridePayload: { providerId: number; modelId: string } | null = null; + if (shouldOverride) { + const parsedModel = parseModelValue(selectedModelValue); + if (!parsedModel) { + message.error(t('preview.retry_failed')); + return; + } + overridePayload = parsedModel; + } + const result = await window.api.task.retry({ taskId: id, - ...(shouldOverride ? { providerId, modelId } : {}), + ...(overridePayload || {}), }); if (result.success) { @@ -450,11 +469,20 @@ const Preview: React.FC = () => { onOk: async () => { setRetrying(true); try { - const { modelId, providerId } = parseModelValue(selectedModelValue); const shouldOverride = selectedModelValue !== defaultModelValue; + let overridePayload: { providerId: number; modelId: string } | null = null; + if (shouldOverride) { + const parsedModel = parseModelValue(selectedModelValue); + if (!parsedModel) { + message.error(t('preview.page_retry_failed')); + return; + } + overridePayload = parsedModel; + } + const result = await window.api.taskDetail.retry( - shouldOverride - ? { pageId: taskDetail.id, providerId, modelId } + overridePayload + ? { pageId: taskDetail.id, ...overridePayload } : { pageId: taskDetail.id } ); diff --git a/src/renderer/pages/__tests__/CloudPreview.test.tsx b/src/renderer/pages/__tests__/CloudPreview.test.tsx index d858aa1..f57a00b 100644 --- a/src/renderer/pages/__tests__/CloudPreview.test.tsx +++ b/src/renderer/pages/__tests__/CloudPreview.test.tsx @@ -1271,6 +1271,63 @@ describe('CloudPreview', () => { }) }) + it('falls back to lite tier and shows error when completed retry all fails', async () => { + const ctx = buildContextValue({ + retryTask: vi.fn().mockResolvedValue({ success: false, error: 'cannot retry now' }), + getTaskById: vi.fn().mockResolvedValue({ + success: true, + data: { + id: 'task-1', + file_type: 'pdf', + file_name: 'done.pdf', + status: 6, + status_name: 'COMPLETED', + page_count: 2, + pages_completed: 2, + pages_failed: 0, + pdf_url: '/done.pdf', + credits_estimated: 10, + credits_consumed: 5, + created_at: '2026-03-03T00:00:00.000Z', + model_tier: 'unknown-tier', + }, + }), + getTaskPages: vi.fn().mockResolvedValue({ + success: true, + data: [ + { + page: 1, + status: 2, + status_name: 'COMPLETED', + markdown: 'page-1', + width_mm: 210, + height_mm: 297, + image_url: 'https://cdn.example.com/p1.png', + }, + ], + }), + }) + + renderPage(ctx) + + await waitFor(() => { + expect(screen.getByText('More Actions')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('More Actions')) + + await waitFor(() => { + expect(screen.getByText('Retry All')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Retry All')) + + await waitFor(() => { + expect(ctx.retryTask).toHaveBeenCalledWith('task-1', 'lite') + expect(mockMessageApi.error).toHaveBeenCalledWith('cannot retry now') + }) + }) + it('handles download markdown action', async () => { const ctx = buildContextValue({ getTaskById: vi.fn().mockResolvedValue({ diff --git a/src/renderer/pages/__tests__/List.test.tsx b/src/renderer/pages/__tests__/List.test.tsx index 70521b5..7e97d87 100644 --- a/src/renderer/pages/__tests__/List.test.tsx +++ b/src/renderer/pages/__tests__/List.test.tsx @@ -1,56 +1,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react' import { BrowserRouter } from 'react-router-dom' import { App } from 'antd' import List from '../List' +const tMock = (key: string, params?: any) => { + const translations: Record = { + 'messages.fetch_failed': 'Failed to fetch tasks', + 'messages.delete_success': 'Task deleted', + 'messages.delete_failed': 'Failed to delete task', + 'messages.action_success': `${params?.action} successful`, + 'messages.action_failed': `${params?.action} failed`, + 'confirmations.delete_title': 'Delete Task', + 'confirmations.delete_content': 'Are you sure you want to delete this task?', + 'confirmations.cancel_title': `Confirm ${params?.action}`, + 'confirmations.cancel_content': `Are you sure you want to ${params?.action}?`, + 'confirmations.ok': 'OK', + 'confirmations.cancel': 'Cancel', + 'status.pending': 'Pending', + 'status.initializing': 'Initializing', + 'status.processing': 'Processing', + 'status.merging_pending': 'Ready to Merge', + 'status.merging': 'Merging', + 'status.completed': 'Completed', + 'status.cancelled': 'Cancelled', + 'status.failed': 'Failed', + 'status.partial_failed': 'Partial Failed', + 'status.unknown': 'Unknown', + 'columns.file': 'File', + 'columns.model': 'Model', + 'columns.progress': 'Progress', + 'columns.status': 'Status', + 'columns.action': 'Actions', + 'actions.view': 'View', + 'actions.cancel': 'Cancel', + 'actions.retry': 'Retry', + 'actions.delete': 'Delete', + 'retry.confirm_with_model': 'Retry Task (Choose Model)', + 'retry.confirm_cloud_with_model': 'Retry Cloud Task (Choose Model)', + 'retry.select_model': 'Select retry model', + 'retry.load_models_failed': 'Failed to load model list', + 'retry.no_models_available': 'No available models', + 'retry.model.lite': 'Fit Lite', + 'retry.model.pro': 'Fit Pro', + 'retry.model.ultra': 'Fit Ultra', + 'task_type.cloud': 'Cloud Task' + } + return translations[key] || key +} + // Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, params?: any) => { - const translations: Record = { - 'messages.fetch_failed': 'Failed to fetch tasks', - 'messages.delete_success': 'Task deleted', - 'messages.delete_failed': 'Failed to delete task', - 'messages.action_success': `${params?.action} successful`, - 'messages.action_failed': `${params?.action} failed`, - 'confirmations.delete_title': 'Delete Task', - 'confirmations.delete_content': 'Are you sure you want to delete this task?', - 'confirmations.cancel_title': `Confirm ${params?.action}`, - 'confirmations.cancel_content': `Are you sure you want to ${params?.action}?`, - 'confirmations.ok': 'OK', - 'confirmations.cancel': 'Cancel', - 'status.pending': 'Pending', - 'status.initializing': 'Initializing', - 'status.processing': 'Processing', - 'status.merging_pending': 'Ready to Merge', - 'status.merging': 'Merging', - 'status.completed': 'Completed', - 'status.cancelled': 'Cancelled', - 'status.failed': 'Failed', - 'status.partial_failed': 'Partial Failed', - 'status.unknown': 'Unknown', - 'columns.file': 'File', - 'columns.model': 'Model', - 'columns.progress': 'Progress', - 'columns.status': 'Status', - 'columns.action': 'Actions', - 'actions.view': 'View', - 'actions.cancel': 'Cancel', - 'actions.retry': 'Retry', - 'actions.delete': 'Delete', - 'retry.confirm_with_model': 'Retry Task (Choose Model)', - 'retry.confirm_cloud_with_model': 'Retry Cloud Task (Choose Model)', - 'retry.select_model': 'Select retry model', - 'retry.load_models_failed': 'Failed to load model list', - 'retry.no_models_available': 'No available models', - 'retry.model.lite': 'Fit Lite', - 'retry.model.pro': 'Fit Pro', - 'retry.model.ultra': 'Fit Ultra', - 'task_type.cloud': 'Cloud Task' - } - return translations[key] || key - }, + t: tMock, i18n: { changeLanguage: vi.fn() } @@ -301,6 +303,111 @@ describe('List', () => { }) }) + it('should retry local failed task with selected provider/model payload', async () => { + vi.mocked(window.api.model.getAll).mockResolvedValue({ + success: true, + data: [ + { + provider: 9, + providerName: 'Custom', + models: [{ id: 'vendor@model', name: 'Vendor@Model' }], + }, + ], + } as any) + vi.mocked(window.api.task.retry).mockResolvedValue({ success: true, data: {} } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('Retry')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Retry')) + + await waitFor(() => { + expect(screen.getAllByText('Retry Task (Choose Model)').length).toBeGreaterThan(0) + }) + + fireEvent.click(screen.getByRole('button', { name: 'OK' })) + + await waitFor(() => { + expect(window.api.task.retry).toHaveBeenCalledWith({ + taskId: 'task-3', + providerId: 9, + modelId: 'vendor@model', + }) + }) + }) + + it('should show load model error and skip retry when model list fetch fails', async () => { + vi.mocked(window.api.model.getAll).mockResolvedValue({ + success: false, + error: 'load failed', + } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('Retry')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Retry')) + + await waitFor(() => { + expect(window.api.task.retry).not.toHaveBeenCalled() + }) + }) + + it('should fallback to lite tier when cloud task tier is unknown', async () => { + vi.mocked(window.api.task.getAll).mockResolvedValue({ + success: true, + data: { + list: [ + { + id: 'cloud-task-1', + filename: 'cloud.pdf', + type: 'pdf', + pages: 2, + model_name: 'Markdown.Fit', + progress: 0, + status: 0, + provider: -1, + }, + ], + total: 1, + }, + } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('Retry')).toBeInTheDocument() + }) + + await waitFor(() => { + expect(screen.getAllByText('Retry').length).toBe(1) + }) + + fireEvent.click(screen.getByText('Retry')) + fireEvent.click(screen.getByRole('button', { name: 'OK' })) + + await waitFor(() => { + expect(mockCloudContext.retryTask).toHaveBeenCalledWith('cloud-task-1', 'lite') + }) + }) + }) describe('Pagination', () => { diff --git a/src/renderer/pages/__tests__/Preview.test.tsx b/src/renderer/pages/__tests__/Preview.test.tsx index 341fd03..53a9fe2 100644 --- a/src/renderer/pages/__tests__/Preview.test.tsx +++ b/src/renderer/pages/__tests__/Preview.test.tsx @@ -742,6 +742,78 @@ describe('Preview', () => { expect(screen.getByText('Retry All')).toBeInTheDocument() }) }) + + it('should call task retry when clicking Retry All for completed tasks', async () => { + const { useAppSpy } = mockUseApp() + vi.mocked(window.api.task.getById).mockResolvedValue({ + success: true, + data: mockCompletedTask + }) + vi.mocked(window.api.task.retry).mockResolvedValue({ + success: true, + data: { id: 'task-1' } + } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('More Actions')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('More Actions')) + + await waitFor(() => { + expect(screen.getByText('Retry All')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Retry All')) + + await waitFor(() => { + expect(window.api.task.retry).toHaveBeenCalledWith({ taskId: 'task-1' }) + }) + + useAppSpy.mockRestore() + }) + + it('should show error when task retry fails from Retry All action', async () => { + const { useAppSpy, message } = mockUseApp() + vi.mocked(window.api.task.getById).mockResolvedValue({ + success: true, + data: mockCompletedTask + }) + vi.mocked(window.api.task.retry).mockResolvedValue({ + success: false, + error: 'retry backend failed' + } as any) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('More Actions')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('More Actions')) + + await waitFor(() => { + expect(screen.getByText('Retry All')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('Retry All')) + + await waitFor(() => { + expect(message.error).toHaveBeenCalledWith('retry backend failed') + }) + + useAppSpy.mockRestore() + }) }) describe('Delete', () => {