From 2f960db1f741446717c69a16348ec274f194d556 Mon Sep 17 00:00:00 2001 From: Ahmed Cool Projects <72823374+AhmedCoolProjects@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:00:02 +0200 Subject: [PATCH 1/5] feat(retry): support model selection when retrying failed pages --- .../__tests__/taskDetail.handler.test.ts | 44 +++++++++ src/main/ipc/handlers/taskDetail.handler.ts | 64 ++++++++++++- src/preload/electron.d.ts | 2 +- src/preload/index.ts | 4 +- src/renderer/electron.d.ts | 2 +- src/renderer/pages/List.tsx | 2 +- src/renderer/pages/Preview.tsx | 89 ++++++++++++++----- 7 files changed, 174 insertions(+), 33 deletions(-) diff --git a/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts b/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts index b482381..07911d9 100644 --- a/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts @@ -361,6 +361,50 @@ describe('TaskDetail Handler', () => { data: { retried: 2 } }) }) + it('should retry failed pages with model override', async () => { + const mockTask = { id: 'task-1', status: 6, completed_count: 8, pages: 10, provider: 1, model: 'old-model' } + const mockProvider = { id: 2, name: 'OpenAI', status: 0 } + const mockModel = { id: 'gpt-4o', name: 'GPT-4o', provider: 2 } + const updatedTask = { ...mockTask, status: 3, failed_count: 0, provider: 2, model: 'gpt-4o', model_name: 'GPT-4o | OpenAI' } + + mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue(mockTask), + update: vi.fn().mockResolvedValue(updatedTask) + }, + provider: { + findUnique: vi.fn().mockResolvedValue(mockProvider) + }, + model: { + findUnique: vi.fn().mockResolvedValue(mockModel) + }, + taskDetail: { + count: vi.fn().mockResolvedValue(2), + updateMany: vi.fn().mockResolvedValue({ count: 2 }) + } + } + return callback(tx) + }) + + const handler = handlers.get('taskDetail:retryFailed') + const result = await handler!({}, { taskId: 'task-1', providerId: 2, modelId: 'gpt-4o' }) + + expect(result).toEqual({ + success: true, + data: { retried: 2 } + }) + }) + + it('should return error when model override params are incomplete in retryFailed', async () => { + const handler = handlers.get('taskDetail:retryFailed') + const result = await handler!({}, { taskId: 'task-1', providerId: 2 }) + + expect(result).toEqual({ + success: false, + error: 'providerId and modelId must be provided together' + }) + }) it('should return error when taskId is missing', async () => { const handler = handlers.get('taskDetail:retryFailed') diff --git a/src/main/ipc/handlers/taskDetail.handler.ts b/src/main/ipc/handlers/taskDetail.handler.ts index d7ec18f..102a278 100644 --- a/src/main/ipc/handlers/taskDetail.handler.ts +++ b/src/main/ipc/handlers/taskDetail.handler.ts @@ -218,16 +218,33 @@ export function registerTaskDetailHandlers() { ); /** - * Retry all failed pages + * Retry all failed pages (with optional model override) */ ipcMain.handle( IPC_CHANNELS.TASK_DETAIL.RETRY_FAILED, - async (_, taskId: string): Promise => { + 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 result = await prisma.$transaction(async (tx) => { // Step 1: Check task exists and status is valid const task = await tx.task.findUnique({ @@ -242,6 +259,41 @@ export function registerTaskDetailHandlers() { throw new Error("Task is cancelled, cannot retry"); } + 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 }, + }); + + if (!model) { + throw new Error("Model not found for provider"); + } + + targetProvider = providerId; + targetModel = modelId; + targetModelName = `${model.name} | ${provider.name}`; + } // Step 2: Count failed pages const failedCount = await tx.taskDetail.count({ where: { @@ -254,7 +306,7 @@ export function registerTaskDetailHandlers() { throw new Error("No failed pages to retry"); } - // Step 3: Update all failed pages + // Step 3: Update all failed pages with new or existing model await tx.taskDetail.updateMany({ where: { task: taskId, @@ -271,6 +323,8 @@ export function registerTaskDetailHandlers() { output_tokens: 0, conversion_time: 0, content: "", + provider: targetProvider, + model: targetModel, }, }); @@ -281,9 +335,11 @@ export function registerTaskDetailHandlers() { failed_count: 0, status: TaskStatus.PROCESSING, progress: Math.round((task.completed_count / task.pages) * 100), + provider: targetProvider, + model: targetModel, + model_name: targetModelName, }, }); - return { updatedCount: failedCount, task: updatedTask }; }, { isolationLevel: 'Serializable', diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index 5f90bca..f1c03df 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -92,7 +92,7 @@ interface WindowAPI { getByPage: (taskId: string, page: number) => Promise; getAllByTask: (taskId: string) => Promise; retry: (params: number | { pageId: number; providerId?: number; modelId?: string }) => Promise; - retryFailed: (taskId: string) => Promise; + retryFailed: (params: string | { taskId: string; providerId?: number; modelId?: string }) => Promise; }; file: { selectDialog: (allowOffice?: boolean) => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index 7643f3b..c467359 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,8 +54,8 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("taskDetail:getAllByTask", taskId), retry: (params: number | { pageId: number; providerId?: number; modelId?: string }) => ipcRenderer.invoke("taskDetail:retry", params), - retryFailed: (taskId: string) => - ipcRenderer.invoke("taskDetail:retryFailed", taskId), + retryFailed: (params: string | { taskId: string; providerId?: number; modelId?: string }) => + ipcRenderer.invoke("taskDetail:retryFailed", params), }, // ==================== File APIs ==================== diff --git a/src/renderer/electron.d.ts b/src/renderer/electron.d.ts index 8452b62..44c3b78 100644 --- a/src/renderer/electron.d.ts +++ b/src/renderer/electron.d.ts @@ -228,7 +228,7 @@ interface ElectronAPI { getByPage: (taskId: string, page: number) => Promise>; getAllByTask: (taskId: string) => Promise>; retry: (params: number | RetryTaskDetailDTO) => Promise>; - retryFailed: (taskId: string) => Promise>; + retryFailed: (params: string | RetryTaskDTO) => Promise>; }; file: { diff --git a/src/renderer/pages/List.tsx b/src/renderer/pages/List.tsx index e1c085c..dfc3c57 100644 --- a/src/renderer/pages/List.tsx +++ b/src/renderer/pages/List.tsx @@ -879,7 +879,7 @@ const List: React.FC = () => { })()} {(() => { const isCloud = record.provider === -1; - if (record.status === 0) { + if (record.status === 0 || record.status === 8) { return ( { } }; - // 重试失败页 + // 重试失败页(支持切换模型) const handleRetryFailed = async () => { - if (!id) return; + if (!id || !task) return; - modal.confirm({ - title: t('preview.confirm_retry_failed'), - content: t('preview.confirm_retry_failed_content'), - okText: tCommon('common.confirm'), - cancelText: tCommon('common.cancel'), - onOk: async () => { - setRetryingFailed(true); - try { - const result = await window.api.taskDetail.retryFailed(id); + 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_failed_success', { count: result.data?.retried || 0 })); - } 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_failed'), + content: ( +
+
{t('preview.select_retry_model')}
+