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

118
src/ai/client.ts Normal file
View File

@@ -0,0 +1,118 @@
import { exec as execCallback } from 'node:child_process';
import { promisify } from 'node:util';
import axios from 'axios';
import type { AiConfig } from '../types';
const DEFAULT_AI_BASE_URL = 'https://openrouter.ai/api';
const DEFAULT_AI_MODEL = 'openai/gpt-4o-mini';
const DEFAULT_AI_TIMEOUT_MS = 15_000;
const exec = promisify(execCallback);
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 async function resolveAiApiKey(
config: Pick<AiConfig, 'apiKey' | 'apiKeyCommand'>,
): Promise<string | null> {
if (config.apiKey && config.apiKey.trim()) {
return config.apiKey.trim();
}
if (config.apiKeyCommand && config.apiKeyCommand.trim()) {
try {
const { stdout } = await exec(config.apiKeyCommand, { timeout: 10_000 });
const key = stdout.trim();
return key.length > 0 ? key : null;
} catch {
return null;
}
}
return null;
}
export interface AiChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface AiChatCompletionRequest {
apiKey: string;
baseUrl?: string;
model?: string;
timeoutMs?: number;
messages: AiChatMessage[];
}
export interface AiChatCompletionCallbacks {
logWarning: (message: string) => void;
}
export async function requestAiChatCompletion(
request: AiChatCompletionRequest,
callbacks: AiChatCompletionCallbacks,
): Promise<string | null> {
if (!request.apiKey.trim()) {
return null;
}
const baseUrl = normalizeOpenAiBaseUrl(request.baseUrl || DEFAULT_AI_BASE_URL);
const model = request.model || DEFAULT_AI_MODEL;
const timeoutMs = request.timeoutMs ?? DEFAULT_AI_TIMEOUT_MS;
try {
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
temperature: 0,
messages: request.messages,
},
{
headers: {
Authorization: `Bearer ${request.apiKey}`,
'Content-Type': 'application/json',
},
timeout: timeoutMs,
},
);
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 AI request error';
callbacks.logWarning(`AI request failed: ${message}`);
return null;
}
}

27
src/ai/config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { AiConfig, AiFeatureConfig } from '../types';
function trimToOverride(value: string | undefined): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function mergeAiConfig(
sharedConfig: AiConfig | undefined,
featureConfig?: AiFeatureConfig | boolean | null,
): AiConfig {
const overrides =
featureConfig && typeof featureConfig === 'object' ? featureConfig : undefined;
const modelOverride = trimToOverride(overrides?.model);
const systemPromptOverride = trimToOverride(overrides?.systemPrompt);
return {
enabled: sharedConfig?.enabled,
apiKey: sharedConfig?.apiKey,
apiKeyCommand: sharedConfig?.apiKeyCommand,
baseUrl: sharedConfig?.baseUrl,
model: modelOverride ?? sharedConfig?.model,
systemPrompt: systemPromptOverride ?? sharedConfig?.systemPrompt,
requestTimeoutMs: sharedConfig?.requestTimeoutMs,
};
}

View File

