Fixes
This commit is contained in:
parent
d7748173a0
commit
29a88e341f
@ -41,6 +41,18 @@ services:
|
|||||||
- ServerOptions__HostName=smtp4dev
|
- ServerOptions__HostName=smtp4dev
|
||||||
networks:
|
networks:
|
||||||
- network
|
- 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:
|
webhook:
|
||||||
image: tarampampam/webhook-tester:2
|
image: tarampampam/webhook-tester:2
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@ -67,6 +67,8 @@
|
|||||||
"sofort/sofortlib-php": "3.3.2",
|
"sofort/sofortlib-php": "3.3.2",
|
||||||
"spatie/array-to-xml": "^3.4",
|
"spatie/array-to-xml": "^3.4",
|
||||||
"spiriitlabs/form-filter-bundle": "12.0.1",
|
"spiriitlabs/form-filter-bundle": "12.0.1",
|
||||||
|
"symfony/ai-agent": "^0.5.0",
|
||||||
|
"symfony/ai-bundle": "^0.5.0",
|
||||||
"symfony/asset": "*",
|
"symfony/asset": "*",
|
||||||
"symfony/asset-mapper": "7.4.*",
|
"symfony/asset-mapper": "7.4.*",
|
||||||
"symfony/console": "*",
|
"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\Shop\CommunicationBundle\PSCShopCommunicationBundle::class => ['all' => true],
|
||||||
PSC\Component\SteplayouterBundle\PSCComponentSteplayouterBundle::class => ['all' => true],
|
PSC\Component\SteplayouterBundle\PSCComponentSteplayouterBundle::class => ['all' => true],
|
||||||
PSC\Component\ApiBundle\PSCComponentApiBundle::class => ['all' => true],
|
PSC\Component\ApiBundle\PSCComponentApiBundle::class => ['all' => true],
|
||||||
|
PSC\Component\AiBundle\PSCComponentAiBundle::class => ['all' => true],
|
||||||
PSC\Component\ConfigurationlayouterBundle\PSCComponentConfigurationlayouterBundle::class => ['all' => true],
|
PSC\Component\ConfigurationlayouterBundle\PSCComponentConfigurationlayouterBundle::class => ['all' => true],
|
||||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
||||||
@ -62,4 +63,5 @@ return [
|
|||||||
Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true],
|
Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true],
|
||||||
Symfony\UX\Vue\VueBundle::class => ['all' => true],
|
Symfony\UX\Vue\VueBundle::class => ['all' => true],
|
||||||
Spiriit\Bundle\FormFilterBundle\SpiriitFormFilterBundle::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 {
|
return static function (ContainerConfigurator $containerConfigurator): void {
|
||||||
$containerConfigurator->extension(
|
$containerConfigurator->extension(
|
||||||
'monolog', [
|
'monolog', [
|
||||||
|
'channels' => ['ai'],
|
||||||
'handlers' => [
|
'handlers' => [
|
||||||
|
'ai' => [
|
||||||
|
'type' => 'stream',
|
||||||
|
'path' => '%kernel.logs_dir%/ai.log',
|
||||||
|
'level' => 'debug',
|
||||||
|
'channels' => ['ai'],
|
||||||
|
],
|
||||||
'main' => [
|
'main' => [
|
||||||
'type' => 'stream',
|
'type' => 'stream',
|
||||||
'path' => '%kernel.logs_dir%/%kernel.environment%.log',
|
'path' => '%kernel.logs_dir%/%kernel.environment%.log',
|
||||||
'level' => 'debug',
|
'level' => 'debug',
|
||||||
'channels' => [
|
'channels' => [
|
||||||
'!event',
|
'!event',
|
||||||
'!php'
|
'!php',
|
||||||
|
'!ai',
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
'console' => [
|
'console' => [
|
||||||
'type' => 'console',
|
'type' => 'console',
|
||||||
'process_psr_3_messages' => false,
|
'process_psr_3_messages' => false,
|
||||||
'channels' => [
|
'channels' => [
|
||||||
'!event',
|
'!event',
|
||||||
'!doctrine',
|
'!doctrine',
|
||||||
'!console'
|
'!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('@PSCComponentApiBundle/Resources/config/routing.yml');
|
||||||
|
|
||||||
|
$routingConfigurator->import('@PSCComponentAiBundle/Resources/config/routing.yml');
|
||||||
|
|
||||||
$routingConfigurator->import('@PSCBackendDashboardBundle/Resources/config/routing.yml');
|
$routingConfigurator->import('@PSCBackendDashboardBundle/Resources/config/routing.yml');
|
||||||
|
|
||||||
$routingConfigurator->import('@PSCBackendDomainBundle/Resources/config/routing.yml');
|
$routingConfigurator->import('@PSCBackendDomainBundle/Resources/config/routing.yml');
|
||||||
|
|||||||
@ -136,6 +136,122 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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')]
|
#[Field(type: 'bool')]
|
||||||
protected $supportlogin;
|
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
|
* @return mixed
|
||||||
*/
|
*/
|
||||||
@ -634,6 +646,18 @@ class Instance implements UserInterface
|
|||||||
$this->parcelCancelationNumberStart = $parcelCancelationNumberStart;
|
$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
|
public function getRoles(): array
|
||||||
{
|
{
|
||||||
return ['ROLE_API'];
|
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->setAdditionalInfos($positionDoc->getAdditionalInfos());
|
||||||
}
|
}
|
||||||
$position->setCustomerInfo((string) $positionDoc->getCustomerInfo());
|
$position->setCustomerInfo((string) $positionDoc->getCustomerInfo());
|
||||||
var_dump(count($this->uploadObjectTypes));
|
|
||||||
var_dump($positionDoc->getUploadMode());
|
|
||||||
|
|
||||||
if ($positionDoc->getUploadMode() != '') {
|
if ($positionDoc->getUploadMode() != '') {
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ use PSC\Shop\EntityBundle\Entity\Shop;
|
|||||||
use PSC\System\PluginBundle\Form\Chain\Field;
|
use PSC\System\PluginBundle\Form\Chain\Field;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
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\FormType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
use Symfony\Component\Form\Extension\Core\Type\NumberType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
@ -103,7 +104,43 @@ class SettingsType extends AbstractType
|
|||||||
'attr' => ['checked' => 'checked', 'readonly' => true],
|
'attr' => ['checked' => 'checked', 'readonly' => true],
|
||||||
'label' => 'PSC Support Login erlauben',
|
'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 */
|
/** @var \PSC\System\PluginBundle\Form\Interfaces\Field $field */
|
||||||
foreach ($this->fields->getFields(\PSC\System\PluginBundle\Form\Interfaces\Field::System) as $field) {
|
foreach ($this->fields->getFields(\PSC\System\PluginBundle\Form\Interfaces\Field::System) as $field) {
|
||||||
$builder->add($field->buildForm($this->formFactory->createNamedBuilder(
|
$builder->add($field->buildForm($this->formFactory->createNamedBuilder(
|
||||||
|
|||||||
@ -46,6 +46,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#wartung" role="tab">Wartungsankündigung</a>
|
<a class="nav-link" data-bs-toggle="tab" href="#wartung" role="tab">Wartungsankündigung</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#ai" role="tab">🤖 KI Assistent</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-10">
|
<div class="col-md-10">
|
||||||
@ -414,6 +417,52 @@
|
|||||||
<section id="topbar">
|
<section id="topbar">
|
||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -435,12 +484,42 @@
|
|||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener("load", (event) => {
|
const aiModels = {
|
||||||
document.querySelector('.upgrade').addEventListener("click", (event) => {
|
'anthropic': 'claude-sonnet-4-6',
|
||||||
fetch('{{ path('psc_backend_system_settings_upgrade') }}').then((result) => {
|
'openai': 'gpt-4o',
|
||||||
document.location='{{ path('psc_backend_login') }}';
|
'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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -175,9 +175,6 @@
|
|||||||
"laminas/laminas-code": {
|
"laminas/laminas-code": {
|
||||||
"version": "4.5.x-dev"
|
"version": "4.5.x-dev"
|
||||||
},
|
},
|
||||||
"lcobucci/clock": {
|
|
||||||
"version": "2.0.x-dev"
|
|
||||||
},
|
|
||||||
"lcobucci/jwt": {
|
"lcobucci/jwt": {
|
||||||
"version": "4.2.x-dev"
|
"version": "4.2.x-dev"
|
||||||
},
|
},
|
||||||
@ -498,6 +495,18 @@
|
|||||||
"ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
|
"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": {
|
"symfony/asset": {
|
||||||
"version": "v4.4.7"
|
"version": "v4.4.7"
|
||||||
},
|
},
|
||||||
@ -847,6 +856,15 @@
|
|||||||
"templates/base.html.twig"
|
"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": {
|
"symfony/ux-autocomplete": {
|
||||||
"version": "2.14",
|
"version": "2.14",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user