diff --git a/assets/controllers/elements/ai_model_autocomplete_controller.js b/assets/controllers/elements/ai_model_autocomplete_controller.js new file mode 100644 index 00000000..e36e6b1f --- /dev/null +++ b/assets/controllers/elements/ai_model_autocomplete_controller.js @@ -0,0 +1,152 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/components/tom-select_extensions.css'; +import TomSelect from "tom-select"; + +import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' +import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' + +TomSelect.define('click_to_edit', TomSelect_click_to_edit) +TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) + +export default class extends Controller { + _tomSelect; + + _platformSelector; + + connect() { + + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + + //Try to find the platform selector + const platformSelector = document.querySelector("select[data-platform-selector-label='" + this.element.dataset.platformSelector + "']"); + //Clear tomselect options, if the platform selector changes + if (platformSelector) { + this.platformSelector = platformSelector; + platformSelector.addEventListener('change', () => { + //Force reload of options by clearing the cache and options of TomSelect and triggering a search with an empty string + this._tomSelect.clearOptions(); + this._tomSelect.clearCache(); + this._tomSelect.load(''); + }); + } + + let settings = { + persistent: false, + create: true, + maxItems: 1, + preload: 'focus', + createOnBlur: true, + selectOnTab: true, + clearAfterSelect: true, + shouldLoad: ((query) => true), + maxOptions: null, + //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin + delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', + dropdownParent: dropdownParent, + render: { + item: (data, escape) => { + return '' + escape(data.label) + ''; + }, + option: (data, escape) => { + if (data.image) { + return "
" + data.label + "
" + } + return '
' + escape(data.label) + '
'; + } + }, + plugins: { + 'autoselect_typed': {}, + 'click_to_edit': {}, + 'clear_button': {}, + "restore_on_backspace": {} + } + }; + + if(this.element.dataset.urlTemplate) { + const base_url = this.element.dataset.urlTemplate; + settings.searchField = "label"; + settings.sortField = "label"; + settings.valueField = "label"; + settings.load = (query, callback) => { + + + if (!this.platformSelector) { + console.error("Platform selector not found for AI model autocomplete"); + callback(); + return; + } + + //Platform is the selected option + const platform = this.platformSelector.value; + if (!platform) { + callback(); + return; + } + + const self = this; + + //Only fetch each platform once + if(self.platformLoaded === platform) { + callback(); + } + + + const url = base_url.replace('__PLATFORM__', encodeURIComponent(platform)); + + fetch(url) + .then(response => response.json()) + .then(json => { + + self.platformLoaded = platform; + + var data = []; + + for (const name in json) { + data.push({ + "label": name, + "capabilities": json[name].capabilities, + }); + } + + callback(data); + }).catch(()=>{ + callback(); + }); + }; + } + this._tomSelect = new TomSelect(this.element, settings); + } + + disconnect() { + super.disconnect(); + //Destroy the TomSelect instance + this._tomSelect.destroy(); + } + +} + + diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 95480329..cca7df98 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -23,7 +23,10 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\Parameters\AbstractParameter; +use App\Services\AI\AIPlatformRegistry; +use App\Services\AI\AIPlatforms; use App\Settings\MiscSettings\IpnSuggestSettings; +use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\HttpFoundation\Response; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Category; @@ -54,6 +57,8 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; /** * In this controller the endpoints for the typeaheads are collected. @@ -223,4 +228,21 @@ class TypeaheadController extends AbstractController return new JsonResponse($ipnSuggestions); } + + #[Route(path: '/ai/{platform}/models', name: 'typeahead_ai_models', requirements: ['platform' => '.+'])] + public function aiModels( + AIPlatforms $platform, + AIPlatformRegistry $platformRegistry, + CacheInterface $cache, + ): JsonResponse { + + $this->denyAccessUnlessGranted('@config.change_system_settings'); + + $models = $cache->get('ai_models_'.$platform->value, function(ItemInterface $item) use ($platformRegistry, $platform) { + $item->expiresAfter(3600); //Cache for 1 hour + return $platformRegistry->getPlatform($platform)->getModelCatalog()->getModels(); + }); + + return new JsonResponse($models); + } } diff --git a/src/Form/Settings/AiModelsType.php b/src/Form/Settings/AiModelsType.php new file mode 100644 index 00000000..78b088ed --- /dev/null +++ b/src/Form/Settings/AiModelsType.php @@ -0,0 +1,62 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Settings; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * An text input with autocomplete for AI models from the given platform. + * The platform is determined by the value of another form field, which is specified by the "platform_selector" option. This allows to filter the available models based on the selected platform. + */ +final class AiModelsType extends AbstractType +{ + public function __construct(private readonly UrlGeneratorInterface $urlGenerator) + { + } + + public function getParent(): string + { + return TextType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + //The target label of the platform select, which is used to filter the models for the selected platform. + $resolver->setRequired('platform_selector'); + $resolver->setAllowedTypes('platform_selector', 'string'); + } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['attr']['data-url-template'] = $this->urlGenerator->generate('typeahead_ai_models', ['platform' => '__PLATFORM__']); + $view->vars['attr']['data-controller'] = 'elements--ai-model-autocomplete'; + + $view->vars['attr']['data-platform-selector'] = $options['platform_selector']; + } +} diff --git a/src/Form/Settings/AiPlatformChoiceType.php b/src/Form/Settings/AiPlatformChoiceType.php index b28f217d..eb48d933 100644 --- a/src/Form/Settings/AiPlatformChoiceType.php +++ b/src/Form/Settings/AiPlatformChoiceType.php @@ -28,8 +28,13 @@ use App\Services\AI\AIPlatforms; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; +/** + * Allow to choose an AI platform from the enabled platforms in the system. This is used in the settings to choose the default platform for AI features. + */ final class AiPlatformChoiceType extends AbstractType { public function __construct(private readonly AIPlatformRegistry $platformRegistry) @@ -49,6 +54,12 @@ final class AiPlatformChoiceType extends AbstractType 'class' => AIPlatforms::class, 'choices' => $choices, 'required' => false, + 'platform_selector_label' => null ]); } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['attr']['data-platform-selector-label'] = $options['platform_selector_label'] ?? $view->vars['id'].'_label'; + } } diff --git a/src/Settings/InfoProviderSystem/AIExtractorSettings.php b/src/Settings/InfoProviderSystem/AIExtractorSettings.php index 69b02637..0852d2ec 100644 --- a/src/Settings/InfoProviderSystem/AIExtractorSettings.php +++ b/src/Settings/InfoProviderSystem/AIExtractorSettings.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Settings\InfoProviderSystem; +use App\Form\Settings\AiModelsType; use App\Form\Settings\AiPlatformChoiceType; use App\Services\AI\AIPlatforms; use App\Settings\SettingsIcon; @@ -36,15 +37,18 @@ use Symfony\Component\Translation\TranslatableMessage as TM; #[SettingsIcon("fa-robot")] class AIExtractorSettings { + private const MODEL_SELECTOR_LABEL = 'ai_extractor'; + use SettingsTrait; #[SettingsParameter(label: new TM("settings.ips.ai_extractor.ai_platform"), description: new TM("settings.ips.ai_extractor.ai_platform.help"), - formType: AiPlatformChoiceType::class, + formType: AiPlatformChoiceType::class, formOptions: ['platform_selector_label' => self::MODEL_SELECTOR_LABEL], envVar: "string:PROVIDER_AI_EXTRACTOR_API_KEY", envVarMode: EnvVarMode::OVERWRITE )] public ?AIPlatforms $platform = null; #[SettingsParameter(label: new TM("settings.ips.ai_extractor.model"), description: new TM("settings.ips.ai_extractor.model.description"), + formType: AiModelsType::class, formOptions: ['platform_selector' => self::MODEL_SELECTOR_LABEL], envVar: "string:PROVIDER_AI_EXTRACTOR_MODEL", envVarMode: EnvVarMode::OVERWRITE )] public string $model = 'z-ai/glm-4.7';