feat(ui): redesign AI Provider settings with provider presets and model catalog

- Replace hardcoded provider list with PROVIDER_PRESETS (OpenAI, Ollama,
  DeepSeek, Groq, OpenRouter, OpenCode Go, Custom)
- Load model lists from models.dev/api.json catalog at startup
- Add Combobox vanilla JS component for model/base-URL selection
- Fetch local Ollama models via live API instead of catalog
- Hide API key values from frontend (boolean-only llm_api_key_set)
- Add i18n translations for all 9+ locales
- Update snapshot tests for new response fields
This commit is contained in:
Will Miao
2026-07-03 16:08:51 +08:00
parent f06c60bd47
commit 4ed9169646
22 changed files with 916 additions and 50 deletions

View File

@@ -662,7 +662,15 @@
"title": "KI-Anbieter", "title": "KI-Anbieter",
"provider": "Anbieter", "provider": "Anbieter",
"providerHelp": "Wählen Sie Ihren LLM-Anbieter. OpenAI und Ollama verwenden voreingestellte API-Endpunkte. Mit \"Benutzerdefiniert\" können Sie jeden OpenAI-kompatiblen Endpunkt angeben.", "providerHelp": "Wählen Sie Ihren LLM-Anbieter. OpenAI und Ollama verwenden voreingestellte API-Endpunkte. Mit \"Benutzerdefiniert\" können Sie jeden OpenAI-kompatiblen Endpunkt angeben.",
"custom": "Benutzerdefiniert (OpenAI-kompatibel)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (lokal)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "Benutzerdefiniert (OpenAI-kompatibel)"
},
"apiBase": "API-Basis-URL", "apiBase": "API-Basis-URL",
"apiBaseHelp": "Die Basis-URL für die LLM-API (z.B. https://api.openai.com/v1). Leer lassen, um die Anbietervoreinstellung zu verwenden.", "apiBaseHelp": "Die Basis-URL für die LLM-API (z.B. https://api.openai.com/v1). Leer lassen, um die Anbietervoreinstellung zu verwenden.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "Konfiguriert", "apiKeyConfigured": "Konfiguriert",
"apiKeySet": "Einrichten", "apiKeySet": "Einrichten",
"model": "Modell", "model": "Modell",
"modelHelp": "Der zu verwendende Modellname (z.B. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Prüfen Sie Ihren Anbieter auf verfügbare Modelle." "modelHelp": "Der zu verwendende Modellname (z.B. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Prüfen Sie Ihren Anbieter auf verfügbare Modelle.",
"modelPlaceholder": "Modell auswählen..."
} }
}, },
"loras": { "loras": {

View File

@@ -661,10 +661,18 @@
"aiProvider": { "aiProvider": {
"title": "AI Provider", "title": "AI Provider",
"provider": "Provider", "provider": "Provider",
"providerHelp": "Choose your LLM provider. OpenAI and Ollama use preset API endpoints. Custom lets you specify any OpenAI-compatible endpoint.", "providerHelp": "Choose your LLM provider. Preset providers set the API base URL automatically. Custom lets you specify any OpenAI-compatible endpoint.",
"custom": "Custom (OpenAI-compatible)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (local)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "Custom (OpenAI-compatible)"
},
"apiBase": "API Base URL", "apiBase": "API Base URL",
"apiBaseHelp": "The base URL for the LLM API (e.g. https://api.openai.com/v1). Leave empty to use the provider default.", "apiBaseHelp": "The base URL for the LLM API. Select a preset or enter a custom URL. The dropdown shows presets for all supported providers.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API Key", "apiKey": "API Key",
"apiKeyHelp": "Your LLM provider API key. Stored locally, never sent to any server except your chosen LLM provider.", "apiKeyHelp": "Your LLM provider API key. Stored locally, never sent to any server except your chosen LLM provider.",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "Configured", "apiKeyConfigured": "Configured",
"apiKeySet": "Set up", "apiKeySet": "Set up",
"model": "Model", "model": "Model",
"modelHelp": "The model name to use (e.g. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Check your provider for available models." "modelHelp": "The model to use. Select from the dropdown (fetched from your provider) or type a custom model name.",
"modelPlaceholder": "Select a model..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,7 +662,15 @@
"title": "Proveedor de IA", "title": "Proveedor de IA",
"provider": "Proveedor", "provider": "Proveedor",
"providerHelp": "Elija su proveedor de LLM. OpenAI y Ollama usan endpoints predefinidos. Personalizado le permite especificar cualquier endpoint compatible con OpenAI.", "providerHelp": "Elija su proveedor de LLM. OpenAI y Ollama usan endpoints predefinidos. Personalizado le permite especificar cualquier endpoint compatible con OpenAI.",
"custom": "Personalizado (compatible con OpenAI)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (local)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "Personalizado (compatible con OpenAI)"
},
"apiBase": "URL base de la API", "apiBase": "URL base de la API",
"apiBaseHelp": "La URL base para la API LLM (p.ej. https://api.openai.com/v1). Déjelo vacío para usar el valor predeterminado del proveedor.", "apiBaseHelp": "La URL base para la API LLM (p.ej. https://api.openai.com/v1). Déjelo vacío para usar el valor predeterminado del proveedor.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "Configurada", "apiKeyConfigured": "Configurada",
"apiKeySet": "Configurar", "apiKeySet": "Configurar",
"model": "Modelo", "model": "Modelo",
"modelHelp": "El nombre del modelo a usar (p.ej. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consulte a su proveedor para ver los modelos disponibles." "modelHelp": "El nombre del modelo a usar (p.ej. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consulte a su proveedor para ver los modelos disponibles.",
"modelPlaceholder": "Seleccionar un modelo..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,7 +662,15 @@
"title": "Fournisseur d'IA", "title": "Fournisseur d'IA",
"provider": "Fournisseur", "provider": "Fournisseur",
"providerHelp": "Choisissez votre fournisseur LLM. OpenAI et Ollama utilisent des endpoints prédéfinis. Personnalisé vous permet de spécifier n'importe quel endpoint compatible OpenAI.", "providerHelp": "Choisissez votre fournisseur LLM. OpenAI et Ollama utilisent des endpoints prédéfinis. Personnalisé vous permet de spécifier n'importe quel endpoint compatible OpenAI.",
"custom": "Personnalisé (compatible OpenAI)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (local)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "Personnalisé (compatible OpenAI)"
},
"apiBase": "URL de base de l'API", "apiBase": "URL de base de l'API",
"apiBaseHelp": "L'URL de base pour l'API LLM (ex. https://api.openai.com/v1). Laissez vide pour utiliser le fournisseur par défaut.", "apiBaseHelp": "L'URL de base pour l'API LLM (ex. https://api.openai.com/v1). Laissez vide pour utiliser le fournisseur par défaut.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "Configurée", "apiKeyConfigured": "Configurée",
"apiKeySet": "Configurer", "apiKeySet": "Configurer",
"model": "Modèle", "model": "Modèle",
"modelHelp": "Le nom du modèle à utiliser (ex. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consultez votre fournisseur pour les modèles disponibles." "modelHelp": "Le nom du modèle à utiliser (ex. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consultez votre fournisseur pour les modèles disponibles.",
"modelPlaceholder": "Sélectionner un modèle..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,7 +662,15 @@
"title": "ספק AI", "title": "ספק AI",
"provider": "ספק", "provider": "ספק",
"providerHelp": "בחר את ספק ה-LLM שלך. OpenAI ו-Ollama משתמשים בנקודות קצה מוגדרות מראש. מותאם אישית מאפשר לך לציין כל נקודת קצה תואמת OpenAI.", "providerHelp": "בחר את ספק ה-LLM שלך. OpenAI ו-Ollama משתמשים בנקודות קצה מוגדרות מראש. מותאם אישית מאפשר לך לציין כל נקודת קצה תואמת OpenAI.",
"custom": "מותאם אישית (תואם OpenAI)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (מקומי)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "מותאם אישית (תואם OpenAI)"
},
"apiBase": "כתובת בסיס API", "apiBase": "כתובת בסיס API",
"apiBaseHelp": "כתובת ה-URL הבסיסית ל-API של LLM (לדוגמה https://api.openai.com/v1). השאר ריק לשימוש בברירת המחדל של הספק.", "apiBaseHelp": "כתובת ה-URL הבסיסית ל-API של LLM (לדוגמה https://api.openai.com/v1). השאר ריק לשימוש בברירת המחדל של הספק.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "הוגדר", "apiKeyConfigured": "הוגדר",
"apiKeySet": "הגדר", "apiKeySet": "הגדר",
"model": "מודל", "model": "מודל",
"modelHelp": "שם המודל לשימוש (לדוגמה deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). בדוק אצל הספק שלך אילו מודלים זמינים." "modelHelp": "שם המודל לשימוש (לדוגמה deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). בדוק אצל הספק שלך אילו מודלים זמינים.",
"modelPlaceholder": "בחר מודל..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,7 +662,15 @@
"title": "AIプロバイダー", "title": "AIプロバイダー",
"provider": "プロバイダー", "provider": "プロバイダー",
"providerHelp": "LLMプロバイダーを選択してください。OpenAIとOllamaはプリセットのAPIエンドポイントを使用します。カスタムでは任意のOpenAI互換エンドポイントを指定できます。", "providerHelp": "LLMプロバイダーを選択してください。OpenAIとOllamaはプリセットのAPIエンドポイントを使用します。カスタムでは任意のOpenAI互換エンドポイントを指定できます。",
"custom": "カスタムOpenAI互換", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollamaローカル",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "カスタムOpenAI 互換)"
},
"apiBase": "APIベースURL", "apiBase": "APIベースURL",
"apiBaseHelp": "LLM APIのベースURLhttps://api.openai.com/v1。空の場合はプロバイダーのデフォルトが使用されます。", "apiBaseHelp": "LLM APIのベースURLhttps://api.openai.com/v1。空の場合はプロバイダーのデフォルトが使用されます。",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "設定済み", "apiKeyConfigured": "設定済み",
"apiKeySet": "設定", "apiKeySet": "設定",
"model": "モデル", "model": "モデル",
"modelHelp": "使用するモデル名deepseek-v4-flash, gemini-2.5-flash, gemma4:12b。プロバイダーで利用可能なモデルをご確認ください。" "modelHelp": "使用するモデル名deepseek-v4-flash, gemini-2.5-flash, gemma4:12b。プロバイダーで利用可能なモデルをご確認ください。",
"modelPlaceholder": "モデルを選択..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,7 +662,15 @@
"title": "AI 제공자", "title": "AI 제공자",
"provider": "제공자", "provider": "제공자",
"providerHelp": "LLM 제공자를 선택하세요. OpenAI와 Ollama는 사전 설정된 API 엔드포인트를 사용합니다. 사용자 정의를 선택하면 모든 OpenAI 호환 엔드포인트를 지정할 수 있습니다.", "providerHelp": "LLM 제공자를 선택하세요. OpenAI와 Ollama는 사전 설정된 API 엔드포인트를 사용합니다. 사용자 정의를 선택하면 모든 OpenAI 호환 엔드포인트를 지정할 수 있습니다.",
"custom": "사용자 정의 (OpenAI 호환)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (로컬)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "사용자 정의 (OpenAI 호환)"
},
"apiBase": "API 기본 URL", "apiBase": "API 기본 URL",
"apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.", "apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "설정됨", "apiKeyConfigured": "설정됨",
"apiKeySet": "설정", "apiKeySet": "설정",
"model": "모델", "model": "모델",
"modelHelp": "사용할 모델 이름 (예: deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). 제공자에서 사용 가능한 모델을 확인하세요." "modelHelp": "사용할 모델 이름 (예: deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). 제공자에서 사용 가능한 모델을 확인하세요.",
"modelPlaceholder": "모델 선택..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,7 +662,15 @@
"title": "Поставщик ИИ", "title": "Поставщик ИИ",
"provider": "Поставщик", "provider": "Поставщик",
"providerHelp": "Выберите поставщика LLM. OpenAI и Ollama используют предустановленные API-эндпоинты. Пользовательский позволяет указать любой совместимый с OpenAI эндпоинт.", "providerHelp": "Выберите поставщика LLM. OpenAI и Ollama используют предустановленные API-эндпоинты. Пользовательский позволяет указать любой совместимый с OpenAI эндпоинт.",
"custom": "Пользовательский (совместимый с OpenAI)", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama (локальный)",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "Пользовательский (совместимый с OpenAI)"
},
"apiBase": "Базовый URL API", "apiBase": "Базовый URL API",
"apiBaseHelp": "Базовый URL для LLM API (например, https://api.openai.com/v1). Оставьте пустым, чтобы использовать значение по умолчанию.", "apiBaseHelp": "Базовый URL для LLM API (например, https://api.openai.com/v1). Оставьте пустым, чтобы использовать значение по умолчанию.",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
@@ -673,7 +681,8 @@
"apiKeyConfigured": "Настроен", "apiKeyConfigured": "Настроен",
"apiKeySet": "Настроить", "apiKeySet": "Настроить",
"model": "Модель", "model": "Модель",
"modelHelp": "Имя модели для использования (например, deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Проверьте доступные модели у вашего поставщика." "modelHelp": "Имя модели для использования (например, deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Проверьте доступные модели у вашего поставщика.",
"modelPlaceholder": "Выберите модель..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,18 +662,27 @@
"title": "AI 提供商", "title": "AI 提供商",
"provider": "提供商", "provider": "提供商",
"providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。", "providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。",
"custom": "自定义(兼容 OpenAI", "providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama本地",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "自定义OpenAI 兼容)"
},
"apiBase": "API 基础地址", "apiBase": "API 基础地址",
"apiBaseHelp": "LLM API 的基础 URL例如 https://api.openai.com/v1。留空则使用提供商默认地址。", "apiBaseHelp": "LLM API 的基础地址。选择预设或输入自定义地址,下拉框显示所有支持的提供商预设。",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 密钥", "apiKey": "API 密钥",
"apiKeyHelp": "您的 LLM 提供商 API 密钥。本地存储,不会发送到您选择的 LLM 提供商之外的任何服务器。", "apiKeyHelp": "LLM 提供商 API 密钥。本地存储,您选择的 LLM 提供商外不会发送到任何服务器。",
"apiKeyPlaceholder": "sk-...", "apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未设置", "apiKeyNotSet": "未设置",
"apiKeyConfigured": "已配置", "apiKeyConfigured": "已配置",
"apiKeySet": "设置", "apiKeySet": "设置",
"model": "模型", "model": "模型",
"modelHelp": "要使用的模型名称(例如 deepseek-v4-flash, gemini-2.5-flash, gemma4:12b。请查看您的提供商支持的可用模型列表。" "modelHelp": "要使用的模型。从下拉框选择(从提供商获取)或输入自定义模型名称。",
"modelPlaceholder": "选择一个模型..."
} }
}, },
"loras": { "loras": {

View File

@@ -662,18 +662,27 @@
"title": "AI 提供者", "title": "AI 提供者",
"provider": "提供者", "provider": "提供者",
"providerHelp": "選擇您的 LLM 提供者。OpenAI 和 Ollama 使用預設 API 端點。自訂允許您指定任何相容 OpenAI 的端點。", "providerHelp": "選擇您的 LLM 提供者。OpenAI 和 Ollama 使用預設 API 端點。自訂允許您指定任何相容 OpenAI 的端點。",
"custom": "自訂(相容 OpenAI", "providerOptions": {
"apiBase": "API 基礎位址", "openai": "OpenAI",
"apiBaseHelp": "LLM API 的基礎 URL例如 https://api.openai.com/v1。留空則使用提供者預設位址。", "ollama": "Ollama本地",
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"opencode-go": "OpenCode Go",
"custom": "自訂OpenAI 相容)"
},
"apiBase": "API 基礎網址",
"apiBaseHelp": "LLM API 的基礎網址。選擇預設或輸入自訂網址,下拉選單顯示所有支援的提供者預設。",
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 金鑰", "apiKey": "API 金鑰",
"apiKeyHelp": "您的 LLM 提供者 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會送到任何伺服器。", "apiKeyHelp": "LLM 提供者 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會送到任何伺服器。",
"apiKeyPlaceholder": "sk-...", "apiKeyPlaceholder": "[TODO: Translate] sk-...",
"apiKeyNotSet": "未設定", "apiKeyNotSet": "未設定",
"apiKeyConfigured": "已設定", "apiKeyConfigured": "已設定",
"apiKeySet": "設定", "apiKeySet": "設定",
"model": "模型", "model": "模型",
"modelHelp": "要使用的模型名稱(例如 deepseek-v4-flash, gemini-2.5-flash, gemma4:12b。請查看您的提供者支援的可用模型列表。" "modelHelp": "要使用的模型。從下拉選單選擇(從提供者取得)或輸入自訂模型名稱。",
"modelPlaceholder": "選擇一個模型..."
} }
}, },
"loras": { "loras": {

View File

@@ -208,6 +208,10 @@ class LoraManager:
# Initialize WebSocket manager # Initialize WebSocket manager
await ServiceRegistry.get_websocket_manager() await ServiceRegistry.get_websocket_manager()
# Preload LLM model catalog (background task, non-blocking)
from .services.llm_service import LLMService
await LLMService.get_instance()
# Initialize scanners in background # Initialize scanners in background
lora_scanner = await ServiceRegistry.get_lora_scanner() lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()

View File

@@ -38,6 +38,7 @@ from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError from ...services.errors import ResourceNotFoundError
from ...services.llm_service import get_provider_model_ids, fetch_ollama_models
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.models import BaseModelMetadata from ...utils.models import BaseModelMetadata
from ...utils.constants import ( from ...utils.constants import (
@@ -1400,8 +1401,9 @@ class SettingsHandler:
"libraries", "libraries",
"active_library", "active_library",
# Sensitive — never expose the actual value to the frontend; # Sensitive — never expose the actual value to the frontend;
# frontend receives a boolean instead (civitai_api_key_set). # frontend receives a boolean instead (*_set).
"civitai_api_key", "civitai_api_key",
"llm_api_key",
} }
) )
@@ -1459,6 +1461,8 @@ class SettingsHandler:
# Sensitive fields: only expose a boolean indicating whether set # Sensitive fields: only expose a boolean indicating whether set
raw_key = self._settings.get("civitai_api_key") raw_key = self._settings.get("civitai_api_key")
response_data["civitai_api_key_set"] = bool(raw_key) response_data["civitai_api_key_set"] = bool(raw_key)
raw_llm_key = self._settings.get("llm_api_key")
response_data["llm_api_key_set"] = bool(raw_llm_key)
settings_file = getattr(self._settings, "settings_file", None) settings_file = getattr(self._settings, "settings_file", None)
if settings_file: if settings_file:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
@@ -1563,6 +1567,42 @@ class SettingsHandler:
logger.error("Error updating settings: %s", exc, exc_info=True) logger.error("Error updating settings: %s", exc, exc_info=True)
return web.Response(status=500, text=str(exc)) return web.Response(status=500, text=str(exc))
async def get_llm_models(self, request: web.Request) -> web.Response:
"""Return the model list for a provider.
For ``ollama`` the list is fetched live from the local Ollama API
(only models actually pulled locally are shown). For all other
providers the opencode model catalog is used.
Query parameters:
provider (required): Internal provider id (``openai``, ``ollama``, etc.).
Returns:
``{"success": true, "models": ["gpt-4o", ...]}``.
"""
provider_id = request.query.get("provider", "").strip()
if not provider_id:
return web.json_response(
{"success": False, "error": "provider query parameter is required", "models": []},
status=400,
)
try:
if provider_id == "ollama":
api_base = request.query.get("api_base", "").strip() or self._settings.get("llm_api_base", "")
if not api_base:
api_base = "http://localhost:11434/v1"
models = await fetch_ollama_models(api_base)
else:
models = await get_provider_model_ids(provider_id)
return web.json_response({"success": True, "models": models})
except Exception as exc:
logger.warning("get_llm_models failed for %s: %s", provider_id, exc)
return web.json_response(
{"success": False, "error": str(exc), "models": []},
status=500,
)
def _validate_example_images_path(self, folder_path: str) -> str | None: def _validate_example_images_path(self, folder_path: str) -> str | None:
if not os.path.exists(folder_path): if not os.path.exists(folder_path):
return f"Path does not exist: {folder_path}" return f"Path does not exist: {folder_path}"
@@ -3354,6 +3394,7 @@ class MiscHandlerSet:
"get_priority_tags": self.settings.get_priority_tags, "get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries, "get_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library, "activate_library": self.settings.activate_library,
"get_llm_models": self.settings.get_llm_models,
"update_usage_stats": self.usage_stats.update_usage_stats, "update_usage_stats": self.usage_stats.update_usage_stats,
"get_usage_stats": self.usage_stats.get_usage_stats, "get_usage_stats": self.usage_stats.get_usage_stats,
"update_lora_code": self.lora_code.update_lora_code, "update_lora_code": self.lora_code.update_lora_code,

