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:
+89
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user