mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
'use strict';
|
|
|
|
import crypto from 'crypto';
|
|
import { translate } from './i18n';
|
|
|
|
export const MAX_DOC_CHARS = 20000;
|
|
export const PROMPT_VERSION = 2;
|
|
export const CACHE_SCHEMA_VERSION = 2;
|
|
export const DEFAULT_MAX_CACHE_ENTRIES = 100;
|
|
export const PROMPT_LANGUAGES = {
|
|
zh: '中文',
|
|
en: 'English',
|
|
auto: 'Auto-detect',
|
|
};
|
|
export const UI_LANGUAGES = {
|
|
auto: 'Auto',
|
|
zh: '中文',
|
|
en: 'English',
|
|
};
|
|
|
|
export const DEFAULT_SETTINGS = {
|
|
uiLanguage: 'auto',
|
|
backend: 'claude-code',
|
|
cliPath: '',
|
|
apiProvider: 'anthropic',
|
|
apiFormat: 'anthropic-messages',
|
|
apiBaseUrl: '',
|
|
apiKey: '',
|
|
apiKeyEnvVar: '',
|
|
apiAuthType: 'auto',
|
|
apiHeaders: '',
|
|
apiMaxTokens: 4096,
|
|
maxDocChars: MAX_DOC_CHARS,
|
|
maxCacheEntries: DEFAULT_MAX_CACHE_ENTRIES,
|
|
promptLanguage: 'zh',
|
|
minCards: 5,
|
|
maxCards: 15,
|
|
customSystemPrompt: '',
|
|
model: 'claude-sonnet-4-6',
|
|
exportFolder: 'Reading/Articles',
|
|
cliTimeoutMs: 120000,
|
|
};
|
|
|
|
export const API_FORMATS = {
|
|
'anthropic-messages': {
|
|
label: 'Anthropic Messages',
|
|
defaultBaseUrl: 'https://api.anthropic.com/v1',
|
|
defaultAuthType: 'x-api-key',
|
|
},
|
|
'openai-chat': {
|
|
label: 'OpenAI Chat Completions',
|
|
defaultBaseUrl: 'https://api.openai.com/v1',
|
|
defaultAuthType: 'bearer',
|
|
tokenLimitField: 'max_tokens',
|
|
},
|
|
'openai-responses': {
|
|
label: 'OpenAI Responses',
|
|
defaultBaseUrl: 'https://api.openai.com/v1',
|
|
defaultAuthType: 'bearer',
|
|
},
|
|
'google-generative-ai': {
|
|
label: 'Google Gemini generateContent',
|
|
defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
|
defaultAuthType: 'x-goog-api-key',
|
|
},
|
|
};
|
|
|
|
export const API_AUTH_TYPES = {
|
|
auto: 'Auto',
|
|
bearer: 'Authorization: Bearer',
|
|
'x-api-key': 'x-api-key',
|
|
'x-goog-api-key': 'x-goog-api-key',
|
|
'api-key': 'api-key',
|
|
none: 'None',
|
|
};
|
|
|
|
export const API_PROVIDER_PRESETS = {
|
|
anthropic: {
|
|
label: 'Anthropic',
|
|
format: 'anthropic-messages',
|
|
baseUrl: 'https://api.anthropic.com/v1',
|
|
authType: 'x-api-key',
|
|
envVar: 'ANTHROPIC_API_KEY',
|
|
model: 'claude-sonnet-4-6',
|
|
},
|
|
openai: {
|
|
label: 'OpenAI',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
authType: 'bearer',
|
|
envVar: 'OPENAI_API_KEY',
|
|
tokenLimitField: 'max_completion_tokens',
|
|
model: 'gpt-5.1',
|
|
},
|
|
'openai-responses': {
|
|
label: 'OpenAI Responses',
|
|
format: 'openai-responses',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
authType: 'bearer',
|
|
envVar: 'OPENAI_API_KEY',
|
|
modelPrefix: 'openai',
|
|
model: 'gpt-5.1',
|
|
},
|
|
google: {
|
|
label: 'Google Gemini',
|
|
format: 'google-generative-ai',
|
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
|
authType: 'x-goog-api-key',
|
|
envVar: 'GEMINI_API_KEY',
|
|
model: 'gemini-3-pro-preview',
|
|
},
|
|
openrouter: {
|
|
label: 'OpenRouter',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://openrouter.ai/api/v1',
|
|
authType: 'bearer',
|
|
envVar: 'OPENROUTER_API_KEY',
|
|
model: '',
|
|
},
|
|
groq: {
|
|
label: 'Groq',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.groq.com/openai/v1',
|
|
authType: 'bearer',
|
|
envVar: 'GROQ_API_KEY',
|
|
model: '',
|
|
},
|
|
deepseek: {
|
|
label: 'DeepSeek',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.deepseek.com',
|
|
authType: 'bearer',
|
|
envVar: 'DEEPSEEK_API_KEY',
|
|
model: 'deepseek-chat',
|
|
},
|
|
moonshot: {
|
|
label: 'Moonshot / Kimi',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.moonshot.ai/v1',
|
|
authType: 'bearer',
|
|
envVar: 'MOONSHOT_API_KEY',
|
|
model: 'kimi-k2.5',
|
|
},
|
|
qianfan: {
|
|
label: 'Baidu Qianfan',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://qianfan.baidubce.com/v2',
|
|
authType: 'bearer',
|
|
envVar: 'QIANFAN_API_KEY',
|
|
model: 'deepseek-v3.2',
|
|
},
|
|
minimax: {
|
|
label: 'MiniMax (Anthropic-compatible)',
|
|
format: 'anthropic-messages',
|
|
baseUrl: 'https://api.minimax.io/anthropic',
|
|
authType: 'bearer',
|
|
envVar: 'MINIMAX_API_KEY',
|
|
model: 'MiniMax-M2.1',
|
|
},
|
|
xai: {
|
|
label: 'xAI',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.x.ai/v1',
|
|
authType: 'bearer',
|
|
envVar: 'XAI_API_KEY',
|
|
model: '',
|
|
},
|
|
mistral: {
|
|
label: 'Mistral',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.mistral.ai/v1',
|
|
authType: 'bearer',
|
|
envVar: 'MISTRAL_API_KEY',
|
|
model: '',
|
|
},
|
|
cerebras: {
|
|
label: 'Cerebras',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.cerebras.ai/v1',
|
|
authType: 'bearer',
|
|
envVar: 'CEREBRAS_API_KEY',
|
|
model: '',
|
|
},
|
|
zai: {
|
|
label: 'Z.AI / GLM',
|
|
format: 'openai-chat',
|
|
baseUrl: 'https://api.z.ai/api/paas/v4',
|
|
authType: 'bearer',
|
|
envVar: 'ZAI_API_KEY',
|
|
model: '',
|
|
},
|
|
ollama: {
|
|
label: 'Ollama (local)',
|
|
format: 'openai-chat',
|
|
baseUrl: 'http://127.0.0.1:11434/v1',
|
|
authType: 'none',
|
|
envVar: '',
|
|
model: '',
|
|
},
|
|
lmstudio: {
|
|
label: 'LM Studio (local)',
|
|
format: 'openai-chat',
|
|
baseUrl: 'http://127.0.0.1:1234/v1',
|
|
authType: 'none',
|
|
envVar: '',
|
|
model: '',
|
|
},
|
|
'custom-openai': {
|
|
label: 'Custom OpenAI-compatible',
|
|
format: 'openai-chat',
|
|
baseUrl: '',
|
|
authType: 'bearer',
|
|
envVar: '',
|
|
model: '',
|
|
},
|
|
'custom-anthropic': {
|
|
label: 'Custom Anthropic-compatible',
|
|
format: 'anthropic-messages',
|
|
baseUrl: '',
|
|
authType: 'x-api-key',
|
|
envVar: '',
|
|
model: '',
|
|
},
|
|
};
|
|
|
|
export function hashContent(text) {
|
|
return crypto.createHash('sha1').update(text, 'utf8').digest('hex');
|
|
}
|
|
|
|
export function stableStringify(value) {
|
|
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
|
|
if (value && typeof value === 'object') {
|
|
return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
export function isApiBackend(backend) {
|
|
return backend === 'api' || backend === 'anthropic-api';
|
|
}
|
|
|
|
export function getApiPreset(settings) {
|
|
return API_PROVIDER_PRESETS[settings.apiProvider] || API_PROVIDER_PRESETS.anthropic;
|
|
}
|
|
|
|
export function getApiFormat(settings) {
|
|
const preset = getApiPreset(settings);
|
|
const format = (settings.apiFormat || preset.format || '').trim();
|
|
return API_FORMATS[format] ? format : preset.format;
|
|
}
|
|
|
|
export function getApiBaseUrl(settings) {
|
|
const format = getApiFormat(settings);
|
|
const preset = getApiPreset(settings);
|
|
const explicit = (settings.apiBaseUrl || '').trim();
|
|
if (explicit) return explicit.replace(/\/+$/, '');
|
|
if ((settings.apiProvider || '').startsWith('custom-')) {
|
|
throw new Error(translate(settings, 'errorCustomProviderBaseUrlRequired'));
|
|
}
|
|
const base = (preset.baseUrl || API_FORMATS[format].defaultBaseUrl || '').trim();
|
|
if (!base) {
|
|
throw new Error(translate(settings, 'errorApiBaseUrlMissing'));
|
|
}
|
|
return base.replace(/\/+$/, '');
|
|
}
|
|
|
|
export function getApiAuthType(settings) {
|
|
const configured = (settings.apiAuthType || 'auto').trim();
|
|
if (configured && configured !== 'auto') return configured;
|
|
const preset = getApiPreset(settings);
|
|
const format = getApiFormat(settings);
|
|
return preset.authType || API_FORMATS[format].defaultAuthType || 'bearer';
|
|
}
|
|
|
|
export function getApiKey(settings) {
|
|
const direct = (settings.apiKey || '').trim();
|
|
if (direct) return direct;
|
|
const envVar = (settings.apiKeyEnvVar || getApiPreset(settings).envVar || '').trim();
|
|
if (envVar && process.env && process.env[envVar]) {
|
|
return String(process.env[envVar]).trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function modelForApi(settings) {
|
|
const raw = (settings.model || '').trim();
|
|
if (!raw) {
|
|
throw new Error(translate(settings, 'errorModelMissing'));
|
|
}
|
|
const preset = getApiPreset(settings);
|
|
const prefixes = [settings.apiProvider, preset.modelPrefix]
|
|
.map(p => (p || '').trim())
|
|
.filter(Boolean);
|
|
const lowerRaw = raw.toLowerCase();
|
|
for (const provider of prefixes) {
|
|
const normalized = provider.toLowerCase();
|
|
if (lowerRaw.startsWith(normalized + '/')) {
|
|
return raw.slice(provider.length + 1).trim();
|
|
}
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
export function applyApiProviderPreset(settings, providerId) {
|
|
const previousPreset = getApiPreset(settings);
|
|
const preset = API_PROVIDER_PRESETS[providerId] || API_PROVIDER_PRESETS.anthropic;
|
|
const previousModel = (settings.model || '').trim();
|
|
const shouldSwapModel =
|
|
!previousModel ||
|
|
previousModel === DEFAULT_SETTINGS.model ||
|
|
(!!previousPreset.model && previousModel === previousPreset.model);
|
|
|
|
settings.apiProvider = providerId;
|
|
settings.apiFormat = preset.format;
|
|
settings.apiBaseUrl = preset.baseUrl;
|
|
settings.apiAuthType = preset.authType || 'auto';
|
|
settings.apiKeyEnvVar = preset.envVar || '';
|
|
if (shouldSwapModel) settings.model = preset.model || '';
|
|
}
|
|
|
|
export function normalizeSettings(settings) {
|
|
if (!UI_LANGUAGES[settings.uiLanguage]) settings.uiLanguage = DEFAULT_SETTINGS.uiLanguage;
|
|
if (!settings.apiProvider || !API_PROVIDER_PRESETS[settings.apiProvider]) {
|
|
settings.apiProvider = 'anthropic';
|
|
}
|
|
const preset = getApiPreset(settings);
|
|
if (!settings.apiFormat || !API_FORMATS[settings.apiFormat]) settings.apiFormat = preset.format;
|
|
if (!settings.apiAuthType || !API_AUTH_TYPES[settings.apiAuthType]) settings.apiAuthType = 'auto';
|
|
if (settings.backend === 'anthropic-api') {
|
|
settings.apiProvider = settings.apiProvider || 'anthropic';
|
|
settings.apiFormat = settings.apiFormat || 'anthropic-messages';
|
|
settings.apiBaseUrl = settings.apiBaseUrl || API_PROVIDER_PRESETS.anthropic.baseUrl;
|
|
settings.apiAuthType = settings.apiAuthType || 'x-api-key';
|
|
settings.apiKeyEnvVar = settings.apiKeyEnvVar || 'ANTHROPIC_API_KEY';
|
|
}
|
|
const n = Number(settings.apiMaxTokens);
|
|
if (!Number.isFinite(n) || n <= 0) settings.apiMaxTokens = DEFAULT_SETTINGS.apiMaxTokens;
|
|
const maxDocChars = Number(settings.maxDocChars);
|
|
if (!Number.isFinite(maxDocChars) || maxDocChars < 1000) settings.maxDocChars = DEFAULT_SETTINGS.maxDocChars;
|
|
settings.maxCacheEntries = normalizeMaxCacheEntries(settings.maxCacheEntries);
|
|
if (!PROMPT_LANGUAGES[settings.promptLanguage]) settings.promptLanguage = DEFAULT_SETTINGS.promptLanguage;
|
|
settings.minCards = normalizeCardCount(settings.minCards, DEFAULT_SETTINGS.minCards);
|
|
settings.maxCards = normalizeCardCount(settings.maxCards, DEFAULT_SETTINGS.maxCards);
|
|
if (settings.maxCards < settings.minCards) settings.maxCards = settings.minCards;
|
|
if (typeof settings.customSystemPrompt !== 'string') settings.customSystemPrompt = '';
|
|
return settings;
|
|
}
|
|
|
|
export function normalizeCardCount(value, fallback) {
|
|
const n = Math.floor(Number(value));
|
|
if (!Number.isFinite(n)) return fallback;
|
|
return Math.min(30, Math.max(1, n));
|
|
}
|
|
|
|
export function normalizeMaxCacheEntries(value) {
|
|
const n = Math.floor(Number(value));
|
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_MAX_CACHE_ENTRIES;
|
|
return n;
|
|
}
|
|
|
|
function cacheEntryTime(entry) {
|
|
const value = entry && (entry.lastAccessedAt || entry.generatedAt || entry.updatedAt);
|
|
const timestamp = Date.parse(value || '');
|
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
}
|
|
|
|
export function pruneCacheEntries(cache, maxEntries) {
|
|
if (!cache || typeof cache !== 'object') return [];
|
|
const max = normalizeMaxCacheEntries(maxEntries);
|
|
const entries = Object.entries(cache);
|
|
if (entries.length <= max) return [];
|
|
|
|
const removed = entries
|
|
.sort(([pathA, entryA], [pathB, entryB]) => {
|
|
const delta = cacheEntryTime(entryA) - cacheEntryTime(entryB);
|
|
return delta || pathA.localeCompare(pathB);
|
|
})
|
|
.slice(0, entries.length - max)
|
|
.map(([filePath]) => filePath);
|
|
|
|
for (const filePath of removed) delete cache[filePath];
|
|
return removed;
|
|
}
|
|
|
|
export function generationFingerprint(settings) {
|
|
const normalized = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settings || {}));
|
|
const apiBackend = isApiBackend(normalized.backend);
|
|
const preset = getApiPreset(normalized);
|
|
const format = getApiFormat(normalized);
|
|
const apiBaseUrl = apiBackend
|
|
? ((normalized.apiBaseUrl || preset.baseUrl || API_FORMATS[format]?.defaultBaseUrl || '').trim().replace(/\/+$/, ''))
|
|
: '';
|
|
return hashContent(stableStringify({
|
|
cacheSchemaVersion: CACHE_SCHEMA_VERSION,
|
|
promptVersion: PROMPT_VERSION,
|
|
maxDocChars: Number(normalized.maxDocChars) || DEFAULT_SETTINGS.maxDocChars,
|
|
promptLanguage: normalized.promptLanguage,
|
|
minCards: normalized.minCards,
|
|
maxCards: normalized.maxCards,
|
|
customSystemPromptHash: hashContent(normalized.customSystemPrompt || ''),
|
|
backend: normalized.backend,
|
|
model: normalized.model,
|
|
apiProvider: apiBackend ? normalized.apiProvider : '',
|
|
apiFormat: apiBackend ? format : '',
|
|
apiBaseUrl,
|
|
apiAuthType: apiBackend ? getApiAuthType(normalized) : '',
|
|
apiHeadersHash: apiBackend ? hashContent(normalized.apiHeaders || '') : '',
|
|
apiMaxTokens: apiBackend ? Number(normalized.apiMaxTokens) || DEFAULT_SETTINGS.apiMaxTokens : 0,
|
|
structuredOutputVersion: 1,
|
|
}));
|
|
}
|
|
|
|
export function cacheEntryMatches(entry, content, settings) {
|
|
return !!entry &&
|
|
entry.schemaVersion === CACHE_SCHEMA_VERSION &&
|
|
entry.contentHash === hashContent(content) &&
|
|
entry.settingsHash === generationFingerprint(settings);
|
|
}
|