@@ -21,6 +21,7 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { MediaGenerator } from './media-generator'; import { MediaGenerator } from './media-generator';
import path from 'path'; import path from 'path';
import { import {
AiConfig,
AnkiConnectConfig, AnkiConnectConfig,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
@@ -135,6 +136,7 @@ export class AnkiIntegration {
private noteUpdateWorkflow: NoteUpdateWorkflow; private noteUpdateWorkflow: NoteUpdateWorkflow;
private fieldGroupingWorkflow: FieldGroupingWorkflow; private fieldGroupingWorkflow: FieldGroupingWorkflow;
private runtime: AnkiIntegrationRuntime; private runtime: AnkiIntegrationRuntime;
private aiConfig: AiConfig;
constructor( constructor(
config: AnkiConnectConfig, config: AnkiConnectConfig,
@@ -147,8 +149,10 @@ export class AnkiIntegration {
duplicate: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo;
}) => Promise<KikuFieldGroupingChoice>, }) => Promise<KikuFieldGroupingChoice>,
knownWordCacheStatePath?: string, knownWordCacheStatePath?: string,
aiConfig: AiConfig = {},
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig };
this.client = new AnkiConnectClient(this.config.url!); this.client = new AnkiConnectClient(this.config.url!);
this.mediaGenerator = new MediaGenerator(); this.mediaGenerator = new MediaGenerator();
this.timingTracker = timingTracker; this.timingTracker = timingTracker;
@@ -253,6 +257,7 @@ export class AnkiIntegration {
private createCardCreationService(): CardCreationService { private createCardCreationService(): CardCreationService {
return new CardCreationService({ return new CardCreationService({
getConfig: () => this.config, getConfig: () => this.config,
getAiConfig: () => this.aiConfig,
getTimingTracker: () => this.timingTracker, getTimingTracker: () => this.timingTracker,
getMpvClient: () => this.mpvClient, getMpvClient: () => this.mpvClient,
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
@@ -1096,7 +1101,10 @@ export class AnkiIntegration {
return requiredFields.every((fieldName) => this.hasFieldValue(noteInfo, fieldName)); return requiredFields.every((fieldName) => this.hasFieldValue(noteInfo, fieldName));
} }
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void { applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>, aiConfig?: AiConfig): void {
if (aiConfig) {
this.aiConfig = { ...aiConfig };
}
this.runtime.applyRuntimeConfigPatch(patch); this.runtime.applyRuntimeConfigPatch(patch);
} }

View 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, '日本語');
});

View File

@@ -1,50 +1,16 @@
import axios from 'axios'; import type { AiConfig } from '../types';
import { requestAiChatCompletion } from '../ai/client';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
const DEFAULT_AI_SYSTEM_PROMPT = const DEFAULT_AI_SYSTEM_PROMPT =
'You are a translation engine. Return only the translated text with no explanations.'; '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 { export interface AiTranslateRequest {
sentence: string; sentence: string;
apiKey: string; apiKey: string;
baseUrl?: string; baseUrl?: string;
model?: string; model?: string;
targetLanguage?: string;
systemPrompt?: string; systemPrompt?: string;
requestTimeoutMs?: number;
} }
export interface AiTranslateCallbacks { export interface AiTranslateCallbacks {
@@ -54,68 +20,46 @@ export interface AiTranslateCallbacks {
export interface AiSentenceTranslationInput { export interface AiSentenceTranslationInput {
sentence: string; sentence: string;
secondarySubText?: string; secondarySubText?: string;
config: { aiEnabled: boolean;
apiKey?: string; aiConfig: AiConfig;
baseUrl?: string;
model?: string;
targetLanguage?: string;
systemPrompt?: string;
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
};
} }
export interface AiSentenceTranslationCallbacks { export interface AiSentenceTranslationCallbacks {
logWarning: (message: string) => void; logWarning: (message: string) => void;
translateSentence?: (
request: AiTranslateRequest,
callbacks: AiTranslateCallbacks,
) => Promise<string | null>;
} }
export async function translateSentenceWithAi( export async function translateSentenceWithAi(
request: AiTranslateRequest, request: AiTranslateRequest,
callbacks: AiTranslateCallbacks, callbacks: AiTranslateCallbacks,
): Promise<string | null> { ): Promise<string | null> {
const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai;
if (!request.apiKey.trim()) { if (!request.apiKey.trim()) {
return null; 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; const prompt = request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT;
try { return requestAiChatCompletion(
const response = await axios.post(
`${baseUrl}/chat/completions`,
{ {
model, apiKey: request.apiKey,
temperature: 0, baseUrl: request.baseUrl,
model: request.model,
timeoutMs: request.requestTimeoutMs,
messages: [ messages: [
{ role: 'system', content: prompt }, { role: 'system', content: prompt },
{ {
role: 'user', role: 'user',
content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`, content: `Translate this text to English:\n\n${request.sentence}`,
}, },
], ],
}, },
{ {
headers: { logWarning: (message) =>
Authorization: `Bearer ${request.apiKey}`, callbacks.logWarning(message.replace(/^AI request failed:/, 'AI translation failed:')),
'Content-Type': 'application/json',
},
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;
}
} }
export async function resolveSentenceBackText( export async function resolveSentenceBackText(
@@ -125,25 +69,23 @@ export async function resolveSentenceBackText(
const hasSecondarySub = Boolean(input.secondarySubText?.trim()); const hasSecondarySub = Boolean(input.secondarySubText?.trim());
let backText = input.secondarySubText?.trim() || ''; let backText = input.secondarySubText?.trim() || '';
const aiConfig = {
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
...input.config,
};
const shouldAttemptAiTranslation = const shouldAttemptAiTranslation =
aiConfig.enabled === true && (aiConfig.alwaysUseAiTranslation === true || !hasSecondarySub); input.aiEnabled === true && input.aiConfig.enabled === true && !hasSecondarySub;
if (!shouldAttemptAiTranslation) return backText; if (!shouldAttemptAiTranslation) return backText;
const translateSentence = callbacks.translateSentence ?? translateSentenceWithAi;
const request: AiTranslateRequest = { const request: AiTranslateRequest = {
sentence: input.sentence, sentence: input.sentence,
apiKey: aiConfig.apiKey ?? '', apiKey: input.aiConfig.apiKey ?? '',
baseUrl: aiConfig.baseUrl, baseUrl: input.aiConfig.baseUrl,
model: aiConfig.model, model: input.aiConfig.model,
targetLanguage: aiConfig.targetLanguage, systemPrompt: input.aiConfig.systemPrompt,
systemPrompt: aiConfig.systemPrompt, requestTimeoutMs: input.aiConfig.requestTimeoutMs,
}; };
const translated = await translateSentenceWithAi(request, { const translated = await translateSentence(request, {
logWarning: (message) => callbacks.logWarning(message), logWarning: (message) => callbacks.logWarning(message),
}); });

View File

@@ -1,5 +1,5 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { AnkiConnectConfig } from '../types'; import { AiConfig, AnkiConnectConfig } from '../types';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
import { MpvClient } from '../types'; import { MpvClient } from '../types';
@@ -62,6 +62,7 @@ interface CardCreationMediaGenerator {
interface CardCreationDeps { interface CardCreationDeps {
getConfig: () => AnkiConnectConfig; getConfig: () => AnkiConnectConfig;
getAiConfig: () => AiConfig;
getTimingTracker: () => SubtitleTimingTracker; getTimingTracker: () => SubtitleTimingTracker;
getMpvClient: () => MpvClient; getMpvClient: () => MpvClient;
getDeck?: () => string | undefined; getDeck?: () => string | undefined;
@@ -495,11 +496,18 @@ export class CardCreationService {
fields[sentenceField] = sentence; 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( const backText = await resolveSentenceBackText(
{ {
sentence, sentence,
secondarySubText, secondarySubText,
config: this.deps.getConfig().ai || {}, aiEnabled: ankiAiEnabled,
aiConfig: this.deps.getAiConfig(),
}, },
{ {
logWarning: (message: string) => log.warn(message), logWarning: (message: string) => log.warn(message),

View File

@@ -30,6 +30,22 @@ function trimToNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : 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 { export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiConnectConfig {
const resolvedUrl = trimToNonEmptyString(config.url) ?? DEFAULT_ANKI_CONNECT_CONFIG.url; const resolvedUrl = trimToNonEmptyString(config.url) ?? DEFAULT_ANKI_CONNECT_CONFIG.url;
const proxySource = const proxySource =
@@ -63,11 +79,7 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
port: normalizedProxyPort, port: normalizedProxyPort,
upstreamUrl: normalizedProxyUpstreamUrl, upstreamUrl: normalizedProxyUpstreamUrl,
}, },
ai: { ai: normalizeAnkiAiConfig(config.ai),
...DEFAULT_ANKI_CONNECT_CONFIG.ai,
...(config.openRouter ?? {}),
...(config.ai ?? {}),
},
media: { media: {
...DEFAULT_ANKI_CONNECT_CONFIG.media, ...DEFAULT_ANKI_CONNECT_CONFIG.media,
...(config.media ?? {}), ...(config.media ?? {}),

View File

@@ -31,7 +31,7 @@ const {
startupWarmups, startupWarmups,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } = const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG; INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
anilist, anilist,
jellyfin, jellyfin,
discordPresence, discordPresence,
ai,
youtubeSubgen, youtubeSubgen,
immersionTracking, immersionTracking,
}; };

View File

@@ -13,7 +13,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {}; const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {}; const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {}; const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {};
const legacyKeys = new Set([ const legacyKeys = new Set([
'audioField', 'audioField',
'imageField', 'imageField',
@@ -42,19 +41,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'autoUpdateNewCards', 'autoUpdateNewCards',
]); ]);
if (ac.openRouter !== undefined) { const {
context.warn( nPlusOne: _nPlusOneConfigFromAnkiConnect,
'ankiConnect.openRouter', ai: _ankiAiConfig,
ac.openRouter, ...ankiConnectWithoutNPlusOne
context.resolved.ankiConnect.ai, } = ac as Record<string, unknown>;
'Deprecated key; use ankiConnect.ai instead.',
);
}
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = ac as Record<
string,
unknown
>;
const ankiConnectWithoutLegacy = Object.fromEntries( const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)), Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)),
); );
@@ -70,10 +61,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
? (ac.fields as (typeof context.resolved)['ankiConnect']['fields']) ? (ac.fields as (typeof context.resolved)['ankiConnect']['fields'])
: {}), : {}),
}, },
ai: {
...context.resolved.ankiConnect.ai,
...(aiSource as (typeof context.resolved)['ankiConnect']['ai']),
},
media: { media: {
...context.resolved.ankiConnect.media, ...context.resolved.ankiConnect.media,
...(isObject(ac.media) ...(isObject(ac.media)
@@ -219,6 +206,56 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
); );
} }
if (isObject(ac.ai)) {
const aiEnabled = asBoolean(ac.ai.enabled);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ac.ai.enabled !== undefined) {
context.warn(
'ankiConnect.ai.enabled',
ac.ai.enabled,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean.',
);
}
const aiModel = asString(ac.ai.model);
if (aiModel !== undefined) {
context.resolved.ankiConnect.ai.model = aiModel;
} else if (ac.ai.model !== undefined) {
context.warn(
'ankiConnect.ai.model',
ac.ai.model,
context.resolved.ankiConnect.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(ac.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
} else if (ac.ai.systemPrompt !== undefined) {
context.warn(
'ankiConnect.ai.systemPrompt',
ac.ai.systemPrompt,
context.resolved.ankiConnect.ai.systemPrompt,
'Expected string.',
);
}
} else {
const aiEnabled = asBoolean(ac.ai);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ac.ai !== undefined) {
context.warn(
'ankiConnect.ai',
ac.ai,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean or object.',
);
}
}
if (Array.isArray(ac.tags)) { if (Array.isArray(ac.tags)) {
const normalizedTags = ac.tags const normalizedTags = ac.tags
.filter((entry): entry is string => typeof entry === 'string') .filter((entry): entry is string => typeof entry === 'string')

View File

@@ -4,6 +4,46 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
export function applyIntegrationConfig(context: ResolveContext): void { export function applyIntegrationConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; const { src, resolved, warn } = context;
if (isObject(src.ai)) {
const booleanKeys = ['enabled'] as const;
for (const key of booleanKeys) {
const value = asBoolean(src.ai[key]);
if (value !== undefined) {
resolved.ai[key] = value;
} else if (src.ai[key] !== undefined) {
warn(`ai.${key}`, src.ai[key], resolved.ai[key], 'Expected boolean.');
}
}
const stringKeys = ['apiKey', 'apiKeyCommand', 'baseUrl', 'model', 'systemPrompt'] as const;
for (const key of stringKeys) {
const value = asString(src.ai[key]);
if (value !== undefined) {
resolved.ai[key] = value;
} else if (src.ai[key] !== undefined) {
warn(`ai.${key}`, src.ai[key], resolved.ai[key], 'Expected string.');
}
}
const requestTimeoutMs = asNumber(src.ai.requestTimeoutMs);
if (
requestTimeoutMs !== undefined &&
Number.isInteger(requestTimeoutMs) &&
requestTimeoutMs > 0
) {
resolved.ai.requestTimeoutMs = requestTimeoutMs;
} else if (src.ai.requestTimeoutMs !== undefined) {
warn(
'ai.requestTimeoutMs',
src.ai.requestTimeoutMs,
resolved.ai.requestTimeoutMs,
'Expected positive integer.',
);
}
} else if (src.ai !== undefined) {
warn('ai', src.ai, resolved.ai, 'Expected object.');
}
if (isObject(src.anilist)) { if (isObject(src.anilist)) {
const enabled = asBoolean(src.anilist.enabled); const enabled = asBoolean(src.anilist.enabled);
if (enabled !== undefined) { if (enabled !== undefined) {

View File

@@ -1,5 +1,7 @@
import { AnkiIntegration } from '../../anki-integration'; import { AnkiIntegration } from '../../anki-integration';
import { mergeAiConfig } from '../../ai/config';
import { import {
AiConfig,
AnkiConnectConfig, AnkiConnectConfig,
JimakuApiResponse, JimakuApiResponse,
JimakuEntry, JimakuEntry,
@@ -30,7 +32,7 @@ interface SubtitleTimingTrackerLike {
export interface AnkiJimakuIpcRuntimeOptions { export interface AnkiJimakuIpcRuntimeOptions {
patchAnkiConnectEnabled: (enabled: boolean) => void; patchAnkiConnectEnabled: (enabled: boolean) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
@@ -100,6 +102,7 @@ export function registerAnkiJimakuIpcRuntime(
options.showDesktopNotification, options.showDesktopNotification,
options.createFieldGroupingCallback(), options.createFieldGroupingCallback(),
options.getKnownWordCacheStatePath(), options.getKnownWordCacheStatePath(),
mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig,
); );
integration.start(); integration.start();
options.setAnkiIntegration(integration); options.setAnkiIntegration(integration);

View File

@@ -125,10 +125,10 @@ test('config hot reload runtime reports validation warnings from reload', () =>
config: deepCloneConfig(DEFAULT_CONFIG), config: deepCloneConfig(DEFAULT_CONFIG),
warnings: [ warnings: [
{ {
path: 'ankiConnect.openRouter', path: 'ankiConnect.ai',
message: 'Deprecated key; use ankiConnect.ai instead.', message: 'Expected boolean.',
value: { enabled: true }, value: { enabled: true },
fallback: {}, fallback: false,
}, },
], ],
path: '/tmp/config.jsonc', path: '/tmp/config.jsonc',

View File

@@ -73,7 +73,11 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
if (key === 'ankiConnect') { if (key === 'ankiConnect') {
const normalizedPrev = { const normalizedPrev = {
...prev.ankiConnect, ...prev.ankiConnect,
ai: next.ankiConnect.ai, ai: {
enabled: next.ankiConnect.ai.enabled,
model: prev.ankiConnect.ai.model,
systemPrompt: prev.ankiConnect.ai.systemPrompt,
},
}; };
if (!isEqual(normalizedPrev, next.ankiConnect)) { if (!isEqual(normalizedPrev, next.ankiConnect)) {
restartRequiredFields.push('ankiConnect'); restartRequiredFields.push('ankiConnect');

View File

@@ -109,6 +109,65 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
assert.equal(setIntegrationCalls, 1); assert.equal(setIntegrationCalls, 1);
}); });
test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => {
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => null,
getResolvedConfig: () => ({
ankiConnect: {
enabled: true,
ai: {
enabled: true,
model: 'openrouter/anki-model',
systemPrompt: 'Translate mined sentence text.',
},
} as never,
ai: {
enabled: true,
apiKey: 'shared-key',
baseUrl: 'https://openrouter.ai/api',
model: 'openrouter/shared-model',
systemPrompt: 'Legacy shared prompt.',
requestTimeoutMs: 15000,
},
}),
getSubtitleTimingTracker: () => ({}),
getMpvClient: () => ({
send: () => {},
}),
getRuntimeOptionsManager: () => ({
getEffectiveAnkiConnectConfig: (config) => config as never,
}),
createAnkiIntegration: (args) => {
assert.equal(args.aiConfig.apiKey, 'shared-key');
assert.equal(args.aiConfig.baseUrl, 'https://openrouter.ai/api');
assert.equal(args.aiConfig.model, 'openrouter/anki-model');
assert.equal(args.aiConfig.systemPrompt, 'Translate mined sentence text.');
return {
start: () => {},
};
},
setAnkiIntegration: () => {},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 5,
deleteNoteId: 6,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
});
test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus changes', () => { test('initializeOverlayRuntime re-syncs overlay shortcuts when tracker focus changes', () => {
let syncCalls = 0; let syncCalls = 0;
const tracker = { const tracker = {

View File

@@ -1,5 +1,6 @@
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import { BaseWindowTracker, createWindowTracker } from '../../window-trackers'; import { BaseWindowTracker, createWindowTracker } from '../../window-trackers';
import { mergeAiConfig } from '../../ai/config';
import { import {
AiConfig, AiConfig,
AnkiConnectConfig, AnkiConnectConfig,
@@ -124,7 +125,7 @@ export function initializeOverlayRuntime(options: {
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
const integration = createAnkiIntegration({ const integration = createAnkiIntegration({
config: effectiveAnkiConfig, config: effectiveAnkiConfig,
aiConfig: config.ai ?? {}, aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai),
subtitleTimingTracker, subtitleTimingTracker,
mpvClient, mpvClient,
showDesktopNotification: options.showDesktopNotification, showDesktopNotification: options.showDesktopNotification,

View File

@@ -19,7 +19,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
broadcastToOverlayWindows: (channel, payload) => broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`), calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
applyAnkiRuntimeConfigPatch: (patch) => { applyAnkiRuntimeConfigPatch: (patch) => {
ankiPatches.push({ enabled: patch.ai.enabled }); ankiPatches.push({ enabled: patch.ai });
}, },
}); });

View File

@@ -8,7 +8,7 @@ type ConfigHotReloadAppliedDeps = {
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => void;
}; };
type ConfigHotReloadMessageDeps = { type ConfigHotReloadMessageDeps = {
@@ -53,7 +53,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
} }
if (diff.hotReloadFields.includes('ankiConnect.ai')) { if (diff.hotReloadFields.includes('ankiConnect.ai')) {
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai }); deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled });
} }
if (diff.hotReloadFields.length > 0) { if (diff.hotReloadFields.length > 0) {

View File

@@ -98,7 +98,7 @@ test('config hot reload applied main deps builder maps callbacks', () => {
deps.refreshGlobalAndOverlayShortcuts(); deps.refreshGlobalAndOverlayShortcuts();
deps.setSecondarySubMode('hover'); deps.setSecondarySubMode('hover');
deps.broadcastToOverlayWindows('config:hot-reload', {}); deps.broadcastToOverlayWindows('config:hot-reload', {});
deps.applyAnkiRuntimeConfigPatch({ ai: {} as never }); deps.applyAnkiRuntimeConfigPatch({ ai: true });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'keybindings', 'keybindings',
'refresh-shortcuts', 'refresh-shortcuts',

View File

@@ -65,7 +65,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => void;
}) { }) {
return () => ({ return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
@@ -74,7 +74,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) => broadcastToOverlayWindows: (channel: string, payload: unknown) =>
deps.broadcastToOverlayWindows(channel, payload), deps.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) =>
deps.applyAnkiRuntimeConfigPatch(patch), deps.applyAnkiRuntimeConfigPatch(patch),
}); });
} }