fancive_obsidian-parallel-r.../src/settings.ts
wujunchen 08f510c65c refactor: split prompt and add localized UI
Move prompt and anchor matching into dedicated modules, add Chinese/English UI translation support, and cover cache fingerprint and translation behavior with tests.

Change-Id: Ieadc2197cba31cf750b83e2893909127da8ecca5
2026-04-25 15:15:26 +08:00

417 lines
13 KiB
TypeScript

'use strict';
import crypto from 'crypto';
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('自定义 provider 需要填写 API Base URL。');
}
const base = (preset.baseUrl || API_FORMATS[format].defaultBaseUrl || '').trim();
if (!base) {
throw new Error('API Base URL 未设置。请在设置里选择 provider 或填写自定义 base URL。');
}
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('Model 未设置。请在设置里填写模型 ID。');
}
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);
}