diff --git a/src/ai/client.ts b/src/ai/client.ts new file mode 100644 index 0000000..8d7384d --- /dev/null +++ b/src/ai/client.ts @@ -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, +): Promise { + 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 { + 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; + } +} diff --git a/src/ai/config.ts b/src/ai/config.ts new file mode 100644 index 0000000..7cdbda2 --- /dev/null +++ b/src/ai/config.ts @@ -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, + }; +} diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 0e3ea27..86f47ff 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -21,6 +21,7 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { MediaGenerator } from './media-generator'; import path from 'path'; import { + AiConfig, AnkiConnectConfig, KikuDuplicateCardInfo, KikuFieldGroupingChoice, @@ -135,6 +136,7 @@ export class AnkiIntegration { private noteUpdateWorkflow: NoteUpdateWorkflow; private fieldGroupingWorkflow: FieldGroupingWorkflow; private runtime: AnkiIntegrationRuntime; + private aiConfig: AiConfig; constructor( config: AnkiConnectConfig, @@ -147,8 +149,10 @@ export class AnkiIntegration { duplicate: KikuDuplicateCardInfo; }) => Promise, knownWordCacheStatePath?: string, + aiConfig: AiConfig = {}, ) { this.config = normalizeAnkiIntegrationConfig(config); + this.aiConfig = { ...aiConfig }; this.client = new AnkiConnectClient(this.config.url!); this.mediaGenerator = new MediaGenerator(); this.timingTracker = timingTracker; @@ -253,6 +257,7 @@ export class AnkiIntegration { private createCardCreationService(): CardCreationService { return new CardCreationService({ getConfig: () => this.config, + getAiConfig: () => this.aiConfig, getTimingTracker: () => this.timingTracker, getMpvClient: () => this.mpvClient, getDeck: () => this.config.deck, @@ -1096,7 +1101,10 @@ export class AnkiIntegration { return requiredFields.every((fieldName) => this.hasFieldValue(noteInfo, fieldName)); } - applyRuntimeConfigPatch(patch: Partial): void { + applyRuntimeConfigPatch(patch: Partial, aiConfig?: AiConfig): void { + if (aiConfig) { + this.aiConfig = { ...aiConfig }; + } this.runtime.applyRuntimeConfigPatch(patch); } diff --git a/src/anki-integration/ai.test.ts b/src/anki-integration/ai.test.ts new file mode 100644 index 0000000..ad8016d --- /dev/null +++ b/src/anki-integration/ai.test.ts @@ -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, '日本語'); +}); diff --git a/src/anki-integration/ai.ts b/src/anki-integration/ai.ts index 150f7a5..034c420 100644 --- a/src/anki-integration/ai.ts +++ b/src/anki-integration/ai.ts @@ -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; } export async function translateSentenceWithAi( request: AiTranslateRequest, callbacks: AiTranslateCallbacks, ): Promise { - 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), }); diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 9d9c63b..1de0a01 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -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), diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index 40f4eb7..dfd5500 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -30,6 +30,22 @@ function trimToNonEmptyString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeAnkiAiConfig(config: AnkiConnectConfig['ai']): NonNullable { + 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 ?? {}), diff --git a/src/config/definitions.ts b/src/config/definitions.ts index b0134c4..9cefaba 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -31,7 +31,7 @@ const { startupWarmups, auto_start_overlay, } = CORE_DEFAULT_CONFIG; -const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } = +const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } = INTEGRATIONS_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; @@ -54,6 +54,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { anilist, jellyfin, discordPresence, + ai, youtubeSubgen, immersionTracking, }; diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 181fd37..fa286fb 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -13,7 +13,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { const media = isObject(ac.media) ? (ac.media as Record) : {}; const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {}; const proxy = isObject(ac.proxy) ? (ac.proxy as Record) : {}; - const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {}; const legacyKeys = new Set([ 'audioField', 'imageField', @@ -42,19 +41,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { 'autoUpdateNewCards', ]); - if (ac.openRouter !== undefined) { - context.warn( - 'ankiConnect.openRouter', - ac.openRouter, - context.resolved.ankiConnect.ai, - 'Deprecated key; use ankiConnect.ai instead.', - ); - } - - const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } = ac as Record< - string, - unknown - >; + const { + nPlusOne: _nPlusOneConfigFromAnkiConnect, + ai: _ankiAiConfig, + ...ankiConnectWithoutNPlusOne + } = ac as Record; const ankiConnectWithoutLegacy = Object.fromEntries( 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']) : {}), }, - ai: { - ...context.resolved.ankiConnect.ai, - ...(aiSource as (typeof context.resolved)['ankiConnect']['ai']), - }, media: { ...context.resolved.ankiConnect.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)) { const normalizedTags = ac.tags .filter((entry): entry is string => typeof entry === 'string') diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 9a000fe..634869e 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -4,6 +4,46 @@ import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyIntegrationConfig(context: ResolveContext): void { 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)) { const enabled = asBoolean(src.anilist.enabled); if (enabled !== undefined) { diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index 71cb5f1..d2cbfd8 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -1,5 +1,7 @@ import { AnkiIntegration } from '../../anki-integration'; +import { mergeAiConfig } from '../../ai/config'; import { + AiConfig, AnkiConnectConfig, JimakuApiResponse, JimakuEntry, @@ -30,7 +32,7 @@ interface SubtitleTimingTrackerLike { export interface AnkiJimakuIpcRuntimeOptions { patchAnkiConnectEnabled: (enabled: boolean) => void; - getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; + getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; getMpvClient: () => MpvClientLike | null; @@ -100,6 +102,7 @@ export function registerAnkiJimakuIpcRuntime( options.showDesktopNotification, options.createFieldGroupingCallback(), options.getKnownWordCacheStatePath(), + mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, ); integration.start(); options.setAnkiIntegration(integration); diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts index f5a9f48..bc88c42 100644 --- a/src/core/services/config-hot-reload.test.ts +++ b/src/core/services/config-hot-reload.test.ts @@ -125,10 +125,10 @@ test('config hot reload runtime reports validation warnings from reload', () => config: deepCloneConfig(DEFAULT_CONFIG), warnings: [ { - path: 'ankiConnect.openRouter', - message: 'Deprecated key; use ankiConnect.ai instead.', + path: 'ankiConnect.ai', + message: 'Expected boolean.', value: { enabled: true }, - fallback: {}, + fallback: false, }, ], path: '/tmp/config.jsonc', diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index a319425..3e751f2 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -73,7 +73,11 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo if (key === 'ankiConnect') { const normalizedPrev = { ...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)) { restartRequiredFields.push('ankiConnect'); diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index b51ac41..ae96c05 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -109,6 +109,65 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled 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', () => { let syncCalls = 0; const tracker = { diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 024e0a6..77887d6 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from 'electron'; import { BaseWindowTracker, createWindowTracker } from '../../window-trackers'; +import { mergeAiConfig } from '../../ai/config'; import { AiConfig, AnkiConnectConfig, @@ -124,7 +125,7 @@ export function initializeOverlayRuntime(options: { const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; const integration = createAnkiIntegration({ config: effectiveAnkiConfig, - aiConfig: config.ai ?? {}, + aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai), subtitleTimingTracker, mpvClient, showDesktopNotification: options.showDesktopNotification, diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 344b67e..1604d53 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -19,7 +19,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`), applyAnkiRuntimeConfigPatch: (patch) => { - ankiPatches.push({ enabled: patch.ai.enabled }); + ankiPatches.push({ enabled: patch.ai }); }, }); diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 4ffba19..46fa9bb 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -8,7 +8,7 @@ type ConfigHotReloadAppliedDeps = { refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; - applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => void; }; type ConfigHotReloadMessageDeps = { @@ -53,7 +53,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied } if (diff.hotReloadFields.includes('ankiConnect.ai')) { - deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai }); + deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled }); } if (diff.hotReloadFields.length > 0) { diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts index 137272a..fc01aa0 100644 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -98,7 +98,7 @@ test('config hot reload applied main deps builder maps callbacks', () => { deps.refreshGlobalAndOverlayShortcuts(); deps.setSecondarySubMode('hover'); deps.broadcastToOverlayWindows('config:hot-reload', {}); - deps.applyAnkiRuntimeConfigPatch({ ai: {} as never }); + deps.applyAnkiRuntimeConfigPatch({ ai: true }); assert.deepEqual(calls, [ 'keybindings', 'refresh-shortcuts', diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index ccbef94..9529024 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -65,7 +65,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; - applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => void; + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => void; }) { return () => ({ setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => @@ -74,7 +74,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), broadcastToOverlayWindows: (channel: string, payload: unknown) => deps.broadcastToOverlayWindows(channel, payload), - applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai'] }) => + applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => deps.applyAnkiRuntimeConfigPatch(patch), }); }