Quelltext des aktuellen Standes
<?php $this->layout('layouts/admin', [
'page_title' => 'KI-Integration – Einstellungen',
'current_admin_page' => 'ai-chat'
]); ?>
<div class="settings-page">
<?= $this->partial('ai-chat-tabs', ['active' => 'integration']) ?>
<form action="/admin/settings/ai-chat" method="POST" class="form" id="aiChatSettingsForm">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<!-- Aktivierung & Funktionen -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🤖 KI-Integration') ?></span>
</div>
<div class="detail-card-body">
<p class="text-muted" style="margin-top:0;">
<?= $this->te('Jedes Modul kann unabhängig aktiviert werden. Alle Module nutzen den konfigurierten KI-Anbieter & API-Key.') ?>
</p>
<!-- Genereller Datenschutzhinweis (Daten-Egress an externen LLM) -->
<div class="ai-privacy-box" style="margin:16px 0;">
<span class="ai-privacy-box-icon">⚠️</span>
<div>
<strong><?= $this->te('Datenschutzhinweis – welche Daten an die KI gehen') ?></strong>
<p style="margin:8px 0 6px;">
<?= $this->t('Bei aktivierter KI werden je nach Modul u. a. folgende Inhalte an den konfigurierten KI-Anbieter übertragen: Dokumentations-/Seiteninhalte, Ticket-Texte, Namen, E-Mail-Adressen, Telefonnummern, IP-/Hostnamen, sowie <strong>Inventar-/Seriennummern, Standorte und Kostendaten</strong>. Personenbezogene Freitext-Daten werden über den Anonymisierungsdienst maskiert, soweit erkannt; <strong>Inventar-/Serien-/Standort-/Kostenangaben werden bewusst <u>nicht</u> maskiert</strong>, damit die KI fachlich sinnvoll antworten kann.') ?>
</p>
<p style="margin:6px 0 0;">
<?= $this->t('<strong>Empfehlung:</strong> Betreiben Sie die KI <strong>lokal/selbst-gehostet</strong> (z. B. Ollama, LM Studio – eigene Endpoint-URL eintragen) oder schließen Sie mit dem Cloud-Anbieter einen <strong>Auftragsverarbeitungsvertrag (AVV/DPA)</strong> ab. Aktivieren Sie nur die Module, deren Daten-Weitergabe Sie bewusst akzeptieren.') ?>
</p>
</div>
</div>
<!-- Feature-Zeilen -->
<div class="ai-features-list">
<!-- Feature 0: KI-Chat -->
<div class="ai-feature-row">
<span class="ai-feature-icon-col">🤖</span>
<div class="ai-feature-body">
<div class="ai-feature-top">
<div class="ai-feature-meta">
<?= $this->t('<span class="ai-feature-name">KI-Chat</span> <span class="ai-feature-subdesc">Chat-Assistent für alle angemeldeten Benutzer – sensible Daten werden vor der Übertragung automatisch anonymisiert</span>') ?>
</div>
<label class="toggle-label ai-feature-toggle-wrap">
<input type="checkbox" name="chat_enabled" value="1"
<?= !empty($chat_enabled) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
</label>
</div>
</div>
</div>
<!-- Feature 1: WYSIWYG-Textverbesserung -->
<div class="ai-feature-row">
<span class="ai-feature-icon-col">✏️</span>
<div class="ai-feature-body">
<div class="ai-feature-top">
<div class="ai-feature-meta">
<?= $this->t('<span class="ai-feature-name">Textverbesserung im WYSIWYG-Editor</span> <span class="ai-feature-subdesc">KI-Button zum Umformulieren von markierten Texten direkt im Editor</span>') ?>
</div>
<label class="toggle-label ai-feature-toggle-wrap">
<input type="checkbox" name="editor_improve_enabled" value="1"
<?= !empty($editor_improve_enabled) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
</label>
</div>
<div class="ai-privacy-box">
<span class="ai-privacy-box-icon">⚠️</span>
<div><?= $this->t('<strong>Datenschutzhinweis:</strong> Texte aus dem Editor werden <em>nicht anonymisiert</em> an die KI-API übertragen. Empfiehlt sich ausschließlich in Kombination mit einer <strong>selbst-gehosteten KI</strong> (z. B. Ollama).') ?></div>
</div>
</div>
</div>
<!-- Feature 2: Topics-Editor KI-Assistent -->
<div class="ai-feature-row">
<span class="ai-feature-icon-col">✨</span>
<div class="ai-feature-body">
<div class="ai-feature-top">
<div class="ai-feature-meta">
<span class="ai-feature-name"><?= $this->te('KI-Assistent im Seiten-Editor') ?> <em><?= $this->te('(Topics)') ?></em></span>
<span class="ai-feature-subdesc"><?= $this->te('Inhalt per Prompt im TinyMCE-Editor erstellen, bearbeiten oder ergänzen') ?></span>
</div>
<label class="toggle-label ai-feature-toggle-wrap">
<input type="checkbox" name="topics_ai_enabled" value="1"
<?= !empty($topics_ai_enabled) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
</label>
</div>
<div class="ai-privacy-box">
<span class="ai-privacy-box-icon">⚠️</span>
<div><?= $this->t('<strong>Datenschutzhinweis:</strong> Der gesamte Editor-Inhalt (HTML) wird <em>nicht anonymisiert</em> an die KI-API übertragen. Empfiehlt sich ausschließlich in Kombination mit einer <strong>selbst-gehosteten KI</strong> (z. B. Ollama).') ?></div>
</div>
</div>
</div>
<!-- Feature 3: Ticket KI-Antwortvorschlag -->
<div class="ai-feature-row">
<span class="ai-feature-icon-col">🎉</span>
<div class="ai-feature-body">
<div class="ai-feature-top">
<div class="ai-feature-meta">
<?= $this->t('<span class="ai-feature-name"> KI-Antwortvorschlag im Ticket-Editor <span class="badge">Experimentell</span> </span> <span class="ai-feature-subdesc">KI analysiert den Ticket-Inhalt und schlägt eine passende Antwort vor</span>') ?>
</div>
<label class="toggle-label ai-feature-toggle-wrap">
<input type="checkbox" name="ticket_ai_suggestions" value="1"
<?= !empty($ticket_ai_suggestions) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
</label>
</div>
<div class="ai-privacy-box">
<span class="ai-privacy-box-icon">⚠️</span>
<div><?= $this->t('<strong>Datenschutzhinweis:</strong> Ticket-Inhalte werden <em>unverschlüsselt und nicht anonymisiert</em> an die KI-API übertragen. Empfiehlt sich ausschließlich in Kombination mit einer <strong>selbst-gehosteten KI</strong> (z. B. Ollama).') ?></div>
</div>
<div class="ai-ticket-context">
<div class="form-group">
<label for="ticket_suggestion_context"><?= $this->te('Unternehmens-Kontext für KI-Antwortvorschläge') ?></label>
<textarea id="ticket_suggestion_context" name="ticket_suggestion_context"
rows="5" class="form-control"
placeholder="<?= $this->te('Hier Informationen eintragen, die die KI kennen soll – z. B.: - Taskmanager ist für Benutzer gesperrt - Passwortrichtlinie: mind. 12 Zeichen, Sonderzeichen, Großbuchstaben - Fernwartung läuft über AnyDesk, ID bitte erfragen - VPN-Zugänge werden nur durch die IT-Abteilung eingerichtet') ?>"
><?= htmlspecialchars($ticket_suggestion_context ?? '', ENT_QUOTES) ?></textarea>
<small class="text-muted" style="margin-top:0.3rem;display:block">
<?= $this->te('Dieser Text wird als zusätzlicher Kontext in jeden KI-Antwortvorschlag eingebunden – z. B. gesperrte Tools, Passwortrichtlinien, interne Prozesse. Einfach stichpunktartig, kein Markdown.') ?>
</small>
</div>
</div>
</div>
</div>
<!-- Feature 4: Ticket-Stimmungsanalyse -->
<div class="ai-feature-row ai-feature-row--last">
<span class="ai-feature-icon-col">🙂</span>
<div class="ai-feature-body">
<div class="ai-feature-top">
<div class="ai-feature-meta">
<?= $this->t('<span class="ai-feature-name"> Stimmungsanalyse in der Ticketliste <span class="badge">Experimentell</span> </span> <span class="ai-feature-subdesc">KI bewertet die Stimmung der ersten Ticket-Nachricht und zeigt sie als Smiley in der Ticketliste</span>') ?>
</div>
<label class="toggle-label ai-feature-toggle-wrap">
<input type="checkbox" name="ticket_sentiment_enabled" value="1"
<?= !empty($ticket_sentiment_enabled) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
</label>
</div>
<div class="ai-privacy-box">
<span class="ai-privacy-box-icon">⚠️</span>
<div><?= $this->t('<strong>Datenschutzhinweis:</strong> Zur Analyse wird der Anfang der ersten Ticket-Nachricht an die KI-API übertragen (Namen/E-Mail-Adressen werden je nach Datenschutz-Modus anonymisiert). Empfiehlt sich besonders in Kombination mit einer <strong>selbst-gehosteten KI</strong> (z. B. Ollama). Die Analyse läuft im Hintergrund über den Scheduler.') ?></div>
</div>
</div>
</div>
</div><!-- /.ai-features-list -->
</div>
</div>
<!-- Anbieter & API-Keys -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🔐 KI-Anbieter & API-Keys') ?></span>
</div>
<div class="detail-card-body">
<p class="text-muted" style="margin-top:0;"><?= $this->te('API-Keys werden verschlüsselt gespeichert (AES-256-GCM).') ?></p>
<div class="form-group">
<label for="ai_provider"><?= $this->te('KI-Anbieter') ?></label>
<select id="ai_provider" name="ai_provider" class="form-control" onchange="updateModelList()">
<?php foreach ($providers as $key => $label): ?>
<option value="<?= $this->e($key) ?>"
<?= ($ai_config['provider'] ?? 'openai') === $key ? 'selected' : '' ?>>
<?= $this->e($label) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<!-- OpenAI -->
<div id="openai-section" class="provider-section" style="display:none">
<div class="form-group">
<label><?= $this->te('OpenAI API-Key') ?></label>
<?php if ($openai_key_set): ?>
<div class="api-key-set">
<span class="api-key-indicator"><?= $this->te('✓ API-Key hinterlegt') ?></span>
<label class="toggle-label" style="margin-top:0.5rem">
<input type="checkbox" name="remove_openai_key" value="1">
<span class="toggle-switch"></span>
<?= $this->te('API-Key entfernen') ?>
</label>
</div>
<?php endif; ?>
<input type="password" name="openai_api_key" class="form-control"
placeholder="<?= $openai_key_set ? $this->te('Neuen API-Key eingeben zum Ändern') : 'sk-...' ?>"
autocomplete="new-password">
<small class="text-muted" style="display:block">
<?= $this->te('API-Key von') ?> <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">platform.openai.com</a>
</small>
</div>
</div>
<!-- Anthropic -->
<div id="anthropic-section" class="provider-section" style="display:none">
<div class="form-group">
<label><?= $this->te('Anthropic API-Key') ?></label>
<?php if ($anthropic_key_set): ?>
<div class="api-key-set">
<span class="api-key-indicator"><?= $this->te('✓ API-Key hinterlegt') ?></span>
<label class="toggle-label" style="margin-top:0.5rem">
<input type="checkbox" name="remove_anthropic_key" value="1">
<span class="toggle-switch"></span>
<?= $this->te('API-Key entfernen') ?>
</label>
</div>
<?php endif; ?>
<input type="password" name="anthropic_api_key" class="form-control"
placeholder="<?= $anthropic_key_set ? 'Neuen API-Key eingeben zum Ändern' : 'sk-ant-...' ?>"
autocomplete="new-password">
<small class="text-muted" style="display:block">
<?= $this->te('API-Key von') ?> <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">console.anthropic.com</a>
</small>
</div>
</div>
<!-- DeepSeek -->
<div id="deepseek-section" class="provider-section" style="display:none">
<div class="form-group">
<label><?= $this->te('DeepSeek API-Key') ?></label>
<?php if ($deepseek_key_set): ?>
<div class="api-key-set">
<span class="api-key-indicator"><?= $this->te('✓ API-Key hinterlegt') ?></span>
<label class="toggle-label" style="margin-top:0.5rem">
<input type="checkbox" name="remove_deepseek_key" value="1">
<span class="toggle-switch"></span>
<?= $this->te('API-Key entfernen') ?>
</label>
</div>
<?php endif; ?>
<input type="password" name="deepseek_api_key" class="form-control"
placeholder="<?= $deepseek_key_set ? 'Neuen API-Key eingeben zum Ändern' : 'sk-...' ?>"
autocomplete="new-password">
<small class="text-muted" style="display:block">
<?= $this->te('API-Key von') ?> <a href="https://platform.deepseek.com/api_keys" target="_blank" rel="noopener">platform.deepseek.com</a>
</small>
</div>
</div>
<!-- Self-hosted / OpenAI-kompatibel -->
<div id="openai_compatible-section" class="provider-section" style="display:none">
<div class="form-group">
<label for="custom_base_url"><?= $this->te('Server-URL') ?></label>
<input type="url" id="custom_base_url" name="custom_base_url" class="form-control"
value="<?= $this->e($ai_config['custom_base_url'] ?? '') ?>"
placeholder="http://localhost:11434">
<small class="text-muted" style="display:block">
<?= $this->t('Basis-URL des lokalen Servers. <code>/v1/chat/completions</code> wird automatisch angehängt.<br> Beispiele: Ollama <code>http://localhost:11434</code> | LM Studio <code>http://localhost:1234</code> | vLLM <code>http://localhost:8000</code>') ?>
</small>
</div>
<div class="form-group">
<label><?= $this->te('API-Key (optional)') ?></label>
<?php if ($custom_key_set): ?>
<div class="api-key-set">
<span class="api-key-indicator"><?= $this->te('✓ API-Key hinterlegt') ?></span>
<label class="toggle-label" style="margin-top:0.5rem">
<input type="checkbox" name="remove_custom_key" value="1">
<span class="toggle-switch"></span>
<?= $this->te('API-Key entfernen') ?>
</label>
</div>
<?php endif; ?>
<input type="password" name="custom_api_key" class="form-control"
placeholder="<?= $custom_key_set ? 'Neuen Key eingeben zum Ändern' : 'Leer lassen wenn kein Key benötigt' ?>"
autocomplete="new-password">
<small class="text-muted" style="display:block"><?= $this->te('Bei lokalen Servern ohne Authentifizierung leer lassen.') ?></small>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="disable_tools" value="1"
<?= !empty($ai_config['disable_tools']) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
<?= $this->te('Tool-Calling deaktivieren') ?>
</label>
<small class="text-muted" style="display:block;margin-top:0.3rem">
<?= $this->te('Aktivieren wenn das Modell kein Function-Calling unterstützt (z.B. reine Text-Modelle). Der KI-Assistent beantwortet dann Fragen ohne Datenbankzugriff.') ?>
</small>
</div>
</div>
</div>
</div>
<!-- Datenschutz -->
<?php $privacyMode = $ai_config['privacy_mode'] ?? 'auto'; ?>
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🛡 Datenschutz-Modus') ?></span>
</div>
<div class="detail-card-body">
<p class="text-muted" style="margin-top:0;"><?= $this->te('Steuert, ob Daten vor dem Senden an die KI anonymisiert werden.') ?></p>
<div class="form-group">
<label for="privacy_mode"><?= $this->te('Anonymisierung') ?></label>
<select id="privacy_mode" name="privacy_mode" class="form-control">
<option value="auto" <?= $privacyMode === 'auto' ? 'selected' : '' ?>><?= $this->te('Automatisch (empfohlen)') ?></option>
<option value="on" <?= $privacyMode === 'on' ? 'selected' : '' ?>><?= $this->te('Immer an') ?></option>
<option value="off" <?= $privacyMode === 'off' ? 'selected' : '' ?>><?= $this->te('Immer aus') ?></option>
</select>
<small class="text-muted" style="display:block;margin-top:0.4rem">
<?= $this->t('<strong>Automatisch:</strong> bei Cloud-Anbietern (OpenAI, Anthropic, DeepSeek) <em>an</em> – Namen, IPs, Hosts und Firmen werden durch Platzhalter ersetzt, bevor sie das Haus verlassen. Bei selbst-gehosteten Modellen <em>aus</em>, da die Daten lokal bleiben.<br> <strong>Immer an</strong> maximiert den Datenschutz, senkt aber die Antwortqualität (das Modell sieht nur Platzhalter). <strong>Immer aus</strong> liefert die besten Antworten – bei Cloud-Anbietern werden dann Klartext-Daten übertragen.<br> Passwörter, API-Keys und Vault-Einträge werden in <em>jedem</em> Modus niemals an die KI gesendet.') ?>
</small>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="experimental_actions" value="1"
<?= !empty($ai_config['experimental_actions']) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
<?= $this->te('Experimentelle Aktionen erlauben') ?>
</label>
<small class="text-muted" style="display:block;margin-top:0.3rem">
<?= $this->te('Erlaubt der KI, auch riskantere Aktionen vorzuschlagen (Doku-Seiten anlegen/ändern, VM-Status, Lagerbestand, Lizenzen, Asset-Übergabe). Jede Aktion muss weiterhin manuell bestätigt werden und erfordert die passende Berechtigung.') ?>
</small>
</div>
<div class="form-group">
<label for="monthly_cost_cap_eur"><?= $this->te('Budget pro Nutzer / Monat ({cur})', ['cur' => $this->currency()]) ?></label>
<input type="number" step="0.01" min="0" id="monthly_cost_cap_eur" name="monthly_cost_cap_eur"
class="form-control" value="<?= htmlspecialchars((string)($ai_config['monthly_cost_cap_eur'] ?? '')) ?>"
placeholder="<?= $this->te('0 = unbegrenzt') ?>">
<small class="text-muted" style="display:block"><?= $this->te('Ist das Budget eines Nutzers im laufenden Monat aufgebraucht, werden weitere Chat-Anfragen blockiert (nur Cloud-Anbieter, selbst-gehostet = kostenlos).') ?></small>
</div>
<div class="form-group">
<label for="monthly_cost_cap_total_eur"><?= $this->te('Gesamt-Budget / Monat ({cur})', ['cur' => $this->currency()]) ?></label>
<input type="number" step="0.01" min="0" id="monthly_cost_cap_total_eur" name="monthly_cost_cap_total_eur"
class="form-control" value="<?= htmlspecialchars((string)($ai_config['monthly_cost_cap_total_eur'] ?? '')) ?>"
placeholder="<?= $this->te('0 = unbegrenzt') ?>">
<small class="text-muted" style="display:block"><?= $this->te('Globales Limit über alle Nutzer. 0 = unbegrenzt.') ?></small>
</div>
<div class="form-group">
<label for="ai_usd_rate"><?= $this->te('Umrechnungskurs USD → {cur}', ['cur' => $this->currency()]) ?></label>
<input type="text" inputmode="decimal" id="ai_usd_rate" name="ai_usd_rate"
class="form-control" value="<?= $this->e((string)($usd_kurs ?? '')) ?>">
<small class="text-muted" style="display:block"><?= $this->te('Die Preislisten der Anbieter stehen in USD; die Kostenschätzung rechnet damit in die Währung der Instanz um.') ?></small>
</div>
<div class="form-group">
<label for="context_window"><?= $this->te('Kontextfenster-Override (Tokens)') ?></label>
<input type="number" step="1" min="0" id="context_window" name="context_window"
class="form-control" value="<?= htmlspecialchars((string)($ai_config['context_window'] ?? '')) ?>"
placeholder="<?= $this->te('0 = automatisch') ?>">
<small class="text-muted" style="display:block"><?= $this->te('Nur nötig, wenn das automatische Limit nicht passt (z.B. ein kleines lokales Modell mit 4096 Tokens).') ?></small>
</div>
</div>
</div>
<!-- Semantische Suche -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🧮 Semantische Doku-Suche (experimentell)') ?></span>
</div>
<div class="detail-card-body">
<p class="text-muted" style="margin-top:0;"><?= $this->te('Findet Doku-Seiten nach Bedeutung statt nur nach Stichwort (Embeddings).') ?></p>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="semantic_search" value="1"
<?= !empty($ai_config['semantic_search']) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
<?= $this->te('Semantische Suche aktivieren') ?>
</label>
<small class="text-muted" style="display:block;margin-top:0.3rem">
<?= $this->t('Ergänzt die Volltextsuche um ähnlichkeitsbasierte Treffer. Erfordert ein Embedding-Modell (OpenAI oder ein lokales Modell wie <code>nomic-embed-text</code>) und eine einmalige Indizierung. Anthropic/DeepSeek bieten keine Embeddings.') ?>
</small>
</div>
<div class="form-group">
<label for="embedding_model"><?= $this->te('Embedding-Modell') ?></label>
<input type="text" id="embedding_model" name="embedding_model" class="form-control"
value="<?= htmlspecialchars((string)($ai_config['embedding_model'] ?? '')) ?>"
placeholder="<?= $this->te('z.B. text-embedding-3-small (OpenAI) oder nomic-embed-text (lokal)') ?>">
</div>
<div class="form-group">
<button type="button" class="btn btn-secondary" id="aiReindexBtn"><?= $this->te('Seiten jetzt indizieren') ?></button>
<span id="aiReindexStatus" style="margin-left:0.6rem;color:var(--gray-600)"></span>
</div>
</div>
</div>
<!-- API-Auto: Web-Suche -->
<!-- Nur sinnvoll, wenn API-Auto- UND KI-Modul aktiv sind; die Funktion
wirkt zudem nur mit Anthropic (Provider-Abhängigkeit per JS). -->
<?php if (($modules['api_auto'] ?? false) && ($modules['ai_chat'] ?? false)): ?>
<div class="detail-card" id="apiAutoWebSearchCard" style="display:none">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🌐 API-Auto: Web-Suche') ?></span>
</div>
<div class="detail-card-body">
<p class="text-muted" style="margin-top:0;"><?= $this->te('Recherchiert die API-Dokumentation im Web, wenn beim Modul „API-Auto" keine Beschreibung hochgeladen wurde.') ?></p>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="api_auto_web_search" value="1"
<?= ($ai_config['api_auto_web_search'] ?? true) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
<?= $this->te('Web-Suche im Wissens-Fallback aktivieren') ?>
</label>
<small class="text-muted" style="display:block;margin-top:0.3rem">
<?= $this->t('Gibst du beim Anlegen einer API-Auto-Verbindung nur einen Namen/eine URL (ohne Beschreibung) an, sucht die KI die offizielle API-Dokumentation im Web. <strong>Nur mit Anthropic (Claude) wirksam</strong> – bei OpenAI, DeepSeek oder lokalen Modellen wird automatisch der reine Wissens-Modus genutzt. Verursacht zusätzliche Anthropic-Kosten.') ?>
</small>
</div>
</div>
</div>
<script>
(function () {
// Karte nur einblenden, wenn als Provider Anthropic gewählt ist –
// reagiert live auf die Provider-Auswahl (noch vor dem Speichern).
var card = document.getElementById('apiAutoWebSearchCard');
var sel = document.getElementById('ai_provider');
if (!card || !sel) { return; }
function sync() { card.style.display = (sel.value === 'anthropic') ? '' : 'none'; }
sel.addEventListener('change', sync);
sync();
})();
</script>
<?php endif; ?>
<script>
(function () {
var btn = document.getElementById('aiReindexBtn');
var out = document.getElementById('aiReindexStatus');
if (!btn) return;
var csrf = (document.querySelector('input[name="_csrf"]') || {}).value || '';
var total = 0;
function run() {
var fd = new FormData(); fd.append('_csrf', csrf);
fetch('/admin/settings/ai-chat/reindex', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { out.textContent = 'Fehler: ' + d.error; btn.disabled = false; return; }
total += (d.indexed || 0);
out.textContent = total + ' indiziert, ' + (d.remaining || 0) + ' verbleibend …';
if (d.remaining > 0 && (d.indexed || 0) > 0) { run(); }
else { out.textContent = 'Fertig: ' + total + ' Seiten indiziert.'; btn.disabled = false; }
})
.catch(function () { out.textContent = 'Netzwerkfehler.'; btn.disabled = false; });
}
btn.addEventListener('click', function () { btn.disabled = true; total = 0; out.textContent = 'Indiziere …'; run(); });
})();
</script>
<!-- Website-Bot (WordPress) -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🌐 Website-Bot (WordPress)') ?></span>
</div>
<div class="detail-card-body">
<p class="text-muted" style="margin-top:0;"><?= $this->te('Ein eng begrenzter Assistent für die öffentliche Website (Bubble & Shortcode im WordPress-Plugin).') ?></p>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="bot_enabled" value="1"
<?= !empty($ai_config['bot_enabled']) ? 'checked' : '' ?>>
<span class="toggle-switch"></span>
<?= $this->te('Website-Bot aktivieren') ?>
</label>
<small class="text-muted" style="display:block;margin-top:0.3rem">
<?= $this->t('Beantwortet nur <strong>einfache allgemeine PC-/IT-Fragen</strong> und Fragen zur <strong>freigegebenen Dokumentation</strong> (Themen-Whitelist unter <a href="/admin/api">API & WordPress</a>). Keine internen Daten, keine Aktionen. Das WordPress-Plugin benötigt einen Token mit Scope <code>assistant</code>.') ?>
</small>
</div>
<div class="form-group">
<label for="bot_intro"><?= $this->te('Begrüßung / Zusatzkontext (optional)') ?></label>
<textarea id="bot_intro" name="bot_intro" class="form-control" rows="2"
placeholder="<?= $this->te('z.B. Firmenname, Tonalität, Hinweis auf das IT-Team …') ?>"><?= htmlspecialchars((string)($ai_config['bot_intro'] ?? '')) ?></textarea>
</div>
<div class="form-group">
<label for="bot_max_tokens"><?= $this->te('Maximale Antwortlänge (Tokens)') ?></label>
<input type="number" step="1" min="128" max="1500" id="bot_max_tokens" name="bot_max_tokens"
class="form-control" value="<?= htmlspecialchars((string)($ai_config['bot_max_tokens'] ?? 700)) ?>">
</div>
</div>
</div>
<!-- Modell & Limits -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('⚙ Modell & Limits') ?></span>
</div>
<div class="detail-card-body">
<div class="form-row-2">
<div class="form-group">
<label for="ai_model"><?= $this->te('Modell') ?></label>
<!-- Dropdown für OpenAI / Anthropic -->
<select id="ai_model" name="ai_model" class="form-control">
<?php foreach ($models as $providerKey => $providerModels): ?>
<?php foreach ($providerModels as $modelId => $modelLabel): ?>
<option value="<?= $this->e($modelId) ?>"
data-provider="<?= $this->e($providerKey) ?>"
<?= ($ai_config['model'] ?? '') === $modelId ? 'selected' : '' ?>>
<?= $this->e($modelLabel) ?>
</option>
<?php endforeach; ?>
<?php endforeach; ?>
</select>
<!-- Freitext für self-hosted -->
<input type="text" id="ai_model_custom" name="ai_model_custom" class="form-control"
style="display:none"
value="<?= $this->e(($ai_config['provider'] ?? '') === 'openai_compatible' ? ($ai_config['model'] ?? '') : '') ?>"
placeholder="<?= $this->te('z.B. llama3, mistral, phi3, gemma2 …') ?>">
<small class="text-muted" id="ai_model_custom_hint" style="display:none">
<?= $this->te('Modellname genau wie vom Server erwartet (Groß-/Kleinschreibung beachten).') ?>
</small>
</div>
<div class="form-group">
<label><?= $this->te('Max. Token pro Funktion') ?></label>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:0.75rem;margin-top:0.25rem">
<div>
<label for="ai_chat_max_tokens" style="font-size:0.85rem;font-weight:500;margin-bottom:0.25rem;display:block"><?= $this->te('💬 Chat') ?></label>
<input type="number" id="ai_chat_max_tokens" name="ai_chat_max_tokens" class="form-control"
value="<?= (int)($ai_config['chat_max_tokens'] ?? 1024) ?>"
min="256" max="4096" step="256">
<small class="text-muted"><?= $this->te('Max. 4096') ?></small>
</div>
<div>
<label for="ai_improve_max_tokens" style="font-size:0.85rem;font-weight:500;margin-bottom:0.25rem;display:block"><?= $this->te('✏️ Textverbesserung') ?></label>
<input type="number" id="ai_improve_max_tokens" name="ai_improve_max_tokens" class="form-control"
value="<?= (int)($ai_config['improve_max_tokens'] ?? 2048) ?>"
min="256" max="4096" step="256">
<small class="text-muted"><?= $this->te('Max. 4096') ?></small>
</div>
<div>
<label for="ai_page_content_max_tokens" style="font-size:0.85rem;font-weight:500;margin-bottom:0.25rem;display:block"><?= $this->te('📄 Seiten-Generator') ?></label>
<input type="number" id="ai_page_content_max_tokens" name="ai_page_content_max_tokens" class="form-control"
value="<?= (int)($ai_config['page_content_max_tokens'] ?? 4096) ?>"
min="256" max="8192" step="256">
<small class="text-muted"><?= $this->te('Max. 8192') ?></small>
</div>
</div>
<small class="text-muted" style="margin-top:0.5rem;display:block"><?= $this->te('Höhere Werte = längere Antworten & höhere API-Kosten. Chat niedrig halten verhindert Timeouts.') ?></small>
<!-- Globaler Fallback-Wert (wird aus dem höchsten der drei Werte berechnet) -->
<input type="hidden" id="ai_max_tokens" name="ai_max_tokens"
value="<?= (int)($ai_config['max_tokens'] ?? 1024) ?>">
</div>
</div>
<!-- Verbindungstest -->
<div class="form-group" style="margin-top:1rem">
<button type="button" class="btn btn-secondary" id="testConnectionBtn">
<?= $this->te('📊 Verbindung testen') ?>
</button>
<span id="testResult" style="margin-left:1rem;font-weight:500"></span>
</div>
</div>
</div>
<!-- Datenschutz-Hinweis -->
<div class="detail-card">
<div class="detail-card-header">
<span class="detail-card-title"><?= $this->te('🔒 Datenschutz & Anonymisierung') ?></span>
</div>
<div class="detail-card-body">
<p><?= $this->t('Folgende Daten werden <strong>automatisch anonymisiert</strong>, bevor sie die externe KI-API erreichen:') ?></p>
<ul style="margin:0.5rem 0 0 1.5rem;line-height:1.8">
<li><?= $this->t('Namen von Benutzern und Ansprechpartnern → <code>[PERSON_X]</code>') ?></li>
<li><?= $this->t('E-Mail-Adressen → <code>[EMAIL_X]</code>') ?></li>
<li><?= $this->t('IP-Adressen und Netzwerkadressen → <code>[IP_X]</code>, <code>[SUBNET_X]</code>') ?></li>
<li><?= $this->t('Server- und Hostnamen → <code>[HOST_X]</code>') ?></li>
<li><?= $this->t('Firmennamen → <code>[COMPANY_X]</code>') ?></li>
<li><?= $this->t('Vertragsbezeichnungen → <code>[CONTRACT_X]</code>') ?></li>
</ul>
<p style="margin-top:0.75rem"><?= $this->t('<strong>Niemals</strong> übertragen werden') ?>:</p>
<ul style="margin:0.5rem 0 0 1.5rem;line-height:1.8">
<li><?= $this->te('Vault-Einträge (Passwörter, Zugangsdaten)') ?></li>
<li><?= $this->te('API-Keys, Zertifikate, kryptografische Schlüssel') ?></li>
<li><?= $this->te('Audit-Log-Rohdaten') ?></li>
<li><?= $this->te('Session-Daten') ?></li>
</ul>
<p class="text-muted" style="margin-top:0.75rem">
<?= $this->te('Die KI-Antworten werden auf dem Server de-anonymisiert und erst dann dem Benutzer angezeigt. Die KI selbst sieht zu keinem Zeitpunkt echte Klarnamen, IPs oder sensible Bezeichnungen.') ?>
</p>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><?= $this->te('💾 Einstellungen speichern') ?></button>
</div>
</form>
</div>
<script>
// Modell-Daten
const allModels = <?= json_encode($models) ?>;
const currentProvider = <?= json_encode($ai_config['provider'] ?? 'openai') ?>;
const currentModel = <?= json_encode($ai_config['model'] ?? '') ?>;
function updateModelList() {
const provider = document.getElementById('ai_provider').value;
const modelSel = document.getElementById('ai_model');
const modelCustom = document.getElementById('ai_model_custom');
const modelHint = document.getElementById('ai_model_custom_hint');
const provModels = allModels[provider] || {};
if (provider === 'openai_compatible') {
// Freitext-Eingabe. Das Dropdown wird deaktiviert, damit es seinen
// Wert nicht mitsendet und den Freitext serverseitig überschreibt.
modelSel.style.display = 'none';
modelSel.disabled = true;
modelCustom.style.display = '';
modelHint.style.display = '';
} else {
modelSel.style.display = '';
modelSel.disabled = false;
modelCustom.style.display = 'none';
modelHint.style.display = 'none';
// Dropdown befüllen
modelSel.innerHTML = '';
for (const [id, label] of Object.entries(provModels)) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = label;
if (id === currentModel) opt.selected = true;
modelSel.appendChild(opt);
}
if (modelSel.options.length > 0 && !modelSel.value) {
modelSel.options[0].selected = true;
}
}
// Provider-Sections ein-/ausblenden
document.querySelectorAll('.provider-section').forEach(s => s.style.display = 'none');
const section = document.getElementById(provider + '-section');
if (section) section.style.display = '';
}
// Initial beim Laden
document.addEventListener('DOMContentLoaded', function() {
updateModelList();
// Verbindungstest
document.getElementById('testConnectionBtn').addEventListener('click', function() {
const btn = this;
const result = document.getElementById('testResult');
btn.disabled = true;
result.textContent = 'Teste...';
result.style.color = '#666';
fetch('/admin/settings/ai-chat/test', {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json())
.then(data => {
if (data.success) {
result.textContent = '✓ ' + (data.reply || 'Verbindung erfolgreich');
result.style.color = '#28a745';
} else {
result.textContent = '✗ ' + (data.error || 'Fehler');
result.style.color = '#dc3545';
}
})
.catch(() => {
result.textContent = '✗ Netzwerkfehler';
result.style.color = '#dc3545';
})
.finally(() => { btn.disabled = false; });
});
});
</script>
<style>
/* ─── Feature-Liste (modul-spezifisch: zeilige Toggle-Liste) ─────────────── */
.ai-features-list { border-top: 1px solid var(--border, var(--gray-200)); margin-top: 1rem; }
.ai-feature-row {
display: flex;
gap: 1rem;
padding: 1.1rem 0;
border-bottom: 1px solid var(--border, var(--gray-200));
transition: background .15s;
}
.ai-feature-row--last { border-bottom: none; }
.ai-feature-icon-col {
font-size: 1.55rem;
line-height: 1;
flex-shrink: 0;
width: 2rem;
text-align: center;
padding-top: .15rem;
}
.ai-feature-body { flex: 1; min-width: 0; }
.ai-feature-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.ai-feature-meta { flex: 1; }
.ai-feature-name {
display: block;
font-weight: 600;
font-size: .95rem;
color: var(--text-primary, var(--gray-900));
line-height: 1.3;
}
.ai-feature-subdesc {
display: block;
font-size: .8rem;
color: var(--text-muted, var(--gray-500));
margin-top: .2rem;
line-height: 1.4;
}
.ai-feature-toggle-wrap {
flex-shrink: 0;
margin: .1rem 0 0 !important;
}
/* ─── Datenschutz-Box (Warn-Callout, modul-spezifisch) ──────────────────── */
.ai-privacy-box {
display: flex;
gap: .6rem;
background: var(--amber-50);
border: 1px solid var(--amber-300);
border-radius: 6px;
padding: .5rem .75rem;
font-size: .8rem;
color: var(--amber-800);
line-height: 1.55;
margin-top: .65rem;
}
.ai-privacy-box-icon { flex-shrink: 0; line-height: 1.55; }
/* ─── Ticket-Kontext-Block ──────────────────────────────────────────────── */
.ai-ticket-context {
margin-top: .9rem;
padding-top: .9rem;
border-top: 1px dashed var(--border, var(--gray-200));
}
/* iOS-Style Toggle-Switches (Feature-An/Aus) – keine zentrale Komponente */
.toggle-label {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
background: var(--slate-300);
border-radius: 9999px;
transition: background .2s ease;
}
.toggle-switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: var(--color-on-accent);
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: transform .2s ease;
}
.toggle-label input[type="checkbox"]:checked + .toggle-switch {
background: var(--color-primary, var(--color-primary));
}
.toggle-label input[type="checkbox"]:checked + .toggle-switch::after {
transform: translateX(20px);
}
.toggle-label input[type="checkbox"]:focus-visible + .toggle-switch {
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), .35);
}
.toggle-label input[type="checkbox"]:disabled + .toggle-switch {
opacity: .5;
cursor: not-allowed;
}
/* API-Key-Status-Anzeige */
.api-key-set {
display: flex;
flex-direction: column;
gap: .25rem;
}
.api-key-indicator {
font-size: .85rem;
font-weight: 600;
color: var(--green-700);
}
</style>