diff --git a/src/backend/application/services/impl/TranslateServiceImpl.ts b/src/backend/application/services/impl/TranslateServiceImpl.ts index e8b274e7..be7c5ce2 100644 --- a/src/backend/application/services/impl/TranslateServiceImpl.ts +++ b/src/backend/application/services/impl/TranslateServiceImpl.ts @@ -19,6 +19,8 @@ import RendererGateway from '@/backend/application/ports/gateways/renderer/Rende import AiProviderService from '@/backend/application/services/AiProviderService'; import ClientProviderService from '@/backend/application/services/ClientProviderService'; import SettingService from '@/backend/application/services/SettingService'; +import { OpenAiService } from '@/backend/application/services/OpenAiService'; +import ModelRoutingService from '@/backend/application/services/ModelRoutingService'; import { YouDaoDictionaryClient } from '@/backend/application/ports/gateways/translate/YouDaoDictionaryClient'; import { TencentTranslateClient } from '@/backend/application/ports/gateways/translate/TencentTranslateClient'; import { getMainLogger } from '@/backend/infrastructure/logger'; @@ -33,6 +35,7 @@ import { resolveSubtitleStyleWithSignature } from '@/common/constants/openaiSubtitlePrompts'; import { RendererTranslationFailure, RendererTranslationItem, TranslationMode } from '@/common/types/TranslationResult'; +import { parseOpenAIBatchTextResult } from '@/backend/application/services/impl/openAiSubtitleBatchParser'; const openAIDictionaryExampleSchema = z.object({ sentence: z.string().describe('Example sentence in English'), @@ -254,6 +257,10 @@ export default class TranslateServiceImpl implements TranslateService { private rendererGateway!: RendererGateway; @inject(TYPES.AiProviderService) private aiProviderService!: AiProviderService; + @inject(TYPES.OpenAiService) + private openAiService!: OpenAiService; + @inject(TYPES.ModelRoutingService) + private modelRoutingService!: ModelRoutingService; @inject(TYPES.CacheService) private cacheService!: CacheService; @inject(TYPES.SettingService) @@ -574,33 +581,43 @@ export default class TranslateServiceImpl implements TranslateService { })), promptConfig.style ); - const result = streamText({ - model, - output: Output.object({ schema }), - prompt, - }); - const streamedTranslations = new Map(); - for await (const partialObject of result.partialOutputStream) { - this.logger.debug('subtitle batch json chunk', { - windowKeys: windowSentences.map((sentence) => sentence.translationKey), - keys: Object.keys(partialObject ?? {}), + let normalizedItems: Array<{ key: string; fileHash: string; translation: string }>; + try { + const result = streamText({ + model, + output: Output.object({ schema }), + prompt, }); - const partialItems = this.normalizeOpenAIBatchResult(partialObject, windowSentences); - const partialUpdates = this.buildStreamingSubtitleUpdates( - partialItems, - requestedKeys, - openAiMode, - streamedTranslations - ); - if (partialUpdates.length > 0) { - this.rendererGateway.fireAndForget('translation/batch-result', { - translations: partialUpdates, + const streamedTranslations = new Map(); + for await (const partialObject of result.partialOutputStream) { + this.logger.debug('subtitle batch json chunk', { + windowKeys: windowSentences.map((sentence) => sentence.translationKey), + keys: Object.keys(partialObject ?? {}), }); + const partialItems = this.normalizeOpenAIBatchResult(partialObject, windowSentences); + const partialUpdates = this.buildStreamingSubtitleUpdates( + partialItems, + requestedKeys, + openAiMode, + streamedTranslations + ); + if (partialUpdates.length > 0) { + this.rendererGateway.fireAndForget('translation/batch-result', { + translations: partialUpdates, + }); + } } + + const finalObject = await result.output; + normalizedItems = this.normalizeOpenAIBatchResult(finalObject, windowSentences); + } catch (structuredError) { + this.logger.warn('OpenAI 结构化字幕流失败,降级为普通 JSON 文本翻译', { + keys: windowSentences.map((sentence) => sentence.translationKey), + error: errorToBriefMessage(structuredError) + }); + normalizedItems = await this.translateOpenAIBatchWithText(prompt, windowSentences); } - const finalObject = await result.output; - const normalizedItems = this.normalizeOpenAIBatchResult(finalObject, windowSentences); const requestedInWindow = windowSentences.filter((sentence) => requestedKeys.has(sentence.translationKey)); const resolvedRequestedKeys = new Set( normalizedItems @@ -674,6 +691,37 @@ export default class TranslateServiceImpl implements TranslateService { } } + private async translateOpenAIBatchWithText( + prompt: string, + windowSentences: Sentence[] + ): Promise> { + const routedModel = this.modelRoutingService.resolveOpenAiModel('subtitleTranslation'); + if (!routedModel) { + return []; + } + + const completion = await this.openAiService.getOpenAi().chat.completions.create({ + model: routedModel.modelId, + messages: [ + { + role: 'system', + content: 'You are a professional subtitle assistant. Return JSON only.' + }, + { + role: 'user', + content: prompt + } + ], + temperature: 0.2 + }); + const text = completion.choices?.[0]?.message?.content ?? ''; + this.logger.debug('subtitle batch text fallback response', { + windowKeys: windowSentences.map((sentence) => sentence.translationKey), + textLength: text.length + }); + return parseOpenAIBatchTextResult(text, windowSentences); + } + /** * 构建 OpenAI 批量字幕翻译的结构化 schema。 * diff --git a/src/backend/application/services/impl/__tests__/TranslateServiceImpl.openaiFallback.test.ts b/src/backend/application/services/impl/__tests__/TranslateServiceImpl.openaiFallback.test.ts new file mode 100644 index 00000000..b0bb3d46 --- /dev/null +++ b/src/backend/application/services/impl/__tests__/TranslateServiceImpl.openaiFallback.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import TranslateServiceImpl from '../TranslateServiceImpl'; + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/tmp/dashplayer-test') + } +})); + +vi.mock('inversify', () => ({ + injectable: () => (target: unknown) => target, + inject: () => (target: unknown, _propertyKey: string) => target, +})); + +describe('TranslateServiceImpl OpenAI text fallback', () => { + it('uses chat completions so OpenAI-compatible providers can translate subtitle batches', async () => { + const service = new TranslateServiceImpl() as unknown as { + openAiService: { + getOpenAi: ReturnType; + }; + modelRoutingService: { + resolveOpenAiModel: ReturnType; + }; + translateOpenAIBatchWithText: ( + prompt: string, + windowSentences: Array<{ fileHash: string; translationKey: string }> + ) => Promise>; + }; + const create = vi.fn().mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ + items: [ + { key: 'file-a:1', translation: '你好' } + ] + }) + } + } + ] + }); + service.openAiService = { + getOpenAi: vi.fn().mockReturnValue({ + chat: { + completions: { + create + } + } + }) + }; + service.modelRoutingService = { + resolveOpenAiModel: vi.fn().mockReturnValue({ + modelId: 'deepseek-v4-flash' + }) + }; + + const result = await service.translateOpenAIBatchWithText('Translate this batch', [ + { fileHash: 'file-a', translationKey: 'file-a:1' } + ]); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + model: 'deepseek-v4-flash', + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: 'Translate this batch' }) + ]) + })); + expect(result).toEqual([ + { key: 'file-a:1', fileHash: 'file-a', translation: '你好' } + ]); + }); +}); diff --git a/src/backend/application/services/impl/__tests__/openAiSubtitleBatchParser.test.ts b/src/backend/application/services/impl/__tests__/openAiSubtitleBatchParser.test.ts new file mode 100644 index 00000000..742dfa11 --- /dev/null +++ b/src/backend/application/services/impl/__tests__/openAiSubtitleBatchParser.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { parseOpenAIBatchTextResult } from '../openAiSubtitleBatchParser'; + +describe('parseOpenAIBatchTextResult', () => { + const windowSentences = [ + { + fileHash: 'file-a', + translationKey: 'file-a:1' + }, + { + fileHash: 'file-a', + translationKey: 'file-a:2' + } + ]; + + it('parses subtitle batch JSON from a fenced model response', () => { + const text = [ + '```json', + '{', + ' "items": [', + ' {"key": "file-a:1", "translation": "你好"},', + ' {"key": "file-a:2", "translation": "世界"}', + ' ]', + '}', + '```' + ].join('\n'); + + expect(parseOpenAIBatchTextResult(text, windowSentences)).toEqual([ + { key: 'file-a:1', fileHash: 'file-a', translation: '你好' }, + { key: 'file-a:2', fileHash: 'file-a', translation: '世界' } + ]); + }); + + it('ignores items whose keys are not in the current subtitle window', () => { + const text = JSON.stringify({ + items: [ + { key: 'file-a:1', translation: '保留' }, + { key: 'file-a:999', translation: '忽略' } + ] + }); + + expect(parseOpenAIBatchTextResult(text, windowSentences)).toEqual([ + { key: 'file-a:1', fileHash: 'file-a', translation: '保留' } + ]); + }); +}); diff --git a/src/backend/application/services/impl/openAiSubtitleBatchParser.ts b/src/backend/application/services/impl/openAiSubtitleBatchParser.ts new file mode 100644 index 00000000..10f46002 --- /dev/null +++ b/src/backend/application/services/impl/openAiSubtitleBatchParser.ts @@ -0,0 +1,77 @@ +import { Sentence } from '@/common/types/SentenceC'; + +export type SubtitleBatchParseSentence = Pick; + +export type SubtitleBatchParseItem = { + key: string; + fileHash: string; + translation: string; +}; + +const sanitizeString = (value?: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const extractJsonText = (text: string): string | null => { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fenced?.[1] ?? text; + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start < 0 || end < start) { + return null; + } + return candidate.slice(start, end + 1); +}; + +export const parseOpenAIBatchTextResult = ( + text: string, + windowSentences: SubtitleBatchParseSentence[] +): SubtitleBatchParseItem[] => { + const jsonText = extractJsonText(text); + if (!jsonText) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + return []; + } + + if (!parsed || typeof parsed !== 'object') { + return []; + } + const items = (parsed as { items?: unknown }).items; + if (!Array.isArray(items)) { + return []; + } + + const sentenceMap = new Map(windowSentences.map((sentence) => [sentence.translationKey, sentence])); + return items + .map((item) => { + if (!item || typeof item !== 'object') { + return null; + } + const record = item as Record; + const key = sanitizeString(record.key); + if (!key) { + return null; + } + const sentence = sentenceMap.get(key); + const translation = sanitizeString(record.translation); + if (!sentence || !translation) { + return null; + } + return { + key, + fileHash: sentence.fileHash, + translation + }; + }) + .filter((item): item is SubtitleBatchParseItem => item !== null); +};