Fixes
This commit is contained in:
parent
d7748173a0
commit
29a88e341f
@ -41,6 +41,18 @@ services:
|
||||
- ServerOptions__HostName=smtp4dev
|
||||
networks:
|
||||
- network
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
networks:
|
||||
- network
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1 # alternatively, use `count: all` for all GPUs
|
||||
capabilities: [gpu]
|
||||
webhook:
|
||||
image: tarampampam/webhook-tester:2
|
||||
restart: always
|
||||
|
||||
@ -67,6 +67,8 @@
|
||||
"sofort/sofortlib-php": "3.3.2",
|
||||
"spatie/array-to-xml": "^3.4",
|
||||
"spiriitlabs/form-filter-bundle": "12.0.1",
|
||||
"symfony/ai-agent": "^0.5.0",
|
||||
"symfony/ai-bundle": "^0.5.0",
|
||||
"symfony/asset": "*",
|
||||
"symfony/asset-mapper": "7.4.*",
|
||||
"symfony/console": "*",
|
||||
|
||||
2102
src/new/composer.lock
generated
2102
src/new/composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -44,6 +44,7 @@ return [
|
||||
PSC\Shop\CommunicationBundle\PSCShopCommunicationBundle::class => ['all' => true],
|
||||
PSC\Component\SteplayouterBundle\PSCComponentSteplayouterBundle::class => ['all' => true],
|
||||
PSC\Component\ApiBundle\PSCComponentApiBundle::class => ['all' => true],
|
||||
PSC\Component\AiBundle\PSCComponentAiBundle::class => ['all' => true],
|
||||
PSC\Component\ConfigurationlayouterBundle\PSCComponentConfigurationlayouterBundle::class => ['all' => true],
|
||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
||||
@ -62,4 +63,5 @@ return [
|
||||
Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true],
|
||||
Symfony\UX\Vue\VueBundle::class => ['all' => true],
|
||||
Spiriit\Bundle\FormFilterBundle\SpiriitFormFilterBundle::class => ['all' => true],
|
||||
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
27
src/new/config/packages/ai.yaml
Normal file
27
src/new/config/packages/ai.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
ai:
|
||||
platform:
|
||||
# Inference Platform configuration
|
||||
# see https://github.com/symfony/ai/tree/main/src/platform#platform-bridges
|
||||
|
||||
# openai:
|
||||
# api_key: '%env(OPENAI_API_KEY)%'
|
||||
|
||||
agent:
|
||||
# Agent configuration
|
||||
# see https://symfony.com/doc/current/ai/bundles/ai-bundle.html
|
||||
|
||||
# default:
|
||||
# platform: 'ai.platform.openai'
|
||||
# model: 'gpt-5-mini'
|
||||
# prompt: |
|
||||
# You are a pirate and you write funny.
|
||||
# tools:
|
||||
# - 'Symfony\AI\Agent\Bridge\Clock\Clock'
|
||||
|
||||
store:
|
||||
# Store configuration
|
||||
|
||||
# chromadb:
|
||||
# default:
|
||||
# client: 'client.service.id'
|
||||
# collection: 'my_collection'
|
||||
@ -7,23 +7,32 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigura
|
||||
return static function (ContainerConfigurator $containerConfigurator): void {
|
||||
$containerConfigurator->extension(
|
||||
'monolog', [
|
||||
'channels' => ['ai'],
|
||||
'handlers' => [
|
||||
'ai' => [
|
||||
'type' => 'stream',
|
||||
'path' => '%kernel.logs_dir%/ai.log',
|
||||
'level' => 'debug',
|
||||
'channels' => ['ai'],
|
||||
],
|
||||
'main' => [
|
||||
'type' => 'stream',
|
||||
'path' => '%kernel.logs_dir%/%kernel.environment%.log',
|
||||
'level' => 'debug',
|
||||
'type' => 'stream',
|
||||
'path' => '%kernel.logs_dir%/%kernel.environment%.log',
|
||||
'level' => 'debug',
|
||||
'channels' => [
|
||||
'!event',
|
||||
'!php'
|
||||
'!event',
|
||||
'!php',
|
||||
'!ai',
|
||||
]
|
||||
],
|
||||
],
|
||||
'console' => [
|
||||
'type' => 'console',
|
||||
'process_psr_3_messages' => false,
|
||||
'type' => 'console',
|
||||
'process_psr_3_messages' => false,
|
||||
'channels' => [
|
||||
'!event',
|
||||
'!doctrine',
|
||||
'!console'
|
||||
'!event',
|
||||
'!doctrine',
|
||||
'!console',
|
||||
'!ai',
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,8 @@ return static function (RoutingConfigurator $routingConfigurator): void {
|
||||
|
||||
$routingConfigurator->import('@PSCComponentApiBundle/Resources/config/routing.yml');
|
||||
|
||||
$routingConfigurator->import('@PSCComponentAiBundle/Resources/config/routing.yml');
|
||||
|
||||
$routingConfigurator->import('@PSCBackendDashboardBundle/Resources/config/routing.yml');
|
||||
|
||||
$routingConfigurator->import('@PSCBackendDomainBundle/Resources/config/routing.yml');
|
||||
|
||||
@ -136,6 +136,122 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if instance.aiProvider %}
|
||||
<div class="col-span-full"
|
||||
x-data="{
|
||||
message: '',
|
||||
history: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
chatUrl: '{{ path("psc_component_ai_chat") }}',
|
||||
async send() {
|
||||
if (!this.message.trim() || this.loading) return;
|
||||
const userMsg = this.message.trim();
|
||||
this.message = '';
|
||||
this.error = '';
|
||||
this.history.push({ role: 'user', content: userMsg });
|
||||
this.loading = true;
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.messages;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
try {
|
||||
const res = await fetch(this.chatUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: userMsg, history: this.history.slice(0, -1) })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
this.error = data.error;
|
||||
this.history.pop();
|
||||
} else {
|
||||
this.history.push({ role: 'assistant', content: data.reply });
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = 'Verbindungsfehler: ' + e.message;
|
||||
this.history.pop();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.messages;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
}">
|
||||
<div class="rounded-sm border border bg-white px-7.5 py-6 shadow-lg dark:border-strokedark dark:bg-boxdark">
|
||||
<div class="p-2 space-y-2">
|
||||
<div class="flex items-center justify-between gap-8 px-4 py-2 mb-2">
|
||||
<h2 class="text-xl font-medium tracking-tight filament-card-heading flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-psc-500">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z" />
|
||||
</svg>
|
||||
KI Assistent
|
||||
<span class="text-xs font-normal text-gray-400">({{ instance.aiProvider }}{% if instance.aiModel %} · {{ instance.aiModel }}{% endif %})</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div aria-hidden="true" class="filament-hr border-t dark:border-gray-700"></div>
|
||||
</div>
|
||||
|
||||
{# Message history #}
|
||||
<div x-ref="messages" class="h-80 overflow-y-auto px-4 py-2 space-y-3">
|
||||
<template x-if="history.length === 0">
|
||||
<p class="text-sm text-gray-400 text-center mt-8">Stellen Sie eine Frage oder geben Sie einen Befehl ein...</p>
|
||||
</template>
|
||||
<template x-for="(msg, idx) in history" :key="idx">
|
||||
<div :class="msg.role === 'user' ? 'flex justify-end' : 'flex justify-start'">
|
||||
<div :class="msg.role === 'user'
|
||||
? 'bg-psc-500 text-white rounded-2xl rounded-tr-sm px-4 py-2 max-w-[75%] text-sm'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-2xl rounded-tl-sm px-4 py-2 max-w-[75%] text-sm whitespace-pre-wrap'">
|
||||
<span x-text="msg.content"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="loading">
|
||||
<div class="flex justify-start">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-2xl rounded-tl-sm px-4 py-3 text-sm text-gray-500">
|
||||
<span class="inline-flex gap-1">
|
||||
<span class="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style="animation-delay:0ms"></span>
|
||||
<span class="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style="animation-delay:150ms"></span>
|
||||
<span class="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce" style="animation-delay:300ms"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Error #}
|
||||
<template x-if="error">
|
||||
<div class="mx-4 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-md text-sm text-red-700" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
{# Input #}
|
||||
<div class="px-4 pt-2 pb-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="message"
|
||||
@keydown.enter.prevent="send()"
|
||||
:disabled="loading"
|
||||
placeholder="Nachricht eingeben..."
|
||||
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-psc-500 focus:border-transparent disabled:opacity-50"
|
||||
>
|
||||
<button
|
||||
@click="send()"
|
||||
:disabled="loading || !message.trim()"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 bg-psc-500 hover:bg-psc-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
</svg>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\Controller;
|
||||
|
||||
use PSC\Component\AiBundle\Service\AiAgentService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class AiChatController extends AbstractController
|
||||
{
|
||||
#[IsGranted('ROLE_SHOP')]
|
||||
#[Route(path: '/ai/chat', name: 'psc_component_ai_chat', methods: ['POST'])]
|
||||
public function chat(Request $request, AiAgentService $agentService): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$message = trim($data['message'] ?? '');
|
||||
$history = $data['history'] ?? [];
|
||||
|
||||
if (empty($message)) {
|
||||
return new JsonResponse(['error' => 'Nachricht ist leer'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$reply = $agentService->chat($message, $history);
|
||||
return new JsonResponse(['reply' => $reply]);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
|
||||
use Symfony\Component\Config\Definition\ConfigurationInterface;
|
||||
|
||||
class Configuration implements ConfigurationInterface
|
||||
{
|
||||
public function getConfigTreeBuilder(): TreeBuilder
|
||||
{
|
||||
$treeBuilder = new TreeBuilder('psc_component_ai');
|
||||
return $treeBuilder;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Loader;
|
||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||
|
||||
class PSCComponentAiExtension extends Extension
|
||||
{
|
||||
public function load(array $configs, ContainerBuilder $container)
|
||||
{
|
||||
$configuration = new Configuration();
|
||||
$this->processConfiguration($configuration, $configs);
|
||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
|
||||
$loader->load('services.yml');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class PSCComponentAiBundle extends Bundle {}
|
||||
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\Platform\Completions;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\ModelClientInterface;
|
||||
use Symfony\AI\Platform\Result\RawHttpResult;
|
||||
use Symfony\AI\Platform\Result\RawResultInterface;
|
||||
use Symfony\Component\HttpClient\EventSourceHttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat completions client.
|
||||
* Works with Ollama, OpenAI, Mistral, Azure OpenAI and any other OpenAI-compatible API.
|
||||
*/
|
||||
final class ModelClient implements ModelClientInterface
|
||||
{
|
||||
private readonly EventSourceHttpClient $httpClient;
|
||||
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
private readonly string $baseUrl,
|
||||
private readonly ?string $apiKey = null,
|
||||
private readonly string $path = '/v1/chat/completions',
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
$this->httpClient = $httpClient instanceof EventSourceHttpClient
|
||||
? $httpClient
|
||||
: new EventSourceHttpClient($httpClient);
|
||||
}
|
||||
|
||||
public function supports(Model $model): bool
|
||||
{
|
||||
return $model->supports(Capability::INPUT_MESSAGES)
|
||||
|| $model->supports(Capability::TOOL_CALLING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int, mixed> $payload
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function request(Model $model, array|string $payload, array $options = []): RawResultInterface
|
||||
{
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
if ($this->apiKey) {
|
||||
$headers['Authorization'] = 'Bearer ' . $this->apiKey;
|
||||
}
|
||||
|
||||
$body = array_merge($payload, ['model' => $model->getName()]);
|
||||
|
||||
// Tools in OpenAI-Format bringen: {type: "function", function: {name, description, parameters}}
|
||||
if (!empty($options['tools'])) {
|
||||
$body['tools'] = array_map(
|
||||
static function (array $tool): array {
|
||||
// Contract liefert flache Objekte {name, description, parameters}
|
||||
// OpenAI erwartet {type: "function", function: {...}}
|
||||
if (!isset($tool['type'])) {
|
||||
return ['type' => 'function', 'function' => $tool];
|
||||
}
|
||||
return $tool;
|
||||
},
|
||||
$options['tools']
|
||||
);
|
||||
$body['tool_choice'] = 'auto';
|
||||
}
|
||||
|
||||
$this->logger->debug('AI request body', [
|
||||
'url' => rtrim($this->baseUrl, '/') . $this->path,
|
||||
'model' => $model->getName(),
|
||||
'tools' => array_column($body['tools'] ?? [], 'function'),
|
||||
'messages' => count($body['messages'] ?? []),
|
||||
]);
|
||||
|
||||
$response = $this->httpClient->request('POST', rtrim($this->baseUrl, '/') . $this->path, [
|
||||
'headers' => $headers,
|
||||
'json' => $body,
|
||||
'timeout' => 120,
|
||||
]);
|
||||
|
||||
return new RawHttpResult($response);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\Platform\Completions;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\Result\RawResultInterface;
|
||||
use Symfony\AI\Platform\Result\ResultInterface;
|
||||
use Symfony\AI\Platform\Result\TextResult;
|
||||
use Symfony\AI\Platform\Result\ToolCall;
|
||||
use Symfony\AI\Platform\Result\ToolCallResult;
|
||||
use Symfony\AI\Platform\ResultConverterInterface;
|
||||
use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface;
|
||||
|
||||
/**
|
||||
* Parses OpenAI-compatible chat completions responses into TextResult or ToolCallResult.
|
||||
*/
|
||||
final class ResultConverter implements ResultConverterInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(Model $model): bool
|
||||
{
|
||||
return $model->supports(Capability::INPUT_MESSAGES)
|
||||
|| $model->supports(Capability::TOOL_CALLING);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function convert(RawResultInterface $result, array $options = []): ResultInterface
|
||||
{
|
||||
$data = $result->getData();
|
||||
|
||||
$choice = $data['choices'][0] ?? null;
|
||||
$this->logger->debug('AI response received', [
|
||||
'finish_reason' => $choice['finish_reason'] ?? null,
|
||||
'has_tool_calls' => !empty($choice['message']['tool_calls']),
|
||||
'content_preview' => substr((string) ($choice['message']['content'] ?? ''), 0, 150),
|
||||
]);
|
||||
if (!$choice) {
|
||||
return new TextResult('');
|
||||
}
|
||||
|
||||
$message = $choice['message'] ?? [];
|
||||
|
||||
// Tool calls zurückgeben wenn vorhanden
|
||||
if (!empty($message['tool_calls'])) {
|
||||
$toolCalls = [];
|
||||
foreach ($message['tool_calls'] as $tc) {
|
||||
$args = $tc['function']['arguments'] ?? '{}';
|
||||
if (is_string($args)) {
|
||||
$args = json_decode($args, true, flags: \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$toolCalls[] = new ToolCall(
|
||||
id: $tc['id'],
|
||||
name: $tc['function']['name'],
|
||||
arguments: $args,
|
||||
);
|
||||
}
|
||||
return new ToolCallResult(...$toolCalls);
|
||||
}
|
||||
|
||||
$content = $message['content'] ?? '';
|
||||
|
||||
// Manche Modelle (z.B. Ollama mit reasoning) nutzen 'thinking' + 'content'
|
||||
if (is_array($content)) {
|
||||
$text = '';
|
||||
foreach ($content as $part) {
|
||||
if (($part['type'] ?? '') === 'text') {
|
||||
$text .= $part['text'];
|
||||
}
|
||||
}
|
||||
$content = $text;
|
||||
}
|
||||
|
||||
return new TextResult((string) $content);
|
||||
}
|
||||
|
||||
public function getTokenUsageExtractor(): ?TokenUsageExtractorInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\Platform;
|
||||
|
||||
use PSC\Component\AiBundle\Platform\Completions\ModelClient;
|
||||
use PSC\Component\AiBundle\Platform\Completions\ResultConverter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
|
||||
use Symfony\AI\Platform\Platform;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Erstellt eine Platform-Instanz für OpenAI-kompatible APIs.
|
||||
* Unterstützt: Ollama, OpenAI, Mistral, Azure OpenAI und andere.
|
||||
*/
|
||||
final class PlatformFactory
|
||||
{
|
||||
public static function create(
|
||||
string $baseUrl,
|
||||
?string $apiKey = null,
|
||||
?HttpClientInterface $httpClient = null,
|
||||
string $completionsPath = '/v1/chat/completions',
|
||||
LoggerInterface $logger = new NullLogger(),
|
||||
): Platform {
|
||||
$modelClient = new ModelClient($httpClient, $baseUrl, $apiKey, $completionsPath, $logger);
|
||||
$resultConverter = new ResultConverter($logger);
|
||||
|
||||
return new Platform(
|
||||
modelClients: [$modelClient],
|
||||
resultConverters: [$resultConverter],
|
||||
modelCatalog: new FallbackModelCatalog(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
psc_component_ai:
|
||||
resource: "@PSCComponentAiBundle/Controller/"
|
||||
type: attribute
|
||||
prefix: /backend
|
||||
@ -0,0 +1,7 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
PSC\Component\AiBundle\:
|
||||
resource: '../../*/*'
|
||||
140
src/new/src/PSC/Component/AiBundle/Service/AiAgentService.php
Normal file
140
src/new/src/PSC/Component/AiBundle/Service/AiAgentService.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Component\AiBundle\Service;
|
||||
|
||||
use PSC\Component\AiBundle\Platform\PlatformFactory;
|
||||
use PSC\Shop\OrderBundle\Ai\Tool\ChangeOrderStatus;
|
||||
use PSC\Shop\OrderBundle\Ai\Tool\GetAvailableOrderStatuses;
|
||||
use PSC\Shop\OrderBundle\Ai\Tool\GetOrder;
|
||||
use PSC\Shop\OrderBundle\Ai\Tool\SearchOrders;
|
||||
use PSC\System\SettingsBundle\Service\Instance;
|
||||
use PSC\System\SettingsBundle\Service\Status;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\AI\Agent\Agent;
|
||||
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
|
||||
use Symfony\AI\Agent\Toolbox\AgentProcessor;
|
||||
use Symfony\AI\Agent\Toolbox\Toolbox;
|
||||
use Symfony\AI\Platform\Message\Message;
|
||||
use Symfony\AI\Platform\Message\MessageBag;
|
||||
use Symfony\AI\Platform\PlatformInterface;
|
||||
use Symfony\AI\Platform\Result\TextResult;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class AiAgentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Instance $instanceService,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly GetOrder $getOrder,
|
||||
private readonly SearchOrders $searchOrders,
|
||||
private readonly ChangeOrderStatus $changeOrderStatus,
|
||||
private readonly GetAvailableOrderStatuses $getAvailableOrderStatuses,
|
||||
private readonly Status $statusService,
|
||||
#[Autowire(service: 'monolog.logger.ai')]
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<array{role: string, content: string}> $history
|
||||
*/
|
||||
public function chat(string $message, array $history = []): string
|
||||
{
|
||||
$instance = $this->instanceService->getInstance();
|
||||
$provider = $instance->getAiProvider();
|
||||
|
||||
if (!$provider) {
|
||||
throw new \RuntimeException('Kein KI Anbieter konfiguriert.');
|
||||
}
|
||||
|
||||
$platform = $this->createPlatform($instance);
|
||||
$model = $instance->getAiModel() ?: 'llama3.2';
|
||||
|
||||
$this->logger->info('AI chat request', [
|
||||
'provider' => $provider,
|
||||
'model' => $model,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
$toolbox = new Toolbox(
|
||||
tools: [
|
||||
$this->getOrder,
|
||||
$this->searchOrders,
|
||||
$this->changeOrderStatus,
|
||||
$this->getAvailableOrderStatuses,
|
||||
],
|
||||
logger: $this->logger,
|
||||
);
|
||||
|
||||
$agentProcessor = new AgentProcessor($toolbox);
|
||||
|
||||
$systemPrompt = $this->buildSystemPrompt($instance);
|
||||
$systemPromptProcessor = new SystemPromptInputProcessor($systemPrompt);
|
||||
|
||||
$agent = new Agent(
|
||||
platform: $platform,
|
||||
model: $model,
|
||||
inputProcessors: [$systemPromptProcessor, $agentProcessor],
|
||||
outputProcessors: [$agentProcessor],
|
||||
);
|
||||
|
||||
$messages = new MessageBag();
|
||||
foreach ($history as $h) {
|
||||
if ($h['role'] === 'user') {
|
||||
$messages->add(Message::ofUser($h['content']));
|
||||
} elseif ($h['role'] === 'assistant') {
|
||||
$messages->add(Message::ofAssistant($h['content']));
|
||||
}
|
||||
}
|
||||
$messages->add(Message::ofUser($message));
|
||||
|
||||
$result = $agent->call($messages);
|
||||
|
||||
if ($result instanceof TextResult) {
|
||||
return $result->getContent();
|
||||
}
|
||||
|
||||
return (string) $result;
|
||||
}
|
||||
|
||||
private function createPlatform($instance): PlatformInterface
|
||||
{
|
||||
$provider = $instance->getAiProvider();
|
||||
$apiKey = $instance->getAiApiKey();
|
||||
$baseUrl = $instance->getAiBaseUrl();
|
||||
|
||||
$baseUrl = match ($provider) {
|
||||
'ollama' => rtrim($baseUrl ?: 'http://localhost:11434', '/'),
|
||||
'openai' => 'https://api.openai.com',
|
||||
'mistral' => 'https://api.mistral.ai',
|
||||
'azure' => rtrim($baseUrl ?: '', '/'),
|
||||
default => rtrim($baseUrl ?: 'http://localhost:11434', '/'),
|
||||
};
|
||||
return PlatformFactory::create(baseUrl: $baseUrl, apiKey: $apiKey ?: null, httpClient: $this->httpClient, logger: $this->logger);
|
||||
}
|
||||
|
||||
private function buildSystemPrompt($instance): string
|
||||
{
|
||||
$shop = $this->instanceService->getShop();
|
||||
|
||||
$statusLines = [];
|
||||
foreach ($this->statusService->getOrderStatuse() as $status) {
|
||||
$statusLines[] = sprintf(' - Code %d: %s', $status->getCode(), $status->getInternalName());
|
||||
}
|
||||
$statusList = implode("\n", $statusLines);
|
||||
|
||||
return sprintf(
|
||||
'Du bist ein KI-Assistent für das PrintshopCreator Backend des Shops "%s". '
|
||||
. 'Du hilfst bei der Verwaltung von Aufträgen, Kunden und Produkten. '
|
||||
. 'Antworte immer auf Deutsch. '
|
||||
. 'Nutze die verfügbaren Tools um Daten abzurufen oder Änderungen vorzunehmen. '
|
||||
. 'Fasse alle durchgeführten Aktionen am Ende kurz zusammen. '
|
||||
. 'Das heutige Datum ist: %s.' . "\n\n"
|
||||
. "Verfügbare Auftragsstatus:\n%s\n"
|
||||
. 'Nutze beim Statuswechsel immer den Code aus dieser Liste, auch wenn der Nutzer den Namen nur ungefähr nennt.',
|
||||
$shop->getTitle(),
|
||||
date('d.m.Y'),
|
||||
$statusList,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -141,6 +141,18 @@ class Instance implements UserInterface
|
||||
#[Field(type: 'bool')]
|
||||
protected $supportlogin;
|
||||
|
||||
#[Field(type: 'string')]
|
||||
protected ?string $aiProvider = null;
|
||||
|
||||
#[Field(type: 'string')]
|
||||
protected ?string $aiApiKey = null;
|
||||
|
||||
#[Field(type: 'string')]
|
||||
protected ?string $aiModel = null;
|
||||
|
||||
#[Field(type: 'string')]
|
||||
protected ?string $aiBaseUrl = null;
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
@ -634,6 +646,18 @@ class Instance implements UserInterface
|
||||
$this->parcelCancelationNumberStart = $parcelCancelationNumberStart;
|
||||
}
|
||||
|
||||
public function getAiProvider(): ?string { return $this->aiProvider; }
|
||||
public function setAiProvider(?string $aiProvider): void { $this->aiProvider = $aiProvider; }
|
||||
|
||||
public function getAiApiKey(): ?string { return $this->aiApiKey; }
|
||||
public function setAiApiKey(?string $aiApiKey): void { $this->aiApiKey = $aiApiKey; }
|
||||
|
||||
public function getAiModel(): ?string { return $this->aiModel; }
|
||||
public function setAiModel(?string $aiModel): void { $this->aiModel = $aiModel; }
|
||||
|
||||
public function getAiBaseUrl(): ?string { return $this->aiBaseUrl; }
|
||||
public function setAiBaseUrl(?string $aiBaseUrl): void { $this->aiBaseUrl = $aiBaseUrl; }
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
return ['ROLE_API'];
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Shop\OrderBundle\Ai\Tool;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PSC\Shop\EntityBundle\Entity\Order;
|
||||
use PSC\Shop\QueueBundle\Event\Order\Status\Change as StatusChangeEvent;
|
||||
use PSC\Shop\QueueBundle\Service\Event\Manager;
|
||||
use PSC\System\SettingsBundle\Service\Shop;
|
||||
use PSC\System\SettingsBundle\Service\Status;
|
||||
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
|
||||
|
||||
/**
|
||||
* AI Tool: Status eines Auftrags ändern.
|
||||
*/
|
||||
#[AsTool(
|
||||
name: 'change_order_status',
|
||||
description: 'Ändert den Status eines Auftrags. Vor der Ausführung sollte der gewünschte Statuscode über get_available_order_statuses ermittelt werden.',
|
||||
)]
|
||||
class ChangeOrderStatus
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Status $statusService,
|
||||
private readonly Manager $eventManager,
|
||||
private readonly Shop $shopService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $identifier Auftragsnummer, UUID oder Alias des Auftrags
|
||||
* @param int $statusCode Neuer Statuscode (aus get_available_order_statuses)
|
||||
*/
|
||||
public function __invoke(string $identifier, int $statusCode): string
|
||||
{
|
||||
$repo = $this->entityManager->getRepository(Order::class);
|
||||
|
||||
/** @var Order|null $order */
|
||||
$order = $repo->findOneBy(['alias' => $identifier])
|
||||
?? $repo->findOneBy(['uuid' => $identifier])
|
||||
?? $repo->findOneBy(['uid' => $identifier]);
|
||||
|
||||
if (!$order) {
|
||||
return json_encode(['error' => 'Auftrag nicht gefunden: ' . $identifier]);
|
||||
}
|
||||
|
||||
$oldStatusName = $this->statusService->getStatusText($order->getStatus(), Status::$InternalName);
|
||||
$newStatusName = $this->statusService->getStatusText($statusCode, Status::$InternalName);
|
||||
|
||||
if ($newStatusName === 'not found') {
|
||||
return json_encode(['error' => 'Ungültiger Statuscode: ' . $statusCode . '. Bitte get_available_order_statuses verwenden.']);
|
||||
}
|
||||
|
||||
$order->setStatus($statusCode);
|
||||
$this->entityManager->persist($order);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Event auslösen (Queue / Benachrichtigungen)
|
||||
$event = new StatusChangeEvent();
|
||||
$event->setShop($this->shopService->getShopByDomain()->getUID());
|
||||
$event->setOrder($order->getUuid());
|
||||
$event->setStatus($order->getStatus());
|
||||
$this->eventManager->addJob($event);
|
||||
|
||||
return json_encode([
|
||||
'success' => true,
|
||||
'alias' => $order->getAlias(),
|
||||
'old_status' => $oldStatusName,
|
||||
'new_status' => $newStatusName,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Shop\OrderBundle\Ai\Tool;
|
||||
|
||||
use PSC\System\SettingsBundle\Service\Status;
|
||||
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
|
||||
|
||||
/**
|
||||
* AI Tool: Alle verfügbaren Auftragsstatus abrufen.
|
||||
*/
|
||||
#[AsTool(
|
||||
name: 'get_available_order_statuses',
|
||||
description: 'Gibt alle im System konfigurierten Auftragsstatus mit ihren Codes zurück. Verwende dies, um den richtigen Statuscode für change_order_status zu ermitteln.',
|
||||
)]
|
||||
class GetAvailableOrderStatuses
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Status $statusService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$statuses = $this->statusService->getOrderStatuse();
|
||||
|
||||
$result = [];
|
||||
foreach ($statuses as $status) {
|
||||
$result[] = [
|
||||
'code' => $status->getCode(),
|
||||
'name' => $status->getInternalName(),
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode(['statuses' => $result], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
70
src/new/src/PSC/Shop/OrderBundle/Ai/Tool/GetOrder.php
Normal file
70
src/new/src/PSC/Shop/OrderBundle/Ai/Tool/GetOrder.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Shop\OrderBundle\Ai\Tool;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PSC\Shop\EntityBundle\Entity\Order;
|
||||
use PSC\Shop\OrderBundle\Service\Order as OrderService;
|
||||
use PSC\System\SettingsBundle\Service\Status;
|
||||
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
|
||||
|
||||
/**
|
||||
* AI Tool: Einen Auftrag anhand von Alias, UUID oder Nummer abrufen.
|
||||
*/
|
||||
#[AsTool(
|
||||
name: 'get_order',
|
||||
description: 'Ruft einen einzelnen Auftrag anhand seiner Auftragsnummer, UUID oder Alias ab. Gibt Auftragsdaten inkl. Status, Kunde und Positionen zurück. Der Preis ist in Eurocent',
|
||||
)]
|
||||
class GetOrder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly OrderService $orderService,
|
||||
private readonly Status $statusService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $identifier Auftragsnummer, UUID oder Alias des Auftrags (z.B. "A-2024-0042")
|
||||
*/
|
||||
public function __invoke(string $identifier): string
|
||||
{
|
||||
$repo = $this->entityManager->getRepository(Order::class);
|
||||
|
||||
/** @var Order|null $order */
|
||||
$order =
|
||||
$repo->findOneBy(['alias' => $identifier]) ?? $repo->findOneBy([
|
||||
'uuid' => $identifier,
|
||||
]) ?? $repo->findOneBy(['uid' => $identifier]);
|
||||
|
||||
if (!$order) {
|
||||
return json_encode(['error' => 'Auftrag nicht gefunden: ' . $identifier]);
|
||||
}
|
||||
|
||||
$contact = $order->getContact();
|
||||
$statusName = $this->statusService->getStatusText($order->getStatus(), Status::$InternalName);
|
||||
|
||||
$model = $this->orderService->getOrderByUid($order->getUid());
|
||||
$positions = [];
|
||||
foreach ($model->getPositions() as $pos) {
|
||||
$positions[] = [
|
||||
'product' => $pos->getProduct()->getTitle(),
|
||||
'quantity' => $pos->getPrice()->getCount(),
|
||||
'price' => $pos->getPrice()->getAllGross(),
|
||||
];
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'uid' => $order->getUid(),
|
||||
'uuid' => $order->getUuid(),
|
||||
'alias' => $order->getAlias(),
|
||||
'status_code' => $order->getStatus(),
|
||||
'status_name' => $statusName,
|
||||
'created' => $order->getCreated()?->format('d.m.Y H:i'),
|
||||
'customer_email' => $contact?->getEmail(),
|
||||
'customer_name' => trim($contact?->getFirstname() . ' ' . $contact?->getLastname()),
|
||||
'company' => $contact?->getCompany(),
|
||||
'total_brutto' => $order->getBrutto(),
|
||||
'positions' => $positions,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
82
src/new/src/PSC/Shop/OrderBundle/Ai/Tool/SearchOrders.php
Normal file
82
src/new/src/PSC/Shop/OrderBundle/Ai/Tool/SearchOrders.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace PSC\Shop\OrderBundle\Ai\Tool;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PSC\Shop\EntityBundle\Entity\Order;
|
||||
use PSC\System\SettingsBundle\Service\Status;
|
||||
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
|
||||
|
||||
/**
|
||||
* AI Tool: Aufträge suchen nach Status oder Kundenname/-email.
|
||||
*/
|
||||
#[AsTool(
|
||||
name: 'search_orders',
|
||||
description: 'Sucht Aufträge nach Statuscode oder Kundendaten (Name, E-Mail). Gibt eine Liste der gefundenen Aufträge zurück.',
|
||||
)]
|
||||
class SearchOrders
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Status $statusService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $statusCode Statuscode des Auftrags (optional, aus get_available_order_statuses ermittelbar)
|
||||
* @param string|null $customer Name oder E-Mail des Kunden (optional)
|
||||
* @param int $limit Maximale Anzahl zurückgegebener Aufträge (Standard: 10)
|
||||
*/
|
||||
public function __invoke(?int $statusCode = null, ?string $customer = null, int $limit = 10): string
|
||||
{
|
||||
$repo = $this->entityManager->getRepository(Order::class);
|
||||
|
||||
$criteria = [];
|
||||
if ($statusCode !== null) {
|
||||
$criteria['status'] = $statusCode;
|
||||
}
|
||||
|
||||
/** @var Order[] $orders */
|
||||
$orders = $repo->findBy($criteria, ['created' => 'DESC'], min($limit, 50));
|
||||
|
||||
// Filtern nach Kundenname/-email wenn angegeben
|
||||
if ($customer !== null) {
|
||||
$customerLower = strtolower($customer);
|
||||
$orders = array_filter($orders, function (Order $order) use ($customerLower) {
|
||||
$contact = $order->getContact();
|
||||
if (!$contact) {
|
||||
return false;
|
||||
}
|
||||
$searchIn = strtolower(implode(' ', [
|
||||
$contact->getEmail() ?? '',
|
||||
$contact->getFirstname() ?? '',
|
||||
$contact->getLastname() ?? '',
|
||||
$contact->getCompany() ?? '',
|
||||
]));
|
||||
return str_contains($searchIn, $customerLower);
|
||||
});
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach (array_slice($orders, 0, $limit) as $order) {
|
||||
$contact = $order->getContact();
|
||||
$statusName = $this->statusService->getStatusText($order->getStatus(), Status::$InternalName);
|
||||
$result[] = [
|
||||
'alias' => $order->getAlias(),
|
||||
'uuid' => $order->getUuid(),
|
||||
'status_code' => $order->getStatus(),
|
||||
'status_name' => $statusName,
|
||||
'created' => $order->getCreated()?->format('d.m.Y'),
|
||||
'customer_email' => $contact?->getEmail(),
|
||||
'customer_name' => trim($contact?->getFirstname() . ' ' . $contact?->getLastname()),
|
||||
'total_brutto' => $order->getBrutto(),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($result)) {
|
||||
return json_encode(['message' => 'Keine Aufträge gefunden.'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
return json_encode(['count' => count($result), 'orders' => $result], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
@ -185,8 +185,6 @@ class Position extends Base
|
||||
$position->setAdditionalInfos($positionDoc->getAdditionalInfos());
|
||||
}
|
||||
$position->setCustomerInfo((string) $positionDoc->getCustomerInfo());
|
||||
var_dump(count($this->uploadObjectTypes));
|
||||
var_dump($positionDoc->getUploadMode());
|
||||
|
||||
if ($positionDoc->getUploadMode() != '') {
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ use PSC\Shop\EntityBundle\Entity\Shop;
|
||||
use PSC\System\PluginBundle\Form\Chain\Field;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
@ -103,7 +104,43 @@ class SettingsType extends AbstractType
|
||||
'attr' => ['checked' => 'checked', 'readonly' => true],
|
||||
'label' => 'PSC Support Login erlauben',
|
||||
])
|
||||
->add('monitoringkey', TextType::class, ['required' => false, 'label' => 'Monitoring Key']);
|
||||
->add('monitoringkey', TextType::class, ['required' => false, 'label' => 'Monitoring Key'])
|
||||
->add('aiProvider', ChoiceType::class, [
|
||||
'required' => false,
|
||||
'label' => 'KI Anbieter',
|
||||
'placeholder' => '-- Kein KI Anbieter --',
|
||||
'choices' => [
|
||||
'🏠 Selbstgehostet' => null,
|
||||
'Ollama (lokal)' => 'ollama',
|
||||
'☁️ Cloud Anbieter' => null,
|
||||
'Anthropic (Claude)' => 'anthropic',
|
||||
'OpenAI (ChatGPT)' => 'openai',
|
||||
'Google (Gemini)' => 'google',
|
||||
'Mistral AI' => 'mistral',
|
||||
'Azure OpenAI' => 'azure',
|
||||
'AWS Bedrock' => 'bedrock',
|
||||
],
|
||||
'group_by' => function ($choice, $key) {
|
||||
if (in_array($choice, ['ollama'])) return 'Selbstgehostet';
|
||||
if (in_array($choice, ['anthropic', 'openai', 'google', 'mistral', 'azure', 'bedrock'])) return 'Cloud';
|
||||
return null;
|
||||
},
|
||||
])
|
||||
->add('aiApiKey', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'API Key',
|
||||
'attr' => ['autocomplete' => 'off'],
|
||||
])
|
||||
->add('aiModel', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Modell',
|
||||
'attr' => ['placeholder' => 'z.B. claude-sonnet-4-6, gpt-4o, llama3.2'],
|
||||
])
|
||||
->add('aiBaseUrl', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Base URL',
|
||||
'attr' => ['placeholder' => 'z.B. http://localhost:11434 (Ollama Standard)'],
|
||||
]);
|
||||
/** @var \PSC\System\PluginBundle\Form\Interfaces\Field $field */
|
||||
foreach ($this->fields->getFields(\PSC\System\PluginBundle\Form\Interfaces\Field::System) as $field) {
|
||||
$builder->add($field->buildForm($this->formFactory->createNamedBuilder(
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#wartung" role="tab">Wartungsankündigung</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#ai" role="tab">🤖 KI Assistent</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
@ -414,6 +417,52 @@
|
||||
<section id="topbar">
|
||||
</textarea>
|
||||
</div>
|
||||
<div class="tab-pane" id="ai" role="tabpanel">
|
||||
<fieldset>
|
||||
<legend>KI Anbieter Konfiguration</legend>
|
||||
<div class="row mb-3">
|
||||
<label class="col-md-3 form-control-label">{{ form_label(form.aiProvider) }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form_widget(form.aiProvider, {attr: {class: 'form-select', id: 'ai_provider_select'}}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3" id="ai_field_apikey">
|
||||
<label class="col-md-3 form-control-label">{{ form_label(form.aiApiKey) }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form_widget(form.aiApiKey) }}
|
||||
<small class="text-muted">Wird nicht angezeigt nach dem Speichern.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-md-3 form-control-label">{{ form_label(form.aiModel) }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form_widget(form.aiModel) }}
|
||||
<small class="text-muted" id="ai_model_hint"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3" id="ai_field_baseurl">
|
||||
<label class="col-md-3 form-control-label">{{ form_label(form.aiBaseUrl) }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form_widget(form.aiBaseUrl) }}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="alert alert-info mt-3">
|
||||
<strong>Unterstützte Anbieter:</strong><br/>
|
||||
<table class="table table-sm mt-2">
|
||||
<thead><tr><th>Anbieter</th><th>Empfohlenes Modell</th><th>API Key nötig</th><th>Base URL</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Ollama</td><td>llama3.2 / mistral</td><td>Nein</td><td>http://localhost:11434</td></tr>
|
||||
<tr><td>Anthropic</td><td>claude-sonnet-4-6</td><td>Ja</td><td>-</td></tr>
|
||||
<tr><td>OpenAI</td><td>gpt-4o</td><td>Ja</td><td>-</td></tr>
|
||||
<tr><td>Google</td><td>gemini-2.0-flash</td><td>Ja</td><td>-</td></tr>
|
||||
<tr><td>Mistral</td><td>mistral-large-latest</td><td>Ja</td><td>-</td></tr>
|
||||
<tr><td>Azure OpenAI</td><td>gpt-4o</td><td>Ja</td><td>Deployment URL</td></tr>
|
||||
<tr><td>AWS Bedrock</td><td>anthropic.claude-3-5-sonnet</td><td>Ja (Access Key)</td><td>-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -435,12 +484,42 @@
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script>
|
||||
window.addEventListener("load", (event) => {
|
||||
document.querySelector('.upgrade').addEventListener("click", (event) => {
|
||||
fetch('{{ path('psc_backend_system_settings_upgrade') }}').then((result) => {
|
||||
document.location='{{ path('psc_backend_login') }}';
|
||||
});
|
||||
})
|
||||
const aiModels = {
|
||||
'anthropic': 'claude-sonnet-4-6',
|
||||
'openai': 'gpt-4o',
|
||||
'google': 'gemini-2.0-flash',
|
||||
'mistral': 'mistral-large-latest',
|
||||
'azure': 'gpt-4o',
|
||||
'bedrock': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
'ollama': 'llama3.2',
|
||||
};
|
||||
|
||||
function updateAiFields() {
|
||||
const provider = document.getElementById('ai_provider_select').value;
|
||||
const needsApiKey = provider && provider !== 'ollama';
|
||||
const needsBaseUrl = provider === 'ollama' || provider === 'azure';
|
||||
|
||||
document.getElementById('ai_field_apikey').style.display = needsApiKey ? '' : 'none';
|
||||
document.getElementById('ai_field_baseurl').style.display = needsBaseUrl ? '' : 'none';
|
||||
|
||||
const modelInput = document.querySelector('[name$="[aiModel]"]');
|
||||
const modelHint = document.getElementById('ai_model_hint');
|
||||
if (provider && aiModels[provider] && modelInput && !modelInput.value) {
|
||||
modelInput.placeholder = 'z.B. ' + aiModels[provider];
|
||||
}
|
||||
if (modelHint && provider) {
|
||||
modelHint.textContent = 'Empfohlen: ' + (aiModels[provider] || '');
|
||||
} else if (modelHint) {
|
||||
modelHint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
const select = document.getElementById('ai_provider_select');
|
||||
if (select) {
|
||||
select.addEventListener('change', updateAiFields);
|
||||
updateAiFields();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -175,9 +175,6 @@
|
||||
"laminas/laminas-code": {
|
||||
"version": "4.5.x-dev"
|
||||
},
|
||||
"lcobucci/clock": {
|
||||
"version": "2.0.x-dev"
|
||||
},
|
||||
"lcobucci/jwt": {
|
||||
"version": "4.2.x-dev"
|
||||
},
|
||||
@ -498,6 +495,18 @@
|
||||
"ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
|
||||
}
|
||||
},
|
||||
"symfony/ai-bundle": {
|
||||
"version": "0.5",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.1",
|
||||
"ref": "917b2a3da78a22e9488e99e1847d1f019d18da76"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/ai.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/asset": {
|
||||
"version": "v4.4.7"
|
||||
},
|
||||
@ -847,6 +856,15 @@
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
},
|
||||
"symfony/ux-autocomplete": {
|
||||
"version": "2.14",
|
||||
"recipe": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user