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:
118
src/ai/client.ts
Normal file
118
src/ai/client.ts
Normal 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
27
src/ai/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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<KikuFieldGroupingChoice>,
|
||||
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<AnkiConnectConfig>): void {
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>, aiConfig?: AiConfig): void {
|
||||
if (aiConfig) {
|
||||
this.aiConfig = { ...aiConfig };
|
||||
}
|
||||
this.runtime.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
|
||||
|
||||
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 ?? {}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const media = isObject(ac.media) ? (ac.media 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 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<string, unknown>;
|
||||
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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user