feat(ai): split shared provider config from Anki runtime

This commit is contained in:
2026-03-08 16:10:51 -07:00
parent f10e905dbd
commit 9e46176519
19 changed files with 457 additions and 133 deletions

View File

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