feat(core): add module scaffolding and provider registries

This commit is contained in:
kyasuda
2026-02-10 13:16:01 -08:00
committed by sudacode
parent 531f8027bd
commit 09e142279a
19 changed files with 822 additions and 0 deletions

21
src/core/action-bus.ts Normal file
View File

@@ -0,0 +1,21 @@
export type ActionWithType = { type: string };
export type ActionHandler<TAction extends ActionWithType> = (
action: TAction,
) => void | Promise<void>;
export class ActionBus<TAction extends ActionWithType> {
private handlers = new Map<string, ActionHandler<TAction>>();
register(type: TAction["type"], handler: ActionHandler<TAction>): void {
this.handlers.set(type, handler);
}
async dispatch(action: TAction): Promise<void> {
const handler = this.handlers.get(action.type);
if (!handler) {
throw new Error(`No handler registered for action: ${action.type}`);
}
await handler(action);
}
}

16
src/core/actions.ts Normal file
View File

@@ -0,0 +1,16 @@
export type AppAction =
| { type: "overlay.toggleVisible" }
| { type: "overlay.toggleInvisible" }
| { type: "overlay.setVisible"; visible: boolean }
| { type: "overlay.setInvisibleVisible"; visible: boolean }
| { type: "overlay.openSettings" }
| { type: "subtitle.copyCurrent" }
| { type: "subtitle.copyMultiplePrompt"; timeoutMs: number }
| { type: "anki.mineSentence" }
| { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number }
| { type: "anki.updateLastCardFromClipboard" }
| { type: "anki.markAudioCard" }
| { type: "kiku.triggerFieldGrouping" }
| { type: "subsync.triggerFromConfig" }
| { type: "secondarySub.toggleMode" }
| { type: "runtimeOptions.openPalette" };

45
src/core/app-context.ts Normal file
View File

