Fixes
Some checks failed
Gitea Actions / Run-Tests-On-Amd64 (push) Failing after 21m52s
Gitea Actions / Merge (push) Successful in 7m44s
Gitea Actions / Run-Tests-On-Arm64 (push) Has been cancelled

This commit is contained in:
Thomas Peterson 2026-02-27 15:13:17 +01:00
parent d7748173a0
commit 29a88e341f
28 changed files with 4395 additions and 2540 deletions

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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],
];

View 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'

View File

@ -7,14 +7,22 @@ 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',
'channels' => [
'!event',
'!php'
'!event',
'!php',
'!ai',
]
],
'console' => [
@ -23,7 +31,8 @@ return static function (ContainerConfigurator $containerConfigurator): void {
'channels' => [
'!event',
'!doctrine',
'!console'
'!console',
'!ai',
]
]
]

File diff suppressed because it is too large Load Diff

View File

@ -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');

View File

@ -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 %}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace PSC\Component\AiBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class PSCComponentAiBundle extends Bundle {}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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(),
);
}
}

View File

@ -0,0 +1,4 @@
psc_component_ai:
resource: "@PSCComponentAiBundle/Controller/"
type: attribute
prefix: /backend

View File

@ -0,0 +1,7 @@
services:
_defaults:
autowire: true
autoconfigure: true
PSC\Component\AiBundle\:
resource: '../../*/*'

View 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,
);
}
}

View File

@ -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'];

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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() != '') {
}

View File

@ -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(

View File

@ -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 %}

View File

@ -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": {