diff --git a/myproject/integrations/migrations/0010_alter_openrouterintegration_model_name.py b/myproject/integrations/migrations/0010_alter_openrouterintegration_model_name.py new file mode 100644 index 0000000..4cb932b --- /dev/null +++ b/myproject/integrations/migrations/0010_alter_openrouterintegration_model_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.10 on 2026-01-23 15:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0009_alter_glmintegration_model_name_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='openrouterintegration', + name='model_name', + field=models.CharField(blank=True, default='', help_text='Название используемой модели OpenRouter (загружается автоматически)', max_length=200, verbose_name='Название модели'), + ), + ] diff --git a/myproject/integrations/models/ai_services/openrouter.py b/myproject/integrations/models/ai_services/openrouter.py index 6d91960..f375c80 100644 --- a/myproject/integrations/models/ai_services/openrouter.py +++ b/myproject/integrations/models/ai_services/openrouter.py @@ -10,14 +10,6 @@ def validate_temperature(value): raise ValidationError('Температура должна быть в диапазоне 0.0-2.0') -# Список доступных моделей OpenRouter (бесплатные) -OPENROUTER_MODEL_CHOICES = [ - ('xiaomi/mimo-v2-flash:free', 'Xiaomi MIMO v2 Flash (Бесплатная)'), - ('mistralai/devstral-2512:free', 'Mistral Devstral 2512 (Бесплатная)'), - ('z-ai/glm-4.5-air:free', 'Z.AI GLM-4.5 Air (Бесплатная)'), - ('qwen/qwen3-coder:free', 'Qwen 3 Coder (Бесплатная)'), -] - # Предустановленные значения температуры OPENROUTER_TEMPERATURE_CHOICES = [ (0.1, '0.1 - Очень консервативно'), @@ -59,11 +51,11 @@ class OpenRouterIntegration(AIIntegration): ) model_name = models.CharField( - max_length=100, - default="xiaomi/mimo-v2-flash:free", - choices=OPENROUTER_MODEL_CHOICES, + max_length=200, + default="", + blank=True, verbose_name="Название модели", - help_text="Название используемой модели OpenRouter" + help_text="Название используемой модели OpenRouter (загружается автоматически)" ) temperature = models.FloatField( diff --git a/myproject/integrations/urls.py b/myproject/integrations/urls.py index dab9ef1..5e3394d 100644 --- a/myproject/integrations/urls.py +++ b/myproject/integrations/urls.py @@ -6,6 +6,7 @@ from .views import ( get_integration_form_data, test_integration_connection, RecommerceBatchSyncView, + get_openrouter_models, ) app_name = 'integrations' @@ -22,4 +23,7 @@ urlpatterns = [ # Синхронизация path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"), + + # OpenRouter модели + path("openrouter/models/", get_openrouter_models, name="openrouter_models"), ] diff --git a/myproject/integrations/views.py b/myproject/integrations/views.py index 5f32330..eb71a3f 100644 --- a/myproject/integrations/views.py +++ b/myproject/integrations/views.py @@ -1,7 +1,10 @@ import json +import logging from django.views.generic import TemplateView from django.http import JsonResponse -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_GET + +logger = logging.getLogger(__name__) from user_roles.mixins import OwnerRequiredMixin from .models import RecommerceIntegration, WooCommerceIntegration, GLMIntegration, OpenRouterIntegration @@ -181,6 +184,44 @@ def get_integration_service(integration_id: str, instance): return None +@require_GET +def get_openrouter_models(request): + """ + GET /settings/integrations/openrouter/models/ + Возвращает список моделей OpenRouter (бесплатные сверху) + """ + import requests + + try: + response = requests.get('https://openrouter.ai/api/v1/models', timeout=10) + response.raise_for_status() + data = response.json() + + models = data.get('data', []) + + # Разделить на бесплатные и платные + free_models = [] + paid_models = [] + + for model in models: + model_id = model.get('id', '') + model_name = model.get('name', model_id) + + if ':free' in model_id: + free_models.append({'id': model_id, 'name': f"{model_name} (Бесплатная)"}) + else: + paid_models.append({'id': model_id, 'name': model_name}) + + # Бесплатные сверху + all_models = free_models + paid_models + + return JsonResponse({'models': all_models}) + + except Exception as e: + logger.error(f"Error fetching OpenRouter models: {e}") + return JsonResponse({'error': str(e)}, status=500) + + class RecommerceBatchSyncView(TemplateView): """ API View для запуска массовой синхронизации с Recommerce. @@ -363,29 +404,33 @@ def get_form_fields_meta(model): 'label': getattr(field, 'verbose_name', field_name), 'help_text': getattr(field, 'help_text', ''), 'required': not getattr(field, 'blank', True), - 'type': 'text', # default + 'type': 'password' if field_name == 'api_key' else 'text', } - - # Определить тип поля - if 'BooleanField' in field.__class__.__name__: - field_info['type'] = 'checkbox' - elif 'URLField' in field.__class__.__name__: - field_info['type'] = 'url' - elif 'secret' in field_name.lower() or 'token' in field_name.lower() or 'key' in field_name.lower(): - field_info['type'] = 'password' - fields.append(field_info) - elif field_name in ['model_name', 'temperature']: + + elif field_name == 'temperature': field = model._meta.get_field(field_name) field_info = { 'name': field_name, 'label': getattr(field, 'verbose_name', field_name), 'help_text': getattr(field, 'help_text', ''), 'required': not getattr(field, 'blank', True), - 'type': 'select', # dropdown + 'type': 'select', 'choices': getattr(field, 'choices', []) } + fields.append(field_info) + elif field_name == 'model_name': + field = model._meta.get_field(field_name) + field_info = { + 'name': field_name, + 'label': getattr(field, 'verbose_name', field_name), + 'help_text': getattr(field, 'help_text', ''), + 'required': not getattr(field, 'blank', True), + 'type': 'select', + 'dynamic_choices': True, + 'choices_url': '/settings/integrations/openrouter/models/' + } fields.append(field_info) # Для WooCommerce показываем только базовые поля для подключения elif model.__name__ == 'WooCommerceIntegration': diff --git a/myproject/system_settings/templates/system_settings/integrations.html b/myproject/system_settings/templates/system_settings/integrations.html index 0c6b3e5..d0966c6 100644 --- a/myproject/system_settings/templates/system_settings/integrations.html +++ b/myproject/system_settings/templates/system_settings/integrations.html @@ -153,8 +153,8 @@ document.addEventListener('DOMContentLoaded', function() { } statusBadge.style.display = 'inline'; - // Построить форму - buildForm(data.fields, data.data || {}); + // Построить форму (теперь асинхронно) + await buildForm(data.fields, data.data || {}); // Показать/скрыть кнопку тестирования const testBtn = document.getElementById('test-connection-btn'); @@ -173,14 +173,14 @@ document.addEventListener('DOMContentLoaded', function() { } // Построение формы из метаданных полей - function buildForm(fields, data) { + async function buildForm(fields, data) { const container = document.getElementById('settings-fields'); container.innerHTML = ''; - - fields.forEach(field => { + + for (const field of fields) { const div = document.createElement('div'); div.className = 'mb-3'; - + if (field.type === 'checkbox') { div.className = 'form-check mb-3'; div.innerHTML = ` @@ -189,43 +189,100 @@ document.addEventListener('DOMContentLoaded', function() { ${field.help_text ? `
${field.help_text}
` : ''} `; + } else if (field.type === 'select') { - div.innerHTML = ` - - - ${field.help_text ? `
${field.help_text}
` : ''} - `; + let optionsHtml = ''; + + if (field.dynamic_choices) { + // Динамическая загрузка options + optionsHtml = ''; + + div.innerHTML = ` + + + ${field.help_text ? `
${field.help_text}
` : ''} + `; + container.appendChild(div); + + // Асинхронная загрузка + const select = div.querySelector('select'); + try { + const response = await fetch(field.choices_url); + const result = await response.json(); + + if (result.error) { + select.innerHTML = ''; + console.error(result.error); + } else { + select.innerHTML = result.models.map(m => + `` + ).join(''); + + if (data[field.name]) { + select.value = data[field.name]; + } + } + } catch (error) { + select.innerHTML = ''; + console.error('Error loading models:', error); + } + + } else { + // Статический select (для temperature) + optionsHtml = field.choices.map(choice => ` + + `).join(''); + + div.innerHTML = ` + + + ${field.help_text ? `
${field.help_text}
` : ''} + `; + } + } else { + // text, password, url const inputType = field.type === 'password' ? 'password' : (field.type === 'url' ? 'url' : 'text'); - const value = data[field.name] || ''; - const placeholder = field.type === 'password' && value === '........' ? 'Введите новое значение для изменения' : ''; - + let value = data[field.name] || ''; + const isMasked = value === '••••••••'; + const placeholder = isMasked ? 'Ключ сохранён. Оставьте пустым, чтобы не менять' : ''; + + // Для password полей показываем звёздочки (8 штук как индикатор сохранённого ключа) + const inputValue = (field.type === 'password' && isMasked) ? '********' : value; + div.innerHTML = ` + ${field.required && !isMasked ? 'required' : ''}> ${field.help_text ? `
${field.help_text}
` : ''} `; } - - container.appendChild(div); - }); + + if (field.type !== 'select' || !field.dynamic_choices) { + container.appendChild(div); + } + } } // Обработчик клика на интеграцию @@ -313,9 +370,9 @@ document.addEventListener('DOMContentLoaded', function() { // Собрать данные формы for (const [key, value] of formData.entries()) { - // Пропустить пустые password поля (не менять если не введено) + // Пропустить пустые password поля или звёздочки (не менять если не введено новое значение) const input = document.getElementById(`field-${key}`); - if (input && input.type === 'password' && !value) continue; + if (input && input.type === 'password' && (!value || value === '********')) continue; data[key] = value; }