@@ -0,0 +1,45 @@
import {
AnkiConnectConfig,
JimakuApiResponse,
JimakuDownloadQuery,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
RuntimeOptionState,
SubsyncManualRunRequest,
SubsyncMode,
SubsyncResult,
} from "../types";
export interface RuntimeOptionsModuleContext {
getAnkiConfig: () => AnkiConnectConfig;
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
onOptionsChanged: (options: RuntimeOptionState[]) => void;
}
export interface AppContext {
runtimeOptions?: RuntimeOptionsModuleContext;
jimaku?: {
getMediaInfo: () => JimakuMediaInfo;
searchEntries: (
query: JimakuSearchQuery,
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
listFiles: (
query: JimakuFilesQuery,
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
downloadFile: (
query: JimakuDownloadQuery,
) => Promise<JimakuDownloadResult>;
};
subsync?: {
getDefaultMode: () => SubsyncMode;
openManualPicker: () => Promise<void>;
runAuto: () => Promise<SubsyncResult>;
runManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
showOsd: (message: string) => void;
runWithSpinner: <T>(task: () => Promise<T>, label?: string) => Promise<T>;
};
}

View File

@@ -0,0 +1,36 @@
import { SubminerModule } from "./module";
export class ModuleRegistry<TContext = unknown> {
private readonly modules: SubminerModule<TContext>[] = [];
register(module: SubminerModule<TContext>): void {
if (this.modules.some((existing) => existing.id === module.id)) {
throw new Error(`Module already registered: ${module.id}`);
}
this.modules.push(module);
}
async initAll(context: TContext): Promise<void> {
for (const module of this.modules) {
if (module.init) {
await module.init(context);
}
}
}
async startAll(): Promise<void> {
for (const module of this.modules) {
if (module.start) {
await module.start();
}
}
}
async stopAll(): Promise<void> {
for (const module of [...this.modules].reverse()) {
if (module.stop) {
await module.stop();
}
}
}
}

6
src/core/module.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface SubminerModule<TContext = unknown> {
id: string;
init?: (context: TContext) => void | Promise<void>;
start?: () => void | Promise<void>;
stop?: () => void | Promise<void>;
}

61
src/ipc/contract.ts Normal file
View File

@@ -0,0 +1,61 @@
export const IPC_CHANNELS = {
rendererToMainInvoke: {
getOverlayVisibility: "get-overlay-visibility",
getVisibleOverlayVisibility: "get-visible-overlay-visibility",
getInvisibleOverlayVisibility: "get-invisible-overlay-visibility",
getCurrentSubtitle: "get-current-subtitle",
getCurrentSubtitleAss: "get-current-subtitle-ass",
getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics",
getSubtitlePosition: "get-subtitle-position",
getSubtitleStyle: "get-subtitle-style",
getMecabStatus: "get-mecab-status",
getKeybindings: "get-keybindings",
getSecondarySubMode: "get-secondary-sub-mode",
getCurrentSecondarySub: "get-current-secondary-sub",
runSubsyncManual: "subsync:run-manual",
getAnkiConnectStatus: "get-anki-connect-status",
runtimeOptionsGet: "runtime-options:get",
runtimeOptionsSet: "runtime-options:set",
runtimeOptionsCycle: "runtime-options:cycle",
kikuBuildMergePreview: "kiku:build-merge-preview",
jimakuGetMediaInfo: "jimaku:get-media-info",
jimakuSearchEntries: "jimaku:search-entries",
jimakuListFiles: "jimaku:list-files",
jimakuDownloadFile: "jimaku:download-file",
},
rendererToMainSend: {
setIgnoreMouseEvents: "set-ignore-mouse-events",
overlayModalClosed: "overlay:modal-closed",
openYomitanSettings: "open-yomitan-settings",
quitApp: "quit-app",
toggleDevTools: "toggle-dev-tools",
toggleOverlay: "toggle-overlay",
saveSubtitlePosition: "save-subtitle-position",
setMecabEnabled: "set-mecab-enabled",
mpvCommand: "mpv-command",
setAnkiConnectEnabled: "set-anki-connect-enabled",
clearAnkiConnectHistory: "clear-anki-connect-history",
kikuFieldGroupingRespond: "kiku:field-grouping-respond",
},
mainToRendererEvent: {
subtitleSet: "subtitle:set",
mpvSubVisibility: "mpv:subVisibility",
subtitlePositionSet: "subtitle-position:set",
mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set",
subtitleAssSet: "subtitle-ass:set",
overlayDebugVisualizationSet: "overlay-debug-visualization:set",
secondarySubtitleSet: "secondary-subtitle:set",
secondarySubtitleMode: "secondary-subtitle:mode",
subsyncOpenManual: "subsync:open-manual",
kikuFieldGroupingRequest: "kiku:field-grouping-request",
runtimeOptionsChanged: "runtime-options:changed",
runtimeOptionsOpen: "runtime-options:open",
},
} as const;
export type RendererToMainInvokeChannel =
(typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke];
export type RendererToMainSendChannel =
(typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend];
export type MainToRendererEventChannel =
(typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent];

19
src/ipc/main-api.ts Normal file
View File

@@ -0,0 +1,19 @@
import { ipcMain, IpcMainEvent } from "electron";
import {
RendererToMainInvokeChannel,
RendererToMainSendChannel,
} from "./contract";
export function onRendererSend(
channel: RendererToMainSendChannel,
listener: (event: IpcMainEvent, ...args: any[]) => void,
): void {
ipcMain.on(channel, listener);
}
export function handleRendererInvoke(
channel: RendererToMainInvokeChannel,
handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown,
): void {
ipcMain.handle(channel, handler);
}

27
src/ipc/renderer-api.ts Normal file
View File

@@ -0,0 +1,27 @@
import { ipcRenderer, IpcRendererEvent } from "electron";
import {
MainToRendererEventChannel,
RendererToMainInvokeChannel,
RendererToMainSendChannel,
} from "./contract";
export function invokeFromRenderer<T>(
channel: RendererToMainInvokeChannel,
...args: unknown[]
): Promise<T> {
return ipcRenderer.invoke(channel, ...args) as Promise<T>;
}
export function sendFromRenderer(
channel: RendererToMainSendChannel,
...args: unknown[]
): void {
ipcRenderer.send(channel, ...args);
}
export function onMainEvent(
channel: MainToRendererEventChannel,
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
): void {
ipcRenderer.on(channel, listener);
}

View File

@@ -0,0 +1,72 @@
import { AppContext } from "../../core/app-context";
import { SubminerModule } from "../../core/module";
import {
JimakuApiResponse,
JimakuDownloadQuery,
JimakuDownloadResult,
JimakuEntry,
JimakuFileEntry,
JimakuFilesQuery,
JimakuMediaInfo,
JimakuSearchQuery,
} from "../../types";
export class JimakuModule implements SubminerModule<AppContext> {
readonly id = "jimaku";
private context: AppContext["jimaku"] | undefined;
init(context: AppContext): void {
if (!context.jimaku) {
throw new Error("Jimaku context is missing");
}
this.context = context.jimaku;
}
getMediaInfo(): JimakuMediaInfo {
if (!this.context) {
return {
title: "",
season: null,
episode: null,
confidence: "low",
filename: "",
rawTitle: "",
};
}
return this.context.getMediaInfo();
}
searchEntries(
query: JimakuSearchQuery,
): Promise<JimakuApiResponse<JimakuEntry[]>> {
if (!this.context) {
return Promise.resolve({
ok: false,
error: { error: "Jimaku module not initialized" },
});
}
return this.context.searchEntries(query);
}
listFiles(
query: JimakuFilesQuery,
): Promise<JimakuApiResponse<JimakuFileEntry[]>> {
if (!this.context) {
return Promise.resolve({
ok: false,
error: { error: "Jimaku module not initialized" },
});
}
return this.context.listFiles(query);
}
downloadFile(query: JimakuDownloadQuery): Promise<JimakuDownloadResult> {
if (!this.context) {
return Promise.resolve({
ok: false,
error: { error: "Jimaku module not initialized" },
});
}
return this.context.downloadFile(query);
}
}

View File

@@ -0,0 +1,61 @@
import { AppContext } from "../../core/app-context";
import { SubminerModule } from "../../core/module";
import { RuntimeOptionsManager } from "../../runtime-options";
import {
AnkiConnectConfig,
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
} from "../../types";
export class RuntimeOptionsModule implements SubminerModule<AppContext> {
readonly id = "runtime-options";
private manager: RuntimeOptionsManager | null = null;
init(context: AppContext): void {
if (!context.runtimeOptions) {
throw new Error("Runtime options context is missing");
}
this.manager = new RuntimeOptionsManager(
context.runtimeOptions.getAnkiConfig,
{
applyAnkiPatch: context.runtimeOptions.applyAnkiPatch,
onOptionsChanged: context.runtimeOptions.onOptionsChanged,
},
);
}
listOptions(): RuntimeOptionState[] {
return this.manager ? this.manager.listOptions() : [];
}
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
return this.manager?.getOptionValue(id);
}
setOptionValue(
id: RuntimeOptionId,
value: RuntimeOptionValue,
): RuntimeOptionApplyResult {
if (!this.manager) {
return { ok: false, error: "Runtime options manager unavailable" };
}
return this.manager.setOptionValue(id, value);
}
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
if (!this.manager) {
return { ok: false, error: "Runtime options manager unavailable" };
}
return this.manager.cycleOption(id, direction);
}
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
if (!this.manager) {
return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {};
}
return this.manager.getEffectiveAnkiConnectConfig(baseConfig);
}
}

