diff --git a/config/retriex/chat-messages.yaml b/config/retriex/chat-messages.yaml index 2d7a88a..2776e08 100644 --- a/config/retriex/chat-messages.yaml +++ b/config/retriex/chat-messages.yaml @@ -30,3 +30,46 @@ parameters: storage: directory_create_failed: 'Stream job directory could not be created.' write_failed: 'Stream job could not be written.' + + frontend: + document: + title: 'AI Agent' + ui: + header_title: 'KI-Agent' + footer_disclaimer: 'powered by mitho® | RetrieX kann fehlerhafte Ausgaben machen. RetrieX verwendet alle Daten zum Trainieren seiner Modelle.' + buttons: + clear: 'Diesen Chat löschen' + send: 'Send' + abort: 'Abbrechen' + options: + aria_label: 'Chat-Anzeigeoptionen' + status_info: 'Statusinfo anzeigen' + input: + prompt_placeholder: 'Stelle eine Frage' + assistant: + loader: 'Antwort wird vorbereitet…' + aborted: '[aborted]' + history_cleared: 'History cleared.' + source_chips: + live_shop_data: 'Live-Shopdaten' + run_meta: + completed_title: 'Abgeschlossen' + interrupted_title: 'Antwort wurde unterbrochen' + completed_status: 'Status: abgeschlossen' + interrupted_status: 'Status: unterbrochen' + completed_empty_source: 'keine belastbare Datenbasis' + interrupted_empty_source: 'nicht vollständig geprüft' + pending_source_marker: 'wird geprüft' + stream: + incomplete: 'Der Antwort-Stream wurde beendet, bevor die Antwort abgeschlossen werden konnte.' + job_not_found_retry: 'Der Antwort-Job wurde nicht mehr gefunden. Bitte sende die Anfrage erneut.' + failed_retry: 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.' + interrupted_retry: 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.' + missing_retry: 'Der Antwort-Job wurde nicht gefunden. Bitte sende die Anfrage erneut.' + stale_retry: 'Der Antwort-Job liefert seit längerer Zeit keine neuen Daten. Der Stream wurde beendet.' + connection_interrupted_retry: 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.' + guards: + no_concrete_shop_response_markers: + - 'keine konkrete shop-suchanfrage erkannt' + - 'shop-suche noch nicht belastbar auflösen' + - 'shop-suche noch nicht belastbar aufloesen' diff --git a/patch_history/RETRIEX_PATCH_64_FRONTEND_CHAT_MESSAGES_CONFIG_README.md b/patch_history/RETRIEX_PATCH_64_FRONTEND_CHAT_MESSAGES_CONFIG_README.md new file mode 100644 index 0000000..0dba0e0 --- /dev/null +++ b/patch_history/RETRIEX_PATCH_64_FRONTEND_CHAT_MESSAGES_CONFIG_README.md @@ -0,0 +1,99 @@ +# RetrieX Patch 64 - Frontend Chat Messages Config + +## Ziel + +p64 erweitert den mit p63 eingeführten `chat-messages.yaml`-Schnitt auf die sichtbaren Frontend-Chattexte in `public/index.html` und `public/assets/js/base.js`. + +Ziel ist, dass die im Browser sichtbaren Chat-UI-, Loader-, Status- und Stream-Fallback-Texte nicht mehr hart in HTML/JavaScript gepflegt werden müssen, sondern zentral über `config/retriex/chat-messages.yaml` konfigurierbar sind. + +## Änderungen + +- `config/retriex/chat-messages.yaml` + - neuer Bereich `frontend` + - UI-Texte für Titel, Header, Buttons, Placeholder, Footer und Optionen + - Assistant-Hinweise wie Loader, Abbruch- und History-Cleared-Meldung + - Frontend-Stream-Fallback-Meldungen + - Run-Meta-Statuslabels + - Live-Shopdaten-Chip-Label + - konfigurierbare Marker für No-Concrete-Shop-Response-Erkennung + +- `src/Config/ChatMessagesConfig.php` + - `getFrontendMessages()` ergänzt + - Validation um Frontend-Message-Pfade erweitert + - List-Validation für konfigurierbare Guard-Marker ergänzt + +- `src/Controller/ChatMessagesController.php` + - neuer JSON-Endpunkt `/chat-messages/frontend` + - liefert den `frontend`-Teil aus `chat-messages.yaml` an das Browser-Frontend + - no-store/no-cache Header gesetzt + +- `public/assets/js/base.js` + - lädt Chat-Frontend-Messages beim Start aus `/chat-messages/frontend` + - wendet UI-Texte über `data-chat-message-*` Attribute an + - ersetzt sichtbare Loader-/Stream-/Status-/Abbruch-/History-Texte durch YAML-Werte + - No-Concrete-Shop-Response-Marker kommen aus YAML statt aus JS-Stringliteralen + +- `public/index.html` + - sichtbare harte UI-Texte entfernt + - stattdessen `data-chat-message-text`, `data-chat-message-placeholder` und `data-chat-message-aria-label` + - Dokumenttitel wird durch `base.js` aus YAML gesetzt + +## Bewusste Nicht-Ziele + +- p64 verschiebt die bereits YAML-basierten Agent-/Production-UI-Blöcke aus `agent.yaml` noch nicht nach `chat-messages.yaml`. +- p64 ändert keine RAG-, Shop-, Prompt-, Retrieval- oder Scoring-Logik. +- Technische Protokollwerte wie SSE-Eventnamen, Job-Statuswerte, Storage Keys, CSS-Klassen und Debug-/Console-Meldungen bleiben im Code. + +## Checks + +Lokal geprüft: + +```bash +php -l src/Config/ChatMessagesConfig.php +php -l src/Controller/ChatMessagesController.php +php -l src/Controller/AskSseController.php +node --check public/assets/js/base.js +python3 - <<'PY' +import yaml +for path in ['config/retriex/chat-messages.yaml', 'config/services.yaml']: + with open(path, 'r', encoding='utf-8') as f: + yaml.safe_load(f) + print(f'YAML OK: {path}') +PY +``` + +Nicht ausführbar im entpackten ZIP ohne `vendor/`: + +```bash +php bin/console mto:agent:config:validate +``` + +Fehler: + +```text +Dependencies are missing. Try running "composer install". +``` + +## Empfohlene manuelle Regression + +Nach Einspielen im echten Projekt mit Vendor-Abhängigkeiten: + +```bash +bin/console mto:agent:config:validate +bin/console mto:agent:regression:test +bin/console mto:agent:config:audit-source --details +bin/console mto:agent:config:audit-patterns --details +``` + +Browser-Check: + +1. Startseite öffnen. +2. Prüfen, ob Titel, Header, Buttons, Placeholder und Footer korrekt erscheinen. +3. Eine normale Anfrage senden und Loader/Statuskarten prüfen. +4. Stream abbrechen und Abbruchmeldung prüfen. +5. Chat löschen und History-Cleared-Meldung prüfen. +6. Einen Stream-/Reconnect-Fehlerfall prüfen, falls reproduzierbar. + +## Folgearbeit + +Falls wirklich alle chat-sichtbaren Texte in exakt einer Datei liegen sollen, sollte ein späterer Patch die bereits YAML-konfigurierten Agent-UI-Bereiche aus `agent.yaml` nach `chat-messages.yaml` verschieben und die Getter in `AgentRunnerConfig` sauber delegieren oder auf eine Message-spezifische Config-Fassade umstellen. diff --git a/public/assets/js/base.js b/public/assets/js/base.js index 11fe3a0..7ab94dc 100644 --- a/public/assets/js/base.js +++ b/public/assets/js/base.js @@ -1,4 +1,4 @@ -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { const chatEl = document.getElementById('chat'); const promptEl = document.getElementById('prompt'); const sendBtn = document.getElementById('send'); @@ -12,6 +12,106 @@ document.addEventListener('DOMContentLoaded', () => { const JOB_COMPLETION_CATCHUP_GRACE_MS = 10000; const JOB_CLIENT_STALE_GRACE_MS = 150000; + let chatMessages = {}; + + function configuredMessage(path, parameters = {}) { + const segments = String(path || '').split('.').filter(Boolean); + let current = chatMessages; + + for (const segment of segments) { + if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) { + return ''; + } + + current = current[segment]; + } + + if (typeof current !== 'string') { + return ''; + } + + return current.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { + if (!Object.prototype.hasOwnProperty.call(parameters, key)) { + return match; + } + + return String(parameters[key] ?? '').replace(/\s+/g, ' ').trim(); + }); + } + + function configuredMessageList(path) { + const segments = String(path || '').split('.').filter(Boolean); + let current = chatMessages; + + for (const segment of segments) { + if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) { + return []; + } + + current = current[segment]; + } + + return Array.isArray(current) ? current.filter((item) => typeof item === 'string' && item.trim() !== '') : []; + } + + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function configuredEmphasis(path) { + const text = configuredMessage(path); + + return text === '' ? '' : `${escapeHtml(text)}`; + } + + async function loadConfiguredChatMessages() { + try { + const response = await fetch('/chat-messages/frontend', { + method: 'GET', + cache: 'no-store', + headers: {'Accept': 'application/json'}, + }); + + if (!response.ok) { + console.error('Chat messages request failed with status:', response.status); + return; + } + + const payload = await response.json(); + chatMessages = payload && typeof payload === 'object' ? payload : {}; + } catch (err) { + console.error('Chat messages load failed:', err); + } + } + + function applyConfiguredUiMessages(root = document) { + const documentTitle = configuredMessage('document.title'); + + if (documentTitle !== '') { + document.title = documentTitle; + } + + root.querySelectorAll('[data-chat-message-text]').forEach((element) => { + element.textContent = configuredMessage(element.getAttribute('data-chat-message-text') || ''); + }); + + root.querySelectorAll('[data-chat-message-placeholder]').forEach((element) => { + element.setAttribute('placeholder', configuredMessage(element.getAttribute('data-chat-message-placeholder') || '')); + }); + + root.querySelectorAll('[data-chat-message-aria-label]').forEach((element) => { + element.setAttribute('aria-label', configuredMessage(element.getAttribute('data-chat-message-aria-label') || '')); + }); + } + + await loadConfiguredChatMessages(); + applyConfiguredUiMessages(); + const state = { abortRequested: false, isStreaming: false, @@ -118,9 +218,8 @@ document.addEventListener('DOMContentLoaded', () => { function isNoConcreteShopResponse(value) { const normalized = normalizeContextHintText(value).toLowerCase(); - return normalized.includes('keine konkrete shop-suchanfrage erkannt') - || normalized.includes('shop-suche noch nicht belastbar auflösen') - || normalized.includes('shop-suche noch nicht belastbar aufloesen'); + return configuredMessageList('guards.no_concrete_shop_response_markers') + .some((marker) => normalized.includes(marker.toLowerCase())); } function rememberCompletedTurn(userPrompt, assistantText) { @@ -278,7 +377,7 @@ document.addEventListener('DOMContentLoaded', () => { } function addLoader() { - return addMessage('assistant', 'Antwort wird vorbereitet…', 'loader'); + return addMessage('assistant', escapeHtml(configuredMessage('assistant.loader')), 'loader'); } function hasMeaningfulChildContent(element) { @@ -504,7 +603,7 @@ document.addEventListener('DOMContentLoaded', () => { let key = canonicalRetriexSourceChipKey(chip.textContent); if (key === 'shopsystem' || key === 'liveshopdaten') { - chip.textContent = 'Live-Shopdaten'; + chip.textContent = configuredMessage('source_chips.live_shop_data'); key = 'liveshopdaten'; } @@ -643,9 +742,9 @@ document.addEventListener('DOMContentLoaded', () => { } const isError = options.state === 'error'; - const titleText = isError ? 'Antwort wurde unterbrochen' : 'Abgeschlossen'; - const statusText = isError ? 'Status: unterbrochen' : 'Status: abgeschlossen'; - const emptySourceText = isError ? 'nicht vollständig geprüft' : 'keine belastbare Datenbasis'; + const titleText = isError ? configuredMessage('run_meta.interrupted_title') : configuredMessage('run_meta.completed_title'); + const statusText = isError ? configuredMessage('run_meta.interrupted_status') : configuredMessage('run_meta.completed_status'); + const emptySourceText = isError ? configuredMessage('run_meta.interrupted_empty_source') : configuredMessage('run_meta.completed_empty_source'); container.querySelectorAll('.retriex-run-meta[data-retriex-meta-id="run-status"]').forEach((card) => { card.setAttribute('data-retriex-meta-state', isError ? 'error' : 'completed'); @@ -664,7 +763,7 @@ document.addEventListener('DOMContentLoaded', () => { const emptySource = card.querySelector('.retriex-source-overview__empty'); - if (emptySource && emptySource.textContent.trim() === 'wird geprüft') { + if (emptySource && emptySource.textContent.trim() === configuredMessage('run_meta.pending_source_marker')) { emptySource.textContent = emptySourceText; } }); @@ -919,7 +1018,7 @@ document.addEventListener('DOMContentLoaded', () => { }; const completeWithJobError = (message) => { - appendError(message || 'Der Antwort-Stream wurde beendet, bevor die Antwort abgeschlossen werden konnte.'); + appendError(message || configuredMessage('stream.incomplete')); complete(); }; @@ -937,7 +1036,7 @@ document.addEventListener('DOMContentLoaded', () => { if (!res.ok) { if (res.status === 404 && Date.now() - lastClientProgressAt > JOB_CLIENT_STALE_GRACE_MS) { - completeWithJobError('Der Antwort-Job wurde nicht mehr gefunden. Bitte sende die Anfrage erneut.'); + completeWithJobError(configuredMessage('stream.job_not_found_retry')); } return; } @@ -952,17 +1051,17 @@ document.addEventListener('DOMContentLoaded', () => { } if (status === 'failed') { - completeWithJobError(message || 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.'); + completeWithJobError(message || configuredMessage('stream.failed_retry')); return; } if (status === 'interrupted') { - completeWithJobError(message || 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'); + completeWithJobError(message || configuredMessage('stream.interrupted_retry')); return; } if (status === 'missing') { - completeWithJobError(message || 'Der Antwort-Job wurde nicht gefunden. Bitte sende die Anfrage erneut.'); + completeWithJobError(message || configuredMessage('stream.missing_retry')); return; } @@ -972,7 +1071,7 @@ document.addEventListener('DOMContentLoaded', () => { const staleAfterSeconds = parsePositiveInteger(statusPayload?.runningStaleAfterSeconds); if (updatedAt > 0 && serverTime > 0 && staleAfterSeconds > 0 && serverTime - updatedAt > staleAfterSeconds + 15) { - completeWithJobError(message || 'Der Antwort-Job liefert seit längerer Zeit keine neuen Daten. Der Stream wurde beendet.'); + completeWithJobError(message || configuredMessage('stream.stale_retry')); return; } } @@ -1065,7 +1164,7 @@ document.addEventListener('DOMContentLoaded', () => { bubble.classList.remove('loader'); - const userMessage = 'Die Verbindung zum Antwort-Stream wurde unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'; + const userMessage = configuredMessage('stream.connection_interrupted_retry'); if (raw.trim() !== '') { const formattedMessage = `${userMessage}`; @@ -1094,7 +1193,7 @@ document.addEventListener('DOMContentLoaded', () => { state.completeStream?.(); await releaseStreamResources(); setBusyUi(false); - addMessage('assistant', '[aborted]'); + addMessage('assistant', configuredEmphasis('assistant.aborted')); }); clearBtn.addEventListener('click', async () => { @@ -1116,6 +1215,6 @@ document.addEventListener('DOMContentLoaded', () => { console.debug('Could not clear last completed turn:', err); } chatEl.innerHTML = ''; - addMessage('assistant', 'History cleared.'); + addMessage('assistant', configuredEmphasis('assistant.history_cleared')); }); }); diff --git a/public/index.html b/public/index.html index 72c66d4..1a23797 100644 --- a/public/index.html +++ b/public/index.html @@ -1,8 +1,8 @@ - +
-