mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(ai): split shared provider config from Anki runtime
This commit is contained in:
64
src/anki-integration/ai.test.ts
Normal file
64
src/anki-integration/ai.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveSentenceBackText } from './ai';
|
||||
|
||||
test('resolveSentenceBackText returns secondary subtitle when ai is disabled', async () => {
|
||||
const result = await resolveSentenceBackText(
|
||||
{
|
||||
sentence: '日本語',
|
||||
secondarySubText: 'existing translation',
|
||||
aiEnabled: false,
|
||||
aiConfig: {},
|
||||
},
|
||||
{
|
||||
logWarning: () => undefined,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, 'existing translation');
|
||||
});
|
||||
|
||||
test('resolveSentenceBackText uses shared ai config when enabled', async () => {
|
||||
const result = await resolveSentenceBackText(
|
||||
{
|
||||
sentence: '日本語',
|
||||
secondarySubText: '',
|
||||
aiEnabled: true,
|
||||
aiConfig: {
|
||||
enabled: true,
|
||||
apiKey: 'abc',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
logWarning: () => undefined,
|
||||
translateSentence: async (request) => {
|
||||
assert.equal(request.apiKey, 'abc');
|
||||
assert.equal(request.model, 'openai/gpt-4o-mini');
|
||||
return 'translated';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, 'translated');
|
||||
});
|
||||
|
||||
test('resolveSentenceBackText falls back to sentence when ai translation fails with no secondary subtitle', async () => {
|
||||
const result = await resolveSentenceBackText(
|
||||
{
|
||||
sentence: '日本語',
|
||||
aiEnabled: true,
|
||||
aiConfig: {
|
||||
enabled: true,
|
||||
apiKey: 'abc',
|
||||
},
|
||||
},
|
||||
{
|
||||
logWarning: () => undefined,
|
||||
translateSentence: async () => null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result, '日本語');
|
||||
});
|
||||
@@ -1,50 +1,16 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import type { AiConfig } from '../types';
|
||||
import { requestAiChatCompletion } from '../ai/client';
|
||||
|
||||
const DEFAULT_AI_SYSTEM_PROMPT =
|
||||
'You are a translation engine. Return only the translated text with no explanations.';
|
||||
|
||||
export function extractAiText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim();
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'type' in item &&
|
||||
(item as { type?: unknown }).type === 'text' &&
|
||||
'text' in item &&
|
||||
typeof (item as { text?: unknown }).text === 'string'
|
||||
) {
|
||||
parts.push((item as { text: string }).text);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('').trim();
|
||||
}
|
||||
|
||||
export function normalizeOpenAiBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, '');
|
||||
if (/\/v1$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/v1`;
|
||||
}
|
||||
|
||||
export interface AiTranslateRequest {
|
||||
sentence: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
targetLanguage?: string;
|
||||
systemPrompt?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AiTranslateCallbacks {
|
||||
@@ -54,68 +20,46 @@ export interface AiTranslateCallbacks {
|
||||
export interface AiSentenceTranslationInput {
|
||||
sentence: string;
|
||||
secondarySubText?: string;
|
||||
config: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
targetLanguage?: string;
|
||||
systemPrompt?: string;
|
||||
enabled?: boolean;
|
||||
alwaysUseAiTranslation?: boolean;
|
||||
};
|
||||
aiEnabled: boolean;
|
||||
aiConfig: AiConfig;
|
||||
}
|
||||
|
||||
export interface AiSentenceTranslationCallbacks {
|
||||
logWarning: (message: string) => void;
|
||||
translateSentence?: (
|
||||
request: AiTranslateRequest,
|
||||
callbacks: AiTranslateCallbacks,
|
||||
) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export async function translateSentenceWithAi(
|
||||
request: AiTranslateRequest,
|
||||
callbacks: AiTranslateCallbacks,
|
||||
): Promise<string | null> {
|
||||
const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai;
|
||||
if (!request.apiKey.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeOpenAiBaseUrl(
|
||||
request.baseUrl || aiConfig.baseUrl || 'https://openrouter.ai/api',
|
||||
);
|
||||
const model = request.model || 'openai/gpt-4o-mini';
|
||||
const targetLanguage = request.targetLanguage || 'English';
|
||||
const prompt = request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
model,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${request.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
return requestAiChatCompletion(
|
||||
{
|
||||
apiKey: request.apiKey,
|
||||
baseUrl: request.baseUrl,
|
||||
model: request.model,
|
||||
timeoutMs: request.requestTimeoutMs,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `Translate this text to English:\n\n${request.sentence}`,
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
|
||||
| { message?: { content?: unknown } }
|
||||
| undefined;
|
||||
return extractAiText(content?.message?.content) || null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown translation error';
|
||||
callbacks.logWarning(`AI translation failed: ${message}`);
|
||||
return null;
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
logWarning: (message) =>
|
||||
callbacks.logWarning(message.replace(/^AI request failed:/, 'AI translation failed:')),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveSentenceBackText(
|
||||
@@ -125,25 +69,23 @@ export async function resolveSentenceBackText(
|
||||
const hasSecondarySub = Boolean(input.secondarySubText?.trim());
|
||||
let backText = input.secondarySubText?.trim() || '';
|
||||
|
||||
const aiConfig = {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
||||
...input.config,
|
||||
};
|
||||
const shouldAttemptAiTranslation =
|
||||
aiConfig.enabled === true && (aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub);
|
||||
input.aiEnabled === true && input.aiConfig.enabled === true && !hasSecondarySub;
|
||||
|
||||
if (!shouldAttemptAiTranslation) return backText;
|
||||
|
||||
const translateSentence = callbacks.translateSentence ?? translateSentenceWithAi;
|
||||
|
||||
const request: AiTranslateRequest = {
|
||||
sentence: input.sentence,
|
||||
apiKey: aiConfig.apiKey ?? '',
|
||||
baseUrl: aiConfig.baseUrl,
|
||||
model: aiConfig.model,
|
||||
targetLanguage: aiConfig.targetLanguage,
|
||||
systemPrompt: aiConfig.systemPrompt,
|
||||
apiKey: input.aiConfig.apiKey ?? '',
|
||||
baseUrl: input.aiConfig.baseUrl,
|
||||
model: input.aiConfig.model,
|
||||
systemPrompt: input.aiConfig.systemPrompt,
|
||||
requestTimeoutMs: input.aiConfig.requestTimeoutMs,
|
||||
};
|
||||
|
||||
const translated = await translateSentenceWithAi(request, {
|
||||
const translated = await translateSentence(request, {
|
||||
logWarning: (message) => callbacks.logWarning(message),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { AnkiConnectConfig } from '../types';
|
||||
import { AiConfig, AnkiConnectConfig } from '../types';
|
||||
import { createLogger } from '../logger';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { MpvClient } from '../types';
|
||||
@@ -62,6 +62,7 @@ interface CardCreationMediaGenerator {
|
||||
|
||||
interface CardCreationDeps {
|
||||
getConfig: () => AnkiConnectConfig;
|
||||
getAiConfig: () => AiConfig;
|
||||
getTimingTracker: () => SubtitleTimingTracker;
|
||||
getMpvClient: () => MpvClient;
|
||||
getDeck?: () => string | undefined;
|
||||
@@ -495,11 +496,18 @@ export class CardCreationService {
|
||||
|
||||
fields[sentenceField] = sentence;
|
||||
|
||||
const ankiAiConfig = this.deps.getConfig().ai;
|
||||
const ankiAiEnabled =
|
||||
typeof ankiAiConfig === 'object' && ankiAiConfig !== null
|
||||
? ankiAiConfig.enabled === true
|
||||
: ankiAiConfig === true;
|
||||
|
||||
const backText = await resolveSentenceBackText(
|
||||
{
|
||||
sentence,
|
||||
secondarySubText,
|
||||
config: this.deps.getConfig().ai || {},
|
||||
aiEnabled: ankiAiEnabled,
|
||||
aiConfig: this.deps.getAiConfig(),
|
||||
},
|
||||
{
|
||||
logWarning: (message: string) => log.warn(message),
|
||||
|
||||
@@ -30,6 +30,22 @@ function trimToNonEmptyString(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeAnkiAiConfig(config: AnkiConnectConfig['ai']): NonNullable<AnkiConnectConfig['ai']> {
|
||||
if (config && typeof config === 'object') {
|
||||
return {
|
||||
enabled: config.enabled === true,
|
||||
model: trimToNonEmptyString(config.model) ?? '',
|
||||
systemPrompt: trimToNonEmptyString(config.systemPrompt) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: config === true,
|
||||
model: '',
|
||||
systemPrompt: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiConnectConfig {
|
||||
const resolvedUrl = trimToNonEmptyString(config.url) ?? DEFAULT_ANKI_CONNECT_CONFIG.url;
|
||||
const proxySource =
|
||||
@@ -63,11 +79,7 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
||||
port: normalizedProxyPort,
|
||||
upstreamUrl: normalizedProxyUpstreamUrl,
|
||||
},
|
||||
ai: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
|
||||
...(config.openRouter ?? {}),
|
||||
...(config.ai ?? {}),
|
||||
},
|
||||
ai: normalizeAnkiAiConfig(config.ai),
|
||||
media: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.media,
|
||||
...(config.media ?? {}),
|
||||
|
||||
Reference in New Issue
Block a user