View File

@@ -0,0 +1,78 @@
import { AppContext } from "../../core/app-context";
import { SubminerModule } from "../../core/module";
import { SubsyncManualRunRequest, SubsyncResult } from "../../types";
export class SubsyncModule implements SubminerModule<AppContext> {
readonly id = "subsync";
private inProgress = false;
private context: AppContext["subsync"] | undefined;
init(context: AppContext): void {
if (!context.subsync) {
throw new Error("Subsync context is missing");
}
this.context = context.subsync;
}
isInProgress(): boolean {
return this.inProgress;
}
async triggerFromConfig(): Promise<void> {
if (!this.context) {
throw new Error("Subsync module not initialized");
}
if (this.inProgress) {
this.context.showOsd("Subsync already running");
return;
}
try {
if (this.context.getDefaultMode() === "manual") {
await this.context.openManualPicker();
this.context.showOsd("Subsync: choose engine and source");
return;
}
this.inProgress = true;
const result = await this.context.runWithSpinner(
() => this.context!.runAuto(),
"Subsync: syncing",
);
this.context.showOsd(result.message);
} catch (error) {
this.context.showOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
this.inProgress = false;
}
}
async runManual(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
if (!this.context) {
return { ok: false, message: "Subsync module not initialized" };
}
if (this.inProgress) {
const busy = "Subsync already running";
this.context.showOsd(busy);
return { ok: false, message: busy };
}
try {
this.inProgress = true;
const result = await this.context.runWithSpinner(
() => this.context!.runManual(request),
"Subsync: syncing",
);
this.context.showOsd(result.message);
return result;
} catch (error) {
const message = `Subsync failed: ${(error as Error).message}`;
this.context.showOsd(message);
return { ok: false, message };
} finally {
this.inProgress = false;
}
}
}

