KI-Feinschliff, Volltextsuche über E-Mails, Prompt für die Antwort-Mail

Drei Erweiterungen, die sich server.js und lib/documents.js teilen und
deshalb zusammen liegen:

Jobsuche: Der Feinschliff (Zusatzbegriffe/Ausschluesse) laesst sich per KI
aus den eigenen Basis-Unterlagen vorschlagen. Der Rollen-Prompt ist wie die
uebrigen unter Vorlagen editierbar; gespeichert wird erst auf Klick.

Suche: Startseite und KI-Chat durchsuchen jetzt per SQLite-FTS5 auch die
E-Mail-Korrespondenz (Absender, Betreff, Text), Notizen und
Stellenbeschreibung - ein Name aus einer Mail findet die Bewerbung. Ranking
per bm25, Fundstellen mit Snippet, Feldfilter (firma:, von:), Phrasen,
Ausschluesse, Umlaut-Varianten (mueller/muller/mueller). Der Index wird von
SQL-Triggern gepflegt, weil Bewerbungen und Mails aus UI, REST-API, IMAP und
KI-Import geschrieben werden.

E-Mail: Vor dem Generieren laesst sich vorgeben, was in der Antwort stehen
soll; die Vorgabe geht als verbindliche Anweisung in den Prompt. Steht schon
ein Entwurf im Feld, ueberarbeitet die KI ihn, statt neu anzufangen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:56:41 +02:00
co-authored by Claude Opus 4.8
parent 3c7f5d7c74
commit d86e40a6ff
7 changed files with 984 additions and 77 deletions
+89 -21
View File
@@ -500,20 +500,34 @@
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Betreff</label>
<input type="text" name="subject" value="<%= /^\s*(re|aw)\s*:/i.test(e.subject || '') ? (e.subject || '') : ('Re: ' + (e.subject || '')) %>"
class="reply-subject w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Nachricht</label>
<div class="flex flex-wrap items-center gap-2">
<!-- KI-Antwort: erst sagen, was drinstehen soll, dann generieren lassen.
Ohne Vorgabe kommt nur eine allgemeine Höflichkeitsantwort heraus. -->
<div class="mb-3 rounded-md border border-purple-200 dark:border-purple-800/60 bg-purple-50/60 dark:bg-purple-900/10 p-3">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1.5">
<label for="ai-prompt-<%= e.id %>" class="flex items-center gap-1.5 text-xs font-medium text-purple-700 dark:text-purple-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
Was soll die KI antworten?
</label>
<select class="ai-reply-typ text-xs px-1.5 py-0.5 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200">
<option value="antwort">Antwort</option>
<option value="absage">Absage (andere Stelle)</option>
</select>
<button type="button" class="ai-reply-btn inline-flex items-center gap-1 text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
</div>
<textarea id="ai-prompt-<%= e.id %>" rows="2"
class="ai-reply-prompt w-full px-3 py-2 border border-purple-200 dark:border-purple-800/60 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"
placeholder="z. B. Termin am Dienstag 14 Uhr zusagen, nach dem Ablauf des Gesprächs fragen oder: kürzer und förmlicher"></textarea>
<div class="flex flex-wrap items-center justify-between gap-2 mt-2">
<p class="ai-reply-status text-xs text-gray-500 dark:text-gray-400">
Ohne Vorgabe antwortet die KI nur allgemein auf die Nachricht.
</p>
<button type="button" class="ai-reply-btn inline-flex items-center gap-1.5 shrink-0 px-3 py-1.5 text-xs font-medium rounded-md bg-purple-600 hover:bg-purple-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-email-id="<%= e.id %>" data-app-id="<%= application.id %>" data-target="reply-<%= e.id %>">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
<span class="ai-reply-label">KI-Antwort generieren</span>
</button>
</div>
</div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nachricht</label>
<textarea name="body" rows="12" required
class="reply-body w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm leading-relaxed"><%= e.reply_quote ? '\n\n' + e.reply_quote : '' %></textarea>
<% if (basisAnhaenge && basisAnhaenge.length) { %>
@@ -861,46 +875,100 @@
});
});
// Generate an AI draft reply and drop it into the reply form.
// KI-Antwort: Der Nutzer gibt vor, was drinstehen soll; die KI schreibt
// die Mail dazu. Steht schon ein Entwurf im Feld, geht er mit — dann
// überarbeitet die KI ihn entlang der neuen Vorgabe, statt ihn (und die
// eigenen Änderungen daran) wegzuwerfen.
document.querySelectorAll('.ai-reply-btn').forEach((btn) => {
const form = document.getElementById(btn.dataset.target);
const typSelect = form ? form.querySelector('.ai-reply-typ') : null;
if (!form) return;
const typSelect = form.querySelector('.ai-reply-typ');
const promptFeld = form.querySelector('.ai-reply-prompt');
const status = form.querySelector('.ai-reply-status');
const body = form.querySelector('.reply-body');
const subject = form.querySelector('.reply-subject');
const label = btn.querySelector('.ai-reply-label');
const syncLabel = () => {
if (!label || !typSelect) return;
label.textContent = typSelect.value === 'absage' ? 'KI-Absage generieren' : 'KI-Antwort generieren';
// Das zitierte Original steht unter dem Entwurf und ist nicht Teil davon.
let zitat = (body ? body.value : '').trim();
const entwurfText = () => {
let t = body ? body.value : '';
if (zitat && t.trimEnd().endsWith(zitat)) t = t.trimEnd().slice(0, -zitat.length);
return t.trim();
};
if (typSelect) { typSelect.addEventListener('change', syncLabel); syncLabel(); }
btn.addEventListener('click', async () => {
if (!form) return;
const syncLabel = () => {
if (!label) return;
const absage = typSelect && typSelect.value === 'absage';
const ueberarbeiten = entwurfText().length > 0;
label.textContent = ueberarbeiten
? 'Entwurf überarbeiten'
: (absage ? 'KI-Absage generieren' : 'KI-Antwort generieren');
};
const melde = (text, art) => {
if (!status) return;
status.textContent = text;
status.className = 'ai-reply-status text-xs ' + (
art === 'fehler' ? 'text-red-600 dark:text-red-400'
: art === 'ok' ? 'text-green-700 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400');
};
if (typSelect) typSelect.addEventListener('change', syncLabel);
if (body) body.addEventListener('input', syncLabel);
syncLabel();
const generieren = async () => {
const typ = typSelect ? typSelect.value : 'antwort';
const hinweise = promptFeld ? promptFeld.value.trim() : '';
const entwurf = entwurfText();
const prev = label ? label.textContent : '';
if (label) label.textContent = 'Generiere …';
if (label) label.textContent = entwurf ? 'Überarbeite …' : 'Generiere …';
btn.disabled = true;
melde(entwurf
? 'Die KI überarbeitet den Entwurf nach deiner Vorgabe …'
: 'Die KI schreibt die Antwort …', 'info');
try {
const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email_id: btn.dataset.emailId, typ }),
body: JSON.stringify({ email_id: btn.dataset.emailId, typ, hinweise, entwurf }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler');
const body = form.querySelector('.reply-body');
const subject = form.querySelector('.reply-subject');
if (body && data.text) {
// Generated reply on top, quoted original beneath — like a mail client.
body.value = data.text + (data.quote ? '\n\n' + data.quote : '');
if (data.quote) zitat = data.quote;
// Entwurf oben, zitiertes Original darunter — wie im Mailprogramm.
body.value = data.text + (zitat ? '\n\n' + zitat : '');
body.focus();
try { body.setSelectionRange(data.text.length, data.text.length); } catch (e) {}
}
if (subject && data.betreff) subject.value = data.betreff;
melde(hinweise
? 'Entwurf nach deiner Vorgabe erstellt prüfen, anpassen, senden.'
: 'Entwurf erstellt. Für Änderungen oben vorgeben, was anders sein soll.', 'ok');
syncLabel();
} catch (e) {
alert('KI-Antwort fehlgeschlagen: ' + e.message);
} finally {
melde('KI-Antwort fehlgeschlagen: ' + (e.message || 'Unbekannter Fehler'), 'fehler');
if (label) label.textContent = prev;
} finally {
btn.disabled = false;
syncLabel();
}
});
};
btn.addEventListener('click', generieren);
// Strg/Cmd + Enter im Prompt-Feld generiert direkt.
if (promptFeld) {
promptFeld.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
if (!btn.disabled) generieren();
}
});
}
});
// While documents are being generated, poll for completion and
+244 -38
View File
@@ -13,8 +13,15 @@
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input id="firmaSearch" type="text" placeholder="Firma suchen …" autocomplete="off"
class="w-full pl-12 pr-10 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
<input id="firmaSearch" type="search" role="searchbox" autocomplete="off" spellcheck="false"
placeholder="Suchen: Firma, Stelle, Notizen, E-Mails …"
aria-label="Bewerbungen und E-Mails durchsuchen"
aria-controls="searchResults" aria-expanded="false"
class="w-full pl-12 pr-20 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
<kbd id="firmaSearchHint"
class="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-block px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] font-sans text-gray-400 dark:text-gray-500 pointer-events-none">/</kbd>
<span id="firmaSearchSpinner"
class="hidden absolute right-10 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 animate-spin"></span>
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
@@ -43,9 +50,26 @@
</div>
</div>
<!-- Suchergebnisse (Volltext über Bewerbungen + E-Mails, /api/suche) -->
<div id="searchPanel" class="hidden bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-3 border-b border-gray-200 dark:border-gray-700">
<p id="searchSummary" class="text-sm text-gray-600 dark:text-gray-300"></p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Durchsucht Firma, Stelle, Notizen, Stellenbeschreibung und die E-Mail-Korrespondenz.
<span class="hidden sm:inline">Verfeinern: <code>firma:bosch</code>, <code>von:müller</code>, <code>"genauer wortlaut"</code>, <code>-zeitarbeit</code></span>
</p>
</div>
<ul id="searchResults" role="listbox" aria-label="Suchergebnisse" class="divide-y divide-gray-100 dark:divide-gray-700/60"></ul>
<div id="searchEmpty" class="hidden px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<p>Nichts gefunden für „<span id="searchEmptyTerm"></span>".</p>
<p class="mt-2 text-sm">Gesucht wird auch in E-Mails prüfe die Schreibweise oder suche nach einem Teilwort.</p>
</div>
<div id="searchError" class="hidden px-6 py-8 text-center text-sm text-red-600 dark:text-red-400"></div>
</div>
<!-- Upcoming appointments (from the application calendar) -->
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div id="termineCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex items-center gap-2 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
@@ -106,7 +130,7 @@
transform: rotate(90deg);
}
</style>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
<div id="listCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
<div class="md:min-w-[760px]">
<!-- Column labels (desktop only — on phones the rows become stacked cards) -->
<div class="app-head app-grid px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
@@ -170,7 +194,7 @@
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
%>
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>" data-search="<%= ((app.firma || '') + ' ' + (app.stelle || '')).toLowerCase().replace(/"/g, '') %>">
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>">
<!-- Eine Zeile pro Bewerbung -->
<div class="app-line app-grid px-6 py-3 text-sm transition-colors <%= hatDetails ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' %>">
<span class="flex items-center justify-center">
@@ -280,9 +304,6 @@
</section>
<% }); %>
</div>
<div id="searchNoResults" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700" style="display:none">
<p>Keine Firma gefunden für „<span id="searchNoResultsTerm"></span>".</p>
</div>
<% } else { %>
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
<p>Keine Bewerbungen gefunden.</p>
@@ -325,51 +346,236 @@
});
});
// ---- Live-Firmensuche: filtert die Bewerbungsliste nach Firma/Stelle ----
// ---- Volltextsuche (/api/suche): Bewerbungen + E-Mail-Korrespondenz ----
// Die Liste unten bleibt der Normalzustand; sobald gesucht wird, tritt an
// ihre Stelle die gerankte Trefferliste mit Fundstellen.
var input = document.getElementById('firmaSearch');
if (!input) return;
var clear = document.getElementById('firmaSearchClear');
var sections = qsa('.status-group');
var noResults = document.getElementById('searchNoResults');
var noResultsTerm = document.getElementById('searchNoResultsTerm');
var hint = document.getElementById('firmaSearchHint');
var spinner = document.getElementById('firmaSearchSpinner');
var panel = document.getElementById('searchPanel');
var results = document.getElementById('searchResults');
var summary = document.getElementById('searchSummary');
var empty = document.getElementById('searchEmpty');
var emptyTerm = document.getElementById('searchEmptyTerm');
var fehler = document.getElementById('searchError');
var listCard = document.getElementById('listCard');
var termineCard = document.getElementById('termineCard');
function filter() {
var q = input.value.trim().toLowerCase();
if (clear) clear.classList.toggle('hidden', !q);
var anyVisible = false;
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
var MARK_START = '\u0002'; // Fundstellen-Marker aus lib/suche.js
var MARK_END = '\u0003';
var timer = null;
var laufend = null; // AbortController der offenen Anfrage
var aktiv = -1; // per Tastatur ausgewählter Treffer
sections.forEach(function (sec) {
var rows = qsa('.app-row', sec);
var visible = 0;
rows.forEach(function (row) {
var match = !q || (row.dataset.search || '').indexOf(q) !== -1;
row.style.display = match ? '' : 'none';
if (match) visible++;
});
if (visible) anyVisible = true;
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
var countEl = sec.querySelector('[data-group-count]');
if (countEl) countEl.textContent = visible;
sec.style.display = (visible === 0 && q) ? 'none' : '';
// Snippet -> HTML: erst escapen (Mailtext ist Fremdinhalt), dann die
// Marker durch <mark> ersetzen. Andersherum wäre es eine XSS-Lücke.
function snippetHtml(s) {
return escapeHtml(s)
.split(escapeHtml(MARK_START)).join('<mark class="bg-yellow-200 dark:bg-yellow-500/40 dark:text-white rounded-sm px-0.5">')
.split(escapeHtml(MARK_END)).join('</mark>');
}
// Während der Suche passende Gruppen aufklappen, nach dem Löschen alles wieder zu.
var body = sec.querySelector('.group-body');
var chev = sec.querySelector('.group-toggle .chevron');
setOpen(body, chev, !!q && visible > 0);
function datumDe(iso) {
if (!iso) return '';
var d = new Date(iso);
return isNaN(d) ? '' : d.toLocaleDateString('de-DE');
}
// "Frau Müller <m.mueller@bosch.de>" -> "Frau Müller"
function absender(v) {
var s = String(v || '').trim();
var m = s.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
return (m ? m[1].trim() : s) || '';
}
// Woher der Treffer stammt — die Herkunft ist bei einer Mailsuche die
// eigentliche Information ("steht in der Mail von Frau Müller").
function herkunft(f) {
if (f.quelle !== 'email' || !f.email) return f.feld_label || 'Bewerbung';
var von = absender(f.email.richtung === 'out' ? f.email.an : f.email.von);
var d = datumDe(f.email.datum);
return 'E-Mail' + (von ? (f.email.richtung === 'out' ? ' an ' : ' von ') + von : '') + (d ? ', ' + d : '');
}
function trefferZeile(t, i) {
var b = t.bewerbung || {};
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.dataset.index = String(i);
var a = document.createElement('a');
a.href = t.typ === 'email' && !t.bewerbung_id ? '/postfach' : '/bewerbung/' + t.bewerbung_id;
a.className = 'block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700/50';
var kopf = document.createElement('div');
kopf.className = 'flex flex-wrap items-baseline gap-x-2 gap-y-1';
var titel = document.createElement('span');
titel.className = 'font-medium text-gray-900 dark:text-white';
titel.textContent = t.bewerbung_id ? (b.firma || '(ohne Firma)') : 'E-Mail im Postfach';
kopf.appendChild(titel);
if (b.stelle) {
var stelle = document.createElement('span');
stelle.className = 'text-sm text-gray-600 dark:text-gray-300';
stelle.textContent = '· ' + b.stelle;
kopf.appendChild(stelle);
}
if (b.status) {
var st = document.createElement('span');
st.className = 'px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
st.textContent = b.status;
kopf.appendChild(st);
}
if (b.datum) {
var dt = document.createElement('span');
dt.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap';
dt.textContent = datumDe(b.datum);
kopf.appendChild(dt);
}
a.appendChild(kopf);
(t.fundstellen || []).forEach(function (f) {
var zeile = document.createElement('p');
zeile.className = 'mt-1.5 text-sm text-gray-600 dark:text-gray-300 flex flex-wrap items-baseline gap-x-2';
var wo = document.createElement('span');
wo.className = 'shrink-0 text-xs uppercase tracking-wide ' +
(f.quelle === 'email' ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500');
wo.textContent = herkunft(f);
var text = document.createElement('span');
text.className = 'min-w-0 break-words';
text.innerHTML = snippetHtml(f.snippet); // vorher escaped, siehe snippetHtml
zeile.appendChild(wo);
zeile.appendChild(text);
a.appendChild(zeile);
});
if (noResults) {
noResults.style.display = (!anyVisible && q) ? '' : 'none';
if (noResultsTerm) noResultsTerm.textContent = input.value.trim();
li.appendChild(a);
return li;
}
function markiere(i) {
var zeilen = qsa('li', results);
if (!zeilen.length) return;
aktiv = (i + zeilen.length) % zeilen.length;
zeilen.forEach(function (li, n) {
li.classList.toggle('bg-blue-50', n === aktiv);
li.classList.toggle('dark:bg-gray-700/60', n === aktiv);
li.setAttribute('aria-selected', n === aktiv ? 'true' : 'false');
});
zeilen[aktiv].scrollIntoView({ block: 'nearest' });
}
function zeigeListe() {
panel.classList.add('hidden');
if (listCard) listCard.classList.remove('hidden');
if (termineCard) termineCard.classList.remove('hidden');
input.setAttribute('aria-expanded', 'false');
aktiv = -1;
}
function zeigeTreffer(daten) {
var treffer = daten.treffer || [];
results.innerHTML = '';
treffer.forEach(function (t, i) { results.appendChild(trefferZeile(t, i)); });
summary.textContent = treffer.length
? treffer.length + (treffer.length === 1 ? ' Treffer' : ' Treffer') + ' für „' + daten.q.trim() + '"'
: 'Keine Treffer';
empty.classList.toggle('hidden', treffer.length > 0);
if (emptyTerm) emptyTerm.textContent = daten.q.trim();
fehler.classList.add('hidden');
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
input.setAttribute('aria-expanded', 'true');
aktiv = -1;
}
function zeigeFehler(msg) {
results.innerHTML = '';
summary.textContent = '';
empty.classList.add('hidden');
fehler.textContent = msg;
fehler.classList.remove('hidden');
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
}
async function suchen(q) {
if (laufend) laufend.abort(); // veraltete Antwort darf die neue nicht überholen
laufend = new AbortController();
spinner.classList.remove('hidden');
try {
var res = await fetch('/api/suche?q=' + encodeURIComponent(q), {
headers: { Accept: 'application/json' }, signal: laufend.signal,
});
if (!res.ok) throw new Error('Die Suche ist fehlgeschlagen (' + res.status + ').');
zeigeTreffer(await res.json());
} catch (e) {
if (e.name === 'AbortError') return; // durch den nächsten Tastendruck ersetzt
zeigeFehler(e.message || 'Die Suche ist fehlgeschlagen.');
} finally {
spinner.classList.add('hidden');
}
}
input.addEventListener('input', filter);
if (clear) clear.addEventListener('click', function () {
function getippt() {
var q = input.value.trim();
clear.classList.toggle('hidden', !q);
if (hint) hint.classList.toggle('hidden', !!q);
clearTimeout(timer);
if (laufend) { laufend.abort(); laufend = null; }
spinner.classList.add('hidden');
if (q.length < MIN) { zeigeListe(); return; }
// Entprellt: erst tippen lassen, dann eine Anfrage stellen.
timer = setTimeout(function () { suchen(q); }, 180);
}
input.addEventListener('input', getippt);
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
input.value = '';
getippt();
input.blur();
return;
}
if (panel.classList.contains('hidden')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); markiere(aktiv + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); markiere(aktiv - 1); }
else if (e.key === 'Enter') {
var zeilen = qsa('li', results);
var ziel = zeilen[aktiv >= 0 ? aktiv : 0];
var link = ziel && ziel.querySelector('a');
if (link) { e.preventDefault(); link.click(); }
}
});
clear.addEventListener('click', function () {
input.value = '';
filter();
getippt();
input.focus();
});
// "/" springt ins Suchfeld — solange man nicht ohnehin schon tippt.
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
e.preventDefault();
input.focus();
input.select();
});
})();
</script>
</main>
+73 -3
View File
@@ -105,9 +105,27 @@
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">
Optional. Rollen und Technologien kommen aus deinem Lebenslauf hier ergänzt du nur Leitplanken.
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Optional. Rollen und Technologien kommen aus deinem Lebenslauf hier ergänzt du nur Leitplanken.
</p>
</div>
<button type="button" id="feinschliffKi"
<%= (!hatUnterlagen || !hatOllamaKey) ? 'disabled' : '' %>
title="<%= !hatUnterlagen ? 'Dafür fehlen Basis-Unterlagen unter Vorlagen.' : (!hatOllamaKey ? 'Dafür fehlt der Ollama-API-Schlüssel in den Einstellungen.' : 'Leitet Vorschläge aus deinen Basis-Unterlagen ab.') %>"
class="inline-flex items-center gap-2 px-3 py-1.5 shrink-0 text-sm border border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 rounded-md transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
<span id="feinschliffKiText">Per KI vorschlagen</span>
</button>
</div>
<p id="feinschliffHinweis" class="hidden text-sm mt-3"></p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3 mb-5">
Der KI-Vorschlag wertet deine <a href="/vorlagen" class="underline">Basis-Unterlagen</a> aus und
überschreibt beide Felder. Gespeichert wird er erst, wenn du unten auf „Speichern“ klickst.
</p>
<div class="grid gap-4 sm:grid-cols-2">
@@ -223,6 +241,58 @@
const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>;
if (offen(initial)) timer = setInterval(poll, 5000);
})();
// "Per KI vorschlagen": lets the model derive the two guard-rail lists from the
// user's Basis-Unterlagen. Only fills the fields — saving stays a deliberate act.
(function () {
const btn = document.getElementById('feinschliffKi');
if (!btn) return;
const label = document.getElementById('feinschliffKiText');
const hinweis = document.getElementById('feinschliffHinweis');
const zusatz = document.getElementById('zusatz_begriffe');
const ausschluesse = document.getElementById('ausschluesse');
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
const FARBE = {
info: 'text-gray-500 dark:text-gray-400',
ok: 'text-green-700 dark:text-green-400',
fehler: 'text-red-600 dark:text-red-400',
};
function melde(text, art) {
hinweis.textContent = text;
hinweis.className = 'text-sm mt-3 ' + FARBE[art];
}
btn.addEventListener('click', async function () {
btn.disabled = true;
label.textContent = 'KI denkt nach …';
melde('Die KI liest deine Basis-Unterlagen das dauert einen Moment.', 'info');
const modus = document.querySelector('input[name="modus"]:checked');
try {
const res = await fetch('/jobsuche/feinschliff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ziel_user: zielUser,
modus: modus ? modus.value : '',
staedte: document.getElementById('staedte').value,
}),
});
const data = await res.json().catch(function () { return {}; });
if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status));
zusatz.value = data.zusatz_begriffe || '';
ausschluesse.value = data.ausschluesse || '';
melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok');
} catch (e) {
melde(e.message || 'Der KI-Vorschlag ist fehlgeschlagen.', 'fehler');
} finally {
btn.disabled = false;
label.textContent = 'Per KI vorschlagen';
}
});
})();
</script>
</body>
</html>