Skip to content

Commit 940e40a

Browse files
committed
feat: service provider supports more than one OpenAI compatible services (add, rename, edit, delete), storages in application and pass to kernel according to the user's choice
1 parent bb3884f commit 940e40a

13 files changed

Lines changed: 617 additions & 50 deletions

File tree

‎electron/main/ipc/preferences-ipc.mjs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const SETTINGS_PREFERENCES = [
4646
'kernelAdvancedOptions',
4747
'translationServices',
4848
'translationServiceHistory',
49+
'customTranslationServices',
4950
];
5051
const APPEARANCE_PREFERENCES = [
5152
'interfaceStyle',
@@ -182,9 +183,12 @@ export function registerPreferencesIPC({
182183
updateAppearance();
183184
if (
184185
SETTINGS_PREFERENCES.some((key) =>
185-
['kernelAdvancedOptions', 'translationServices', 'translationServiceHistory'].includes(
186-
key,
187-
)
186+
[
187+
'kernelAdvancedOptions',
188+
'translationServices',
189+
'translationServiceHistory',
190+
'customTranslationServices',
191+
].includes(key)
188192
? JSON.stringify(previous[key]) !== JSON.stringify(next[key])
189193
: previous[key] !== next[key],
190194
)

‎electron/main/services/preferences.mjs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { SUPPORTED_UI_LANGUAGES } from '../../../shared/i18n/ui-language.mjs';
22
import { cloneGlossaries, validGlossaries } from '../../../shared/translation/glossary.mjs';
3+
import {
4+
cloneCustomServices,
5+
isValidCustomServices,
6+
} from '../../../shared/translation/custom-services.mjs';
37
import { validShortcutOverrides } from '../../../shared/commands/shortcuts.mjs';
48
import { isValidTranslationFont } from '../../../shared/reader/translation-font.mjs';
59
import {
@@ -66,6 +70,7 @@ const DEFAULT_PREFERENCES = Object.freeze({
6670
defaultPageCropY: 0,
6771
autoAlignDocumentWidth: false,
6872
kernelAdvancedOptions: {},
73+
customTranslationServices: [],
6974
showKernelToolbarShortcut: false,
7075
autoHideHeader: true,
7176
uiLanguage: 'en',
@@ -220,6 +225,7 @@ function clonePreferences(value) {
220225
const clone = {
221226
...value,
222227
kernelAdvancedOptions: cloneKernelAdvancedOptions(value?.kernelAdvancedOptions),
228+
customTranslationServices: cloneCustomServices(value?.customTranslationServices),
223229
};
224230
if (Object.hasOwn(value || {}, 'shortcutBindings'))
225231
clone.shortcutBindings = { ...value.shortcutBindings };
@@ -288,6 +294,7 @@ const VALIDATORS = {
288294
uiLanguage: (value) => ['system', ...SUPPORTED_UI_LANGUAGES].includes(value),
289295
translationServiceHistory: isValidTranslationServiceHistory,
290296
translationServices: isValidTranslationServices,
297+
customTranslationServices: isValidCustomServices,
291298
};
292299
const VALIDATION_MESSAGES = {
293300
documentOpenMode: 'Invalid document opening mode',
@@ -327,6 +334,7 @@ const VALIDATION_MESSAGES = {
327334
translationServiceHistory: 'Invalid translation service history',
328335
uiLanguage: 'Invalid UI language preference',
329336
translationServices: 'Invalid translation service preferences',
337+
customTranslationServices: 'Invalid OpenAI-compatible service preferences',
330338
};
331339

332340
function copyUnknown(value) {
@@ -352,7 +360,9 @@ function upgrade(value) {
352360
? cloneTranslationServices(value[key])
353361
: key === 'translationServiceHistory'
354362
? cloneTranslationServiceHistory(value[key])
355-
: value[key];
363+
: key === 'customTranslationServices'
364+
? cloneCustomServices(value[key])
365+
: value[key];
356366
if (!VALIDATORS.sourceLanguage(value.sourceLanguage)) {
357367
const entry = Object.entries(LANGUAGE_CODES).find(
358368
([, code]) => code === value.kernelAdvancedOptions?.pdf_math_fast?.lang_in,

‎electron/main/services/service-credentials.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { randomUUID } from 'node:crypto';
66
const SERVICE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
77
const FIELD_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
88
const MAX_VALUE_LENGTH = 16384;
9-
const ENGINES = new Set(['pdf_inspector', 'pdf_math_fast', 'pdf_math_precise']);
9+
// `custom` holds keys for user-defined OpenAI-compatible services shared by all kernels.
10+
const ENGINES = new Set(['pdf_inspector', 'pdf_math_fast', 'pdf_math_precise', 'custom']);
1011

1112
function isObject(value) {
1213
return value !== null && typeof value === 'object' && !Array.isArray(value);

‎server/translation/provider-runtime.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LANGUAGE_CODES, translationLanguageCode } from '../../shared/translation/languages.mjs';
2+
import { resolveCustomSelection } from '../../shared/translation/custom-services.mjs';
23
import { selectTranslationProvider, createTranslationProvider } from './translation-provider.mjs';
34

45
const autoService = { id: 'auto', label: 'Automatic (SiliconFlow free)', fields: [] };
@@ -73,6 +74,8 @@ export function createTranslationRuntime({
7374
language,
7475
{ cacheOnly = false } = {},
7576
) {
77+
// User-defined services run on the kernel's own OpenAI-compatible backend.
78+
selection = resolveCustomSelection(kernel, selection);
7679
if (selection?.id === 'chatgpt-subscription') {
7780
if (!chatGPTSubscription) throw Error('ChatGPT Subscription requires the desktop app.');
7881
const account = await chatGPTSubscription.status();
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// User-defined OpenAI-compatible services are stored once for the application
2+
// and projected into each kernel as that kernel's own OpenAI-compatible backend.
3+
export const CUSTOM_SERVICE_PREFIX = 'custom-';
4+
export const CUSTOM_CREDENTIAL_BUCKET = 'custom';
5+
export const MAX_CUSTOM_SERVICES = 32;
6+
export const MAX_CUSTOM_SERVICE_NAME = 80;
7+
const CUSTOM_ID = /^custom-[a-z0-9]{8,32}$/;
8+
const TEXT_LIMIT = 2048;
9+
10+
// Backend service and field ids for each kernel's OpenAI-compatible translator.
11+
const BACKENDS = Object.freeze({
12+
pdf_inspector: { service: 'openai', key: 'key', baseUrl: 'base_url', model: 'model' },
13+
pdf_math_fast: { service: 'openailiked', key: 'api_key', baseUrl: 'base_url', model: 'model' },
14+
pdf_math_precise: {
15+
service: 'openaicompatible',
16+
key: 'openai_compatible_api_key',
17+
baseUrl: 'openai_compatible_base_url',
18+
model: 'openai_compatible_model',
19+
},
20+
});
21+
22+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
23+
const singleLine = (value, limit) =>
24+
typeof value === 'string' && value.length <= limit && !/[\r\n\0]/.test(value);
25+
26+
export function isCustomServiceId(value) {
27+
return typeof value === 'string' && CUSTOM_ID.test(value);
28+
}
29+
30+
export function createCustomServiceId(random = globalThis.crypto) {
31+
const bytes = random.getRandomValues(new Uint8Array(6));
32+
return CUSTOM_SERVICE_PREFIX + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
33+
}
34+
35+
export function validCustomService(value) {
36+
return (
37+
record(value) &&
38+
isCustomServiceId(value.id) &&
39+
singleLine(value.name, MAX_CUSTOM_SERVICE_NAME) &&
40+
singleLine(value.base_url ?? '', TEXT_LIMIT) &&
41+
singleLine(value.model ?? '', TEXT_LIMIT)
42+
);
43+
}
44+
45+
export function isValidCustomServices(value) {
46+
if (!Array.isArray(value) || value.length > MAX_CUSTOM_SERVICES) return false;
47+
const ids = new Set();
48+
for (const entry of value) {
49+
if (!validCustomService(entry) || ids.has(entry.id)) return false;
50+
ids.add(entry.id);
51+
}
52+
return true;
53+
}
54+
55+
export function cloneCustomServices(value) {
56+
const result = [],
57+
ids = new Set();
58+
for (const entry of Array.isArray(value) ? value : []) {
59+
if (!validCustomService(entry) || ids.has(entry.id)) continue;
60+
ids.add(entry.id);
61+
result.push({
62+
id: entry.id,
63+
name: entry.name,
64+
base_url: entry.base_url || '',
65+
model: entry.model || '',
66+
});
67+
if (result.length === MAX_CUSTOM_SERVICES) break;
68+
}
69+
return result;
70+
}
71+
72+
// A kernel can host custom services only when its catalog ships the backend.
73+
export function customBackendFor(kernel, services = []) {
74+
const backend = BACKENDS[kernel];
75+
return backend && services.some((service) => service.id === backend.service) ? backend : null;
76+
}
77+
78+
export function customServiceDescriptors(kernel, services, customServices) {
79+
if (!customBackendFor(kernel, services)) return [];
80+
return cloneCustomServices(customServices).map((entry) => ({
81+
id: entry.id,
82+
label: entry.name || entry.id,
83+
custom: true,
84+
fields: [
85+
{ id: 'key', label: 'API key', type: 'string', secret: true, required: true },
86+
{ id: 'base_url', label: 'Base URL', type: 'url', required: true },
87+
{ id: 'model', label: 'Model', type: 'string', required: true },
88+
],
89+
}));
90+
}
91+
92+
// Present global definitions as ordinary per-kernel profiles and secrets.
93+
export function withCustomProfiles(config = {}, customServices = []) {
94+
const profiles = { ...(record(config.profiles) ? config.profiles : {}) };
95+
let values = config.values;
96+
for (const entry of cloneCustomServices(customServices)) {
97+
const entryValues = {
98+
...(entry.base_url ? { base_url: entry.base_url } : {}),
99+
...(entry.model ? { model: entry.model } : {}),
100+
};
101+
profiles[entry.id] = { values: entryValues };
102+
if (config.id === entry.id) values = entryValues;
103+
}
104+
return { ...config, values, profiles };
105+
}
106+
107+
export function withCustomCredentials(credentials = {}, kernel = '') {
108+
const custom = record(credentials?.[CUSTOM_CREDENTIAL_BUCKET])
109+
? credentials[CUSTOM_CREDENTIAL_BUCKET]
110+
: {};
111+
if (!kernel || !Object.keys(custom).length) return credentials || {};
112+
return {
113+
...credentials,
114+
[kernel]: { ...(record(credentials?.[kernel]) ? credentials[kernel] : {}), ...custom },
115+
};
116+
}
117+
118+
export function customSelectionValues(id, customServices = [], credentials = {}) {
119+
const entry = cloneCustomServices(customServices).find((item) => item.id === id);
120+
if (!entry) return null;
121+
const key = credentials?.[CUSTOM_CREDENTIAL_BUCKET]?.[id]?.key;
122+
return {
123+
...(entry.base_url ? { base_url: entry.base_url } : {}),
124+
...(entry.model ? { model: entry.model } : {}),
125+
...(typeof key === 'string' && key ? { key } : {}),
126+
};
127+
}
128+
129+
// Server side: rewrite a custom selection into the kernel's backend selection.
130+
export function resolveCustomSelection(kernel, selection) {
131+
if (!isCustomServiceId(selection?.id)) return selection;
132+
const backend = BACKENDS[kernel];
133+
if (!backend) throw Error('Unsupported translation service for this kernel');
134+
const values = record(selection.values) ? selection.values : {};
135+
const text = (name) => (typeof values[name] === 'string' ? values[name].trim() : '');
136+
const key = text('key'),
137+
baseUrl = text('base_url'),
138+
model = text('model');
139+
if (!key || !baseUrl || !model)
140+
throw Error('API key, Base URL and Model are required for this OpenAI-compatible service');
141+
return {
142+
id: backend.service,
143+
values: { [backend.key]: key, [backend.baseUrl]: baseUrl, [backend.model]: model },
144+
};
145+
}

0 commit comments

Comments
 (0)