95
src/subsync/engines.ts Normal file
View File

@@ -0,0 +1,95 @@
export type SubsyncEngine = "alass" | "ffsubsync";
export interface SubsyncCommandResult {
ok: boolean;
code: number | null;
stderr: string;
stdout: string;
error?: string;
}
export interface SubsyncEngineExecutionContext {
referenceFilePath: string;
videoPath: string;
inputSubtitlePath: string;
outputPath: string;
audioStreamIndex: number | null;
resolveExecutablePath: (
configuredPath: string,
commandName: string,
) => string;
resolvedPaths: {
alassPath: string;
ffsubsyncPath: string;
};
runCommand: (command: string, args: string[]) => Promise<SubsyncCommandResult>;
}
export interface SubsyncEngineProvider {
engine: SubsyncEngine;
execute: (
context: SubsyncEngineExecutionContext,
) => Promise<SubsyncCommandResult>;
}
type SubsyncEngineProviderFactory = () => SubsyncEngineProvider;
const subsyncEngineProviderFactories = new Map<SubsyncEngine, SubsyncEngineProviderFactory>();
export function registerSubsyncEngineProvider(
engine: SubsyncEngine,
factory: SubsyncEngineProviderFactory,
): void {
if (subsyncEngineProviderFactories.has(engine)) {
return;
}
subsyncEngineProviderFactories.set(engine, factory);
}
export function createSubsyncEngineProvider(
engine: SubsyncEngine,
): SubsyncEngineProvider | null {
const factory = subsyncEngineProviderFactories.get(engine);
if (!factory) return null;
return factory();
}
function registerDefaultSubsyncEngineProviders(): void {
registerSubsyncEngineProvider("alass", () => ({
engine: "alass",
execute: async (context: SubsyncEngineExecutionContext) => {
const alassPath = context.resolveExecutablePath(
context.resolvedPaths.alassPath,
"alass",
);
return context.runCommand(alassPath, [
context.referenceFilePath,
context.inputSubtitlePath,
context.outputPath,
]);
},
}));
registerSubsyncEngineProvider("ffsubsync", () => ({
engine: "ffsubsync",
execute: async (context: SubsyncEngineExecutionContext) => {
const ffsubsyncPath = context.resolveExecutablePath(
context.resolvedPaths.ffsubsyncPath,
"ffsubsync",
);
const args = [
context.videoPath,
"-i",
context.inputSubtitlePath,
"-o",
context.outputPath,
];
if (context.audioStreamIndex !== null) {
args.push("--reference-stream", `0:${context.audioStreamIndex}`);
}
return context.runCommand(ffsubsyncPath, args);
},
}));
}
registerDefaultSubsyncEngineProviders();

