diff --git a/docs/assets/retry-model-selection.png b/docs/assets/retry-model-selection.png new file mode 100644 index 0000000..0024aa2 Binary files /dev/null and b/docs/assets/retry-model-selection.png differ diff --git a/src/main/ipc/handlers/__tests__/file.handler.test.ts b/src/main/ipc/handlers/__tests__/file.handler.test.ts index d757882..9b1c23d 100644 --- a/src/main/ipc/handlers/__tests__/file.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/file.handler.test.ts @@ -5,6 +5,10 @@ const mockTaskRepository = { findById: vi.fn() } +const mockTaskDetailRepository = { + findByTaskId: vi.fn() +} + const mockFileLogic = { getUploadDir: vi.fn() } @@ -63,6 +67,10 @@ vi.mock('../../../../core/domain/repositories/TaskRepository.js', () => ({ default: mockTaskRepository })) +vi.mock('../../../../core/domain/repositories/TaskDetailRepository.js', () => ({ + default: mockTaskDetailRepository +})) + vi.mock('../../../../core/infrastructure/services/FileService.js', () => ({ default: mockFileLogic })) @@ -291,15 +299,56 @@ describe('File Handler', () => { }) }) - it('should return error when merged_path is missing', async () => { - mockTaskRepository.findById.mockResolvedValue({ id: 'task-1', merged_path: null }) + it('should assemble completed pages when merged_path is missing', async () => { + mockTaskRepository.findById.mockResolvedValue({ + id: 'task-1', + filename: 'document.pdf', + merged_path: null, + }) + mockTaskDetailRepository.findByTaskId.mockResolvedValue([ + { page: 2, status: 2, content: '## Page 2' }, + { page: 1, status: 2, content: '# Page 1' }, + { page: 3, status: -1, content: '' }, + ]) + mockDialog.showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/downloads/document.md', + }) + + const handler = handlers.get('file:downloadMarkdown') + const result = await handler!({}, 'task-1') + + expect(result).toEqual({ + success: true, + data: { savedPath: '/downloads/document.md', pages: 2 }, + }) + expect(mockFs.writeFileSync).toHaveBeenCalledWith( + '/downloads/document.md', + '\n\n# Page 1\n\n---\n\n\n\n## Page 2', + { encoding: 'utf-8' } + ) + }) + + it('should return error when no completed pages are available', async () => { + mockTaskRepository.findById.mockResolvedValue({ + id: 'task-1', + filename: 'document.pdf', + merged_path: null, + }) + mockTaskDetailRepository.findByTaskId.mockResolvedValue([ + { page: 1, status: -1, content: '' }, + ]) + mockDialog.showSaveDialog.mockResolvedValue({ + canceled: false, + filePath: '/downloads/document.md', + }) const handler = handlers.get('file:downloadMarkdown') const result = await handler!({}, 'task-1') expect(result).toEqual({ success: false, - error: 'Merged file does not exist, task may not be completed' + error: 'No completed pages available to download', }) }) diff --git a/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts b/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts index b482381..ab0f752 100644 --- a/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts @@ -59,8 +59,10 @@ vi.mock('../../../../core/infrastructure/db/index.js', () => ({ vi.mock('../../../../shared/types/TaskStatus.js', () => ({ TaskStatus: { + FAILED: 0, PROCESSING: 3, - CANCELLED: 7 + CANCELLED: 7, + PARTIAL_FAILED: 8 } })) @@ -336,10 +338,11 @@ describe('TaskDetail Handler', () => { describe('taskDetail:retryFailed', () => { it('should retry all failed pages', async () => { - const mockTask = { id: 'task-1', status: 6, completed_count: 8, pages: 10 } + const mockTask = { id: 'task-1', status: 0, completed_count: 8, pages: 10 } const updatedTask = { ...mockTask, status: 3, failed_count: 0 } + const updateMany = vi.fn().mockResolvedValue({ count: 2 }) - mockPrisma.$transaction.mockImplementation(async (callback: any) => { + mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => { const tx = { task: { findUnique: vi.fn().mockResolvedValue(mockTask), @@ -347,7 +350,7 @@ describe('TaskDetail Handler', () => { }, taskDetail: { count: vi.fn().mockResolvedValue(2), - updateMany: vi.fn().mockResolvedValue({ count: 2 }) + updateMany } } return callback(tx) @@ -360,6 +363,87 @@ describe('TaskDetail Handler', () => { success: true, data: { retried: 2 } }) + expect(updateMany).toHaveBeenCalledWith({ + where: { + task: 'task-1', + status: { in: [-1, 0] }, + }, + data: expect.objectContaining({ + status: 0, + }), + }) + }) + it('should retry failed pages with model override', async () => { + const mockTask = { id: 'task-1', status: 8, 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.each([ + ['created', -1], + ['pending', 1], + ['splitting', 2], + ['processing', 3], + ['ready to merge', 4], + ['merging', 5], + ['completed', 6], + ['cancelled', 7], + ])('should reject %s tasks', async (_statusName, status) => { + mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => { + const tx = { + task: { + findUnique: vi.fn().mockResolvedValue({ id: 'task-1', status }), + }, + } + return callback(tx) + }) + + const handler = handlers.get('taskDetail:retryFailed') + const result = await handler!({}, 'task-1') + + expect(result).toEqual({ + success: false, + error: 'Can only retry failed or partially failed tasks' + }) + }) + + 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 () => { @@ -373,7 +457,7 @@ describe('TaskDetail Handler', () => { }) it('should return error when no failed pages exist', async () => { - const mockTask = { id: 'task-1', status: 6 } + const mockTask = { id: 'task-1', status: 8 } mockPrisma.$transaction.mockImplementation(async (callback: any) => { const tx = { diff --git a/src/main/ipc/handlers/file.handler.ts b/src/main/ipc/handlers/file.handler.ts index 47bd730..6c43b6e 100644 --- a/src/main/ipc/handlers/file.handler.ts +++ b/src/main/ipc/handlers/file.handler.ts @@ -3,8 +3,10 @@ import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; import taskRepository from "../../../core/domain/repositories/TaskRepository.js"; +import taskDetailRepository from "../../../core/domain/repositories/TaskDetailRepository.js"; import fileLogic from "../../../core/infrastructure/services/FileService.js"; import { ImagePathUtil } from "../../../core/infrastructure/adapters/split/index.js"; +import { PageStatus } from "../../../shared/types/PageStatus.js"; import { IPC_CHANNELS } from "../../../shared/ipc/channels.js"; import type { IpcResponse } from "../../../shared/ipc/responses.js"; @@ -62,7 +64,9 @@ export function registerFileHandlers() { ); /** - * Download merged Markdown file + * Download Markdown file. + * Prefers merged_path when present; otherwise assembles completed pages + * so partial/failed tasks can still export finished work. */ ipcMain.handle( IPC_CHANNELS.FILE.DOWNLOAD_MARKDOWN, @@ -72,43 +76,56 @@ export function registerFileHandlers() { return { success: false, error: "Task ID is required" }; } - // Get task info const task = await taskRepository.findById(taskId); if (!task) { return { success: false, error: "Task not found" }; } - if (!task.merged_path) { - return { success: false, error: "Merged file does not exist, task may not be completed" }; - } - - // Check if file exists - if (!fs.existsSync(task.merged_path)) { - return { success: false, error: "Merged file is missing" }; - } - - // Open save dialog - const result = await dialog.showSaveDialog({ + const defaultPath = task.filename.replace(/\.[^/.]+$/, ".md"); + const saveResult = await dialog.showSaveDialog({ title: "Save Markdown File", - defaultPath: task.filename.replace(/\.[^/.]+$/, ".md"), + defaultPath, filters: [ { name: "Markdown Files", extensions: ["md"] }, { name: "All Files", extensions: ["*"] }, ], }); - // User cancelled - if (result.canceled || !result.filePath) { + if (saveResult.canceled || !saveResult.filePath) { return { success: false, error: "User cancelled save" }; } - // Copy file to destination - fs.copyFileSync(task.merged_path, result.filePath); + if (task.merged_path && fs.existsSync(task.merged_path)) { + fs.copyFileSync(task.merged_path, saveResult.filePath); + return { + success: true, + data: { savedPath: saveResult.filePath }, + }; + } + + // Fallback: assemble completed pages for partial downloads + const details = await taskDetailRepository.findByTaskId(taskId); + const completedPages = details + .filter((detail) => detail.status === PageStatus.COMPLETED && detail.content) + .sort((a, b) => a.page - b.page); + + if (completedPages.length === 0) { + return { + success: false, + error: "No completed pages available to download", + }; + } + + const markdown = completedPages + .map((page) => `\n\n${page.content}`) + .join("\n\n---\n\n"); + + fs.writeFileSync(saveResult.filePath, markdown, { encoding: "utf-8" }); return { success: true, - data: { savedPath: result.filePath }, + data: { savedPath: saveResult.filePath, pages: completedPages.length }, }; } catch (error: any) { console.error("[IPC] file:downloadMarkdown error:", error); diff --git a/src/main/ipc/handlers/taskDetail.handler.ts b/src/main/ipc/handlers/taskDetail.handler.ts index d7ec18f..1c7b9f4 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({ @@ -238,28 +255,66 @@ export function registerTaskDetailHandlers() { throw new Error("Task not found"); } - if (task.status === TaskStatus.CANCELLED) { - throw new Error("Task is cancelled, cannot retry"); + const retryableStatuses: TaskStatus[] = [ + TaskStatus.FAILED, + TaskStatus.PARTIAL_FAILED, + ]; + if (!retryableStatuses.includes(task.status)) { + throw new Error("Can only retry failed or partially failed tasks"); } - // Step 2: Count failed pages - const failedCount = await tx.taskDetail.count({ - where: { - task: taskId, - status: PageStatus.FAILED, - }, + 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 unfinished pages (FAILED + leftover PENDING orphans) + const unfinishedWhere = { + task: taskId, + status: { in: [PageStatus.FAILED, PageStatus.PENDING] }, + }; + + const unfinishedCount = await tx.taskDetail.count({ + where: unfinishedWhere, }); - if (failedCount === 0) { + if (unfinishedCount === 0) { throw new Error("No failed pages to retry"); } - // Step 3: Update all failed pages + // Step 3: Re-queue unfinished pages with new or existing model await tx.taskDetail.updateMany({ - where: { - task: taskId, - status: PageStatus.FAILED, - }, + where: unfinishedWhere, data: { status: PageStatus.PENDING, retry_count: 0, @@ -271,20 +326,24 @@ export function registerTaskDetailHandlers() { output_tokens: 0, conversion_time: 0, content: "", + provider: targetProvider, + model: targetModel, }, }); - // Step 4: Update task + // Step 4: Update task — keep completed_count, clear failed_count const updatedTask = await tx.task.update({ where: { id: taskId }, data: { 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 }; + return { updatedCount: unfinishedCount, 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/locales/ar-SA/list.json b/src/renderer/locales/ar-SA/list.json index e296ac7..14640c9 100644 --- a/src/renderer/locales/ar-SA/list.json +++ b/src/renderer/locales/ar-SA/list.json @@ -42,5 +42,19 @@ "task_type": { "cloud": "مهمة سحابية", "local": "مهمة محلية" + }, + "retry": { + "confirm_with_model": "إعادة محاولة المهمة (اختيار النموذج)", + "confirm_failed_with_model": "إعادة محاولة الصفحات الفاشلة (اختيار النموذج)", + "confirm_cloud_with_model": "إعادة محاولة مهمة السحابة (اختيار النموذج)", + "select_model": "اختر نموذج إعادة المحاولة", + "load_models_failed": "فشل تحميل قائمة النماذج", + "no_models_available": "لا توجد نماذج متاحة. يرجى تكوين النماذج في الإعدادات أولاً.", + "failed_success": "تمت إضافة {{count}} صفحة فاشلة إلى قائمة إعادة المحاولة", + "model": { + "lite": "Fit Lite", + "pro": "Fit Pro", + "ultra": "Fit Ultra" + } } } diff --git a/src/renderer/locales/en-US/list.json b/src/renderer/locales/en-US/list.json index 949c229..eb82a02 100644 --- a/src/renderer/locales/en-US/list.json +++ b/src/renderer/locales/en-US/list.json @@ -41,10 +41,12 @@ }, "retry": { "confirm_with_model": "Retry Task (Choose Model)", + "confirm_failed_with_model": "Retry Failed Pages (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.", + "failed_success": "{{count}} failed pages have been added to retry queue", "model": { "lite": "Fit Lite", "pro": "Fit Pro", diff --git a/src/renderer/locales/fa-IR/list.json b/src/renderer/locales/fa-IR/list.json index a0ad01e..854e08f 100644 --- a/src/renderer/locales/fa-IR/list.json +++ b/src/renderer/locales/fa-IR/list.json @@ -42,5 +42,19 @@ "task_type": { "cloud": "تسک ابری", "local": "تسک محلی" + }, + "retry": { + "confirm_with_model": "تلاش مجدد وظیفه (انتخاب مدل)", + "confirm_failed_with_model": "تلاش مجدد صفحات ناموفق (انتخاب مدل)", + "confirm_cloud_with_model": "تلاش مجدد وظیفه ابری (انتخاب مدل)", + "select_model": "مدل تلاش مجدد را انتخاب کنید", + "load_models_failed": "بارگذاری فهرست مدل‌ها ناموفق بود", + "no_models_available": "مدلی در دسترس نیست. ابتدا مدل‌ها را در تنظیمات پیکربندی کنید.", + "failed_success": "{{count}} صفحه ناموفق به صف تلاش مجدد اضافه شد", + "model": { + "lite": "Fit Lite", + "pro": "Fit Pro", + "ultra": "Fit Ultra" + } } } diff --git a/src/renderer/locales/ja-JP/list.json b/src/renderer/locales/ja-JP/list.json index c43ec18..f214ad5 100644 --- a/src/renderer/locales/ja-JP/list.json +++ b/src/renderer/locales/ja-JP/list.json @@ -42,5 +42,19 @@ "task_type": { "cloud": "クラウドタスク", "local": "ローカルタスク" + }, + "retry": { + "confirm_with_model": "タスクを再試行(モデル選択)", + "confirm_failed_with_model": "失敗ページを再試行(モデル選択)", + "confirm_cloud_with_model": "クラウドタスクを再試行(モデル選択)", + "select_model": "再試行モデルを選択", + "load_models_failed": "モデル一覧の読み込みに失敗しました", + "no_models_available": "利用可能なモデルがありません。先に設定でモデルを構成してください。", + "failed_success": "{{count}}個の失敗ページが再試行キューに追加されました", + "model": { + "lite": "Fit Lite", + "pro": "Fit Pro", + "ultra": "Fit Ultra" + } } } diff --git a/src/renderer/locales/ru-RU/list.json b/src/renderer/locales/ru-RU/list.json index f3b290e..f528156 100644 --- a/src/renderer/locales/ru-RU/list.json +++ b/src/renderer/locales/ru-RU/list.json @@ -42,5 +42,19 @@ "task_type": { "cloud": "Облачная задача", "local": "Локальная задача" + }, + "retry": { + "confirm_with_model": "Повторить задачу (выбор модели)", + "confirm_failed_with_model": "Повторить неудачные (выбор модели)", + "confirm_cloud_with_model": "Повторить облачную задачу (выбор модели)", + "select_model": "Выберите модель для повтора", + "load_models_failed": "Не удалось загрузить список моделей", + "no_models_available": "Нет доступных моделей. Сначала настройте модели в параметрах.", + "failed_success": "{{count}} неудачных страниц добавлено в очередь на повтор", + "model": { + "lite": "Fit Lite", + "pro": "Fit Pro", + "ultra": "Fit Ultra" + } } } diff --git a/src/renderer/locales/zh-CN/list.json b/src/renderer/locales/zh-CN/list.json index 6ab567a..96661b9 100644 --- a/src/renderer/locales/zh-CN/list.json +++ b/src/renderer/locales/zh-CN/list.json @@ -41,10 +41,12 @@ }, "retry": { "confirm_with_model": "重试任务(可切换模型)", + "confirm_failed_with_model": "重试失败页(可切换模型)", "confirm_cloud_with_model": "重试云端任务(可切换模型)", "select_model": "选择重试模型", "load_models_failed": "加载模型列表失败", "no_models_available": "暂无可用模型,请先在设置中配置模型", + "failed_success": "{{count}} 个失败页面已加入重试队列", "model": { "lite": "Fit Lite", "pro": "Fit Pro", diff --git a/src/renderer/pages/List.tsx b/src/renderer/pages/List.tsx index e1c085c..a387c8f 100644 --- a/src/renderer/pages/List.tsx +++ b/src/renderer/pages/List.tsx @@ -542,9 +542,12 @@ const List: React.FC = () => { }; // 本地任务重试(支持切换模型) + // PARTIAL_FAILED(8): only retry failed pages; FAILED(0)/others: full restart const handleRetryTask = async (task: Task | CloudTask) => { if (!task.id) return; + const isPartialFailed = task.status === 8; + try { const modelOptions = await loadLocalModelOptions(); if (modelOptions.length === 0) { @@ -563,7 +566,9 @@ const List: React.FC = () => { : modelOptions[0].value; modal.confirm({ - title: t('retry.confirm_with_model'), + title: isPartialFailed + ? t('retry.confirm_failed_with_model') + : t('retry.confirm_with_model'), content: (
{t('retry.select_model')}
@@ -587,14 +592,33 @@ const List: React.FC = () => { } const { modelId, providerId } = parsedModel; + const shouldOverride = selectedModelValue !== defaultModelValue; try { - const result = await window.api.task.retry({ - taskId: task.id as string, - providerId, - modelId, - }); + const result = isPartialFailed + ? await window.api.taskDetail.retryFailed( + shouldOverride + ? { taskId: task.id as string, providerId, modelId } + : (task.id as string) + ) + : await window.api.task.retry({ + taskId: task.id as string, + ...(shouldOverride ? { providerId, modelId } : {}), + }); + if (result.success) { - message.success(t('messages.action_success', { action: t('actions.retry') })); + const retried = + isPartialFailed && + result.data && + typeof result.data === 'object' && + 'retried' in result.data && + typeof result.data.retried === 'number' + ? result.data.retried + : undefined; + message.success( + typeof retried === 'number' + ? t('retry.failed_success', { count: retried }) + : t('messages.action_success', { action: t('actions.retry') }) + ); fetchTasks(pagination.current, pagination.pageSize); } else { message.error(result.error || t('messages.action_failed', { action: t('actions.retry') })); @@ -879,7 +903,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')}
+