View File

@@ -154,6 +154,11 @@ class ModelPageView:
) )
self._template_env._i18n_filter_added = True # type: ignore[attr-defined] self._template_env._i18n_filter_added = True # type: ignore[attr-defined]
from ...services.llm_service import PROVIDER_PRESETS, get_all_provider_models
catalog_provider_ids = [p for p in PROVIDER_PRESETS if p != "custom"]
provider_models = await get_all_provider_models(catalog_provider_ids)
template_context = { template_context = {
"is_initializing": is_initializing, "is_initializing": is_initializing,
"settings": self._settings, "settings": self._settings,
@@ -161,6 +166,8 @@ class ModelPageView:
"folders": [], "folders": [],
"t": self._server_i18n.get_translation, "t": self._server_i18n.get_translation,
"version": self._get_app_version(), "version": self._get_app_version(),
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
"provider_models_json": json.dumps(provider_models),
} }
if not is_initializing: if not is_initializing:

View File

@@ -22,6 +22,7 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"), RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"), RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),

View File

@@ -19,11 +19,165 @@ from .errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Default API base URLs per provider # ---------------------------------------------------------------------------
# Model catalog sourced from opencode's maintained model registry.
# maps provider_id -> list of model IDs.
# ---------------------------------------------------------------------------
_MODEL_CATALOG_URL = "https://models.dev/api.json"
# In-memory cache: maps provider slug -> list of model ID strings.
_catalog_cache: Optional[Dict[str, List[str]]] = None
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
async def _load_model_catalog() -> Dict[str, List[str]]:
"""Fetch and parse the model catalog, returning ``{provider_id: [model_id, ...]}``.
The JSON at ``_MODEL_CATALOG_URL`` is a dict keyed by provider slug; each
value has a ``models`` sub-dict keyed by model ID. Only the model IDs are
kept. The result is cached in memory after the first successful fetch.
Subsequent calls return the cached data immediately.
"""
global _catalog_cache
if _catalog_cache is not None:
return _catalog_cache
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
async with session.get(_MODEL_CATALOG_URL) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
return _catalog_cache or {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
return _catalog_cache or {}
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
return _catalog_cache or {}
result: Dict[str, List[str]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids = [str(mid) for mid in models_dict.keys() if isinstance(mid, str)]
if model_ids:
result[provider_id] = model_ids
_catalog_cache = result
logger.info(
"Loaded model catalog: %d providers, %d total models",
len(result),
sum(len(m) for m in result.values()),
)
return result
# Short timeout for Ollama's local API
_OLLAMA_API_TIMEOUT = aiohttp.ClientTimeout(total=8)
async def fetch_ollama_models(api_base: str) -> List[str]:
"""Fetch locally available models from a running Ollama instance.
Uses Ollama's OpenAI-compatible ``GET {api_base}/models`` endpoint.
Returns an empty list if Ollama is not reachable (not running).
"""
url = f"{api_base.rstrip('/')}/models"
try:
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
async with session.get(url) as resp:
if resp.status != 200:
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
return []
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
logger.debug("Ollama not reachable at %s: %s", api_base, exc)
return []
raw = data.get("data") if isinstance(data, dict) else None
if not isinstance(raw, list):
return []
return [
str(entry["id"]) for entry in raw
if isinstance(entry, dict) and isinstance(entry.get("id"), str)
]
async def get_provider_model_ids(provider_id: str) -> List[str]:
"""Return the list of known model IDs for *provider_id* from the catalog.
The catalog is loaded on first call and cached thereafter. If the
provider is not found an empty list is returned (never raises).
"""
catalog = await _load_model_catalog()
return catalog.get(provider_id, [])
async def get_all_provider_models(
provider_ids: List[str],
) -> Dict[str, List[str]]:
"""Return model lists for a subset of providers in one call.
Loads the catalog (cached) and returns only the requested providers.
Handy for embedding lightweight data into the template context.
"""
catalog = await _load_model_catalog()
return {
pid: catalog.get(pid, [])
for pid in provider_ids
}
# Provider preset definitions.
# Each entry contains display metadata and defaults for the UI.
# The key is the internal provider id stored in ``llm_provider``.
# Models are NOT listed here — they come from the opencode model catalog at
# runtime (see :func:`get_provider_model_ids`).
PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
"openai": {
"name": "OpenAI",
"api_base": "https://api.openai.com/v1",
"requires_key": True,
},
"ollama": {
"name": "Ollama (local)",
"api_base": "http://localhost:11434/v1",
"requires_key": False,
},
"deepseek": {
"name": "DeepSeek",
"api_base": "https://api.deepseek.com/v1",
"requires_key": True,
},
"groq": {
"name": "Groq",
"api_base": "https://api.groq.com/openai/v1",
"requires_key": True,
},
"openrouter": {
"name": "OpenRouter",
"api_base": "https://openrouter.ai/api/v1",
"requires_key": True,
},
"opencode-go": {
"name": "OpenCode Go",
"api_base": "https://opencode.ai/zen/go/v1",
"requires_key": True,
},
# "custom" is handled specially (no preset api_base, requires user input)
}
# Legacy lookup derived from PROVIDER_PRESETS for backward compat.
_PROVIDER_DEFAULTS: Dict[str, str] = { _PROVIDER_DEFAULTS: Dict[str, str] = {
"openai": "https://api.openai.com/v1", pid: info["api_base"]
"ollama": "http://localhost:11434/v1", for pid, info in PROVIDER_PRESETS.items()
# "custom" requires an explicit llm_api_base from the user if info.get("api_base")
} }
# Request timeout for LLM calls (seconds) # Request timeout for LLM calls (seconds)
@@ -57,6 +211,10 @@ class LLMService:
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
cls._instance = cls(get_settings_manager()) cls._instance = cls(get_settings_manager())
# Start preloading the model catalog in the background so
# the settings UI never blocks on it. The catalog is
# cached after the first fetch (see _load_model_catalog).
asyncio.create_task(_load_model_catalog())
return cls._instance return cls._instance
@classmethod @classmethod
@@ -79,20 +237,31 @@ class LLMService:
"model": self._settings.get("llm_model", ""), "model": self._settings.get("llm_model", ""),
} }
@staticmethod
def _provider_requires_key(provider: str) -> bool:
"""Return ``False`` when the given provider id does not need an API key."""
preset = PROVIDER_PRESETS.get(provider, {})
return bool(preset.get("requires_key", True))
def is_configured(self) -> bool: def is_configured(self) -> bool:
"""Return ``True`` when the LLM provider is minimally configured. """Return ``True`` when the LLM provider is minimally configured.
A provider is considered configured when ``llm_model`` is set and A provider is considered configured when ``llm_model`` is set and
(for non-Ollama) an API key is configured. an API key is configured for providers that require one (e.g.
Ollama does not).
""" """
cfg = self._get_config() cfg = self._get_config()
has_model = bool(cfg["model"]) has_model = bool(cfg["model"])
has_key = bool(cfg["api_key"]) or cfg["provider"] == "ollama" has_key = bool(cfg["api_key"]) or not self._provider_requires_key(cfg["provider"])
return has_model and has_key return has_model and has_key
def _resolve_api_base(self, provider: str, api_base: str) -> str: def _resolve_api_base(self, provider: str, api_base: str) -> str:
"""Resolve the API base URL for the given provider.""" """Resolve the API base URL for the given provider.
If ``api_base`` is explicitly set (non-empty), it takes priority.
Otherwise the default from :data:`PROVIDER_PRESETS` is used.
"""
if api_base: if api_base:
return api_base.rstrip("/") return api_base.rstrip("/")
@@ -115,12 +284,13 @@ class LLMService:
cfg = self._get_config() cfg = self._get_config()
has_model = bool(cfg["model"]) has_model = bool(cfg["model"])
has_key = bool(cfg["api_key"]) or cfg["provider"] == "ollama" needs_key = self._provider_requires_key(cfg["provider"])
has_key = bool(cfg["api_key"]) or not needs_key
if not (has_model and has_key): if not (has_model and has_key):
parts = [] parts = []
if not has_model: if not has_model:
parts.append("No LLM model specified") parts.append("No LLM model specified")
if not has_key and cfg["provider"] != "ollama": if not has_key and needs_key:
parts.append("No LLM API key configured") parts.append("No LLM API key configured")
detail = "; ".join(parts) if parts else "LLM provider is not configured" detail = "; ".join(parts) if parts else "LLM provider is not configured"
raise LLMNotConfiguredError( raise LLMNotConfiguredError(

View File

@@ -1592,3 +1592,45 @@ input:checked + .toggle-slider:before {
animation: settings-highlight-pulse 1.5s ease-in-out 3; animation: settings-highlight-pulse 1.5s ease-in-out 3;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
} }
/* ---- Combobox panel for AI Provider settings ---- */
/* The panel is appended to <body> by Combobox.js and positioned relative to
the enhanced <input>. Styles reuse settings-modal CSS variables. */
.lm-combobox-panel {
position: absolute;
z-index: 10002;
max-height: 240px;
overflow-y: auto;
background: var(--lora-surface, #2a2a2a);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
border-radius: var(--border-radius-xs, 6px);
box-shadow: var(--shadow-elevated, 0 6px 18px rgba(0, 0, 0, 0.45));
font-size: 0.95em;
color: var(--text-color, rgba(226, 232, 240, 0.9));
padding: 4px 0;
box-sizing: border-box;
}
.lm-combobox-option {
padding: 6px 12px;
cursor: pointer;
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lm-combobox-option:hover,
.lm-combobox-option.is-active {
background: rgba(from var(--lora-accent) r g b / 0.2);
color: var(--lora-accent);
}
.lm-combobox-empty {
padding: 8px 12px;
color: var(--text-color);
opacity: 0.45;
font-style: italic;
user-select: none;
}

View File

@@ -0,0 +1,394 @@
// Combobox.js — Reusable dropdown-suggestion + free-text input component.
//
// Enhances an existing <input> element with a dropdown panel that merges static
// `presets` with asynchronously fetched options (`fetchOptions`). The input
// remains a free-text field — selecting a dropdown option is optional, the
// user can always type an arbitrary value.
//
// Zero dependencies: pure DOM manipulation. Exported on `window.Combobox`
// so non-module callers can instantiate it, and as a named ES module export
// for callers that import it directly.
//
// Usage:
// const box = new Combobox(inputEl, {
// presets: ['masterpiece', 'best quality'],
// fetchOptions: async (q) => await fetchSuggestions(q),
// placeholder: 'Type a value…',
// onSelect: (value) => console.log('chose', value),
// });
// box.updatePresets(['new', 'presets']);
// box.setValue('masterpiece');
const DEBOUNCE_MS = 300;
export class Combobox {
/**
* @param {HTMLInputElement} inputElement Existing <input> to enhance.
* @param {Object} options
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
* Async function returning dynamic suggestions for the current input.
* @param {string} [options.placeholder] Placeholder text for the empty state.
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
*/
constructor(inputElement, options = {}) {
if (!inputElement || inputElement.tagName !== 'INPUT') {
console.error('Combobox: expected an <input> element');
return;
}
this.input = inputElement;
this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
this.placeholder = options.placeholder || '';
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
// Internal state
this._isOpen = false;
this._activeIndex = -1;
this._renderedOptions = []; // current visible option strings (de-duplicated, merged)
this._fetchToken = 0; // guards against out-of-order async fetch results
this._fetchTimer = null;
this._suppressInputOpen = false; // guards setValue() from reopening the dropdown
this._buildDropdown();
this._bindEvents();
}
// ---- public API ----
/**
* Replace the preset list. Re-renders the dropdown if it is open.
* @param {string[]} presets
* @returns {void}
*/
updatePresets(presets) {
this.presets = Array.isArray(presets) ? [...presets] : [];
if (this._isOpen) {
this._refresh();
}
}
/**
* Set the input value programmatically without triggering the dropdown
* or firing synthetic events.
* @param {string} value
* @returns {void}
*/
setValue(value) {
const prev = this._suppressInputOpen;
this._suppressInputOpen = true;
this.input.value = value ?? '';
this._suppressInputOpen = prev;
if (this._isOpen) {
this._refresh();
}
}
// ---- build ----
_buildDropdown() {
const panel = document.createElement('div');
panel.className = 'lm-combobox-panel';
panel.setAttribute('role', 'listbox');
panel.style.display = 'none';
// Append to <body> so the panel is never clipped by an overflow:hidden
// ancestor; positioning is recomputed on each open.
document.body.appendChild(panel);
this.panel = panel;
if (this.placeholder) {
this.input.setAttribute('placeholder', this.placeholder);
}
this.input.setAttribute('autocomplete', 'off');
this.input.setAttribute('role', 'combobox');
this.input.setAttribute('aria-autocomplete', 'list');
this.input.setAttribute('aria-expanded', 'false');
}
// ---- event wiring ----
_bindEvents() {
this.input.addEventListener('focus', () => {
if (this._suppressInputOpen) return;
this._open();
});
this.input.addEventListener('input', () => {
if (this._suppressInputOpen) return;
this._open(); // no-op if already open
this._refresh(); // re-filter by current input value
this._scheduleFetch();
});
this.input.addEventListener('keydown', (event) => this._onKeyDown(event));
// Click an option (delegated)
this.panel.addEventListener('click', (event) => {
const item = event.target.closest('.lm-combobox-option');
if (!item) return;
const value = item.dataset.value;
if (value !== undefined) {
this._choose(value);
}
});
// Hover updates the active highlight so keyboard + mouse stay in sync.
this.panel.addEventListener('mouseover', (event) => {
const item = event.target.closest('.lm-combobox-option');
if (!item) return;
const idx = Number(item.dataset.index);
if (!Number.isNaN(idx)) {
this._setActiveIndex(idx);
}
});
// Click outside closes the dropdown.
this._outsideClickHandler = (event) => {
if (this._isOpen && !this.input.contains(event.target) && !this.panel.contains(event.target)) {
this._close();
}
};
document.addEventListener('mousedown', this._outsideClickHandler);
// Reposition on viewport changes while open.
this._resizeHandler = () => {
if (this._isOpen) this._position();
};
window.addEventListener('resize', this._resizeHandler);
window.addEventListener('scroll', this._resizeHandler, true);
}
// ---- keyboard ----
_onKeyDown(event) {
if (!this._isOpen) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this._open();
this._setActiveIndex(0);
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this._setActiveIndex(this._activeIndex + 1);
break;
case 'ArrowUp':
event.preventDefault();
this._setActiveIndex(this._activeIndex - 1);
break;
case 'Enter':
// Only intercept Enter to pick an option when one is actively
// highlighted; otherwise let the input's default behavior
// (form submit / free-text commit) proceed.
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
event.preventDefault();
this._choose(this._renderedOptions[this._activeIndex]);
}
break;
case 'Escape':
event.preventDefault();
this._close();
this.input.focus();
break;
case 'Tab':
// Allow normal tab navigation; just close the panel.
this._close();
break;
}
}
// ---- open / close ----
_open() {
if (this._isOpen) return;
this._isOpen = true;
this.panel.style.display = 'block';
this.input.setAttribute('aria-expanded', 'true');
// On open, render ALL presets — do not filter by the current input
// value. Filtering on the input event is handled separately.
this._render(this.presets);
this._position();
}
_close() {
if (!this._isOpen) return;
this._isOpen = false;
this.panel.style.display = 'none';
this.input.setAttribute('aria-expanded', 'false');
this._activeIndex = -1;
this._cancelFetch();
}
_position() {
const rect = this.input.getBoundingClientRect();
const panelHeight = this.panel.offsetHeight;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
// Flip above the input when there is more room there.
const placeAbove = spaceBelow < panelHeight && spaceAbove > spaceBelow;
const top = placeAbove
? rect.top + window.scrollY - panelHeight
: rect.bottom + window.scrollY;
this.panel.style.top = `${Math.max(0, top)}px`;
this.panel.style.left = `${rect.left + window.scrollX}px`;
this.panel.style.minWidth = `${rect.width}px`;
}
// ---- rendering ----
/** Render a list of strings into the panel. */
_render(items) {
this._renderedOptions = items;
this.panel.innerHTML = '';
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'lm-combobox-empty';
empty.textContent = this.placeholder ? this.placeholder : 'No options';
this.panel.appendChild(empty);
this._activeIndex = -1;
return;
}
const fragment = document.createDocumentFragment();
items.forEach((opt, idx) => {
const item = document.createElement('div');
item.className = 'lm-combobox-option';
item.setAttribute('role', 'option');
item.dataset.value = opt;
item.dataset.index = String(idx);
item.textContent = opt;
if (idx === this._activeIndex) {
item.classList.add('is-active');
}
fragment.appendChild(item);
});
this.panel.appendChild(fragment);
if (this._activeIndex >= items.length) {
this._setActiveIndex(items.length - 1);
}
}
/** Filter presets by current input value and re-render. */
_refresh() {
const value = this.input.value;
const filtered = this._filterPresets(value);
const merged = this._mergeUnique(filtered, this._fetchedOptions || []);
this._render(merged);
}
_filterPresets(value) {
const v = (value || '').toLowerCase();
if (!v) return [...this.presets];
return this.presets.filter((p) => String(p).toLowerCase().startsWith(v));
}
_mergeUnique(...lists) {
const seen = new Set();
const out = [];
for (const list of lists) {
for (const item of list) {
const key = String(item);
if (!seen.has(key)) {
seen.add(key);
out.push(key);
}
}
}
return out;
}
_setActiveIndex(idx) {
const max = this._renderedOptions.length - 1;
const clamped = Math.max(-1, Math.min(max, idx));
this._activeIndex = clamped;
// Update DOM classes without full re-render.
const items = this.panel.querySelectorAll('.lm-combobox-option');
items.forEach((el, i) => {
el.classList.toggle('is-active', i === clamped);
});
// Scroll the active item into view inside the panel.
if (clamped >= 0 && items[clamped]) {
items[clamped].scrollIntoView({ block: 'nearest' });
}
}
/**
* Remove the panel from the DOM and detach event listeners.
* Call this before discarding the Combobox instance.
*/
destroy() {
this._close();
if (this.panel && this.panel.parentNode) {
this.panel.parentNode.removeChild(this.panel);
}
document.removeEventListener('mousedown', this._outsideClickHandler);
window.removeEventListener('resize', this._resizeHandler);
window.removeEventListener('scroll', this._resizeHandler, true);
}
_choose(value) {
this.input.value = value;
this._close();
if (typeof this.onSelect === 'function') {
this.onSelect(value);
}
// Re-focus without reopening the dropdown.
this._suppressInputOpen = true;
this.input.focus();
this._suppressInputOpen = false;
}
// ---- async fetch (debounced) ----
_scheduleFetch() {
if (!this.fetchOptions) return;
this._cancelFetch();
this._fetchTimer = setTimeout(() => {
this._fetchTimer = null;
this._runFetch();
}, DEBOUNCE_MS);
}
_cancelFetch() {
if (this._fetchTimer) {
clearTimeout(this._fetchTimer);
this._fetchTimer = null;
}
this._fetchToken++; // invalidate any in-flight result
}
async _runFetch() {
if (!this.fetchOptions) return;
const token = this._fetchToken;
const value = this.input.value;
let results;
try {
results = await this.fetchOptions(value);
} catch (err) {
console.error('Combobox fetchOptions error:', err);
results = [];
}
// Stale guard: a newer fetch or close superseded this one.
if (token !== this._fetchToken || !this._isOpen) return;
this._fetchedOptions = Array.isArray(results) ? results : [];
this._refresh();
}
}
// Expose for non-module callers (templates load via <script type="module">,
// but some widget code reads globals off `window`).
if (typeof window !== 'undefined') {
window.Combobox = Combobox;
}