46
src/subtitle/pipeline.ts Normal file
View File

@@ -0,0 +1,46 @@
import { TokenMergerProvider } from "../token-mergers";
import { TokenizerProvider } from "../tokenizers";
import { SubtitleData } from "../types";
import {
normalizeDisplayText,
normalizeTokenizerInput,
} from "./stages/normalize";
import { tokenizeStage } from "./stages/tokenize";
import { mergeStage } from "./stages/merge";
export interface SubtitlePipelineDeps {
getTokenizer: () => TokenizerProvider | null;
getTokenMerger: () => TokenMergerProvider | null;
}
export class SubtitlePipeline {
private readonly deps: SubtitlePipelineDeps;
constructor(deps: SubtitlePipelineDeps) {
this.deps = deps;
}
async process(text: string): Promise<SubtitleData> {
if (!text) {
return { text, tokens: null };
}
const displayText = normalizeDisplayText(text);
if (!displayText) {
return { text, tokens: null };
}
const tokenizeText = normalizeTokenizerInput(displayText);
try {
const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText);
const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens);
if (!mergedTokens || mergedTokens.length === 0) {
return { text: displayText, tokens: null };
}
return { text: displayText, tokens: mergedTokens };
} catch {
return { text: displayText, tokens: null };
}
}
}

View File

@@ -0,0 +1,12 @@
import { TokenMergerProvider } from "../../token-mergers";
import { MergedToken, Token } from "../../types";
export function mergeStage(
mergerProvider: TokenMergerProvider | null,
tokens: Token[] | null,
): MergedToken[] | null {
if (!mergerProvider || !tokens || tokens.length === 0) {
return null;
}
return mergerProvider.merge(tokens);
}

View File

@@ -0,0 +1,14 @@
export function normalizeDisplayText(text: string): string {
return text
.replace(/\r\n/g, "\n")
.replace(/\\N/g, "\n")
.replace(/\\n/g, "\n")
.trim();
}
export function normalizeTokenizerInput(displayText: string): string {
return displayText
.replace(/\n/g, " ")
.replace(/\s+/g, " ")
.trim();
}

View File

@@ -0,0 +1,12 @@
import { TokenizerProvider } from "../../tokenizers";
import { Token } from "../../types";
export async function tokenizeStage(
tokenizerProvider: TokenizerProvider | null,
input: string,
): Promise<Token[] | null> {
if (!tokenizerProvider || !input) {
return null;
}
return tokenizerProvider.tokenize(input);
}

View File

@@ -0,0 +1,42 @@
import { mergeTokens as defaultMergeTokens } from "../token-merger";
import { MergedToken, Token } from "../types";
export interface TokenMergerProvider {
id: string;
merge: (tokens: Token[]) => MergedToken[];
}
type TokenMergerProviderFactory = () => TokenMergerProvider;
const tokenMergerProviderFactories = new Map<string, TokenMergerProviderFactory>();
export function registerTokenMergerProvider(
id: string,
factory: TokenMergerProviderFactory,
): void {
if (tokenMergerProviderFactories.has(id)) {
return;
}
tokenMergerProviderFactories.set(id, factory);
}
export function getRegisteredTokenMergerProviderIds(): string[] {
return Array.from(tokenMergerProviderFactories.keys());
}
export function createTokenMergerProvider(
id = "default",
): TokenMergerProvider | null {
const factory = tokenMergerProviderFactories.get(id);
if (!factory) return null;
return factory();
}
function registerDefaultTokenMergerProviders(): void {
registerTokenMergerProvider("default", () => ({
id: "default",
merge: (tokens: Token[]) => defaultMergeTokens(tokens),
}));
}
registerDefaultTokenMergerProviders();

