mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-07-05 17:01:16 -03:00
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:
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
394
static/js/components/Combobox.js
Normal file
394
static/js/components/Combobox.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user