View File

@@ -15,6 +15,7 @@ import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { i18n } from './i18n/index.js'; import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js'; import { onboardingManager } from './managers/OnboardingManager.js';
import './components/Combobox.js';
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js'; import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js'; import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
import { initializeEventManagement } from './utils/eventManagementInit.js'; import { initializeEventManagement } from './utils/eventManagementInit.js';

View File

@@ -165,6 +165,18 @@ class AgentManager {
* *
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
_readProviderRequiresKey(providerId) {
const script = document.getElementById('llmProviderPresets');
if (!script) return true; // safe default
try {
const presets = JSON.parse(script.textContent);
const preset = presets[providerId];
return preset ? preset.requires_key !== false : true;
} catch {
return true;
}
}
async isLlmConfigured() { async isLlmConfigured() {
try { try {
const response = await fetch('/api/lm/settings'); const response = await fetch('/api/lm/settings');
@@ -172,8 +184,9 @@ class AgentManager {
const data = await response.json(); const data = await response.json();
const provider = data.settings?.llm_provider; const provider = data.settings?.llm_provider;
const hasModel = !!data.settings?.llm_model; const hasModel = !!data.settings?.llm_model;
const hasKey = !!data.settings?.llm_api_key; const hasKey = !!(data.settings?.llm_api_key_set || data.settings?.llm_api_key);
return hasModel && (hasKey || provider === 'ollama'); const needsKey = this._readProviderRequiresKey(provider);
return hasModel && (hasKey || !needsKey);
} catch { } catch {
return false; return false;
} }

View File

@@ -829,20 +829,106 @@ export class SettingsManager {
this.updateApiKeyStatus(); this.updateApiKeyStatus();
this.updateLlmApiKeyStatus(); this.updateLlmApiKeyStatus();
// AI Provider settings // ── AI Provider settings ──────────────────────────────────────
// Load provider presets from the JSON script tag embedded in the template
this._providerPresets = {};
this._providerModels = {};
const presetsScript = document.getElementById('llmProviderPresets');
if (presetsScript) {
try {
this._providerPresets = JSON.parse(presetsScript.textContent);
} catch (_) {
this._providerPresets = {};
}
}
const modelsScript = document.getElementById('llmProviderModels');
if (modelsScript) {
try {
this._providerModels = JSON.parse(modelsScript.textContent);
} catch (_) {
this._providerModels = {};
}
}
const llmProviderSelect = document.getElementById('llmProvider'); const llmProviderSelect = document.getElementById('llmProvider');
if (llmProviderSelect) { if (llmProviderSelect) {
llmProviderSelect.value = state.global.settings.llm_provider || 'openai'; llmProviderSelect.value = state.global.settings.llm_provider || 'openai';
} }
// Destroy previous combobox instances before creating new ones,
// since loadSettingsToUI() runs on every modal open.
if (this._llmApiBaseCombobox) { this._llmApiBaseCombobox.destroy(); }
if (this._llmModelCombobox) { this._llmModelCombobox.destroy(); }
const llmApiBaseInput = document.getElementById('llmApiBase'); const llmApiBaseInput = document.getElementById('llmApiBase');
if (llmApiBaseInput) { if (llmApiBaseInput) {
llmApiBaseInput.value = state.global.settings.llm_api_base || ''; llmApiBaseInput.value = state.global.settings.llm_api_base || '';
const presetUrls = Object.values(this._providerPresets)
.map(p => p.api_base)
.filter(Boolean);
if (typeof Combobox !== 'undefined') {
this._llmApiBaseCombobox = new Combobox(llmApiBaseInput, {
presets: presetUrls,
placeholder: 'https://api.openai.com/v1',
});
}
} }
// Helper to update model Combobox presets from catalog / Ollama API
const llmModelInput = document.getElementById('llmModel'); const llmModelInput = document.getElementById('llmModel');
if (llmModelInput) { this._llmModelCombobox = null;
llmModelInput.value = state.global.settings.llm_model || ''; if (llmModelInput && typeof Combobox !== 'undefined') {
const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai';
const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []);
this._llmModelCombobox = new Combobox(llmModelInput, {
presets: fallbackModels,
placeholder: translate('settings.aiProvider.modelPlaceholder', {}, 'Select a model...'),
onSelect: (value) => {
state.global.settings.llm_model = value;
this.saveSetting('llm_model', value)
.then(() => showToast('toast.settings.settingsUpdated', { setting: 'model' }, 'success'))
.catch(() => {});
},
});
}
const _loadModelPresets = async (provider) => {
if (!this._llmModelCombobox) return;
if (provider === 'ollama') {
try {
const apiBase = document.getElementById('llmApiBase')?.value?.trim() || 'http://localhost:11434/v1';
const resp = await fetch(`/api/lm/llm/models?provider=ollama&api_base=${encodeURIComponent(apiBase)}`);
if (resp.ok) {
const data = await resp.json();
if (data.success && Array.isArray(data.models)) {
this._llmModelCombobox.updatePresets(data.models);
return;
}
}
} catch (_) {}
this._llmModelCombobox.updatePresets([]);
} else {
this._llmModelCombobox.updatePresets(this._providerModels[provider] || []);
}
};
_loadModelPresets(llmProviderSelect ? llmProviderSelect.value : 'openai');
// Provider change → auto-fill API Base URL + update model presets
if (llmProviderSelect) {
llmProviderSelect.addEventListener('change', () => {
const provider = llmProviderSelect.value;
const preset = this._providerPresets[provider];
if (preset) {
if (llmApiBaseInput && preset.api_base) {
llmApiBaseInput.value = preset.api_base;
if (this._llmApiBaseCombobox) {
this._llmApiBaseCombobox.setValue(preset.api_base);
}
llmApiBaseInput.dispatchEvent(new Event('blur'));
}
}
_loadModelPresets(provider);
});
} }
const civitaiHostSelect = document.getElementById('civitaiHost'); const civitaiHostSelect = document.getElementById('civitaiHost');
@@ -2949,7 +3035,7 @@ export class SettingsManager {
} }
updateLlmApiKeyStatus() { updateLlmApiKeyStatus() {
const hasKey = !!state.global.settings.llm_api_key; const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key);
const statusText = document.getElementById('llmApiKeyStatusText'); const statusText = document.getElementById('llmApiKeyStatusText');
const actionBtn = document.getElementById('llmApiKeyActionBtn'); const actionBtn = document.getElementById('llmApiKeyActionBtn');
if (!statusText || !actionBtn) return; if (!statusText || !actionBtn) return;

View File

@@ -157,9 +157,13 @@
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="llmProvider" onchange="settingsManager.saveSelectSetting('llmProvider', 'llm_provider')"> <select id="llmProvider" onchange="settingsManager.saveSelectSetting('llmProvider', 'llm_provider')">
<option value="openai">OpenAI</option> <option value="openai">{{ t('settings.aiProvider.providerOptions.openai') }}</option>
<option value="ollama">Ollama (local)</option> <option value="ollama">{{ t('settings.aiProvider.providerOptions.ollama') }}</option>
<option value="custom">{{ t('settings.aiProvider.custom') }}</option> <option value="deepseek">{{ t('settings.aiProvider.providerOptions.deepseek') }}</option>
<option value="groq">{{ t('settings.aiProvider.providerOptions.groq') }}</option>
<option value="openrouter">{{ t('settings.aiProvider.providerOptions.openrouter') }}</option>
<option value="opencode-go">{{ t('settings.aiProvider.providerOptions.opencode-go') }}</option>
<option value="custom">{{ t('settings.aiProvider.providerOptions.custom') }}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -171,10 +175,12 @@
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.apiBaseHelp') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.apiBaseHelp') }}"></i>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<div class="text-input-wrapper"> <div class="text-input-wrapper lm-combobox-container">
<input type="text" id="llmApiBase" <input type="text" id="llmApiBase"
class="lm-combobox-input"
value="{{ settings.get('llm_api_base', '') }}" value="{{ settings.get('llm_api_base', '') }}"
placeholder="{{ t('settings.aiProvider.apiBasePlaceholder') }}" placeholder="{{ t('settings.aiProvider.apiBasePlaceholder') }}"
autocomplete="off"
onblur="settingsManager.saveInputSetting('llmApiBase', 'llm_api_base')" onblur="settingsManager.saveInputSetting('llmApiBase', 'llm_api_base')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" /> onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div> </div>
@@ -222,10 +228,12 @@
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.modelHelp') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.modelHelp') }}"></i>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<div class="text-input-wrapper"> <div class="text-input-wrapper lm-combobox-container">
<input type="text" id="llmModel" <input type="text" id="llmModel"
class="lm-combobox-input"
value="{{ settings.get('llm_model', '') }}" value="{{ settings.get('llm_model', '') }}"
placeholder="e.g. gpt-4o-mini" placeholder="{{ t('settings.aiProvider.modelPlaceholder') }}"
autocomplete="off"
onblur="settingsManager.saveInputSetting('llmModel', 'llm_model')" onblur="settingsManager.saveInputSetting('llmModel', 'llm_model')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" /> onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div> </div>
@@ -234,6 +242,14 @@
</div> </div>
</div> </div>
<!-- Provider presets + model lists for frontend -->
<script id="llmProviderPresets" type="application/json">
{{ provider_presets_json | safe }}
</script>
<script id="llmProviderModels" type="application/json">
{{ provider_models_json | safe }}
</script>
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
<h4>{{ t('settings.sections.downloads') }}</h4> <h4>{{ t('settings.sections.downloads') }}</h4>

View File

@@ -28,6 +28,7 @@
'settings': dict({ 'settings': dict({
'civitai_api_key_set': True, 'civitai_api_key_set': True,
'language': 'en', 'language': 'en',
'llm_api_key_set': False,
'theme': 'dark', 'theme': 'dark',
}), }),
'success': True, 'success': True,