53
src/tokenizers/index.ts Normal file
View File

@@ -0,0 +1,53 @@
import { MecabTokenizer } from "../mecab-tokenizer";
import { MecabStatus, Token } from "../types";
export interface TokenizerProvider {
id: string;
checkAvailability: () => Promise<boolean>;
tokenize: (text: string) => Promise<Token[] | null>;
getStatus: () => MecabStatus;
setEnabled: (enabled: boolean) => void;
}
type TokenizerProviderFactory = () => TokenizerProvider;
const tokenizerProviderFactories = new Map<string, TokenizerProviderFactory>();
export function registerTokenizerProvider(
id: string,
factory: TokenizerProviderFactory,
): void {
if (tokenizerProviderFactories.has(id)) {
return;
}
tokenizerProviderFactories.set(id, factory);
}
export function getRegisteredTokenizerProviderIds(): string[] {
return Array.from(tokenizerProviderFactories.keys());
}
export function createTokenizerProvider(
id = "mecab",
): TokenizerProvider | null {
const factory = tokenizerProviderFactories.get(id);
if (!factory) {
return null;
}
return factory();
}
function registerDefaultTokenizerProviders(): void {
registerTokenizerProvider("mecab", () => {
const mecab = new MecabTokenizer();
return {
id: "mecab",
checkAvailability: () => mecab.checkAvailability(),
tokenize: (text: string) => mecab.tokenize(text),
getStatus: () => mecab.getStatus(),
setEnabled: (enabled: boolean) => mecab.setEnabled(enabled),
};
});
}
registerDefaultTokenizerProviders();

106
src/translators/index.ts Normal file
View File

@@ -0,0 +1,106 @@
import axios from "axios";
export interface TranslationRequest {
sentence: string;
apiKey: string;
baseUrl: string;
model: string;
targetLanguage: string;
systemPrompt: string;
timeoutMs?: number;
}
export interface TranslationProvider {
id: string;
translate: (request: TranslationRequest) => Promise<string | null>;
}
type TranslationProviderFactory = () => TranslationProvider;
const translationProviderFactories = new Map<string, TranslationProviderFactory>();
export function registerTranslationProvider(
id: string,
factory: TranslationProviderFactory,
): void {
if (translationProviderFactories.has(id)) {
return;
}
translationProviderFactories.set(id, factory);
}
export function createTranslationProvider(
id = "openai-compatible",
): TranslationProvider | null {
const factory = translationProviderFactories.get(id);
if (!factory) return null;
return factory();
}
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();
}
function normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
if (/\/v1$/i.test(trimmed)) {
return trimmed;
}
return `${trimmed}/v1`;
}
function registerDefaultTranslationProviders(): void {
registerTranslationProvider("openai-compatible", () => ({
id: "openai-compatible",
translate: async (request: TranslationRequest): Promise<string | null> => {
const response = await axios.post(
`${normalizeOpenAiBaseUrl(request.baseUrl)}/chat/completions`,
{
model: request.model,
temperature: 0,
messages: [
{ role: "system", content: request.systemPrompt },
{
role: "user",
content: `Translate this text to ${request.targetLanguage}:\n\n${request.sentence}`,
},
],
},
{
headers: {
Authorization: `Bearer ${request.apiKey}`,
"Content-Type": "application/json",
},
timeout: request.timeoutMs ?? 15000,
},
);
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
| { message?: { content?: unknown } }
| undefined;
const translated = extractAiText(content?.message?.content);
return translated || null;
},
}));
}
registerDefaultTranslationProviders();