Upload via GUI (69 Dateien)

This commit is contained in:
2026-10-23 17:22:55 +00:00
parent a03825e8e8
commit bd4a63284f
8 changed files with 201 additions and 8 deletions
+84
View File
@@ -174,7 +174,91 @@ async function adminTicketRules(body, addBtn) {
load();
}
// ─── Digitaler Assistent (Einstellungen, zusätzliche Antworten, Fragen-Log, Gleichbedeutende Wörter, Ausprobieren) ───
async function adminAssistant(body) {
const A = 'assistant.php';
const tabs = [['settings', 'Einstellungen'], ['answers', 'Zusätzliche Antworten'], ['log', 'Fragen-Log'], ['synonyms', 'Gleichbedeutende Wörter'], ['test', 'Frage ausprobieren']];
let tab = 'settings';
const logF = { result: '', period: '30', q: '', page: 1 };
const bar = h('div', { class: 'tabs wrap' }), view = h('div');
body.append(bar, view);
const paintBar = () => clear(bar).append(...tabs.map(([k, l]) => h('button', { class: 'tab' + (tab === k ? ' active' : ''), onclick: () => { tab = k; paintBar(); paint(); } }, l)));
const paint = () => ({ settings: pSettings, answers: pAnswers, log: pLog, synonyms: pSynonyms, test: pTest })[tab]();
async function pSettings() {
const o = await run(() => api(A, { query: { section: 'overview' } })); if (!o) return;
const s = o.settings, d = o.defaults;
const en = h('input', { type: 'checkbox', checked: s.enabled }), mode = h('select', {}, ...opt([['offline_only', 'Nur wenn der Live-Chat ausgeschaltet oder niemand erreichbar ist (Standard)'], ['always', 'Immer (zum Testen)']], s.mode));
const name = h('input', { value: s.name, maxlength: 60, placeholder: d.name }), greet = h('textarea', { rows: 3, maxlength: 500, placeholder: d.greeting }, s.greeting), none = h('textarea', { rows: 2, maxlength: 500, placeholder: d.noAnswer }, s.noAnswer);
const dept = h('select', {}, ...opt(named(o.departments), s.departmentId || '', '— Standardabteilung (bzw. Abteilung des gewählten Themas) —'));
const prio = h('select', {}, ...opt(named(o.priorities), s.priorityId || '', '— Standard —'));
const days = h('input', { type: 'number', min: 0, max: 3650, value: s.logDays, style: 'max-width:140px' });
const st = o.state;
clear(view).append(h('div', { class: 'panel' },
!o.dbReady ? h('div', { class: 'warnbox' }, 'Die Datenbank ist noch nicht aktuell. Bitte im Adminportal unter System-Update „Datenbank aktualisieren“ klicken.') : null,
h('p', { class: 'muted' }, 'Der Assistent beantwortet Fragen von Kunden aus der Wissensdatenbank und aus den zusätzlichen Antworten. Findet er nichts, bietet er ein Ticket an. Es wird kein externer KI-Dienst verwendet.'),
h('p', {}, 'Zustand jetzt: ', h('b', {}, !s.enabled ? 'ausgeschaltet' : st.activeNow ? 'aktiv (Kunden sehen den Assistenten)' : 'bereit, aber gerade nicht sichtbar'),
h('span', { class: 'muted' }, ` · Live-Chat ${st.liveChatEnabled ? 'an' : 'aus'} · Wissensdatenbank im Portal ${st.kbEnabled ? 'an' : 'aus'}`)),
h('label', { class: 'check' }, en, 'Digitalen Assistenten einschalten'),
h('label', {}, 'Wann ist er aktiv?'), mode, h('label', {}, 'Name des Assistenten'), name, h('label', {}, 'Begrüßung'), greet, h('label', {}, 'Text, wenn er keine Antwort findet'), none,
h('label', {}, 'Abteilung für Tickets des Assistenten'), dept, h('label', {}, 'Priorität für Tickets des Assistenten'), prio,
h('label', {}, 'Fragen-Log: Aufbewahrung in Tagen (0 = nichts speichern)'), days,
h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run(() => post(A, { action: 'save_settings', enabled: en.checked, mode: mode.value, name: name.value, greeting: greet.value, noAnswer: none.value, departmentId: numOrNull(dept.value) || 0, priorityId: numOrNull(prio.value) || 0, logDays: +days.value || 0 }), 'Gespeichert').then(pSettings) }, 'Speichern'))));
}
async function pAnswers() {
const r = await run(() => api(A, { query: { section: 'answers' } })); if (!r) return;
const add = h('button', { class: 'btn', onclick: () => edit(null) }, '+ Antwort hinzufügen');
clear(view).append(h('div', { class: 'row' }, h('span', { class: 'grow muted' }, 'Diese Antworten haben Vorrang vor der Wissensdatenbank. Der Assistent findet sie über die Wörter in der Frage und in den Stichwörtern.'), add),
table(['Frage / Thema', 'Stichwörter', 'Genutzt', 'Status', ''], r.items.map((a) => h('tr', { class: 'click', onclick: () => edit(a) }, h('td', {}, a.title, h('div', { class: 'muted' }, a.answer.slice(0, 90))), h('td', { class: 'muted' }, a.keywords), h('td', {}, a.hits + '×'), h('td', {}, a.isActive ? 'Aktiv' : 'Aus'),
h('td', {}, delBtn(() => run(async () => { await post(A, { action: 'delete_answer', id: a.id }); pAnswers(); }, 'Gelöscht')))))));
}
function edit(a, prefill) {
const title = h('input', { value: a?.title ?? prefill ?? '', maxlength: 255, placeholder: 'z. B. Wie lange dauert der Versand?' }), kw = h('input', { value: a?.keywords ?? '', maxlength: 1000, placeholder: 'z. B. Lieferzeit, Paket, Lieferung' });
const ans = h('textarea', { rows: 6, maxlength: 4000, placeholder: 'Reiner Text. Adressen mit https:// werden anklickbar.' }, a?.answer ?? ''), act = h('input', { type: 'checkbox', checked: a ? a.isActive : true });
modal(a ? 'Antwort bearbeiten' : 'Neue Antwort', h('div', {}, h('label', {}, 'Frage / Thema *'), title, h('label', {}, 'Stichwörter (optional, durch Komma getrennt)'), kw, h('label', {}, 'Antwort *'), ans, h('label', { class: 'check' }, act, 'Aktiv')),
[{ label: 'Speichern', fn: async (close) => { if (await run(() => post(A, { action: 'save_answer', id: a?.id ?? 0, title: title.value, keywords: kw.value, answer: ans.value, isActive: act.checked }), 'Gespeichert')) { close(); tab = 'answers'; paintBar(); pAnswers(); } } }]);
}
async function pLog() {
const r = await run(() => api(A, { query: { section: 'log', result: logF.result, period: logF.period, q: logF.q, page: logF.page } })); if (!r) return;
const reset = () => { logF.page = 1; pLog(); };
const tiles = h('div', { class: 'cards' }, ...Object.entries(r.filters).map(([k, label]) => h('div', { class: 'stat link' + (logF.result === k ? ' active' : ''), onclick: () => { logF.result = logF.result === k ? '' : k; reset(); } }, h('div', { class: 'n' }, r.stats[k] ?? 0), h('div', { class: 'muted' }, label))));
const sel = h('select', { onchange: (e) => { logF.result = e.target.value; reset(); } }, ...opt(Object.entries(r.filters), logF.result, 'Alle Ergebnisse'));
const per = h('select', { onchange: (e) => { logF.period = e.target.value; reset(); } }, ...opt([['today', 'Heute'], ['7', 'Letzte 7 Tage'], ['30', 'Letzte 30 Tage'], ['all', 'Gesamter Zeitraum']], logF.period));
const q = h('input', { value: logF.q, placeholder: 'Suchen…', onkeydown: (e) => { if (e.key === 'Enter') { logF.q = e.target.value; reset(); } } });
const clearBtn = h('button', { class: 'btn ghost sm', onclick: () => confirm('Das gesamte Fragen-Log löschen?') && run(async () => { await post(A, { action: 'clear_log' }); reset(); }, 'Log geleert') }, 'Log leeren');
clear(view).append(h('p', { class: 'muted' }, 'Zeigt, was Kunden den Assistenten gefragt haben und ob er eine Antwort fand. Gespeichert werden nur Frage und Ergebnis (kein Name, keine E-Mail-Adresse).'), tiles,
h('div', { class: 'row' }, sel, per, q, h('button', { class: 'btn secondary', onclick: () => { logF.q = q.value; reset(); } }, 'Filtern'), h('span', { class: 'grow' }), clearBtn),
r.top.length ? h('div', { class: 'panel' }, h('b', {}, 'Häufige Fragen ohne Antwort'), ...r.top.map((t) => h('div', { class: 'row' }, h('span', { class: 'grow' }, `${t.question} (${t.n}×)`), h('button', { class: 'btn ghost sm', onclick: () => { tab = 'answers'; paintBar(); edit(null, t.question); } }, 'Antwort dazu anlegen')))) : null,
table(['Zeit', 'Frage', 'Ergebnis', 'Antwort / Ticket', ''], r.items.map((x) => h('tr', {}, h('td', { class: 'muted' }, fmtDate(x.createdAt)), h('td', {}, x.question), h('td', {}, (r.filters[x.result] || x.result) + (x.helpful === null ? '' : x.helpful ? ' 👍' : ' 👎')), h('td', { class: 'muted' }, x.ticketNumber ? '#' + x.ticketNumber : (x.matchedTitle || '')),
h('td', {}, x.result === 'none' ? h('button', { class: 'btn ghost sm', onclick: () => { tab = 'answers'; paintBar(); edit(null, x.question); } }, 'Antwort anlegen') : null)))),
h('div', { class: 'row pager' }, h('button', { class: 'btn ghost sm', disabled: r.page <= 1, onclick: () => { logF.page--; pLog(); } }, '‹'), h('span', { class: 'muted' }, `${r.total} Einträge · Seite ${r.page} von ${r.pages}`), h('button', { class: 'btn ghost sm', disabled: r.page >= r.pages, onclick: () => { logF.page++; pLog(); } }, '›')));
}
async function pSynonyms() {
const o = await run(() => api(A, { query: { section: 'overview' } })); if (!o) return;
const t = h('textarea', { rows: 8, maxlength: 5000, placeholder: 'Zugang, Einladung, beitreten\nGeld zurück, Erstattung, Rückzahlung' }, o.settings.synonyms);
clear(view).append(h('div', { class: 'panel' }, h('p', { class: 'muted' }, 'Wörter, die Kunden und Artikel unterschiedlich nennen, aber dasselbe meinen. Eine Gruppe je Zeile, durch Komma getrennt, zum Beispiel „Zugang, Einladung, beitreten“. Einige Gruppen sind eingebaut, deine ergänzen oder überschreiben sie.'), t,
h('div', { class: 'row top' }, h('button', { class: 'btn', onclick: () => run(async () => { const r = await post(A, { action: 'save_synonyms', text: t.value }); toast(`Gespeichert (${r.groups} Gruppen)`); }) }, 'Speichern'))));
}
function pTest() {
const q = h('input', { placeholder: 'z. B. Wie setze ich mein Passwort zurück?', maxlength: 300 }), out = h('div');
const go = () => run(async () => {
const r = await post(A, { action: 'test', question: q.value });
clear(out).append(r.found ? h('div', { class: 'panel' }, h('div', { class: 'muted' }, (r.result.source === 'custom' ? 'Zusätzliche Antwort' : 'Wissensdatenbank') + ': ' + r.result.title), h('p', { style: 'white-space:pre-wrap' }, r.result.text), ...r.result.others.map((x) => h('div', { class: 'muted' }, 'Weiterer Treffer: ' + x)))
: h('div', { class: 'muted' }, 'Keine passende Antwort: Der Assistent würde anbieten, ein Ticket zu erstellen. Lege dafür eine zusätzliche Antwort an oder ergänze die Wissensdatenbank.'));
});
q.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
clear(view).append(h('div', { class: 'panel' }, h('p', { class: 'muted' }, 'Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).'), h('div', { class: 'row' }, q, h('button', { class: 'btn', onclick: go }, 'Testen')), out));
}
paintBar(); paint();
}
ADMIN.push(
{ key: 'assistant', label: 'Digitaler Assistent', perm: 'manage_assistant', custom: adminAssistant },
{ key: 'teams', label: 'Teams', perm: 'manage_teams', custom: adminTeams, add: true },
{ key: 'roles', label: 'Rollen', perm: 'manage_roles', custom: adminRoles, add: true },
{ key: 'escalations', label: 'Eskalationsregeln', perm: 'manage_escalations', custom: adminEscalations, add: true },