mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -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 { 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 ?? {}),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user