Skip to content

Commit 4d27634

Browse files
committed
feat: ai proofread
1 parent 00596d9 commit 4d27634

16 files changed

Lines changed: 1645 additions & 281 deletions

File tree

main/helpers/ipcProofreadHandlers.ts

Lines changed: 328 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ export function setupProofreadHandlers(): void {
204204
items,
205205
name,
206206
}: {
207-
items: Omit<
207+
items: (Omit<
208208
ProofreadItem,
209-
'id' | 'status' | 'lastPosition' | 'totalCount' | 'modifiedCount'
210-
>[];
209+
'id' | 'lastPosition' | 'totalCount' | 'modifiedCount'
210+
> & { status?: ProofreadItem['status'] })[];
211211
name?: string;
212212
},
213213
) => {
@@ -598,5 +598,330 @@ Only respond with the translation, nothing else.`;
598598
},
599599
);
600600

601+
// 批量优化字幕
602+
ipcMain.handle(
603+
'batchOptimizeSubtitles',
604+
async (
605+
event,
606+
{
607+
subtitles,
608+
providerId,
609+
customPrompt,
610+
batchSize = 5,
611+
maxRetries = 2,
612+
}: {
613+
subtitles: Array<{
614+
id: string;
615+
index: number;
616+
sourceContent: string;
617+
targetContent: string;
618+
}>;
619+
providerId?: string;
620+
customPrompt?: string;
621+
batchSize?: number;
622+
maxRetries?: number;
623+
},
624+
) => {
625+
try {
626+
logMessage(
627+
`Starting batch optimization: ${subtitles.length} subtitles in batches of ${batchSize}`,
628+
'info',
629+
);
630+
631+
// 获取用户配置
632+
const userConfig = store.get('userConfig') || {};
633+
const translateProviderId = providerId || userConfig.translateProvider;
634+
635+
if (!translateProviderId || translateProviderId === '-1') {
636+
return {
637+
success: false,
638+
error: '请先选择一个 AI 翻译服务',
639+
};
640+
}
641+
642+
// 获取翻译提供商
643+
const providers = store.get('translationProviders') || [];
644+
const provider = providers.find(
645+
(p: Provider) => p.id === translateProviderId,
646+
);
647+
648+
if (!provider) {
649+
return {
650+
success: false,
651+
error: '未找到选择的翻译服务',
652+
};
653+
}
654+
655+
if (!provider.isAi) {
656+
return {
657+
success: false,
658+
error: 'AI 优化功能仅支持 AI 翻译服务',
659+
};
660+
}
661+
662+
const translator =
663+
TRANSLATOR_MAP[provider.type as keyof typeof TRANSLATOR_MAP];
664+
if (!translator) {
665+
return {
666+
success: false,
667+
error: `不支持的翻译服务类型: ${provider.type}`,
668+
};
669+
}
670+
671+
const sourceLanguage = userConfig.sourceLanguage || 'en';
672+
const targetLanguage = userConfig.targetLanguage || 'zh';
673+
674+
// 构建默认批量优化提示词
675+
const defaultBatchPrompt = `You are a professional subtitle translator and proofreader. Optimize the following subtitle translations.
676+
677+
For each subtitle, improve the translation to:
678+
1. More accurately convey the original meaning
679+
2. Use natural and fluent expressions
680+
3. Be appropriate for subtitle display (concise but complete)
681+
4. Maintain the original tone and style
682+
683+
Input format: JSON object with subtitle IDs as keys and {source, target} as values
684+
Output format: JSON object with the same IDs and optimized translations as values
685+
686+
IMPORTANT: You MUST return a valid JSON object. Do NOT include any text before or after the JSON. Only output the JSON object.`;
687+
688+
const results: Array<{
689+
id: string;
690+
index: number;
691+
sourceContent: string;
692+
originalTarget: string;
693+
optimizedTarget: string;
694+
status: 'success' | 'error' | 'skipped';
695+
error?: string;
696+
}> = [];
697+
698+
const totalBatches = Math.ceil(subtitles.length / batchSize);
699+
let processedCount = 0;
700+
701+
// 分批处理
702+
for (let i = 0; i < subtitles.length; i += batchSize) {
703+
const batch = subtitles.slice(i, i + batchSize);
704+
const currentBatchIndex = Math.floor(i / batchSize) + 1;
705+
let retryCount = 0;
706+
let batchSuccess = false;
707+
708+
logMessage(
709+
`Processing batch ${currentBatchIndex}/${totalBatches} with ${batch.length} subtitles`,
710+
'info',
711+
);
712+
713+
// 发送进度更新
714+
const progress = Math.round(
715+
(processedCount / subtitles.length) * 100,
716+
);
717+
event.sender.send('batchOptimizeProgress', {
718+
progress,
719+
currentBatch: currentBatchIndex,
720+
totalBatches,
721+
processedCount,
722+
totalCount: subtitles.length,
723+
});
724+
725+
while (!batchSuccess && retryCount <= maxRetries) {
726+
try {
727+
// 构建批量输入
728+
const batchInput: Record<
729+
string,
730+
{ source: string; target: string }
731+
> = {};
732+
batch.forEach((sub) => {
733+
batchInput[sub.id] = {
734+
source: sub.sourceContent,
735+
target: sub.targetContent || '',
736+
};
737+
});
738+
739+
// 构建提示词
740+
let optimizePrompt = customPrompt || defaultBatchPrompt;
741+
optimizePrompt = optimizePrompt
742+
.replace(/\{\{sourceLanguage\}\}/g, sourceLanguage)
743+
.replace(/\{\{targetLanguage\}\}/g, targetLanguage);
744+
745+
const fullPrompt = `${optimizePrompt}\n\nSubtitles to optimize:\n${JSON.stringify(batchInput, null, 2)}`;
746+
747+
// 配置翻译器
748+
const optimizedProvider = {
749+
...provider,
750+
systemPrompt:
751+
'You are a professional subtitle optimizer. Output ONLY valid JSON. No explanations, no markdown, just the JSON object.',
752+
useJsonMode: true,
753+
structuredOutput: 'disabled' as const,
754+
};
755+
756+
logMessage(
757+
`Batch ${currentBatchIndex} attempt ${retryCount + 1}/${maxRetries + 1}`,
758+
'info',
759+
);
760+
761+
const response = await translator(
762+
fullPrompt,
763+
optimizedProvider,
764+
sourceLanguage,
765+
targetLanguage,
766+
);
767+
768+
logMessage(
769+
`Batch ${currentBatchIndex} response: ${response}`,
770+
'info',
771+
);
772+
773+
// 解析响应
774+
const parsedResponse = parseOptimizationResponse(response);
775+
776+
if (parsedResponse && typeof parsedResponse === 'object') {
777+
// 处理结果
778+
batch.forEach((sub) => {
779+
const optimized = parsedResponse[sub.id];
780+
if (optimized !== undefined) {
781+
results.push({
782+
id: sub.id,
783+
index: sub.index,
784+
sourceContent: sub.sourceContent,
785+
originalTarget: sub.targetContent,
786+
optimizedTarget:
787+
typeof optimized === 'string'
788+
? optimized
789+
: optimized?.target ||
790+
optimized?.translation ||
791+
String(optimized),
792+
status: 'success',
793+
});
794+
} else {
795+
results.push({
796+
id: sub.id,
797+
index: sub.index,
798+
sourceContent: sub.sourceContent,
799+
originalTarget: sub.targetContent,
800+
optimizedTarget: sub.targetContent,
801+
status: 'skipped',
802+
error: '未在响应中找到对应结果',
803+
});
804+
}
805+
});
806+
807+
processedCount += batch.length;
808+
batchSuccess = true;
809+
logMessage(
810+
`Batch ${currentBatchIndex}/${totalBatches} completed successfully`,
811+
'info',
812+
);
813+
} else {
814+
throw new Error('无法解析 AI 响应');
815+
}
816+
} catch (error) {
817+
retryCount++;
818+
if (retryCount <= maxRetries) {
819+
logMessage(
820+
`Batch ${currentBatchIndex} failed, retry ${retryCount}/${maxRetries}: ${error}`,
821+
'warning',
822+
);
823+
await new Promise((resolve) =>
824+
setTimeout(resolve, 1000 * retryCount),
825+
);
826+
} else {
827+
logMessage(
828+
`Batch ${currentBatchIndex} failed after ${maxRetries} retries: ${error}`,
829+
'error',
830+
);
831+
// 批次失败,标记所有字幕为错误
832+
batch.forEach((sub) => {
833+
results.push({
834+
id: sub.id,
835+
index: sub.index,
836+
sourceContent: sub.sourceContent,
837+
originalTarget: sub.targetContent,
838+
optimizedTarget: sub.targetContent,
839+
status: 'error',
840+
error: String(error),
841+
});
842+
});
843+
processedCount += batch.length;
844+
batchSuccess = true; // 继续下一批
845+
}
846+
}
847+
}
848+
}
849+
850+
// 发送完成进度
851+
event.sender.send('batchOptimizeProgress', {
852+
progress: 100,
853+
currentBatch: totalBatches,
854+
totalBatches,
855+
processedCount: subtitles.length,
856+
totalCount: subtitles.length,
857+
completed: true,
858+
});
859+
860+
logMessage(
861+
`Batch optimization completed: ${results.filter((r) => r.status === 'success').length}/${subtitles.length} successful`,
862+
'info',
863+
);
864+
865+
return {
866+
success: true,
867+
data: {
868+
results,
869+
summary: {
870+
total: subtitles.length,
871+
success: results.filter((r) => r.status === 'success').length,
872+
error: results.filter((r) => r.status === 'error').length,
873+
skipped: results.filter((r) => r.status === 'skipped').length,
874+
},
875+
},
876+
};
877+
} catch (error) {
878+
logMessage(`Error in batch optimization: ${error}`, 'error');
879+
return {
880+
success: false,
881+
error: error instanceof Error ? error.message : String(error),
882+
};
883+
}
884+
},
885+
);
886+
601887
logMessage('Proofread IPC handlers initialized', 'info');
602888
}
889+
890+
// 辅助函数:解析优化响应
891+
function parseOptimizationResponse(
892+
response: string,
893+
): Record<string, any> | null {
894+
const cleanResponse = response
895+
.replace(/<think>[\s\S]*?<\/think>/g, '')
896+
.trim();
897+
898+
// 尝试直接解析
899+
try {
900+
return JSON.parse(cleanResponse);
901+
} catch {}
902+
903+
// 尝试提取 JSON 块
904+
const jsonMatch = cleanResponse.match(/```(?:json)?\s*([\s\S]*?)```/);
905+
if (jsonMatch) {
906+
try {
907+
return JSON.parse(jsonMatch[1].trim());
908+
} catch {}
909+
}
910+
911+
// 尝试找到 JSON 对象
912+
const objectMatch = cleanResponse.match(/\{[\s\S]*\}/);
913+
if (objectMatch) {
914+
try {
915+
return JSON.parse(objectMatch[0]);
916+
} catch {
917+
// 尝试修复常见的 JSON 错误
918+
try {
919+
const { jsonrepair } = require('jsonrepair');
920+
const repaired = jsonrepair(objectMatch[0]);
921+
return JSON.parse(repaired);
922+
} catch {}
923+
}
924+
}
925+
926+
return null;
927+
}

0 commit comments

Comments
 (0)