This commit is contained in:
Thomas Peterson 2026-03-06 17:10:38 +01:00
parent 5a9dfe8d5a
commit 285e0e2d33
25 changed files with 1028 additions and 222 deletions

View File

@ -46,13 +46,13 @@ services:
networks:
- network
restart: always
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1 # alternatively, use `count: all` for all GPUs
# capabilities: [gpu]
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

@ -18,7 +18,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
final class ModelClient implements ModelClientInterface
{
private readonly EventSourceHttpClient $httpClient;
private readonly HttpClientInterface $httpClient;
public function __construct(
HttpClientInterface $httpClient,
@ -44,13 +44,23 @@ final class ModelClient implements ModelClientInterface
*/
public function request(Model $model, array|string $payload, array $options = []): RawResultInterface
{
$headers = ['Content-Type' => 'application/json'];
$headers = [
'Content-Type' => 'application/json',
// Lowercase 'accept' ist wichtig: EventSourceHttpClient prüft exakt $options['headers']['accept']
// mit ??= — uppercase 'Accept' würde NICHT matchen und trotzdem text/event-stream setzen.
'accept' => 'application/json',
];
if ($this->apiKey) {
$headers['Authorization'] = 'Bearer ' . $this->apiKey;
}
$body = array_merge($payload, ['model' => $model->getName()]);
// Ollama-spezifische Optionen (z.B. num_ctx) direkt in den Body mergen
if (!empty($options['ollama_options'])) {
$body['options'] = $options['ollama_options'];
}
// Tools in OpenAI-Format bringen: {type: "function", function: {name, description, parameters}}
if (!empty($options['tools'])) {
$body['tools'] = array_map(
@ -77,7 +87,7 @@ final class ModelClient implements ModelClientInterface
$response = $this->httpClient->request('POST', rtrim($this->baseUrl, '/') . $this->path, [
'headers' => $headers,
'json' => $body,
'timeout' => 120,
'timeout' => 180,
]);
return new RawHttpResult($response);

View File

@ -4,33 +4,47 @@ namespace PSC\Component\ApiBundle\Api\Shop;
use Doctrine\ORM\EntityManagerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes\JsonContent;
use OpenApi\Attributes\Response;
use OpenApi\Attributes\Tag;
use PSC\Component\ApiBundle\Dto\Error\NotFound;
use PSC\Component\ApiBundle\Dto\Shop\Shops;
use PSC\Component\ApiBundle\Model\Shop;
use PSC\Component\ApiBundle\Model\Shop\Domain;
use PSC\Shop\EntityBundle\Repository\ShopContactRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Yaml\Yaml;
class Get extends AbstractController
{
private EntityManagerInterface $entityManager;
private \PSC\Component\ApiBundle\Hydrate\Shop $hydrateShop;
public function __construct(
EntityManagerInterface $entityManager,
\PSC\Component\ApiBundle\Hydrate\Shop $hydrateShop,
) {
$this->entityManager = $entityManager;
$this->hydrateShop = $hydrateShop;
private EntityManagerInterface $entityManager,
private \PSC\Component\ApiBundle\Hydrate\Shop $hydrateShop,
private ShopContactRepository $shopContactRepository,
) {}
#[Response(
response: 200,
description: 'my shops',
content: new JsonContent(ref: new Model(type: PSC\Component\ApiBundle\Dto\Shop\Shops\Output::class)),
)]
#[Tag(name: 'Shops')]
#[Security(name: 'ApiKeyAuth')]
#[Security(name: 'Bearer')]
#[Route(path: '/my_shops', methods: ['GET'])]
#[IsGranted('ROLE_SHOP')]
public function myShops(): JsonResponse
{
$shops = $this->entityManager->getRepository(\PSC\Shop\EntityBundle\Entity\Shop::class)->findAll();
$data = [];
/** @var \PSC\Shop\EntityBundle\Entity\Shop $shop */
foreach ($shops as $shop) {
$data[] = $this->hydrateShop->hydrateToModel($shop);
}
return $this->json(new Shops\Output($data));
}
#[Response(
@ -45,7 +59,7 @@ class Get extends AbstractController
#[IsGranted('ROLE_SHOP')]
public function allAction(): JsonResponse
{
$shops = $this->entityManager->getRepository(\PSC\Shop\EntityBundle\Entity\Shop::class)->findAll();
$shops = $this->shopContactRepository->myEditableShops($this->getUser());
$data = [];
/** @var \PSC\Shop\EntityBundle\Entity\Shop $shop */

View File

@ -13,9 +13,11 @@
namespace PSC\Shop\EntityBundle\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use PSC\Shop\EntityBundle\Entity\Contact;
use PSC\Shop\EntityBundle\Entity\ShopContact;
/**
* ShopContactRepository
@ -26,8 +28,13 @@ use PSC\Shop\EntityBundle\Entity\Contact;
* @package PSC\Shop\Entity
* @subpackage Repositorys
*/
class ShopContactRepository extends EntityRepository
class ShopContactRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ShopContact::class);
}
/**
* Gibt einen Shop per Apikey zurück
*
@ -42,12 +49,13 @@ class ShopContactRepository extends EntityRepository
public function myEditableShops(Contact $contact)
{
$query = $this->getEntityManager()->createQuery(
'
$query = $this
->getEntityManager()
->createQuery('
SELECT sc FROM PSC\Shop\EntityBundle\Entity\ShopContact sc
JOIN sc.shop s
WHERE sc.contact = :id AND sc.admin = 1 ORDER BY sc.admin ASC, s.title ASC',
)->setParameter('id', $contact->getId());
WHERE sc.contact = :id AND sc.admin = 1 ORDER BY sc.admin ASC, s.title ASC')
->setParameter('id', $contact->getId());
try {
$result = $query->execute();
$tmpCollection = new ArrayCollection();
@ -62,12 +70,15 @@ class ShopContactRepository extends EntityRepository
public function myEditableShopsWithNoDeleted(Contact $contact)
{
$query = $this->getEntityManager()->createQuery(
'
$query = $this
->getEntityManager()
->createQuery(
'
SELECT sc FROM PSC\Shop\EntityBundle\Entity\ShopContact sc
JOIN sc.shop s
WHERE sc.contact = :id AND s.deleted = 0 AND sc.admin = 1 ORDER BY sc.admin ASC, s.title ASC',
)->setParameter('id', $contact->getId());
)
->setParameter('id', $contact->getId());
try {
$result = $query->execute();
$tmpCollection = new ArrayCollection();
@ -82,14 +93,16 @@ class ShopContactRepository extends EntityRepository
public function changeSelectedShop(Contact $contact, $shop_uuid)
{
$this->getEntityManager()
$this
->getEntityManager()
->createQuery('
UPDATE PSC\Shop\EntityBundle\Entity\ShopContact sc
SET sc.selected = 0
WHERE sc.contact = :id AND sc.selected = 1')
->setParameter('id', $contact->getId())
->execute();
$this->getEntityManager()
$this
->getEntityManager()
->createQuery('
UPDATE PSC\Shop\EntityBundle\Entity\ShopContact sc
SET sc.selected = 1
@ -101,7 +114,8 @@ class ShopContactRepository extends EntityRepository
public function updateAdmin($contactId, $shopId, $admin = true)
{
$this->createQueryBuilder('sc')
$this
->createQueryBuilder('sc')
->update()
->set('sc.admin', '?1')
->setParameter(1, $admin)
@ -114,7 +128,8 @@ class ShopContactRepository extends EntityRepository
public function resetAdmin($contactId, $admin = true)
{
$this->createQueryBuilder('sc')
$this
->createQueryBuilder('sc')
->update()
->set('sc.admin', '?1')
->setParameter(1, $admin)

View File

@ -452,7 +452,7 @@
<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>Ollama</td><td>qwen2.5-coder:3b / 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>
@ -486,12 +486,12 @@
<script>
const aiModels = {
'anthropic': 'claude-sonnet-4-6',
'openai': 'gpt-4o',
'openai': 'gpt-4o-mini',
'google': 'gemini-2.0-flash',
'mistral': 'mistral-large-latest',
'azure': 'gpt-4o',
'bedrock': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
'ollama': 'llama3.2',
'ollama': 'qwen2.5-coder:3b',
};
function updateAiFields() {

View File

@ -0,0 +1,40 @@
<?php
namespace Plugin\Custom\PSC\FormBuilder\Api\Ai;
use Plugin\Custom\PSC\FormBuilder\Service\FormBuilderAiService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes\Tag;
use Nelmio\ApiDocBundle\Attribute\Security;
class Chat extends AbstractController
{
public function __construct(
private readonly FormBuilderAiService $aiService,
) {}
#[Route(path: '/ai/chat', methods: ['POST'])]
#[Tag('FormBuilder')]
#[IsGranted('ROLE_ADMIN')]
#[Security(name: 'Bearer')]
public function chat(Request $request): JsonResponse
{
$body = json_decode($request->getContent(), true);
$message = $body['message'] ?? '';
$xml = $body['xml'] ?? '';
$formulas = $body['formulas'] ?? '';
$parameter = $body['parameter'] ?? '';
$paperDb = $body['paperDb'] ?? '';
$history = $body['history'] ?? [];
$result = $this->aiService->chat($message, $xml, $formulas, $parameter, $paperDb, $history);
return $this->json($result);
// result shape: { reply: string, xml?: string, formulas?: string, parameter?: string }
}
}

View File

@ -18,6 +18,7 @@ import XmlView from './app/XmlView.vue'
import PaperDBView from './app/PaperDBView.vue'
import FormelPanel from './app/FormelPanel.vue'
import ParameterPanel from './app/ParameterPanel.vue'
import AiPanel from './app/AiPanel.vue'
import SaveLayoutDialog from './app/dialogs/SaveLayoutDialog.vue'
import LoadLayoutDialog from './app/dialogs/LoadLayoutDialog.vue'
import Preview from './app/preview/Preview.vue'
@ -38,6 +39,7 @@ function onOuterCollapse() {
isOuterOpen.value = false
globalStore.setShowFormel(false)
globalStore.setShowParameter(false)
globalStore.setShowAi(false)
}
function onOuterExpand() {
@ -47,7 +49,7 @@ function onOuterExpand() {
function toggleFormel() {
if (globalStore.showFormel) {
globalStore.setShowFormel(false)
if (!globalStore.showParameter) {
if (!globalStore.showParameter && !globalStore.showAi) {
outerCodePanelRef.value?.collapse()
}
} else {
@ -61,7 +63,7 @@ function toggleFormel() {
function toggleParameter() {
if (globalStore.showParameter) {
globalStore.setShowParameter(false)
if (!globalStore.showFormel) {
if (!globalStore.showFormel && !globalStore.showAi) {
outerCodePanelRef.value?.collapse()
}
} else {
@ -71,6 +73,20 @@ function toggleParameter() {
}
}
}
function toggleAi() {
if (globalStore.showAi) {
globalStore.setShowAi(false)
if (!globalStore.showFormel && !globalStore.showParameter) {
outerCodePanelRef.value?.collapse()
}
} else {
globalStore.setShowAi(true)
if (!isOuterOpen.value) {
outerCodePanelRef.value?.expand()
}
}
}
</script>
<template>
@ -124,6 +140,14 @@ function toggleParameter() {
>
{{ $t('parameter_view') }}
</Button>
<Button
@click="toggleAi"
:variant="globalStore.showAi ? 'default' : 'outline'"
size="sm"
class="text-xs"
>
{{ $t('ki_view') }}
</Button>
</div>
</div>
@ -176,7 +200,7 @@ function toggleParameter() {
<!-- Inner panels via v-if no nested collapsible needed -->
<div class="flex-1 min-h-0">
<ResizablePanelGroup
v-if="globalStore.showFormel || globalStore.showParameter"
v-if="globalStore.showFormel || globalStore.showParameter || globalStore.showAi"
id="code-panel-group"
direction="vertical"
class="h-full"
@ -184,25 +208,39 @@ function toggleParameter() {
<ResizablePanel
v-if="globalStore.showFormel"
:order="1"
:default-size="globalStore.showParameter ? 50 : 100"
:default-size="(globalStore.showParameter || globalStore.showAi) ? 50 : 100"
:min-size="15"
>
<FormelPanel />
</ResizablePanel>
<ResizableHandle
v-if="globalStore.showFormel && globalStore.showParameter"
v-if="globalStore.showFormel && (globalStore.showParameter || globalStore.showAi)"
with-handle
/>
<ResizablePanel
v-if="globalStore.showParameter"
:order="2"
:default-size="globalStore.showFormel ? 50 : 100"
:default-size="(globalStore.showFormel || globalStore.showAi) ? 50 : 100"
:min-size="15"
>
<ParameterPanel />
</ResizablePanel>
<ResizableHandle
v-if="globalStore.showParameter && globalStore.showAi"
with-handle
/>
<ResizablePanel
v-if="globalStore.showAi"
:order="3"
:default-size="(globalStore.showFormel || globalStore.showParameter) ? 50 : 100"
:min-size="15"
>
<AiPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>

View File

@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, nextTick, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '../ui/button'
import { useGlobalStore } from '../../stores/Global'
import { sendAiMessage } from '../../lib/api'
const { t } = useI18n()
const store = useGlobalStore()
interface Message {
role: 'user' | 'assistant'
content: string
xml?: string
}
const history = ref<Message[]>([])
const input = ref('')
const loading = ref(false)
const elapsedSeconds = ref(0)
const messagesEnd = ref<HTMLDivElement | null>(null)
let elapsedTimer: ReturnType<typeof setInterval> | null = null
function startTimer() {
elapsedSeconds.value = 0
elapsedTimer = setInterval(() => { elapsedSeconds.value++ }, 1000)
}
function stopTimer() {
if (elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
}
}
onUnmounted(stopTimer)
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth' })
})
}
async function send() {
const message = input.value.trim()
if (!message || loading.value) return
history.value.push({ role: 'user', content: message })
input.value = ''
loading.value = true
startTimer()
scrollToBottom()
try {
const result: any = await sendAiMessage(
message,
store.xml,
store.formulas,
store.parameter,
store.paperContainer,
history.value.slice(0, -1)
)
let replyText = result.reply || ''
if (result.xml || result.formulas || result.parameter) {
await store.applyAiResult(result)
if (result.xml) replyText += (replyText ? '\n' : '') + t('ai_applied_xml')
if (result.formulas) replyText += (replyText ? '\n' : '') + t('ai_applied_formulas')
if (result.parameter) replyText += (replyText ? '\n' : '') + t('ai_applied_parameter')
}
if (!replyText) replyText = '✓'
history.value.push({ role: 'assistant', content: replyText, xml: result.xml })
} catch {
history.value.push({ role: 'assistant', content: t('ai_error') })
} finally {
stopTimer()
loading.value = false
scrollToBottom()
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
send()
}
}
</script>
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="px-3 py-1.5 border-b border-gray-200 bg-slate-100 shrink-0 flex items-center justify-between">
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">{{ $t('ki_view') }}</span>
<Button
v-if="store.aiSnapshots.length > 0"
@click="store.undoAiSnapshot()"
variant="outline"
size="sm"
class="text-xs h-6 px-2"
>
Rückgängig
</Button>
</div>
<!-- Message list -->
<div class="flex-1 overflow-y-auto p-3 space-y-2 min-h-0">
<div
v-for="(msg, i) in history"
:key="i"
:class="[
'max-w-[85%] rounded-lg px-3 py-2 text-sm break-words',
msg.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'mr-auto bg-slate-100 text-slate-800'
]"
>
<span class="whitespace-pre-wrap">{{ msg.content }}</span>
<details v-if="msg.xml" class="mt-2">
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-700 select-none">XML anzeigen</summary>
<pre class="mt-1 text-xs bg-white rounded border border-slate-200 p-2 overflow-x-auto whitespace-pre">{{ msg.xml }}</pre>
</details>
</div>
<!-- Spinner -->
<div v-if="loading" class="mr-auto flex items-center gap-2 bg-slate-100 rounded-lg px-3 py-2">
<svg class="animate-spin h-4 w-4 text-slate-500 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="text-xs text-slate-500">{{ elapsedSeconds }}s</span>
</div>
<div ref="messagesEnd" />
</div>
<!-- Input area -->
<div class="shrink-0 border-t border-gray-200 p-2 flex gap-2">
<textarea
v-model="input"
:placeholder="$t('ai_placeholder')"
:disabled="loading"
@keydown="onKeydown"
rows="2"
class="flex-1 resize-none rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
<Button
@click="send"
:disabled="loading || !input.trim()"
size="sm"
class="self-end"
>
{{ $t('ai_send') }}
</Button>
</div>
</div>
</template>

View File

@ -18,8 +18,17 @@ const cmOptions = {
theme: 'default',
};
watch(xmlString, (xml) => {
store.xml = xml
let updatingFromStore = false
watch(xmlString, (newVal) => {
if (updatingFromStore) return
store.xml = newVal
})
watch(() => store.xml, (newVal) => {
updatingFromStore = true
xmlString.value = xmlFormat(newVal)
updatingFromStore = false
})
function manualSync() {

View File

@ -2,6 +2,7 @@
import SelectElement from '../../../model/SelectElement';
import { computed } from 'vue';
import { Input } from '../../../components/ui/input'
import { Button } from '../../../components/ui/button'
import {
Select,
SelectContent,
@ -10,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from '../../../components/ui/select'
import { useGlobalStore } from '../../../stores/Global'
const props = defineProps<{
modelValue: SelectElement
@ -17,11 +19,12 @@ const props = defineProps<{
let emit = defineEmits(['update:modelValue'])
const globalStore = useGlobalStore()
const theModel = computed<SelectElement>({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
</script>
<template>
@ -52,4 +55,7 @@ const theModel = computed<SelectElement>({
</Select>
<label>{{ $t('container') }}</label>
<Input v-model="theModel!.container"/>
<Button class="mt-2" @click="globalStore.setShowOptions(true)">
{{ $t('edit_options') }}
</Button>
</template>

View File

@ -3,7 +3,6 @@ import { useGlobalStore } from '../../../stores/Global'
import SelectElement from '../../../model/SelectElement';
import Option from '../../../model/select/Option';
import { computed } from 'vue';
import { Button } from '../../../components/ui/button'
import OptionElement from './OptionElement.vue'
import { ref, watch } from 'vue'
import {
@ -14,7 +13,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../../../components/ui/dialog'
const props = defineProps<{
@ -51,9 +49,6 @@ watch(openModal, (newOpenModal: boolean) => {
<template>
<Dialog v-if="globalStore.getActiveItem.type === 3" v-model:open="openModal">
<DialogTrigger>
<Button class="mt-2">{{ $t('edit_options') }}</Button>
</DialogTrigger>
<DialogContent class="min-w-full grid-rows-[auto_minmax(0,1fr)_auto] p-4 max-h-[90dvh]">
<DialogHeader>
<DialogTitle>{{ $t('edit_options') }}</DialogTitle>

View File

@ -60,5 +60,12 @@
"load": "Laden",
"enter_layout_name_alert": "Bitte gib einen Namen für das Layout ein.",
"save_layout_failed_alert": "Layout konnte nicht gespeichert werden.",
"load_layout_confirm": "Möchtest du dieses Layout wirklich laden? Dein aktuelles Design wird überschrieben."
"load_layout_confirm": "Möchtest du dieses Layout wirklich laden? Dein aktuelles Design wird überschrieben.",
"ki_view": "KI",
"ai_send": "Senden",
"ai_placeholder": "Schreibe eine Nachricht...",
"ai_applied_xml": "XML wurde übernommen.",
"ai_applied_formulas": "Formeln wurden übernommen.",
"ai_applied_parameter": "Parameter wurden übernommen.",
"ai_error": "Fehler beim Senden der Nachricht."
}

View File

@ -60,5 +60,12 @@
"load": "Load",
"enter_layout_name_alert": "Please enter a name for the layout.",
"save_layout_failed_alert": "Failed to save layout.",
"load_layout_confirm": "Are you sure you want to load this layout? This will overwrite your current design."
"load_layout_confirm": "Are you sure you want to load this layout? This will overwrite your current design.",
"ki_view": "AI",
"ai_send": "Send",
"ai_placeholder": "Write a message...",
"ai_applied_xml": "XML has been applied.",
"ai_applied_formulas": "Formulas have been applied.",
"ai_applied_parameter": "Parameter has been applied.",
"ai_error": "Error sending the message."
}

View File

@ -174,6 +174,22 @@ export const fetchPreview = async (shopUuid: string, json: object[], values?: Re
}
};
export const sendAiMessage = async (
message: string,
xml: string,
formulas: string,
parameter: string,
paperDb: string,
history: { role: string; content: string }[]
) => {
const response = await api.post('api/plugin/custom/psc/formbuilder/ai/chat', {
json: { message, xml, formulas, parameter, paperDb, history },
timeout: 180000,
});
return await response.json();
// returns { reply: string, xml?: string, formulas?: string, parameter?: string }
};
export const fetchMediaUrl = async (uuid: string) => {
try {
const response = await api.get(`api/media/${uuid}`);

View File

@ -20,6 +20,8 @@ export const useGlobalStore = defineStore('global', {
showLoadLayoutDialog: false,
showFormel: false,
showParameter: false,
showAi: false,
aiSnapshots: [] as { xml: string; formulas: string; parameter: string }[],
sourceDragUuid: "",
dragMode: "",
json: "",
@ -104,6 +106,45 @@ export const useGlobalStore = defineStore('global', {
setShowParameter(value: boolean) {
this.showParameter = value
},
setShowAi(value: boolean) {
this.showAi = value
},
saveAiSnapshot() {
this.aiSnapshots.push({ xml: this.xml, formulas: this.formulas, parameter: this.parameter })
if (this.aiSnapshots.length > 10) this.aiSnapshots.shift()
},
undoAiSnapshot() {
const snapshot = this.aiSnapshots.pop()
if (!snapshot) return false
this.xml = snapshot.xml
this.formulas = snapshot.formulas
this.parameter = snapshot.parameter
const itemStore = useItemStore()
return saveXmlToApi(this.productUuid, snapshot.xml).then((result: any) => {
this.setXML(result.xml)
this.setJSON(result.json)
this.formulaData = JSON.parse(result.jsonGraph)
itemStore.parseJSON(result.json)
})
},
async applyAiResult(result: { xml?: string; formulas?: string; parameter?: string }) {
this.saveAiSnapshot()
if (result.xml) {
const itemStore = useItemStore()
this.syncing = true
try {
const syncResult: any = await saveXmlToApi(this.productUuid, result.xml)
this.setXML(syncResult.xml)
this.setJSON(syncResult.json)
this.formulaData = JSON.parse(syncResult.jsonGraph)
itemStore.parseJSON(syncResult.json)
} finally {
this.syncing = false
}
}
if (result.formulas) this.formulas = result.formulas
if (result.parameter) this.parameter = result.parameter
},
syncFormulasAndParameter() {
this.syncing = true
saveFomulasAndParameterToApi(this.shopUuid, this.formulas, this.parameter).then((result: any) => {

View File

@ -33,7 +33,7 @@ export default defineConfig({
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('Authorization', 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NzI2OTc2MzgsImV4cCI6MTc3MjcwMTIzOCwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfU0hPUF9PUEVSQVRPUiIsIlJPTEVfVVNFUiIsIlJPTEVfVVNFUiIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9FZGl0IiwiUk9MRV9QU0NfQ29sbGVjdF9Db250YWN0X0FkZCIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9EZWxldGUiLCJST0xFX1BTQ19Db2xsZWN0X0NvbnRhY3RfTG9jayIsIlJPTEVfUFNDX1IyX1NlbmRjbG91ZF9TaG93Il0sInVpZCI6MX0.PNZeyLmg6vXNYekPb7cm5oDUKcXVbD1tYUWpMdkkAow_Vs0-1NwAUpg-lLCGvYfOmy9_7MWSEdHoaSRAalr1CC8KFdyeLAF1IjFlNRyytCzHUA5g3ygY8_FWykr122U4pexllsrUcxJVif5ekpuUtkpPY4xn4U9F0DvrrDX1xgf9NLOfVaVwv6RkNtiOR8oqnzQKwkuO79KLGOtodjVPxp320yYF9D8kf2mzqjFQLujD1vc_SqD8Sdal3on51RAYqO677gRwWl92vg9-pzAhiRKlwsXvmOnI3ow3PF9ZsyC2Qv7okuyChNlVC8L4ThhMixry-K0jmIZbO0nintidiQ');
proxyReq.setHeader('Authorization', 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE3NzI4MDIxNDksImV4cCI6MTc3MjgwNTc0OSwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfU0hPUF9PUEVSQVRPUiIsIlJPTEVfVVNFUiIsIlJPTEVfVVNFUiIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9FZGl0IiwiUk9MRV9QU0NfQ29sbGVjdF9Db250YWN0X0FkZCIsIlJPTEVfUFNDX0NvbGxlY3RfQ29udGFjdF9EZWxldGUiLCJST0xFX1BTQ19Db2xsZWN0X0NvbnRhY3RfTG9jayIsIlJPTEVfUFNDX1IyX1NlbmRjbG91ZF9TaG93Il0sInVpZCI6MX0.Sq-flCd9Td-P8dstLYms2x3EXdXV3IdR6fbMD2scLHShyGAkebJTJ2tKEVykIRE90oKOIERGKTE8NwlUjQqAfFSI0_oMMYChP7mkw0tgntFjvVWyjVxcCQCvA5AGqQHLnMXpxfbo--NCtnqUzZzg55HeBm67gTpiZIORziwzyvPxHqy-JfURkP3W1c0X1BmXYiM_zAelQCkqwKNsUQBSr9WAV5ZOjKa0ZbRV7cRvktNb1H4UUTEd8somwu6hTgataySBYgWrrL34Kcrokbt3EmchBA70LajRzgZO80Z0_4Ir6IJJkeBUYQnCTNT-fvZmwV7pQZ_nIZjtNNxKweqmng');
});
},
},

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,419 @@
<?php
namespace Plugin\Custom\PSC\FormBuilder\Service;
use PSC\Component\AiBundle\Platform\PlatformFactory;
use PSC\System\SettingsBundle\Service\Instance;
use Psr\Log\LoggerInterface;
use Symfony\AI\Agent\Agent;
use Symfony\AI\Agent\Input;
use Symfony\AI\Agent\InputProcessor\SystemPromptInputProcessor;
use Symfony\AI\Agent\InputProcessorInterface;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Result\TextResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class FormBuilderAiService
{
public function __construct(
private readonly Instance $instanceService,
private readonly HttpClientInterface $httpClient,
#[Autowire(service: 'monolog.logger.ai')]
private readonly LoggerInterface $logger,
) {}
public function chat(
string $message,
string $xml,
string $formulas,
string $parameter,
string $paperDb,
array $history = [],
): array {
$instance = $this->instanceService->getInstance();
$provider = $instance->getAiProvider();
if (!$provider) {
$this->logger->warning('FormBuilder AI: Kein KI-Anbieter konfiguriert.');
return [
'reply' => 'Kein KI-Anbieter konfiguriert. Bitte konfiguriere einen KI-Anbieter in den Shop-Einstellungen.',
];
}
$model = $instance->getAiModel() ?: 'llama3.2';
$this->logger->info('FormBuilder AI: Chat-Anfrage', [
'provider' => $provider,
'model' => $model,
'message' => $message,
'history_count' => count($history),
]);
$platform = PlatformFactory::create(
baseUrl: $this->resolveBaseUrl($instance),
apiKey: $instance->getAiApiKey() ?: null,
httpClient: $this->httpClient,
logger: $this->logger,
);
$systemPrompt = $this->buildSystemPrompt();
$systemPromptProcessor = new SystemPromptInputProcessor($systemPrompt);
$ollamaOptionsProcessor = null;
if ($provider === 'ollama') {
$ollamaOptionsProcessor = new class implements InputProcessorInterface {
public function processInput(Input $input): void
{
$options = $input->getOptions();
$options['ollama_options']['num_ctx'] = 8192;
$input->setOptions($options);
}
};
}
$inputProcessors = array_filter([$systemPromptProcessor, $ollamaOptionsProcessor]);
$agent = new Agent(platform: $platform, model: $model, inputProcessors: array_values($inputProcessors));
$messages = new MessageBag();
// Few-Shot-Beispiel 1: Einfaches Einfügen
$messages->add(Message::ofUser(
"AKTUELLER ZUSTAND:\n\nXML:\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<kalkulation>\n <artikel>\n <name>Visitenkarten</name>\n <option id=\"auflage\" name=\"Auflage\" type=\"Input\" default=\"100\" min=\"50\" max=\"5000\"/>\n </artikel>\n</kalkulation>\n\nFormeln:\n(leer)\n\nParameter:\n(leer)\n\nAUFGABE: Füge ein Inputfeld für die Breite zwischen Auflage und dem Ende ein, min 50, max 400.",
));
$messages->add(Message::ofAssistant(
"```xml\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<kalkulation>\n <artikel>\n <name>Visitenkarten</name>\n <option id=\"auflage\" name=\"Auflage\" type=\"Input\" default=\"100\" min=\"50\" max=\"5000\"/>\n <option id=\"breite\" name=\"Breite\" type=\"Input\" default=\"50\" min=\"50\" max=\"400\"/>\n </artikel>\n</kalkulation>\n```\nIch habe das Inputfeld \"Breite\" nach \"Auflage\" eingefügt.",
));
// Few-Shot-Beispiel 2: Formel mit Feld-ID aus der XML (nicht erfinden!)
$messages->add(Message::ofUser(
"AKTUELLER ZUSTAND:\n\nXML:\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<kalkulation>\n <artikel>\n <name>Flyer</name>\n <option id=\"auflage\" name=\"Auflage\" type=\"Input\" default=\"500\" min=\"100\" max=\"10000\"/>\n </artikel>\n</kalkulation>\n\nFormeln:\n(leer)\n\nParameter:\n(leer)\n\nAUFGABE: Füge ein verstecktes Preisfeld hinzu das 0.05 pro Stück berechnet.",
));
$messages->add(Message::ofAssistant(
"```xml\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<kalkulation>\n <artikel>\n <name>Flyer</name>\n <option id=\"auflage\" name=\"Auflage\" type=\"Input\" default=\"500\" min=\"100\" max=\"10000\"/>\n <option id=\"preis\" name=\"Preis\" type=\"Hidden\">\n <auflage>\n <grenze formel=\"\$Vauflage\$V*0.05\">1-</grenze>\n </auflage>\n </option>\n </artikel>\n</kalkulation>\n```\nIch habe das Hidden-Feld \"preis\" hinzugefügt. Die Formel verwendet \$Vauflage\$V, weil das Mengenfeld in dieser XML die ID \"auflage\" hat.",
));
// Few-Shot-Beispiel 3: Mehrdimensionale Kalkulation (Select + Auflage → Hidden)
$messages->add(Message::ofUser(
"AKTUELLER ZUSTAND:\n\nXML:\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<kalkulation>\n <artikel>\n <name>Druck</name>\n <option id=\"auflage\" name=\"Auflage\" type=\"Input\" default=\"100\" min=\"1\" max=\"10000\"/>\n <option id=\"format\" name=\"Format\" type=\"Select\" default=\"a4\">\n <opt id=\"a4\" name=\"A4\"/>\n <opt id=\"a3\" name=\"A3\"/>\n </option>\n </artikel>\n</kalkulation>\n\nFormeln:\n(leer)\n\nParameter:\n(leer)\n\nAUFGABE: Füge ein verstecktes Preisfeld hinzu. A4 kostet 0.10 pro Stück, A3 kostet 0.18 pro Stück.",
));
$messages->add(Message::ofAssistant(
"```xml\n<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<kalkulation>\n <artikel>\n <name>Druck</name>\n <option id=\"auflage\" name=\"Auflage\" type=\"Input\" default=\"100\" min=\"1\" max=\"10000\"/>\n <option id=\"format\" name=\"Format\" type=\"Select\" default=\"a4\">\n <opt id=\"a4\" name=\"A4\"/>\n <opt id=\"a3\" name=\"A3\"/>\n </option>\n <option id=\"preis\" name=\"Preis\" type=\"Hidden\">\n <format>\n <grenze formel=\"\$Vauflage\$V*0.10\">a4</grenze>\n <grenze formel=\"\$Vauflage\$V*0.18\">a3</grenze>\n </format>\n </option>\n </artikel>\n</kalkulation>\n```\nIch habe das Hidden-Feld \"preis\" mit einer Abhängigkeit von \"format\" eingefügt. Die Grenzen matchen auf die opt-IDs \"a4\" und \"a3\".",
));
foreach ($history as $h) {
if ($h['role'] === 'user') {
$messages->add(Message::ofUser($h['content']));
} elseif ($h['role'] === 'assistant') {
$messages->add(Message::ofAssistant($h['content']));
}
}
$userMessage = $this->buildUserMessage($message, $xml, $formulas, $parameter, $paperDb);
$this->logger->info('FormBuilder AI: UserMessage', ['message' => $userMessage]);
$messages->add(Message::ofUser($userMessage));
$result = $agent->call($messages);
$text = $result instanceof TextResult ? $result->getContent() : (string) $result;
$this->logger->info('FormBuilder AI: Raw-Antwort', ['text' => $text]);
$parsed = $this->parseResponse($text);
$this->logger->info('FormBuilder AI: Antwort geparst', [
'reply_length' => strlen($parsed['reply']),
'has_xml' => isset($parsed['xml']),
'has_formulas' => isset($parsed['formulas']),
'has_parameter' => isset($parsed['parameter']),
]);
return $parsed;
}
private function parseResponse(string $text): array
{
$result = ['reply' => $text];
// XML: case-insensitive, also match plain ``` blocks containing <kalkulation>
if (preg_match('/```xml\s*([\s\S]*?)\s*```/i', $text, $m)) {
$result['xml'] = trim($m[1]);
} elseif (preg_match('/```\s*(<\?xml[\s\S]*?<\/kalkulation>)\s*```/i', $text, $m)) {
$result['xml'] = trim($m[1]);
} elseif (preg_match('/(<\?xml[\s\S]*?<\/kalkulation>)/i', $text, $m)) {
// Raw XML without code fence
$result['xml'] = trim($m[1]);
} elseif (preg_match('/(<kalkulation>[\s\S]*?<\/kalkulation>)/i', $text, $m)) {
$result['xml'] = trim($m[1]);
}
if (preg_match('/```php\s*\/\/ formulas\s*([\s\S]*?)\s*```/i', $text, $m)) {
$result['formulas'] = trim($m[1]);
}
if (preg_match('/```php\s*\/\/ parameter\s*([\s\S]*?)\s*```/i', $text, $m)) {
$result['parameter'] = trim($m[1]);
}
// Strip all code blocks from the reply text
$reply = trim(preg_replace('/```[\s\S]*?```/', '', $text));
// Also strip raw XML if it was captured without fences
if (isset($result['xml'])) {
$reply = trim(str_replace($result['xml'], '', $reply));
}
$result['reply'] = $reply;
return $result;
}
private function buildSystemPrompt(): string
{
return <<<'PROMPT'
Du bist ein spezialisiertes KI-Tool für den PrintshopCreator FormBuilder.
Antworte auf Deutsch. Führe Änderungen sofort durch.
REGELN:
- Ändere AUSSCHLIESSLICH was der Nutzer explizit angefragt hat.
- Bestehende Felder, IDs und Werte NIEMALS umbenennen oder verändern, außer wenn direkt danach gefragt.
- Gib bei XML-Änderungen immer die KOMPLETTE XML zurück.
- Alle IDs (`id="..."`) immer lowercase: nur Kleinbuchstaben, Ziffern, Unterstrich. Niemals Großbuchstaben oder Sonderzeichen in IDs!
- Lies die aktuelle XML im AKTUELLER ZUSTAND-Abschnitt und verwende NUR dort vorhandene Feld-IDs in Formeln und Abhängigkeiten.
KONVENTION: Mengenfeld immer `id="auflage"`
- Wenn eine Kalkulation ein Anzahl-/Mengen-/Auflagenfeld benötigt, heißt dieses immer `id="auflage"`.
- In Formeln und Abhängigkeiten: `$Vauflage$V` und `<auflage>...</auflage>`.
- Existiert bereits ein Mengenfeld mit anderem Namen, NICHT umbenennen diese Regel gilt nur für neu angelegte Felder.
ABSOLUTES VERBOT:
- `<grenze>` darf NIEMALS direkt als Kind von `<option>` stehen. Immer innerhalb eines Abhängigkeits-Elements (z.B. `<auflage>`, `<format>`, `<farbe>`).
- Formeln (`formel="..."`) dürfen NUR Variablen (`$V...$V`, `$CV...$CV`, `$PM...$P`, `$F...$F`), Zahlen und mathematische Operatoren enthalten. KEIN XML, KEINE Bedingungen, KEINE if/else-Logik in Formeln.
- Komplexe Berechnungen die von mehreren Feldern abhängen `<grenzen>` mit mehreren Abhängigkeits-Elementen verwenden (siehe ABHÄNGIGKEITEN).
## FELDTYPEN
```xml
<!-- Texteingabe mit Grenzen -->
<option id="menge" name="Menge" type="Input" default="100" min="1" max="5000" require="true"/>
<!-- Auswahl -->
<option id="farbe" name="Farbe" type="Select" default="rot">
<opt id="rot" name="Rot"/>
<opt id="blau" name="Blau"/>
</option>
<!-- Papierdatenbank -->
<option id="papier" name="Papier" type="Select" mode="papierdb" container="container_id" default="bdm250"/>
<!-- Verstecktes Berechnungsfeld -->
<option id="calc_preis" name="Preis" type="Hidden">
<auflage>
<grenze formel="$Vmenge$V*0.15">1-</grenze>
</auflage>
</option>
<!-- Überschrift / Text / Textarea -->
<option id="h1" name="Abschnitt" type="Headline" variant="2"/>
<option id="info" name="" type="Text" default="Hinweistext"/>
<option id="notiz" name="Notiz" type="Textarea"/>
<!-- Layout: Zeile mit Spalten -->
<option id="zeile1" name="" type="Row">
<column id="col1">
<option id="breite" name="Breite mm" type="Input" default="100" min="50" max="700"/>
</column>
<column id="col2">
<option id="hoehe" name="Höhe mm" type="Input" default="100" min="50" max="700"/>
</column>
</option>
<!-- Gruppe -->
<option id="grp" name="Optionen" type="Fieldset">
<option id="f1" name="Feld" type="Input"/>
</option>
```
## GRENZEN (Wertebereiche / Preisberechnung)
KRITISCH Struktur von <grenze>:
- Das `formel`-Attribut enthält die BERECHNUNG (z.B. `$Vauflage$V*0.05`)
- Der TAG-INHALT enthält NUR den BEREICH oder WERT (z.B. `1-`, `1-100`, `501-`, `42`, `rot`)
- NIEMALS eine Formel in den Tag-Inhalt schreiben!
FALSCH: `<grenze formel="$Vauflage$V*0.05">$Vauflage$V*3</grenze>`
RICHTIG: `<grenze formel="$Vauflage$V*0.05">1-</grenze>`
Mögliche Bereichsangaben im Tag-Inhalt:
- `1-` = ab 1 (unbegrenzt oben)
- `1-100` = von 1 bis 100
- `101-500` = von 101 bis 500
- `501-` = ab 501
- `42` = genau der Wert 42
- `10,20,50` = genau diese Werte
- `rot` = der Select-Optionswert "rot"
```xml
<!-- Mengenstaffel mit Formel -->
<abcfeld>
<grenze formel="$Vabcfeld$V*1.20">1-100</grenze>
<grenze formel="$Vabcfeld$V*1.10">101-500</grenze>
<grenze formel="$Vabcfeld$V*0.95">501-</grenze>
</abcfeld>
<!-- Mit Preis und Pauschale -->
<grenze formel="$Vabcfeld$V*0.90" preis="5.00" pauschale="12.50">1-</grenze>
<!-- Statischer Wert -->
<grenze calc_value="(20)">1-</grenze>
<!-- Mehrere calc_values -->
<grenze calc_value="$PMmaschinenkosten$P" calc_value_2="$PMstundenlohn$P">1-</grenze>
```
## ABHÄNGIGKEITEN (Dependencies)
KRITISCH: Der Name des Abhängigkeits-Elements MUSS exakt die `id` eines vorhandenen Options-Feldes aus der aktuellen XML sein.
Schaue in der XML nach: `<option id="XYZ" ...>` dann heißt das Element `<XYZ>`.
NIEMALS einen selbst erfundenen Namen verwenden!
Beispiel: XML enthält `<option id="auflage" ...>` und `<option id="farbe" ...>`:
- Abhängigkeit von Auflage `<auflage>...</auflage>`
- Abhängigkeit von Farbe `<farbe>...</farbe>`
```xml
<!-- Hidden-Feld abhängig vom Select mit id="farbe" -->
<option id="aufpreis" name="Aufpreis" type="Hidden">
<farbe>
<grenze calc_value="0.00">rot</grenze>
<grenze calc_value="5.00">blau</grenze>
<grenze calc_value="8.00">gruen</grenze>
</farbe>
</option>
<!-- Hidden-Feld abhängig vom Input mit id="auflage" -->
<option id="rabatt" name="Rabatt" type="Hidden">
<auflage>
<grenze calc_value="0.00">1-99</grenze>
<grenze calc_value="0.05">100-499</grenze>
<grenze calc_value="0.10">500-</grenze>
</auflage>
</option>
<!-- Select-Optionen mit Mengenstaffel (id des Inputs = "stueck") -->
<option id="veredelung" name="Veredelung" type="Select" default="ohne">
<opt id="ohne" name="Ohne">
<stueck>
<grenze calc_value="0">1-</grenze>
</stueck>
</opt>
<opt id="glanzlack" name="Glanzlack">
<stueck>
<grenze formel="$Vstueck$V*0.08">1-500</grenze>
<grenze formel="$Vstueck$V*0.06">501-</grenze>
</stueck>
</opt>
</option>
<!-- Mehrfache Abhängigkeiten (id="format" und id="veredelung" müssen in XML existieren) -->
<option id="druck" name="Druckkosten" type="Hidden">
<grenzen>
<format>
<grenze formel="$Vstueck$V*0.10">a4</grenze>
<grenze formel="$Vstueck$V*0.18">a3</grenze>
</format>
<veredelung>
<grenze formel="$CVdruck_format$CV*1.2">glanzlack</grenze>
<grenze formel="$CVdruck_format$CV">ohne</grenze>
</veredelung>
</grenzen>
</option>
```
## FORMELVARIABLEN
KRITISCH: Variablen haben immer ein schließendes Suffix NIEMALS weglassen!
- `$Vfeld_id$V` Eingabewert eines Input/Select-Feldes
- `$CVfeld_id$CV` Berechneter Wert eines Hidden-Feldes
- `$PMparameter_name$P` Maschinenparameter
- `$Fformel_name$F` Externe Formel
- `$PPparameter_name$P` Shop-Parameter
FALSCH: `$Vmenge` / `$CVpreis` / `$PMsatz`
RICHTIG: `$Vmenge$V` / `$CVpreis$CV` / `$PMsatz$P`
KRITISCH: Verwende in Formeln und Abhängigkeiten NUR Feld-IDs die in der aktuellen XML vorhanden sind!
Beispiel: Wenn das Mengenfeld `auflage` heißt, dann `$Vauflage$V` NICHT `$Vmenge$V`.
Schaue immer zuerst in der aktuellen XML nach den vorhandenen Feld-IDs (id="...").
Hilfsfunktionen in Formeln: `round()`, `tonumber()`, `ceil()`, `floor()`
## UPLOADS
```xml
<uploads>
<upload id="druckdaten" name="Druckdaten" description="Bitte PDF hochladen">
<preflight id="Preflight-PDF"/>
</upload>
</uploads>
```
## PRECALC (Vorkalkulationen)
```xml
<precalc>
<group name="Standard">
<calc name="100 Stück"><menge>100</menge></calc>
<calc name="250 Stück"><menge>250</menge></calc>
</group>
</precalc>
```
## AUSGABE bei XML-Änderung
```xml
<?xml version="1.0" encoding="utf-8"?>
<kalkulation>...</kalkulation>
```
Dann 1 Satz: was wurde wo eingefügt/geändert.
PROMPT;
}
private function buildUserMessage(
string $message,
string $xml,
string $formulas,
string $parameter,
string $paperDb,
): string {
$xmlSection = $xml ?: '(leer)';
$formulasSection = $formulas ?: '(leer)';
$parameterSection = $parameter ?: '(leer)';
return <<<MSG
AKTUELLER ZUSTAND:
XML:
{$xmlSection}
Formeln:
{$formulasSection}
Parameter:
{$parameterSection}
AUFGABE: {$message}
MSG;
}
private function resolveBaseUrl($instance): string
{
$provider = $instance->getAiProvider();
$baseUrl = $instance->getAiBaseUrl();
return 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', '/'),
};
}
}

View File

@ -9,7 +9,7 @@ class OcrMarker
private float $x = 0.0;
private float $y = 0.0;
private float $size = 2.0; // Diameter in mm for dots, font size for text, size for triangle
private string $text = '';
private ?string $text = '';
public function getType(): string
{
@ -66,12 +66,12 @@ class OcrMarker
return $this;
}
public function getText(): string
public function getText(): ?string
{
return $this->text;
}
public function setText(string $text): self
public function setText(?string $text): self
{
$this->text = $text;
return $this;

View File

@ -13,7 +13,7 @@ class ExportPrintJob extends Base
private \PSC\Shop\OrderBundle\Model\Order\Position $position;
private \PSC\Shop\OrderBundle\Model\Order $order;
private string $domain = "";
private string $domain = '';
public function setDomain(string $domain): void
{
@ -23,21 +23,16 @@ class ExportPrintJob extends Base
public function call()
{
$domain = $this->liveUrl;
if($this->test) {
if ($this->test) {
$domain = $this->stagingUrl;
}
$this->token();
$response = $this->client->request(
'POST', $domain . 'orders', [
'headers' =>
[...$this->buildHeaders(), ...$this->buildBearerTokenHeader()]
,
"json" => $this->buildData(),
]
);
$response = $this->client->request('POST', $domain . 'orders', [
'headers' => [...$this->buildHeaders(), ...$this->buildBearerTokenHeader()],
'json' => $this->buildData(),
]);
return $response->toArray();
}
/**
@ -57,62 +52,95 @@ class ExportPrintJob extends Base
$auflage = null;
$productId = null;
foreach($infos as $info) {
if($info['name'] == 'auflage') {
foreach ($infos as $info) {
if ($info['name'] == 'auflage') {
$auflage = $info['value'];
}elseif($info['name'] == 'shipping') {
} elseif ($info['name'] == 'shipping') {
$shipping = $info['value'];
}elseif($info['name'] == 'productId') {
} elseif ($info['name'] == 'productId') {
$productId = $info['value'];
}else {
} else {
$temp[] = ['name' => $info['name'], 'value' => $info['value']];
}
}
/**
* @var \TP_Basket_Item $objPosition
*/
$objPosition = unserialize(($this->position->getData()));
* @var \TP_Basket_Item $objPosition
*/
$objPosition = unserialize($this->position->getData());
$files = [];
if(count($this->position->getUploads()) > 0) {
if (count($this->position->getUploads()) > 0) {
foreach ($this->position->getUploads() as $upload) {
$files = ['url' => 'https://' . $this->domain . '/'.$upload->getPath()];
$files = ['url' => 'https://' . $this->domain . '/' . $upload->getPath()];
}
}
if($this->position->getProduct()->getUploadProvidedFile() && $this->position->getProduct()->getUploadProvidedFile()->getUrl() != "") {
$files = ['url' => 'https://' . $this->domain . $this->position->getProduct()->getUploadProvidedFile()->getUrl()];
if (
$this->position->getProduct()->getUploadProvidedFile()
&& $this->position->getProduct()->getUploadProvidedFile()->getUrl() != ''
) {
$files = ['url' => 'https://' . $this->domain . $this->position
->getProduct()
->getUploadProvidedFile()
->getUrl()];
}
$outfile = '/data/www/old/market/steplayouter/basket/' . $this->order->getUid() . '/' . $this->position->getPos() . '/'.$this->order->getAlias() . '_' . $this->position->getPos().'.pdf';
$outfile =
'/data/www/old/market/steplayouter/basket/'
. $this->order->getUid()
. '/'
. $this->position->getPos()
. '/'
. $this->order->getAlias()
. '_'
. $this->position->getPos()
. '.pdf';
if(file_exists($outfile)) {
$files = ['url' => 'https://' . $this->domain . '/apps/market/steplayouter/basket/' . $this->order->getUid() . '/' . $this->position->getPos() . '/'.$this->order->getAlias() . '_' . $this->position->getPos().'.pdf'];
if (file_exists($outfile)) {
$files = [
'url' =>
'https://'
. $this->domain
. '/apps/market/steplayouter/basket/'
. $this->order->getUid()
. '/'
. $this->position->getPos()
. '/'
. $this->order->getAlias()
. '_'
. $this->position->getPos()
. '.pdf',
];
}
return [
'invoiceAddress' => [
'company' => $this->order->getSenderAddress()->getCompany(),
'firstName' => $this->order->getSenderAddress()->getFirstname()?: '',
'lastName' => $this->order->getSenderAddress()->getLastname()?: '',
'street1' => $this->order->getSenderAddress()->getStreet() . ' ' . $this->order->getSenderAddress()->getHouseNumber(),
'postalCode' => $this->order->getSenderAddress()->getZip(),
'city' => $this->order->getSenderAddress()->getCity(),
'phone' => (string)$this->order->getSenderAddress()->getPhone()?: '0000000',
'country' => 'DE',
],
'deliveryAddress' => [
'invoiceAddress' => [
'company' => $this->order->getSenderAddress()->getCompany(),
'firstName' => $this->order->getSenderAddress()->getFirstname() ?: '',
'lastName' => $this->order->getSenderAddress()->getLastname() ?: '',
'street1' =>
$this->order->getSenderAddress()->getStreet()
. ' '
. $this->order->getSenderAddress()->getHouseNumber(),
'postalCode' => $this->order->getSenderAddress()->getZip(),
'city' => $this->order->getSenderAddress()->getCity(),
'phone' => (string) $this->order->getSenderAddress()->getPhone() ?: '0000000',
'country' => 'DE',
],
'deliveryAddress' => [
'company' => $this->order->getDeliveryAddress()->getCompany(),
'street1' => $this->order->getDeliveryAddress()->getStreet() . ' ' . $this->order->getDeliveryAddress()->getHouseNumber(),
'street1' =>
$this->order->getDeliveryAddress()->getStreet()
. ' '
. $this->order->getDeliveryAddress()->getHouseNumber(),
'postalCode' => $this->order->getDeliveryAddress()->getZip(),
'lastName' => $this->order->getDeliveryAddress()->getLastname(),
'firstName' => $this->order->getDeliveryAddress()->getFirstname(),
'city' => $this->order->getDeliveryAddress()->getCity(),
'phone' => $this->order->getDeliveryAddress()->getPhone()?: '0000000',
'phone' => $this->order->getDeliveryAddress()->getPhone() ?: '0000000',
'country' => 'DE',
],
],
'items' => [[
'product' => [
'productId' => $productId,
@ -122,9 +150,9 @@ class ExportPrintJob extends Base
'deliveryOption' => $shipping,
'files' => $files,
'reference' => [
'text' => $this->order->getAlias() . ' ' . $this->position->getPos()
]
]]
'text' => $this->order->getAlias() . ' ' . $this->position->getPos(),
],
]],
];
}

View File

@ -89,21 +89,19 @@ const ProductForm = ({shop, pos, handleClose, handleChange}) => {
return (
<>
<div className='flex'>
<div className='flex-1'>
<div className='flex gap-6'>
<div className='flex-1 min-w-0'>
<Form schema={schema}
uiSchema={uiSchema}
formData={formData}
onChange={(e) => changeCalc(e.formData)}
validator={validator}/>
</div>
<div className=''>
<>
<h5>Netto: <Currency price={ price.allNet} /></h5>
<h5>Mwert: <Currency price={ price.allVat} /></h5>
<h4>Brutto: <Currency price={ price.allGross} /></h4>
<Button onClick={addProduct} type={3} variant={"success"} />
</>
<div className='w-48 shrink-0 border-l border-gray-200 pl-6 pt-2'>
<h5 className='text-sm text-gray-600 mb-1'>Netto: <Currency price={ price.allNet} /></h5>
<h5 className='text-sm text-gray-600 mb-1'>Mwst: <Currency price={ price.allVat} /></h5>
<h4 className='text-base font-semibold mb-4'>Brutto: <Currency price={ price.allGross} /></h4>
<Button onClick={addProduct} type={3} variant={"success"} />
</div>
</div>

View File

@ -13,7 +13,7 @@ class ShopService {
async getShops(): Promise<Shop[]> {
return await axios.get('/apps/api/shops', {
return await axios.get('/apps/api/my_shops', {
headers: {
'Authorization': 'Bearer ' + this.token.currentToken
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long