Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 70 additions & 22 deletions src/backend/application/services/impl/TranslateServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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'),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string>();
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<string, string>();
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
Expand Down Expand Up @@ -674,6 +691,37 @@ export default class TranslateServiceImpl implements TranslateService {
}
}

private async translateOpenAIBatchWithText(
prompt: string,
windowSentences: Sentence[]
): Promise<Array<{ key: string; fileHash: string; translation: string }>> {
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。
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
};
modelRoutingService: {
resolveOpenAiModel: ReturnType<typeof vi.fn>;
};
translateOpenAIBatchWithText: (
prompt: string,
windowSentences: Array<{ fileHash: string; translationKey: string }>
) => Promise<Array<{ key: string; fileHash: string; translation: string }>>;
};
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: '你好' }
]);
});
});
Original file line number Diff line number Diff line change
@@ -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: '保留' }
]);
});
});
77 changes: 77 additions & 0 deletions src/backend/application/services/impl/openAiSubtitleBatchParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Sentence } from '@/common/types/SentenceC';

export type SubtitleBatchParseSentence = Pick<Sentence, 'fileHash' | 'translationKey'>;

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<string, unknown>;
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);
};