mirror of
https://github.com/jacobinwwey/obsidian-NotEMD.git
synced 2026-07-22 05:48:27 +00:00
feat(settings): land metadata-driven provider panel
This commit is contained in:
parent
199acda84b
commit
0cdd4acc8c
15 changed files with 1148 additions and 124 deletions
|
|
@ -250,8 +250,9 @@ Access plugin settings via:
|
|||
* **API Key**: Required for most cloud providers (e.g., OpenAI, Anthropic, DeepSeek, Qwen, Qwen Code, Doubao, Moonshot, Xiaomi MiMo, GLM, Z AI, MiniMax, Huawei Cloud MaaS, Baidu Qianfan, SiliconFlow, Google, Mistral, Azure OpenAI, OpenRouter, xAI, Groq, Together, Fireworks, Requesty). Not needed for Ollama. Optional for LM Studio and the generic `OpenAI Compatible` preset when your endpoint accepts anonymous or placeholder access.
|
||||
* **Base URL / Endpoint**: The API endpoint for the service. Defaults are provided, but you may need to change this for local models (LMStudio, Ollama), gateways (OpenRouter, Requesty, OpenAI Compatible), or specific Azure deployments. **Required for Azure OpenAI.**
|
||||
* **Model**: The specific model name/ID to use (e.g., `gpt-4o`, `claude-3-5-sonnet-20240620`, `google/gemini-flash-1.5`, `grok-4`, `moonshotai/kimi-k2-instruct-0905`, `accounts/fireworks/models/kimi-k2p5`, `anthropic/claude-3-7-sonnet-latest`). Ensure the model is available at your endpoint/provider.
|
||||
* **Temperature**: Controls the randomness of the LLM's output (0=deterministic, 1=max creativity). Lower values (e.g., 0.2-0.5) are generally better for structured tasks.
|
||||
* **API Version (Azure Only)**: Required for Azure OpenAI deployments (e.g., `2024-02-15-preview`).
|
||||
* **Advanced Settings**: Secondary tuning controls such as `Temperature`, `Top-p`, reasoning hints, DeepSeek thinking mode, and the provider-specific output-token override now live behind **Show advanced settings**. If a provider already has saved advanced overrides, this section auto-expands so existing behavior stays visible instead of being silently hidden.
|
||||
* **Fetch Model List**: Supported providers can query the endpoint for available model IDs without replacing manual model entry. In the first batch this covers OpenAI-compatible `/models`, Ollama tags, and Google Gemini model listing. The fetched list is transient suggestions only; your saved `Model` string remains the source of truth.
|
||||
3. **Test Connection**: Use the "Test Connection" button for the active provider to verify your settings. OpenAI-compatible providers now use provider-aware checks: endpoints such as `Qwen`, `Qwen Code`, `Doubao`, `Moonshot`, `Xiaomi MiMo`, `GLM`, `Z AI`, `MiniMax`, `Huawei Cloud MaaS`, `Baidu Qianfan`, `SiliconFlow`, `Groq`, `Together`, `Fireworks`, `LMStudio`, and `OpenAI Compatible` probe `chat/completions` directly, while providers with a reliable `/models` endpoint can still use model listing first. If the first probe fails with a transient network disconnect such as `ERR_CONNECTION_CLOSED`, Notemd automatically falls back to the stable retry sequence instead of failing immediately.
|
||||
4. **Manage Provider Configurations**: Use the "Export Providers" and "Import Providers" buttons to save/load your LLM provider settings to/from a `notemd-providers.json` file within the plugin's configuration directory. This allows for easy backup and sharing.
|
||||
5. **Preset Coverage**: In addition to the original providers, Notemd now includes preset entries for `Qwen`, `Qwen Code`, `Doubao`, `Moonshot`, `Xiaomi MiMo`, `GLM`, `Z AI`, `MiniMax`, `Huawei Cloud MaaS`, `Baidu Qianfan`, `SiliconFlow`, `xAI`, `Groq`, `Together`, `Fireworks`, `Requesty`, and a generic `OpenAI Compatible` target for LiteLLM, vLLM, Perplexity, Vercel AI Gateway, or custom proxies.
|
||||
|
|
|
|||
|
|
@ -232,8 +232,9 @@ Notemd 通过与各种大型语言模型 (LLM) 集成来增强您的 Obsidian
|
|||
- **API 密钥**: 大多数云提供商(例如 OpenAI、Anthropic、DeepSeek、Qwen、Qwen Code、Doubao、Moonshot、Xiaomi MiMo、GLM、Z AI、MiniMax、Huawei Cloud MaaS、Baidu Qianfan、SiliconFlow、Google、Mistral、Azure OpenAI、OpenRouter、xAI、Groq、Together、Fireworks、Requesty)需要。Ollama 不需要。LMStudio 和通用的 `OpenAI Compatible` 预设在某些允许匿名或占位密钥的端点上可留空。
|
||||
- **基础 URL / 端点**: 服务的 API 端点。提供了默认值,但您可能需要为本地模型(LMStudio、Ollama)、网关(OpenRouter、Requesty、OpenAI Compatible)或特定 Azure 部署修改此项。**Azure OpenAI 必填。**
|
||||
- **模型**: 要使用的具体模型名称/ID(例如 `gpt-4o`, `claude-3-5-sonnet-20240620`, `google/gemini-flash-1.5`, `grok-4`, `moonshotai/kimi-k2-instruct-0905`, `accounts/fireworks/models/kimi-k2p5`, `anthropic/claude-3-7-sonnet-latest`)。请确保该模型在您的端点/提供商处可用。
|
||||
- **温度**: 控制 LLM 输出的随机性(0=确定性,1=最大创造力)。较低的值(例如 0.2-0.5)通常更适合结构化任务。
|
||||
- **API 版本(仅 Azure)**: Azure OpenAI 部署需要(例如 `2024-02-15-preview`)。
|
||||
- **高级设置**: `Temperature`、`Top-p`、reasoning hint、DeepSeek thinking mode、provider 级 output-token override 等次级调优项现在统一收入 **显示高级设置**。如果某个 provider 已经保存了 advanced override,该区域会默认展开,避免现有行为被静默藏起来。
|
||||
- **获取模型列表**: 已支持的 provider 可以直接向端点查询可用 model ID,但不会替代手动 `Model` 输入。首批仅覆盖 OpenAI-compatible `/models`、Ollama tags 与 Google Gemini model listing。获取到的列表只是瞬时建议,持久化真值仍然是用户保存的 `Model` 字符串。
|
||||
|
||||
3. **测试连接**: 使用活动提供商的“测试连接”按钮来验证您的设置。OpenAI-compatible 提供商现在会按 provider 特性自动选择测试策略:`Qwen`、`Qwen Code`、`Doubao`、`Moonshot`、`Xiaomi MiMo`、`GLM`、`Z AI`、`MiniMax`、`Huawei Cloud MaaS`、`Baidu Qianfan`、`SiliconFlow`、`Groq`、`Together`、`Fireworks`、`LMStudio` 与 `OpenAI Compatible` 会直接探测 `chat/completions`,而具备稳定 `/models` 端点的服务仍会优先走模型列表探测。如果首次探测遇到 `ERR_CONNECTION_CLOSED` 这类瞬时网络断连,Notemd 会自动切入稳定重试序列,而不是立刻报错。
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,22 @@ This document summarizes the major functional and architectural changes implemen
|
|||
|
||||
### Highlights
|
||||
|
||||
- Provider settings now converge on a metadata-driven core/advanced split, and supported providers can fetch transient model suggestions without creating a second persisted provider catalog.
|
||||
- Developer-gated advanced batch folder selection now supports saved profiles, one-run overrides, preset chips, and inline file preview before execution.
|
||||
- Developer-gated relaxed input file restrictions now land as a shared runtime contract for selected source-preserving tasks, including PDF text extraction through Obsidian's PDF runtime.
|
||||
- Release-facing version truth, welcome digest, release notes, and chronicle refresh are synchronized at the `1.9.0` boundary.
|
||||
|
||||
### New Features
|
||||
|
||||
- Added provider-field taxonomy metadata (`core`, `contextual`, `advanced`, `developer`) so the provider settings panel can render required/default-visible controls separately from secondary tuning controls.
|
||||
- Added lightweight in-settings model discovery for OpenAI-compatible `/models`, Ollama tags, and Google Gemini model listing. Discovery results are transient suggestions only; persisted provider state still uses the existing manual `model` string.
|
||||
- Added the `Relax input file restrictions` developer setting plus the shared supported-input reader layer.
|
||||
- Extended advanced folder-task selection with profile-aware folder locking, temporary overrides, preview panels, and example/preset chips.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Provider advanced settings now auto-expand when a provider already has saved advanced overrides, preserving visibility for existing `data.json` behavior instead of silently collapsing non-default live config.
|
||||
- Added discovery prerequisite validation and endpoint normalization so `/chat/completions`, `/models`, and `/tags` style base URLs do not cause malformed model-list requests.
|
||||
- Unified task-aware input validation across sidebar actions, command entrypoints, batch adapters, and path-based diagram generation.
|
||||
- Corrected relaxed-mode batch empty-folder notices so they describe supported inputs rather than hard-coded `.md` / `.txt` assumptions.
|
||||
- Kept source-mutating and verbatim-sensitive flows intentionally restricted while widening only the safe allowlist.
|
||||
|
|
|
|||
|
|
@ -205,6 +205,20 @@ export const STRINGS_EN = {
|
|||
modelName: 'Model',
|
||||
modelDesc: 'Model name to use with {provider}.',
|
||||
modelPlaceholder: 'Enter model name',
|
||||
advancedSettingsName: 'Show advanced settings',
|
||||
advancedSettingsDesc: 'Reveal tuning and provider-specific overrides for {provider}.',
|
||||
advancedSettingsHint: 'Advanced settings are expanded because this provider already has non-default advanced values saved.',
|
||||
fetchModelsName: 'Fetch model list',
|
||||
fetchModelsDesc: 'Query the provider endpoint for available model IDs. Manual model entry remains supported.',
|
||||
fetchModelsButton: 'Fetch models',
|
||||
fetchModelsLoading: 'Fetching...',
|
||||
fetchModelsSuccess: 'Fetched {count} models for {provider}.',
|
||||
fetchModelsEmpty: 'No models were returned by {provider}. You can still enter a model manually.',
|
||||
fetchModelsUnavailable: '{provider} does not expose a supported model-list endpoint in this first batch. Enter the model manually.',
|
||||
fetchModelsError: 'Failed to fetch models from {provider}: {message}',
|
||||
discoveredModelsName: 'Discovered models',
|
||||
discoveredModelsDesc: 'Choose a discovered model to fill the model field, or keep manual entry.',
|
||||
discoveredModelsUseButton: 'Use',
|
||||
temperatureName: 'Temperature',
|
||||
temperatureDesc: 'Controls randomness (0=deterministic, 1=creative).',
|
||||
maxOutputTokensName: 'Provider output token override',
|
||||
|
|
@ -220,6 +234,7 @@ export const STRINGS_EN = {
|
|||
apiVersionName: 'API version',
|
||||
apiVersionDesc: 'Required API version for Azure OpenAI (e.g., 2024-02-15-preview)',
|
||||
apiVersionPlaceholder: 'Enter API version',
|
||||
contextualSettingsName: 'Provider-specific required settings',
|
||||
testConnectionName: 'Test {provider} connection',
|
||||
testConnectionDesc: 'Verify API key, endpoint, and model accessibility.',
|
||||
testConnectionButton: 'Test connection',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,20 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
|
|||
modelName: '模型',
|
||||
modelDesc: '{provider} 使用的模型名称。',
|
||||
modelPlaceholder: '输入模型名称',
|
||||
advancedSettingsName: '显示高级设置',
|
||||
advancedSettingsDesc: '展开 {provider} 的调优项与提供商特定覆盖项。',
|
||||
advancedSettingsHint: '由于当前提供商已保存非默认高级值,因此默认展开高级设置。',
|
||||
fetchModelsName: '获取模型列表',
|
||||
fetchModelsDesc: '向提供商 endpoint 查询可用模型 ID。仍然支持手动填写模型。',
|
||||
fetchModelsButton: '获取模型',
|
||||
fetchModelsLoading: '获取中...',
|
||||
fetchModelsSuccess: '已为 {provider} 获取 {count} 个模型。',
|
||||
fetchModelsEmpty: '{provider} 没有返回模型列表。你仍然可以手动输入模型。',
|
||||
fetchModelsUnavailable: '{provider} 在当前第一批实现中不提供受支持的模型列表 endpoint。请手动输入模型。',
|
||||
fetchModelsError: '从 {provider} 获取模型列表失败:{message}',
|
||||
discoveredModelsName: '已发现的模型',
|
||||
discoveredModelsDesc: '选择一个已发现模型填入模型字段,或继续手动输入。',
|
||||
discoveredModelsUseButton: '使用',
|
||||
temperatureName: '温度',
|
||||
temperatureDesc: '控制随机性(0=确定性,1=更有创造性)。',
|
||||
maxOutputTokensName: '提供商输出 Token 覆盖上限',
|
||||
|
|
@ -223,6 +237,7 @@ export const STRINGS_ZH_CN: DeepPartial<NotemdEnglishStrings> = {
|
|||
apiVersionName: 'API 版本',
|
||||
apiVersionDesc: 'Azure OpenAI 所需的 API 版本(例如 2024-02-15-preview)',
|
||||
apiVersionPlaceholder: '输入 API 版本',
|
||||
contextualSettingsName: '提供商特定必填设置',
|
||||
testConnectionName: '测试 {provider} 连接',
|
||||
testConnectionDesc: '验证 API Key、endpoint 和模型是否可访问。',
|
||||
testConnectionButton: '测试连接',
|
||||
|
|
|
|||
|
|
@ -208,6 +208,20 @@ export const STRINGS_ZH_TW: DeepPartial<NotemdEnglishStrings> = {
|
|||
modelName: '模型',
|
||||
modelDesc: '{provider} 使用的模型名稱。',
|
||||
modelPlaceholder: '輸入模型名稱',
|
||||
advancedSettingsName: '顯示進階設定',
|
||||
advancedSettingsDesc: '展開 {provider} 的調校項與提供者特定覆寫項。',
|
||||
advancedSettingsHint: '由於目前提供者已儲存非預設的進階值,因此預設展開進階設定。',
|
||||
fetchModelsName: '取得模型清單',
|
||||
fetchModelsDesc: '向提供者 endpoint 查詢可用模型 ID。仍然支援手動輸入模型。',
|
||||
fetchModelsButton: '取得模型',
|
||||
fetchModelsLoading: '取得中...',
|
||||
fetchModelsSuccess: '已為 {provider} 取得 {count} 個模型。',
|
||||
fetchModelsEmpty: '{provider} 沒有回傳模型清單。你仍然可以手動輸入模型。',
|
||||
fetchModelsUnavailable: '{provider} 在目前第一批實作中不提供受支援的模型清單 endpoint。請手動輸入模型。',
|
||||
fetchModelsError: '從 {provider} 取得模型清單失敗:{message}',
|
||||
discoveredModelsName: '已發現的模型',
|
||||
discoveredModelsDesc: '選擇一個已發現模型填入模型欄位,或繼續手動輸入。',
|
||||
discoveredModelsUseButton: '使用',
|
||||
temperatureName: '溫度',
|
||||
temperatureDesc: '控制隨機性(0=決定性,1=更具創造性)。',
|
||||
maxOutputTokensName: '提供者輸出 Token 覆蓋上限',
|
||||
|
|
@ -223,6 +237,7 @@ export const STRINGS_ZH_TW: DeepPartial<NotemdEnglishStrings> = {
|
|||
apiVersionName: 'API 版本',
|
||||
apiVersionDesc: 'Azure OpenAI 所需的 API 版本(例如 2024-02-15-preview)',
|
||||
apiVersionPlaceholder: '輸入 API 版本',
|
||||
contextualSettingsName: '提供者特定必填設定',
|
||||
testConnectionName: '測試 {provider} 連線',
|
||||
testConnectionDesc: '驗證 API Key、endpoint 與模型是否可存取。',
|
||||
testConnectionButton: '測試連線',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,27 @@ export type LLMProviderTransport = 'openai-compatible' | 'anthropic' | 'google'
|
|||
export type LLMProviderApiKeyMode = 'required' | 'optional' | 'none';
|
||||
export type LLMProviderApiTestMode = 'models-then-chat' | 'chat-only';
|
||||
export type LLMProviderValidationLevel = 'warning' | 'error';
|
||||
export type LLMProviderSettingFieldId =
|
||||
| 'apiKey'
|
||||
| 'baseUrl'
|
||||
| 'model'
|
||||
| 'temperature'
|
||||
| 'topP'
|
||||
| 'reasoningEffort'
|
||||
| 'thinkingEnabled'
|
||||
| 'apiVersion'
|
||||
| 'maxOutputTokens';
|
||||
export type LLMProviderSettingFieldGroup = 'core' | 'contextual' | 'advanced' | 'developer';
|
||||
export type LLMProviderModelDiscoveryMode = 'none' | 'openai-compatible-models' | 'ollama-tags' | 'google-models';
|
||||
|
||||
export interface LLMProviderSettingFieldDefinition {
|
||||
id: LLMProviderSettingFieldId;
|
||||
group: LLMProviderSettingFieldGroup;
|
||||
}
|
||||
|
||||
export interface LLMProviderModelDiscoveryDefinition {
|
||||
mode: LLMProviderModelDiscoveryMode;
|
||||
}
|
||||
|
||||
export interface LLMProviderDefinition {
|
||||
name: string;
|
||||
|
|
@ -15,6 +36,8 @@ export interface LLMProviderDefinition {
|
|||
description: string;
|
||||
setupHint: string;
|
||||
defaultConfig: LLMProviderConfig;
|
||||
settingFields: LLMProviderSettingFieldDefinition[];
|
||||
modelDiscovery: LLMProviderModelDiscoveryDefinition;
|
||||
}
|
||||
|
||||
export interface LLMProviderValidationIssue {
|
||||
|
|
@ -23,6 +46,23 @@ export interface LLMProviderValidationIssue {
|
|||
}
|
||||
|
||||
const DEEPSEEK_RECOMMENDED_THINKING_OUTPUT_TOKENS = 8000;
|
||||
const OPENAI_COMPATIBLE_REASONING_PROVIDER_NAMES = new Set([
|
||||
'DeepSeek',
|
||||
'OpenAI',
|
||||
'OpenAI Compatible',
|
||||
'Azure OpenAI'
|
||||
]);
|
||||
const OPENAI_COMPATIBLE_MODEL_DISCOVERY_PROVIDER_NAMES = new Set([
|
||||
'DeepSeek',
|
||||
'OpenAI',
|
||||
'Mistral',
|
||||
'OpenRouter',
|
||||
'xAI',
|
||||
'Requesty',
|
||||
'OpenAI Compatible'
|
||||
]);
|
||||
|
||||
type ProviderDefinitionInput = Omit<LLMProviderDefinition, 'settingFields' | 'modelDiscovery'>;
|
||||
|
||||
const KNOWN_MODEL_MAX_OUTPUT_TOKENS: Partial<Record<string, Record<string, number>>> = {
|
||||
'DeepSeek': {
|
||||
|
|
@ -273,7 +313,70 @@ const KNOWN_MODEL_MAX_OUTPUT_TOKENS: Partial<Record<string, Record<string, numbe
|
|||
}
|
||||
};
|
||||
|
||||
export const LLM_PROVIDER_DEFINITIONS: LLMProviderDefinition[] = [
|
||||
function supportsReasoningEffortSetting(definition: ProviderDefinitionInput): boolean {
|
||||
return OPENAI_COMPATIBLE_REASONING_PROVIDER_NAMES.has(definition.name);
|
||||
}
|
||||
|
||||
function resolveModelDiscoveryMode(definition: ProviderDefinitionInput): LLMProviderModelDiscoveryDefinition {
|
||||
if (definition.transport === 'ollama') {
|
||||
return { mode: 'ollama-tags' };
|
||||
}
|
||||
|
||||
if (definition.transport === 'google') {
|
||||
return { mode: 'google-models' };
|
||||
}
|
||||
|
||||
if (definition.transport === 'openai-compatible' && OPENAI_COMPATIBLE_MODEL_DISCOVERY_PROVIDER_NAMES.has(definition.name)) {
|
||||
return { mode: 'openai-compatible-models' };
|
||||
}
|
||||
|
||||
return { mode: 'none' };
|
||||
}
|
||||
|
||||
function buildProviderSettingFields(definition: ProviderDefinitionInput): LLMProviderSettingFieldDefinition[] {
|
||||
const fields: LLMProviderSettingFieldDefinition[] = [];
|
||||
|
||||
if (definition.apiKeyMode !== 'none') {
|
||||
fields.push({ id: 'apiKey', group: 'core' });
|
||||
}
|
||||
|
||||
fields.push(
|
||||
{ id: 'baseUrl', group: 'core' },
|
||||
{ id: 'model', group: 'core' }
|
||||
);
|
||||
|
||||
if (definition.transport === 'azure-openai') {
|
||||
fields.push({ id: 'apiVersion', group: 'contextual' });
|
||||
}
|
||||
|
||||
fields.push({ id: 'temperature', group: 'advanced' });
|
||||
|
||||
if (definition.transport === 'openai-compatible') {
|
||||
fields.push({ id: 'topP', group: 'advanced' });
|
||||
}
|
||||
|
||||
if (supportsReasoningEffortSetting(definition)) {
|
||||
fields.push({ id: 'reasoningEffort', group: 'advanced' });
|
||||
}
|
||||
|
||||
if (definition.name === 'DeepSeek') {
|
||||
fields.push({ id: 'thinkingEnabled', group: 'advanced' });
|
||||
}
|
||||
|
||||
fields.push({ id: 'maxOutputTokens', group: 'developer' });
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function createProviderDefinition(definition: ProviderDefinitionInput): LLMProviderDefinition {
|
||||
return {
|
||||
...definition,
|
||||
settingFields: buildProviderSettingFields(definition),
|
||||
modelDiscovery: resolveModelDiscoveryMode(definition)
|
||||
};
|
||||
}
|
||||
|
||||
const RAW_LLM_PROVIDER_DEFINITIONS: ProviderDefinitionInput[] = [
|
||||
{
|
||||
name: 'DeepSeek',
|
||||
category: 'cloud',
|
||||
|
|
@ -693,6 +796,8 @@ export const LLM_PROVIDER_DEFINITIONS: LLMProviderDefinition[] = [
|
|||
}
|
||||
];
|
||||
|
||||
export const LLM_PROVIDER_DEFINITIONS: LLMProviderDefinition[] = RAW_LLM_PROVIDER_DEFINITIONS.map(createProviderDefinition);
|
||||
|
||||
const PROVIDER_INDEX = new Map(LLM_PROVIDER_DEFINITIONS.map((definition, index) => [definition.name, index]));
|
||||
const PROVIDER_MAP = new Map(LLM_PROVIDER_DEFINITIONS.map(definition => [definition.name, definition]));
|
||||
|
||||
|
|
@ -725,6 +830,92 @@ export function isOpenAICompatibleProvider(name: string): boolean {
|
|||
return getLLMProviderDefinition(name)?.transport === 'openai-compatible';
|
||||
}
|
||||
|
||||
export function getProviderSettingFields(name: string): LLMProviderSettingFieldDefinition[] {
|
||||
return getLLMProviderDefinition(name)?.settingFields ?? [];
|
||||
}
|
||||
|
||||
export function getProviderModelDiscoveryDefinition(name: string): LLMProviderModelDiscoveryDefinition {
|
||||
return getLLMProviderDefinition(name)?.modelDiscovery ?? { mode: 'none' };
|
||||
}
|
||||
|
||||
function getProviderFieldValue(provider: LLMProviderConfig, fieldId: LLMProviderSettingFieldId): unknown {
|
||||
switch (fieldId) {
|
||||
case 'apiKey':
|
||||
return provider.apiKey;
|
||||
case 'baseUrl':
|
||||
return provider.baseUrl;
|
||||
case 'model':
|
||||
return provider.model;
|
||||
case 'temperature':
|
||||
return provider.temperature;
|
||||
case 'topP':
|
||||
return provider.topP;
|
||||
case 'reasoningEffort':
|
||||
return provider.reasoningEffort;
|
||||
case 'thinkingEnabled':
|
||||
return provider.thinkingEnabled;
|
||||
case 'apiVersion':
|
||||
return provider.apiVersion;
|
||||
case 'maxOutputTokens':
|
||||
return provider.maxOutputTokens;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasExplicitProviderFieldValue(
|
||||
provider: LLMProviderConfig,
|
||||
definition: LLMProviderDefinition,
|
||||
field: LLMProviderSettingFieldDefinition
|
||||
): boolean {
|
||||
const currentValue = getProviderFieldValue(provider, field.id);
|
||||
const defaultValue = getProviderFieldValue(definition.defaultConfig, field.id);
|
||||
|
||||
switch (field.id) {
|
||||
case 'temperature':
|
||||
return Number.isFinite(Number(currentValue)) && Number(currentValue) !== Number(defaultValue);
|
||||
case 'topP':
|
||||
return Number.isFinite(Number(currentValue));
|
||||
case 'reasoningEffort':
|
||||
return typeof currentValue === 'string' && currentValue.trim() !== '' && currentValue.trim().toLowerCase() !== 'none';
|
||||
case 'thinkingEnabled':
|
||||
return currentValue === true;
|
||||
case 'maxOutputTokens':
|
||||
return Number.isFinite(Number(currentValue)) && Number(currentValue) > 0;
|
||||
default:
|
||||
return currentValue !== undefined && currentValue !== null && currentValue !== defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPersistedAdvancedProviderSettings(provider: LLMProviderConfig): boolean {
|
||||
const definition = getLLMProviderDefinition(provider.name);
|
||||
if (!definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return definition.settingFields.some(field =>
|
||||
(field.group === 'advanced' || field.group === 'developer')
|
||||
&& hasExplicitProviderFieldValue(provider, definition, field)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowProviderSettingField(
|
||||
provider: LLMProviderConfig,
|
||||
field: LLMProviderSettingFieldDefinition,
|
||||
options?: { developerMode?: boolean }
|
||||
): boolean {
|
||||
if (field.group !== 'developer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const definition = getLLMProviderDefinition(provider.name);
|
||||
if (!definition) {
|
||||
return options?.developerMode === true;
|
||||
}
|
||||
|
||||
return options?.developerMode === true || hasExplicitProviderFieldValue(provider, definition, field);
|
||||
}
|
||||
|
||||
function looksLikeDoubaoEndpointId(model: string): boolean {
|
||||
return /^ep-[a-z0-9-]{8,}$/i.test(model.trim());
|
||||
}
|
||||
|
|
|
|||
161
src/providerModelDiscovery.ts
Normal file
161
src/providerModelDiscovery.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { LLMProviderConfig } from './types';
|
||||
import {
|
||||
getProviderModelDiscoveryDefinition
|
||||
} from './llmProviders';
|
||||
|
||||
type ProviderModelDiscoveryResult = {
|
||||
models: string[];
|
||||
source: 'remote' | 'none';
|
||||
};
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizeEndpointBaseUrl(baseUrl: string, suffixes: string[]): string {
|
||||
let normalized = trimTrailingSlashes(baseUrl.trim());
|
||||
if (!normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
if (normalized.endsWith(suffix)) {
|
||||
normalized = normalized.slice(0, -suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return trimTrailingSlashes(normalized);
|
||||
}
|
||||
|
||||
function requireBaseUrl(provider: LLMProviderConfig): string {
|
||||
const normalized = provider.baseUrl.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`${provider.name} model discovery requires a Base URL / endpoint.`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOpenAICompatibleBaseUrl(baseUrl: string): string {
|
||||
return normalizeEndpointBaseUrl(baseUrl, ['/chat/completions', '/models']);
|
||||
}
|
||||
|
||||
function normalizeOllamaBaseUrl(baseUrl: string): string {
|
||||
return normalizeEndpointBaseUrl(baseUrl, ['/tags']);
|
||||
}
|
||||
|
||||
function normalizeGoogleBaseUrl(baseUrl: string): string {
|
||||
return normalizeEndpointBaseUrl(baseUrl, ['/models']);
|
||||
}
|
||||
|
||||
function buildOpenAICompatibleHeaders(provider: LLMProviderConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (provider.apiKey || provider.name === 'LMStudio') {
|
||||
headers['Authorization'] = `Bearer ${provider.apiKey || 'EMPTY'}`;
|
||||
}
|
||||
|
||||
if (provider.name === 'OpenRouter' || provider.name === 'Requesty') {
|
||||
headers['HTTP-Referer'] = 'https://github.com/Jacobinwwey/obsidian-NotEMD';
|
||||
headers['X-Title'] = 'Notemd Obsidian Plugin';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function normalizeModelList(values: Iterable<string>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const models: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
models.push(trimmed);
|
||||
}
|
||||
|
||||
return models.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, options?: { headers?: Record<string, string> }): Promise<any> {
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: options?.headers ?? {},
|
||||
throw: false
|
||||
});
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Model discovery request failed: ${response.status} ${response.text || ''}`.trim());
|
||||
}
|
||||
|
||||
return response.json;
|
||||
}
|
||||
|
||||
async function discoverOpenAICompatibleModels(provider: LLMProviderConfig): Promise<string[]> {
|
||||
const baseUrl = normalizeOpenAICompatibleBaseUrl(requireBaseUrl(provider));
|
||||
|
||||
const data = await fetchJson(`${baseUrl}/models`, {
|
||||
headers: buildOpenAICompatibleHeaders(provider)
|
||||
});
|
||||
|
||||
const modelIds = Array.isArray(data?.data)
|
||||
? data.data
|
||||
.map((entry: any) => typeof entry?.id === 'string' ? entry.id : '')
|
||||
: [];
|
||||
|
||||
return normalizeModelList(modelIds);
|
||||
}
|
||||
|
||||
async function discoverOllamaModels(provider: LLMProviderConfig): Promise<string[]> {
|
||||
const baseUrl = normalizeOllamaBaseUrl(requireBaseUrl(provider));
|
||||
|
||||
const data = await fetchJson(`${baseUrl}/tags`);
|
||||
const modelIds = Array.isArray(data?.models)
|
||||
? data.models
|
||||
.flatMap((entry: any) => [
|
||||
typeof entry?.model === 'string' ? entry.model : '',
|
||||
typeof entry?.name === 'string' ? entry.name : ''
|
||||
])
|
||||
: [];
|
||||
|
||||
return normalizeModelList(modelIds);
|
||||
}
|
||||
|
||||
async function discoverGoogleModels(provider: LLMProviderConfig): Promise<string[]> {
|
||||
const baseUrl = normalizeGoogleBaseUrl(requireBaseUrl(provider));
|
||||
const apiKey = provider.apiKey.trim();
|
||||
if (!apiKey) {
|
||||
throw new Error(`${provider.name} model discovery requires an API key.`);
|
||||
}
|
||||
|
||||
const data = await fetchJson(`${baseUrl}/models?key=${encodeURIComponent(apiKey)}`);
|
||||
const modelIds = Array.isArray(data?.models)
|
||||
? data.models
|
||||
.map((entry: any) => typeof entry?.name === 'string' ? entry.name.replace(/^models\//, '') : '')
|
||||
: [];
|
||||
|
||||
return normalizeModelList(modelIds);
|
||||
}
|
||||
|
||||
export async function discoverProviderModels(provider: LLMProviderConfig): Promise<ProviderModelDiscoveryResult> {
|
||||
const definition = getProviderModelDiscoveryDefinition(provider.name);
|
||||
|
||||
switch (definition.mode) {
|
||||
case 'openai-compatible-models':
|
||||
return { models: await discoverOpenAICompatibleModels(provider), source: 'remote' };
|
||||
case 'ollama-tags':
|
||||
return { models: await discoverOllamaModels(provider), source: 'remote' };
|
||||
case 'google-models':
|
||||
return { models: await discoverGoogleModels(provider), source: 'remote' };
|
||||
case 'none':
|
||||
default:
|
||||
return { models: [], source: 'none' };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import {
|
||||
createDefaultProviders,
|
||||
getProviderModelDiscoveryDefinition,
|
||||
getProviderSettingFields,
|
||||
getKnownModelMaxOutputTokens,
|
||||
getLLMProviderDefinition,
|
||||
getOrderedProviderNames,
|
||||
hasPersistedAdvancedProviderSettings,
|
||||
shouldShowProviderSettingField,
|
||||
isOpenAICompatibleProvider
|
||||
} from '../llmProviders';
|
||||
|
||||
|
|
@ -158,4 +162,94 @@ describe('llmProviders registry', () => {
|
|||
expect(getKnownModelMaxOutputTokens('OpenAI', 'unknown-model')).toBeUndefined();
|
||||
expect(getKnownModelMaxOutputTokens('Unknown Provider', 'gpt-4o')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('provider metadata exposes field taxonomy and first-batch discovery capabilities', () => {
|
||||
expect(getProviderSettingFields('OpenAI')).toEqual(expect.arrayContaining([
|
||||
{ id: 'apiKey', group: 'core' },
|
||||
{ id: 'baseUrl', group: 'core' },
|
||||
{ id: 'model', group: 'core' },
|
||||
{ id: 'temperature', group: 'advanced' },
|
||||
{ id: 'topP', group: 'advanced' },
|
||||
{ id: 'reasoningEffort', group: 'advanced' },
|
||||
{ id: 'maxOutputTokens', group: 'developer' }
|
||||
]));
|
||||
expect(getProviderSettingFields('Azure OpenAI')).toEqual(expect.arrayContaining([
|
||||
{ id: 'apiVersion', group: 'contextual' }
|
||||
]));
|
||||
expect(getProviderSettingFields('DeepSeek')).toEqual(expect.arrayContaining([
|
||||
{ id: 'thinkingEnabled', group: 'advanced' }
|
||||
]));
|
||||
|
||||
expect(getProviderModelDiscoveryDefinition('OpenAI')).toEqual({ mode: 'openai-compatible-models' });
|
||||
expect(getProviderModelDiscoveryDefinition('Ollama')).toEqual({ mode: 'ollama-tags' });
|
||||
expect(getProviderModelDiscoveryDefinition('Google')).toEqual({ mode: 'google-models' });
|
||||
expect(getProviderModelDiscoveryDefinition('Azure OpenAI')).toEqual({ mode: 'none' });
|
||||
});
|
||||
|
||||
test('advanced visibility detection only expands when persisted advanced values are present', () => {
|
||||
expect(hasPersistedAdvancedProviderSettings({
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5
|
||||
})).toBe(false);
|
||||
|
||||
expect(hasPersistedAdvancedProviderSettings({
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5,
|
||||
topP: 0.7
|
||||
})).toBe(true);
|
||||
|
||||
expect(hasPersistedAdvancedProviderSettings({
|
||||
name: 'Azure OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://example.azure.com',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5,
|
||||
apiVersion: '2025-01-01-preview'
|
||||
})).toBe(false);
|
||||
|
||||
expect(hasPersistedAdvancedProviderSettings({
|
||||
name: 'DeepSeek',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-v4-pro',
|
||||
temperature: 0.5,
|
||||
thinkingEnabled: true
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
test('developer-only provider fields stay hidden unless developer mode is on or a persisted override exists', () => {
|
||||
const developerField = getProviderSettingFields('OpenAI').find(field => field.id === 'maxOutputTokens');
|
||||
expect(developerField).toBeDefined();
|
||||
|
||||
expect(shouldShowProviderSettingField({
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5
|
||||
}, developerField!, { developerMode: false })).toBe(false);
|
||||
|
||||
expect(shouldShowProviderSettingField({
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5
|
||||
}, developerField!, { developerMode: true })).toBe(true);
|
||||
|
||||
expect(shouldShowProviderSettingField({
|
||||
name: 'OpenAI',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5,
|
||||
maxOutputTokens: 4096
|
||||
}, developerField!, { developerMode: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,4 +11,14 @@ describe('settings provider connection reuse', () => {
|
|||
expect(source).not.toContain("import { testAPI } from '../llmUtils'");
|
||||
expect(source).toContain('hasBlockingProviderValidationIssues');
|
||||
});
|
||||
|
||||
test('renders provider settings through shared metadata/discovery helpers instead of provider-name-only field branching', () => {
|
||||
const source = fs.readFileSync(settingsTabPath, 'utf8');
|
||||
|
||||
expect(source).toContain('getProviderSettingFields');
|
||||
expect(source).toContain('hasPersistedAdvancedProviderSettings');
|
||||
expect(source).toContain('discoverProviderModels');
|
||||
expect(source).toContain('advancedSettingsName');
|
||||
expect(source).toContain('fetchModelsName');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
230
src/tests/providerModelDiscovery.test.ts
Normal file
230
src/tests/providerModelDiscovery.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { discoverProviderModels } from '../providerModelDiscovery';
|
||||
|
||||
jest.mock('obsidian');
|
||||
|
||||
describe('provider model discovery', () => {
|
||||
const mockedRequestUrl = requestUrl as jest.MockedFunction<typeof requestUrl>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedRequestUrl.mockReset();
|
||||
});
|
||||
|
||||
test('discovers models from OpenAI-compatible providers via /models', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: {
|
||||
data: [
|
||||
{ id: 'gpt-4o' },
|
||||
{ id: 'gpt-4.1' },
|
||||
{ id: 'gpt-4o' }
|
||||
]
|
||||
}
|
||||
} as any);
|
||||
|
||||
const result = await discoverProviderModels({
|
||||
name: 'OpenAI',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'https://api.openai.com/v1/models',
|
||||
method: 'GET'
|
||||
}));
|
||||
expect(result).toEqual({
|
||||
models: ['gpt-4.1', 'gpt-4o'],
|
||||
source: 'remote'
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes OpenAI-compatible chat/completions endpoints before fetching models', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: { data: [{ id: 'gpt-4.1' }] }
|
||||
} as any);
|
||||
|
||||
await discoverProviderModels({
|
||||
name: 'OpenAI',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
model: 'gpt-4.1',
|
||||
temperature: 0.5
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'https://api.openai.com/v1/models'
|
||||
}));
|
||||
});
|
||||
|
||||
test('sends gateway headers for Requesty/OpenRouter-compatible discovery calls', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: { data: [{ id: 'claude-3.7-sonnet' }] }
|
||||
} as any);
|
||||
|
||||
await discoverProviderModels({
|
||||
name: 'Requesty',
|
||||
apiKey: 'rq-test',
|
||||
baseUrl: 'https://router.requesty.ai/v1',
|
||||
model: 'claude-3.7-sonnet',
|
||||
temperature: 0.5
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer rq-test',
|
||||
'HTTP-Referer': 'https://github.com/Jacobinwwey/obsidian-NotEMD',
|
||||
'X-Title': 'Notemd Obsidian Plugin'
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
test('discovers models from Ollama providers via /tags', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: {
|
||||
models: [
|
||||
{ model: 'llama3.1:latest', name: 'llama3.1:latest' },
|
||||
{ name: 'qwen2.5:14b' }
|
||||
]
|
||||
}
|
||||
} as any);
|
||||
|
||||
const result = await discoverProviderModels({
|
||||
name: 'Ollama',
|
||||
apiKey: '',
|
||||
baseUrl: 'http://localhost:11434/api',
|
||||
model: 'llama3',
|
||||
temperature: 0.7
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'http://localhost:11434/api/tags'
|
||||
}));
|
||||
expect(result.models).toEqual(['llama3.1:latest', 'qwen2.5:14b']);
|
||||
});
|
||||
|
||||
test('normalizes Ollama /tags endpoints before fetching model tags', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: { models: [{ model: 'llama3.1:latest' }] }
|
||||
} as any);
|
||||
|
||||
await discoverProviderModels({
|
||||
name: 'Ollama',
|
||||
apiKey: '',
|
||||
baseUrl: 'http://localhost:11434/api/tags',
|
||||
model: 'llama3',
|
||||
temperature: 0.7
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'http://localhost:11434/api/tags'
|
||||
}));
|
||||
});
|
||||
|
||||
test('discovers models from Google providers via v1 models list', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: {
|
||||
models: [
|
||||
{ name: 'models/gemini-2.5-pro' },
|
||||
{ name: 'models/gemini-2.5-flash' }
|
||||
]
|
||||
}
|
||||
} as any);
|
||||
|
||||
const result = await discoverProviderModels({
|
||||
name: 'Google',
|
||||
apiKey: 'google-key',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
model: 'gemini-2.5-pro',
|
||||
temperature: 0.5
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'https://generativelanguage.googleapis.com/v1beta/models?key=google-key'
|
||||
}));
|
||||
expect(result.models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
|
||||
});
|
||||
|
||||
test('normalizes Google /models endpoints before fetching model list', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: '',
|
||||
json: { models: [{ name: 'models/gemini-2.5-pro' }] }
|
||||
} as any);
|
||||
|
||||
await discoverProviderModels({
|
||||
name: 'Google',
|
||||
apiKey: 'google-key',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models',
|
||||
model: 'gemini-2.5-pro',
|
||||
temperature: 0.5
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'https://generativelanguage.googleapis.com/v1beta/models?key=google-key'
|
||||
}));
|
||||
});
|
||||
|
||||
test('fails with a clear error when required discovery prerequisites are missing', async () => {
|
||||
await expect(discoverProviderModels({
|
||||
name: 'Google',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
model: 'gemini-2.5-pro',
|
||||
temperature: 0.5
|
||||
})).rejects.toThrow('Google model discovery requires an API key.');
|
||||
|
||||
await expect(discoverProviderModels({
|
||||
name: 'OpenAI',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: '',
|
||||
model: 'gpt-4.1',
|
||||
temperature: 0.5
|
||||
})).rejects.toThrow('OpenAI model discovery requires a Base URL / endpoint.');
|
||||
});
|
||||
|
||||
test('surfaces non-success model-discovery responses as actionable errors', async () => {
|
||||
mockedRequestUrl.mockResolvedValue({
|
||||
status: 401,
|
||||
text: 'Unauthorized',
|
||||
json: {}
|
||||
} as any);
|
||||
|
||||
await expect(discoverProviderModels({
|
||||
name: 'OpenAI',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4.1',
|
||||
temperature: 0.5
|
||||
})).rejects.toThrow('Model discovery request failed: 401 Unauthorized');
|
||||
});
|
||||
|
||||
test('returns an empty none-source result for unsupported providers', async () => {
|
||||
const result = await discoverProviderModels({
|
||||
name: 'Azure OpenAI',
|
||||
apiKey: 'azure-key',
|
||||
baseUrl: 'https://example.openai.azure.com',
|
||||
model: 'deployment-name',
|
||||
temperature: 0.5,
|
||||
apiVersion: '2025-01-01-preview'
|
||||
});
|
||||
|
||||
expect(mockedRequestUrl).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
models: [],
|
||||
source: 'none'
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/tests/providerSettingsStyles.test.ts
Normal file
15
src/tests/providerSettingsStyles.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const stylesPath = path.join(__dirname, '..', '..', 'styles.css');
|
||||
|
||||
describe('provider settings styles', () => {
|
||||
test('styles ship dedicated selectors for advanced provider settings and discovered model rows', () => {
|
||||
const styles = fs.readFileSync(stylesPath, 'utf8');
|
||||
|
||||
expect(styles).toContain('.notemd-provider-advanced-settings');
|
||||
expect(styles).toContain('.notemd-provider-model-list');
|
||||
expect(styles).toContain('.notemd-provider-model-item');
|
||||
expect(styles).toContain('.notemd-provider-model-item code');
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,20 @@ describe('supported UI locale provider config coverage', () => {
|
|||
{ label: 'settings.providerConfig.modelName', get: (strings: typeof en) => strings.settings.providerConfig.modelName },
|
||||
{ label: 'settings.providerConfig.modelDesc', get: (strings: typeof en) => strings.settings.providerConfig.modelDesc },
|
||||
{ label: 'settings.providerConfig.modelPlaceholder', get: (strings: typeof en) => strings.settings.providerConfig.modelPlaceholder },
|
||||
{ label: 'settings.providerConfig.advancedSettingsName', get: (strings: typeof en) => strings.settings.providerConfig.advancedSettingsName },
|
||||
{ label: 'settings.providerConfig.advancedSettingsDesc', get: (strings: typeof en) => strings.settings.providerConfig.advancedSettingsDesc },
|
||||
{ label: 'settings.providerConfig.advancedSettingsHint', get: (strings: typeof en) => strings.settings.providerConfig.advancedSettingsHint },
|
||||
{ label: 'settings.providerConfig.fetchModelsName', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsName },
|
||||
{ label: 'settings.providerConfig.fetchModelsDesc', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsDesc },
|
||||
{ label: 'settings.providerConfig.fetchModelsButton', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsButton },
|
||||
{ label: 'settings.providerConfig.fetchModelsLoading', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsLoading },
|
||||
{ label: 'settings.providerConfig.fetchModelsSuccess', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsSuccess },
|
||||
{ label: 'settings.providerConfig.fetchModelsEmpty', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsEmpty },
|
||||
{ label: 'settings.providerConfig.fetchModelsUnavailable', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsUnavailable },
|
||||
{ label: 'settings.providerConfig.fetchModelsError', get: (strings: typeof en) => strings.settings.providerConfig.fetchModelsError },
|
||||
{ label: 'settings.providerConfig.discoveredModelsName', get: (strings: typeof en) => strings.settings.providerConfig.discoveredModelsName },
|
||||
{ label: 'settings.providerConfig.discoveredModelsDesc', get: (strings: typeof en) => strings.settings.providerConfig.discoveredModelsDesc },
|
||||
{ label: 'settings.providerConfig.discoveredModelsUseButton', get: (strings: typeof en) => strings.settings.providerConfig.discoveredModelsUseButton },
|
||||
{ label: 'settings.providerConfig.temperatureName', get: (strings: typeof en) => strings.settings.providerConfig.temperatureName },
|
||||
{ label: 'settings.providerConfig.temperatureDesc', get: (strings: typeof en) => strings.settings.providerConfig.temperatureDesc },
|
||||
{ label: 'settings.providerConfig.maxOutputTokensDesc', get: (strings: typeof en) => strings.settings.providerConfig.maxOutputTokensDesc },
|
||||
|
|
@ -51,6 +65,7 @@ describe('supported UI locale provider config coverage', () => {
|
|||
{ label: 'settings.providerConfig.apiVersionName', get: (strings: typeof en) => strings.settings.providerConfig.apiVersionName },
|
||||
{ label: 'settings.providerConfig.apiVersionDesc', get: (strings: typeof en) => strings.settings.providerConfig.apiVersionDesc },
|
||||
{ label: 'settings.providerConfig.apiVersionPlaceholder', get: (strings: typeof en) => strings.settings.providerConfig.apiVersionPlaceholder },
|
||||
{ label: 'settings.providerConfig.contextualSettingsName', get: (strings: typeof en) => strings.settings.providerConfig.contextualSettingsName },
|
||||
{ label: 'settings.providerConfig.testConnectionName', get: (strings: typeof en) => strings.settings.providerConfig.testConnectionName },
|
||||
{ label: 'settings.providerConfig.testConnectionDesc', get: (strings: typeof en) => strings.settings.providerConfig.testConnectionDesc },
|
||||
{ label: 'settings.providerConfig.testConnectionButton', get: (strings: typeof en) => strings.settings.providerConfig.testConnectionButton },
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import { FolderTaskFileSelectionProfile, LLMProviderConfig, NotemdSettings, Task
|
|||
import { DEFAULT_SETTINGS } from '../constants';
|
||||
import {
|
||||
getLLMProviderDefinition,
|
||||
getProviderModelDiscoveryDefinition,
|
||||
getProviderSettingFields,
|
||||
getOrderedProviderNames,
|
||||
getProviderValidationIssues,
|
||||
hasBlockingProviderValidationIssues
|
||||
hasBlockingProviderValidationIssues,
|
||||
hasPersistedAdvancedProviderSettings,
|
||||
LLMProviderSettingFieldDefinition,
|
||||
shouldShowProviderSettingField
|
||||
} from '../llmProviders';
|
||||
import { getDefaultPrompt } from '../promptUtils'; // Import for default prompts
|
||||
import {
|
||||
|
|
@ -28,14 +33,22 @@ import { SUPPORTED_UI_LOCALES } from '../i18n/uiLocales';
|
|||
import { formatI18n, getI18nStrings } from '../i18n';
|
||||
import { runProviderConnectionTestWithHost } from '../operations/providerConnectionTestCommandHostAdapter';
|
||||
import { getFolderTaskFileSelectionProfiles, getFolderTaskRegexValidationError } from '../folderTaskFileSelector';
|
||||
import { discoverProviderModels } from '../providerModelDiscovery';
|
||||
|
||||
// Define specific key types for settings accessed dynamically
|
||||
type ProviderSettingKey = 'addLinksProvider' | 'researchProvider' | 'generateTitleProvider' | 'translateProvider';
|
||||
type ModelSettingKey = 'addLinksModel' | 'researchModel' | 'generateTitleModel' | 'translateModel';
|
||||
|
||||
type ProviderPanelState = {
|
||||
discoveredModels: string[];
|
||||
fetchStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
fetchMessage?: string;
|
||||
};
|
||||
|
||||
|
||||
export class NotemdSettingTab extends PluginSettingTab {
|
||||
plugin: NotemdPlugin;
|
||||
private providerPanelState = new Map<string, ProviderPanelState>();
|
||||
|
||||
constructor(app: App, plugin: NotemdPlugin) {
|
||||
super(app, plugin);
|
||||
|
|
@ -129,13 +142,13 @@ export class NotemdSettingTab extends PluginSettingTab {
|
|||
setting: Setting,
|
||||
options: {
|
||||
placeholder?: string;
|
||||
value: number;
|
||||
value: number | string;
|
||||
onCommit: (rawValue: string) => Promise<string | void>;
|
||||
}
|
||||
): void {
|
||||
this.addDeferredTextSetting(setting, {
|
||||
placeholder: options.placeholder,
|
||||
value: String(options.value),
|
||||
value: typeof options.value === 'string' ? options.value : String(options.value),
|
||||
onCommit: async (rawValue) => {
|
||||
const normalized = await options.onCommit(rawValue);
|
||||
if (typeof normalized === 'string') {
|
||||
|
|
@ -247,6 +260,300 @@ export class NotemdSettingTab extends PluginSettingTab {
|
|||
};
|
||||
}
|
||||
|
||||
private getProviderPanelState(providerName: string): ProviderPanelState {
|
||||
let state = this.providerPanelState.get(providerName);
|
||||
if (!state) {
|
||||
state = {
|
||||
discoveredModels: [],
|
||||
fetchStatus: 'idle'
|
||||
};
|
||||
this.providerPanelState.set(providerName, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private async updateProviderField(
|
||||
provider: LLMProviderConfig,
|
||||
update: (draft: LLMProviderConfig) => void
|
||||
): Promise<void> {
|
||||
update(provider);
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
|
||||
private addProviderTextField(
|
||||
setting: Setting,
|
||||
provider: LLMProviderConfig,
|
||||
read: () => string,
|
||||
placeholder: string,
|
||||
onCommit: (value: string) => Promise<void>
|
||||
): void {
|
||||
this.addDeferredTextSetting(setting, {
|
||||
placeholder,
|
||||
value: read(),
|
||||
onCommit
|
||||
});
|
||||
}
|
||||
|
||||
private addProviderNumberField(
|
||||
setting: Setting,
|
||||
provider: LLMProviderConfig,
|
||||
read: () => string,
|
||||
placeholder: string,
|
||||
onCommit: (value: string) => Promise<string | void>
|
||||
): void {
|
||||
this.addDeferredNumberSetting(setting, {
|
||||
placeholder,
|
||||
value: read(),
|
||||
onCommit
|
||||
});
|
||||
}
|
||||
|
||||
private renderProviderModelDiscovery(containerEl: HTMLElement, provider: LLMProviderConfig): void {
|
||||
const providerI18n = getI18nStrings({ uiLocale: this.plugin.settings.uiLocale }).settings.providerConfig;
|
||||
const discovery = getProviderModelDiscoveryDefinition(provider.name);
|
||||
const panelState = this.getProviderPanelState(provider.name);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.fetchModelsName)
|
||||
.setDesc(
|
||||
discovery.mode === 'none'
|
||||
? formatI18n(providerI18n.fetchModelsUnavailable, { provider: provider.name })
|
||||
: providerI18n.fetchModelsDesc
|
||||
)
|
||||
.addButton(button => {
|
||||
button
|
||||
.setButtonText(panelState.fetchStatus === 'loading' ? providerI18n.fetchModelsLoading : providerI18n.fetchModelsButton)
|
||||
.setDisabled(discovery.mode === 'none' || panelState.fetchStatus === 'loading')
|
||||
.onClick(async () => {
|
||||
panelState.fetchStatus = 'loading';
|
||||
panelState.fetchMessage = undefined;
|
||||
this.display();
|
||||
|
||||
try {
|
||||
const result = await discoverProviderModels(provider);
|
||||
panelState.discoveredModels = result.models;
|
||||
panelState.fetchStatus = 'success';
|
||||
panelState.fetchMessage = result.models.length > 0
|
||||
? formatI18n(providerI18n.fetchModelsSuccess, {
|
||||
provider: provider.name,
|
||||
count: result.models.length
|
||||
})
|
||||
: formatI18n(providerI18n.fetchModelsEmpty, { provider: provider.name });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
panelState.fetchStatus = 'error';
|
||||
panelState.fetchMessage = formatI18n(providerI18n.fetchModelsError, {
|
||||
provider: provider.name,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
if (panelState.fetchMessage) {
|
||||
const callout = containerEl.createDiv({
|
||||
cls: `notemd-provider-validation ${panelState.fetchStatus === 'error' ? 'notemd-provider-validation-error' : 'notemd-provider-validation-warning'}`
|
||||
});
|
||||
callout.createEl('strong', {
|
||||
text: panelState.fetchStatus === 'error'
|
||||
? providerI18n.validationWarning
|
||||
: providerI18n.discoveredModelsName
|
||||
});
|
||||
callout.createEl('p', { text: panelState.fetchMessage });
|
||||
}
|
||||
|
||||
if (panelState.discoveredModels.length > 0) {
|
||||
const listWrapper = containerEl.createDiv({ cls: 'notemd-provider-callout' });
|
||||
listWrapper.createEl('strong', { text: providerI18n.discoveredModelsName });
|
||||
listWrapper.createEl('p', { text: providerI18n.discoveredModelsDesc });
|
||||
|
||||
const list = listWrapper.createDiv({ cls: 'notemd-provider-model-list' });
|
||||
panelState.discoveredModels.forEach(modelId => {
|
||||
const row = list.createDiv({ cls: 'notemd-provider-model-item' });
|
||||
row.createEl('code', { text: modelId });
|
||||
const useButton = new ButtonComponent(row);
|
||||
useButton
|
||||
.setButtonText(providerI18n.discoveredModelsUseButton)
|
||||
.onClick(async () => {
|
||||
provider.model = modelId;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderProviderField(
|
||||
containerEl: HTMLElement,
|
||||
provider: LLMProviderConfig,
|
||||
field: LLMProviderSettingFieldDefinition
|
||||
): void {
|
||||
const providerI18n = getI18nStrings({ uiLocale: this.plugin.settings.uiLocale }).settings.providerConfig;
|
||||
const defaultProvider = DEFAULT_SETTINGS.providers.find(p => p.name === provider.name);
|
||||
|
||||
switch (field.id) {
|
||||
case 'apiKey': {
|
||||
const definition = getLLMProviderDefinition(provider.name);
|
||||
const apiKeyMode = definition?.apiKeyMode ?? 'required';
|
||||
const apiKeyDescription = apiKeyMode === 'optional'
|
||||
? formatI18n(providerI18n.apiKeyDescOptional, { provider: provider.name })
|
||||
: formatI18n(providerI18n.apiKeyDescRequired, {
|
||||
provider: provider.name,
|
||||
extra: provider.name === 'LMStudio' ? providerI18n.apiKeyExtraLmStudio : ''
|
||||
});
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.apiKeyName)
|
||||
.setDesc(apiKeyDescription);
|
||||
this.addProviderTextField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.apiKey,
|
||||
provider.name === 'LMStudio' ? providerI18n.apiKeyPlaceholderLmStudio : providerI18n.apiKeyPlaceholderDefault,
|
||||
async value => this.updateProviderField(provider, draft => { draft.apiKey = value; })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'baseUrl': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.baseUrlName)
|
||||
.setDesc(formatI18n(providerI18n.baseUrlDesc, {
|
||||
provider: provider.name,
|
||||
required: provider.name === 'Azure OpenAI' ? providerI18n.baseUrlRequired : ''
|
||||
}));
|
||||
this.addProviderTextField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.baseUrl,
|
||||
defaultProvider?.baseUrl || providerI18n.baseUrlPlaceholder,
|
||||
async value => this.updateProviderField(provider, draft => { draft.baseUrl = value.trim(); })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'model': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.modelName)
|
||||
.setDesc(formatI18n(providerI18n.modelDesc, { provider: provider.name }));
|
||||
this.addProviderTextField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.model,
|
||||
defaultProvider?.model || providerI18n.modelPlaceholder,
|
||||
async value => this.updateProviderField(provider, draft => { draft.model = value.trim(); })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'temperature': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.temperatureName)
|
||||
.setDesc(providerI18n.temperatureDesc);
|
||||
setting.addSlider(slider => slider
|
||||
.setLimits(0, 1, 0.1)
|
||||
.setValue(provider.temperature)
|
||||
.onChange(async value => {
|
||||
provider.temperature = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
.setDynamicTooltip());
|
||||
break;
|
||||
}
|
||||
case 'topP': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.topPName)
|
||||
.setDesc(providerI18n.topPDesc);
|
||||
this.addProviderNumberField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.topP !== undefined ? String(provider.topP) : '',
|
||||
providerI18n.topPPlaceholder,
|
||||
async value => {
|
||||
const parsed = Number.parseFloat(value.trim());
|
||||
await this.updateProviderField(provider, draft => {
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
draft.topP = parsed;
|
||||
} else {
|
||||
delete draft.topP;
|
||||
}
|
||||
});
|
||||
return provider.topP !== undefined ? String(provider.topP) : '';
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'reasoningEffort': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.reasoningEffortName)
|
||||
.setDesc(providerI18n.reasoningEffortDesc);
|
||||
this.addProviderTextField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.reasoningEffort || '',
|
||||
providerI18n.reasoningEffortPlaceholder,
|
||||
async value => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
await this.updateProviderField(provider, draft => {
|
||||
if (['none', 'low', 'medium', 'high'].includes(normalized)) {
|
||||
draft.reasoningEffort = normalized;
|
||||
} else {
|
||||
delete draft.reasoningEffort;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'thinkingEnabled': {
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.thinkingEnabledName)
|
||||
.setDesc(providerI18n.thinkingEnabledDesc)
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(provider.thinkingEnabled === true)
|
||||
.onChange(async value => {
|
||||
provider.thinkingEnabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'apiVersion': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.apiVersionName)
|
||||
.setDesc(providerI18n.apiVersionDesc);
|
||||
this.addProviderTextField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.apiVersion || '',
|
||||
providerI18n.apiVersionPlaceholder,
|
||||
async value => this.updateProviderField(provider, draft => { draft.apiVersion = value.trim(); })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'maxOutputTokens': {
|
||||
const setting = new Setting(containerEl)
|
||||
.setName(providerI18n.maxOutputTokensName)
|
||||
.setDesc(providerI18n.maxOutputTokensDesc);
|
||||
this.addProviderNumberField(
|
||||
setting,
|
||||
provider,
|
||||
() => provider.maxOutputTokens !== undefined ? String(provider.maxOutputTokens) : '',
|
||||
String(this.plugin.settings.maxTokens),
|
||||
async value => {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
await this.updateProviderField(provider, draft => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
draft.maxOutputTokens = parsed;
|
||||
} else {
|
||||
delete draft.maxOutputTokens;
|
||||
}
|
||||
});
|
||||
return provider.maxOutputTokens !== undefined ? String(provider.maxOutputTokens) : '';
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderProviderSummary(containerEl: HTMLElement, provider: LLMProviderConfig): void {
|
||||
const definition = getLLMProviderDefinition(provider.name);
|
||||
if (!definition) {
|
||||
|
|
@ -811,129 +1118,40 @@ export class NotemdSettingTab extends PluginSettingTab {
|
|||
this.renderProviderSummary(containerEl, activeProvider);
|
||||
this.renderProviderValidation(containerEl, activeProvider);
|
||||
|
||||
const providerDefinition = getLLMProviderDefinition(activeProvider.name);
|
||||
const apiKeyMode = providerDefinition?.apiKeyMode ?? 'required';
|
||||
const isOpenAICompatible = providerDefinition?.transport === 'openai-compatible';
|
||||
const providerFields = getProviderSettingFields(activeProvider.name)
|
||||
.filter(field => shouldShowProviderSettingField(activeProvider, field, {
|
||||
developerMode: this.plugin.settings.enableDeveloperMode
|
||||
}));
|
||||
|
||||
if (apiKeyMode !== 'none') {
|
||||
const apiKeyDescription = apiKeyMode === 'optional'
|
||||
? formatI18n(providerI18n.apiKeyDescOptional, { provider: activeProvider.name })
|
||||
: formatI18n(providerI18n.apiKeyDescRequired, {
|
||||
provider: activeProvider.name,
|
||||
extra: activeProvider.name === 'LMStudio' ? providerI18n.apiKeyExtraLmStudio : ''
|
||||
});
|
||||
providerFields
|
||||
.filter(field => field.group === 'core')
|
||||
.forEach(field => this.renderProviderField(containerEl, activeProvider, field));
|
||||
|
||||
const hasContextualFields = providerFields.some(field => field.group === 'contextual');
|
||||
if (hasContextualFields) {
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.apiKeyName)
|
||||
.setDesc(apiKeyDescription)
|
||||
.addText(text => text
|
||||
.setPlaceholder(activeProvider.name === 'LMStudio' ? providerI18n.apiKeyPlaceholderLmStudio : providerI18n.apiKeyPlaceholderDefault)
|
||||
.setValue(activeProvider.apiKey)
|
||||
.onChange(async (value) => { activeProvider.apiKey = value; await this.plugin.saveSettings(); }));
|
||||
.setName(providerI18n.contextualSettingsName)
|
||||
.setHeading();
|
||||
providerFields
|
||||
.filter(field => field.group === 'contextual')
|
||||
.forEach(field => this.renderProviderField(containerEl, activeProvider, field));
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.baseUrlName)
|
||||
.setDesc(formatI18n(providerI18n.baseUrlDesc, {
|
||||
provider: activeProvider.name,
|
||||
required: activeProvider.name === 'Azure OpenAI' ? providerI18n.baseUrlRequired : ''
|
||||
}))
|
||||
.addText(text => text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.providers.find(p => p.name === activeProvider.name)?.baseUrl || providerI18n.baseUrlPlaceholder)
|
||||
.setValue(activeProvider.baseUrl)
|
||||
.onChange(async (value) => { activeProvider.baseUrl = value; await this.plugin.saveSettings(); }));
|
||||
this.renderProviderModelDiscovery(containerEl, activeProvider);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.modelName)
|
||||
.setDesc(formatI18n(providerI18n.modelDesc, { provider: activeProvider.name }))
|
||||
.addText(text => text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.providers.find(p => p.name === activeProvider.name)?.model || providerI18n.modelPlaceholder)
|
||||
.setValue(activeProvider.model)
|
||||
.onChange(async (value) => { activeProvider.model = value; await this.plugin.saveSettings(); }));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.temperatureName)
|
||||
.setDesc(providerI18n.temperatureDesc)
|
||||
.addSlider(slider => slider
|
||||
.setLimits(0, 1, 0.1)
|
||||
.setValue(activeProvider.temperature)
|
||||
.onChange(async (value) => { activeProvider.temperature = value; await this.plugin.saveSettings(); })
|
||||
.setDynamicTooltip());
|
||||
|
||||
const showManualOutputTokenOverride =
|
||||
this.plugin.settings.enableDeveloperMode || activeProvider.maxOutputTokens !== undefined;
|
||||
|
||||
if (showManualOutputTokenOverride) {
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.maxOutputTokensName)
|
||||
.setDesc(providerI18n.maxOutputTokensDesc)
|
||||
.addText(text => text
|
||||
.setPlaceholder(String(this.plugin.settings.maxTokens))
|
||||
.setValue(activeProvider.maxOutputTokens !== undefined ? String(activeProvider.maxOutputTokens) : '')
|
||||
.onChange(async (value) => {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
activeProvider.maxOutputTokens = parsed;
|
||||
} else {
|
||||
delete activeProvider.maxOutputTokens;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
|
||||
if (isOpenAICompatible) {
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.topPName)
|
||||
.setDesc(providerI18n.topPDesc)
|
||||
.addText(text => text
|
||||
.setPlaceholder(providerI18n.topPPlaceholder)
|
||||
.setValue(activeProvider.topP !== undefined ? String(activeProvider.topP) : '')
|
||||
.onChange(async (value) => {
|
||||
const parsed = Number.parseFloat(value.trim());
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
activeProvider.topP = parsed;
|
||||
} else {
|
||||
delete activeProvider.topP;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.reasoningEffortName)
|
||||
.setDesc(providerI18n.reasoningEffortDesc)
|
||||
.addText(text => text
|
||||
.setPlaceholder(providerI18n.reasoningEffortPlaceholder)
|
||||
.setValue(activeProvider.reasoningEffort || '')
|
||||
.onChange(async (value) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['none', 'low', 'medium', 'high'].includes(normalized)) {
|
||||
activeProvider.reasoningEffort = normalized;
|
||||
} else {
|
||||
delete activeProvider.reasoningEffort;
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
|
||||
if (activeProvider.name === 'DeepSeek') {
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.thinkingEnabledName)
|
||||
.setDesc(providerI18n.thinkingEnabledDesc)
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(activeProvider.thinkingEnabled === true)
|
||||
.onChange(async (value) => {
|
||||
activeProvider.thinkingEnabled = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
|
||||
if (activeProvider.name === 'Azure OpenAI') {
|
||||
new Setting(containerEl)
|
||||
.setName(providerI18n.apiVersionName)
|
||||
.setDesc(providerI18n.apiVersionDesc)
|
||||
.addText(text => text
|
||||
.setPlaceholder(providerI18n.apiVersionPlaceholder)
|
||||
.setValue(activeProvider.apiVersion || '')
|
||||
.onChange(async (value) => { activeProvider.apiVersion = value; await this.plugin.saveSettings(); }));
|
||||
const advancedFields = providerFields.filter(field => field.group === 'advanced' || field.group === 'developer');
|
||||
if (advancedFields.length > 0) {
|
||||
const advancedDetails = containerEl.createEl('details', { cls: 'notemd-section-card notemd-provider-advanced-settings' });
|
||||
advancedDetails.open = hasPersistedAdvancedProviderSettings(activeProvider);
|
||||
const summary = advancedDetails.createEl('summary', { cls: 'notemd-section-summary' });
|
||||
summary.setText(providerI18n.advancedSettingsName);
|
||||
advancedDetails.createEl('p', {
|
||||
text: advancedDetails.open
|
||||
? providerI18n.advancedSettingsHint
|
||||
: formatI18n(providerI18n.advancedSettingsDesc, { provider: activeProvider.name }),
|
||||
cls: 'notemd-section-description'
|
||||
});
|
||||
advancedFields.forEach(field => this.renderProviderField(advancedDetails, activeProvider, field));
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
|
|||
38
styles.css
38
styles.css
|
|
@ -985,6 +985,44 @@
|
|||
border-color: color-mix(in srgb, var(--notemd-border) 70%, #f97316 30%);
|
||||
}
|
||||
|
||||
.notemd-provider-advanced-settings {
|
||||
margin: 0 0 0.95em;
|
||||
}
|
||||
|
||||
.notemd-provider-advanced-settings > .setting-item {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.notemd-provider-model-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55em;
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.notemd-provider-model-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75em;
|
||||
padding: 0.6em 0.7em;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--notemd-border) 78%, #14b8a6 22%);
|
||||
background: color-mix(in srgb, var(--background-primary) 90%, #f0fdfa 10%);
|
||||
}
|
||||
|
||||
.notemd-provider-model-item code {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.notemd-provider-model-item button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notemd-language-select {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue