Add AI application assistant: Indeed import + Ollama document generation

- Browser extension (Chromium MV3) injecting a "send to tracker" button next
  to the Indeed job description; scrapes job info and posts it to a new
  /api/indeed-import endpoint (CORS-enabled), configurable tracker URL via popup.
- New "Entwurf" status. Imports create a draft and trigger background AI
  generation of tailored Anschreiben + Lebenslauf (PDF attachments) via the
  Ollama Cloud API, grounded strictly in user-provided base documents.
- Vorlagen page to manage base documents; attachments UI, generation status
  polling, regenerate and download routes on the application page.
- Schema: ort/stellenbeschreibung/quelle_url/generierung_* columns, plus
  basis_dokumente and anhaenge tables (with migrations).
- Config via .env (OLLAMA_API_KEY/OLLAMA_MODEL/OLLAMA_HOST); dependency-free
  .env loader. Dockerfile copies lib/, .dockerignore added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:21:28 +02:00
co-authored by Claude Opus 4.8
parent 6952309de9
commit fd20ca0daf
18 changed files with 1453 additions and 7 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
data
extension
indeed_example.txt
.env
.env.*
*.log
.git
.idea
.vscode
npm-debug.log*
+11
View File
@@ -0,0 +1,11 @@
# Kopiere diese Datei nach .env und trage deinen Schlüssel ein.
# .env wird NICHT eingecheckt (siehe .gitignore).
# Ollama Cloud API-Schlüssel https://ollama.com/settings/keys
OLLAMA_API_KEY=
# Optional Cloud-Modell (Standard: gpt-oss:120b)
# OLLAMA_MODEL=gpt-oss:120b
# Optional anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
# OLLAMA_HOST=https://ollama.com
+1
View File
@@ -28,6 +28,7 @@ COPY --from=builder /app/node_modules ./node_modules
# Application source
COPY package.json ./
COPY server.js ./
COPY lib ./lib
COPY views ./views
COPY public ./public
+52
View File
@@ -10,8 +10,47 @@ Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monats
- **Filterfunktion**: Filterung nach Monat und Jahr
- **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status
- **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte
- **KI-Bewerbungsassistent**: Indeed-Stellen per Browser-Erweiterung importieren,
Status „Entwurf", und automatisch zugeschnittene Bewerbungsunterlagen (Anschreiben
& Lebenslauf als PDF) auf Basis eigener Vorlagen erstellen lassen
- **Responsive Design**: Optimiert für Desktop und Mobile Geräte
## KI-Bewerbungsassistent (Indeed → Entwurf mit Unterlagen)
Ablauf:
1. **Vorlagen hinterlegen** Unter **Vorlagen** (Kopfzeile) das eigene
Standard-Anschreiben und den Lebenslauf als Text einfügen. Diese Basis-Unterlagen
sind die Faktengrundlage; die KI erfindet nichts hinzu.
2. **Stelle importieren** Mit der Browser-Erweiterung (`extension/`) auf Indeed den
Button **„An Bewerbungs-Tracker senden"** klicken.
3. **Automatische Verarbeitung** Der Tracker legt die Bewerbung mit Status
**Entwurf** an und generiert im Hintergrund über die **Ollama Cloud API**
zugeschnittene Bewerbungsunterlagen, die als PDF-Anhänge an der Bewerbung hängen.
### Konfiguration
Für die KI-Generierung wird ein Ollama-Cloud-API-Schlüssel benötigt
([ollama.com/settings/keys](https://ollama.com/settings/keys)). Am einfachsten über
eine (nicht eingecheckte) `.env`-Datei kopiere `.env.example` nach `.env`:
```bash
cp .env.example .env
# in .env eintragen:
# OLLAMA_API_KEY=...
# OLLAMA_MODEL=gpt-oss:120b # optional, Standard
# OLLAMA_HOST=https://ollama.com # optional; z. B. http://localhost:11434 für lokale Ollama
npm start
```
Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`.
Ohne Schlüssel wird die Bewerbung trotzdem als Entwurf angelegt; die Generierung
schlägt dann kontrolliert mit einem Hinweis fehl und kann später per
**„Neu generieren"** wiederholt werden.
Details zur Erweiterung: siehe [`extension/README.md`](extension/README.md).
## Technologien
- **Backend**: Node.js + Express.js
@@ -152,6 +191,19 @@ Bewerbung aktualisieren
### DELETE /api/bewerbungen/:id
Bewerbung löschen
### POST /api/indeed-import
Stelle importieren (von der Browser-Erweiterung genutzt): legt einen Entwurf an und
startet die KI-Generierung der Unterlagen. CORS ist für diesen Endpunkt aktiviert.
### GET /api/bewerbungen/:id/generierung
Generierungsstatus und aktuelle Anhänge einer Bewerbung abfragen (Polling).
### GET /api/basis-dokumente
Hinterlegte Basis-Unterlagen (Vorlagen) abrufen
### GET /anhaenge/:id/download
Generierten Anhang (PDF) herunterladen
### GET /api/bewerbungen/filter
Bewerbungen mit Filter abrufen
+50
View File
@@ -0,0 +1,50 @@
# Bewerbungs-Tracker Indeed Import (Browser-Erweiterung)
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
Bewerbungs-Tracker. Dort wird die Stelle als **Entwurf** angelegt und die KI
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
Vorlagen).
## Installation (Entwicklermodus)
1. Bewerbungs-Tracker starten (Standard: `http://localhost:3000`).
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
3. **Entwicklermodus** oben rechts aktivieren.
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
## Konfiguration
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
## Verwendung
1. Auf `indeed.com` eine Stelle öffnen.
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
Button **„An Bewerbungs-Tracker senden"**.
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
Bewerbung im Tracker.
## Voraussetzungen im Tracker
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
hinterlegen — diese dienen der KI als Faktengrundlage.
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt z. B. in der `.env`-Datei des Trackers ,
damit die KI-Generierung läuft.
## Ausgelesene Felder
| Feld | Quelle (Indeed DOM) |
|------|---------------------|
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
| Gehalt/Art | `#salaryInfoAndJobType` |
| Stellenbeschreibung | `#jobDescriptionText` |
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
Stelle per `MutationObserver` und hält den Button aktuell.
+56
View File
@@ -0,0 +1,56 @@
// Service worker: performs the cross-origin POST to the Bewerbungs-Tracker.
// Routing the request through the background worker (instead of the content
// script) keeps it independent of the Indeed page's Content-Security-Policy.
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
function getTrackerUrl() {
return new Promise((resolve) => {
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
url = url.replace(/\/+$/, ''); // strip trailing slashes
resolve(url);
});
});
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.type === 'IMPORT_JOB') {
(async () => {
try {
const base = await getTrackerUrl();
const res = await fetch(base + '/api/indeed-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message.payload),
});
let data = {};
try { data = await res.json(); } catch (_) { /* non-JSON response */ }
if (!res.ok) {
sendResponse({
ok: false,
error: (data && data.error) || `Server antwortete mit ${res.status}`,
});
return;
}
sendResponse({
ok: true,
id: data.id,
message: data.message,
openUrl: data.url ? base + data.url : null,
});
} catch (err) {
sendResponse({
ok: false,
error:
'Konnte den Bewerbungs-Tracker nicht erreichen. Läuft er, und ist die ' +
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
});
}
})();
return true; // keep the message channel open for the async response
}
});
+48
View File
@@ -0,0 +1,48 @@
/* Button injected next to the Indeed job description */
.bt-import-wrap {
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 6px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.bt-import-btn {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
background: #16a34a;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: background 0.15s ease, opacity 0.15s ease;
}
.bt-import-btn:hover { background: #15803d; }
.bt-import-btn:disabled { opacity: 0.7; cursor: default; }
.bt-import-btn svg { width: 18px; height: 18px; flex: 0 0 auto; }
.bt-import-status {
font-size: 13px;
line-height: 1.4;
}
.bt-import-status a { color: #2563eb; text-decoration: underline; }
.bt-import-status.bt-ok { color: #16a34a; }
.bt-import-status.bt-err { color: #dc2626; }
/* Spinner */
.bt-spin {
width: 16px; height: 16px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-top-color: #fff;
border-radius: 50%;
animation: bt-rotate 0.7s linear infinite;
}
@keyframes bt-rotate { to { transform: rotate(360deg); } }
+212
View File
@@ -0,0 +1,212 @@
// Content script: injects an "An Bewerbungs-Tracker senden" button next to the
// Indeed job description, scrapes the job details and forwards them to the
// tracker's import API (via the background service worker).
(function () {
'use strict';
const BTN_ID = 'bt-import-container';
// ----- Scraping ---------------------------------------------------------
function cleanText(el) {
if (!el) return '';
return (el.innerText || el.textContent || '').replace(/\s+\n/g, '\n').trim();
}
// The visible detail pane (right side on search pages, whole page on /viewjob).
function getDetailRoot() {
return (
document.querySelector('.jobsearch-JobComponent') ||
document.querySelector('#jobsearch-ViewjobPaneWrapper') ||
document.querySelector('.jobsearch-RightPane') ||
document
);
}
function getTitle(root) {
const el =
root.querySelector('[data-testid="jobsearch-JobInfoHeader-title"]') ||
root.querySelector('.jobsearch-JobInfoHeader-title');
if (!el) return '';
// Drop the trailing "- job post" / "- Stellenanzeige" label Indeed appends.
let text = cleanText(el);
text = text.replace(/\s*-\s*(job post|Stellenanzeige)\s*$/i, '').trim();
return text;
}
function getCompany(root) {
const container =
root.querySelector('[data-testid="jobsearch-CompanyInfoContainer"]') || root;
const el =
container.querySelector('[data-testid="inlineHeader-companyName"]') ||
container.querySelector('[data-company-name="true"]') ||
container.querySelector('[data-testid="company-name"]');
return cleanText(el);
}
function getLocation(root) {
const el =
root.querySelector('[data-testid="jobsearch-JobInfoHeader-companyLocation"]') ||
root.querySelector('[data-testid="inlineHeader-companyLocation"]');
return cleanText(el);
}
function getSalary(root) {
const el =
root.querySelector('#salaryInfoAndJobType') ||
root.querySelector('[data-testid="jobsearch-OtherJobDetailsContainer"]');
return cleanText(el);
}
function getDescription(root) {
const el = root.querySelector('#jobDescriptionText');
return cleanText(el);
}
// Build a stable job URL. On the search page the selected job key lives in the
// `vjk` query param; a clean /viewjob URL is nicer than the search URL.
function getSourceUrl() {
try {
const u = new URL(window.location.href);
const vjk = u.searchParams.get('vjk') || u.searchParams.get('jk');
if (vjk) return `${u.origin}/viewjob?jk=${vjk}`;
} catch (_) { /* ignore */ }
return window.location.href;
}
function scrapeJob() {
const root = getDetailRoot();
return {
firma: getCompany(root),
stelle: getTitle(root),
ort: getLocation(root),
gehalt: getSalary(root),
stellenbeschreibung: getDescription(root),
quelle_url: getSourceUrl(),
};
}
// ----- Button UI --------------------------------------------------------
const ICON_SEND =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13">' +
'</line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
function buildContainer() {
const wrap = document.createElement('div');
wrap.className = 'bt-import-wrap';
wrap.id = BTN_ID;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bt-import-btn';
btn.innerHTML = ICON_SEND + '<span>An Bewerbungs-Tracker senden</span>';
const status = document.createElement('div');
status.className = 'bt-import-status';
btn.addEventListener('click', () => onClick(btn, status));
wrap.appendChild(btn);
wrap.appendChild(status);
return wrap;
}
function setStatus(status, html, cls) {
status.className = 'bt-import-status' + (cls ? ' ' + cls : '');
status.innerHTML = html;
}
function onClick(btn, status) {
const job = scrapeJob();
if (!job.firma || !job.stelle) {
setStatus(status, 'Konnte Firma oder Stelle nicht auslesen. Bitte eine Stelle öffnen.', 'bt-err');
return;
}
btn.disabled = true;
const label = btn.querySelector('span');
const originalLabel = label ? label.textContent : '';
btn.innerHTML = '<span class="bt-spin"></span><span>Wird gesendet…</span>';
setStatus(status, '', '');
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: job }, (resp) => {
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
if (chrome.runtime.lastError) {
btn.disabled = false;
setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err');
return;
}
if (!resp || !resp.ok) {
btn.disabled = false;
setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err');
return;
}
const link = resp.openUrl
? ` <a href="${resp.openUrl}" target="_blank" rel="noopener">Entwurf öffnen</a>`
: '';
setStatus(
status,
'✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link,
'bt-ok'
);
// Re-enable after a moment so the user can send again if needed.
setTimeout(() => { btn.disabled = false; }, 1500);
});
}
// ----- Injection + SPA handling -----------------------------------------
// Insert the button just above the job description text, so it sits right
// next to the "Vollständige Stellenbeschreibung" heading.
function findAnchor() {
const heading = document.querySelector('#jobDescriptionTitleHeading');
if (heading) return { node: heading, position: 'after' };
const desc = document.querySelector('#jobDescriptionText');
if (desc) return { node: desc, position: 'before' };
const titleContainer = document.querySelector('.jobsearch-JobInfoHeader-title-container');
if (titleContainer) return { node: titleContainer, position: 'after' };
return null;
}
function inject() {
const anchor = findAnchor();
if (!anchor) return;
const existing = document.getElementById(BTN_ID);
// Re-inject if missing or detached from the current anchor's parent.
if (existing && existing.parentNode === anchor.node.parentNode) return;
if (existing) existing.remove();
const container = buildContainer();
if (anchor.position === 'after') {
anchor.node.parentNode.insertBefore(container, anchor.node.nextSibling);
} else {
anchor.node.parentNode.insertBefore(container, anchor.node);
}
}
// Indeed is a single-page app: the detail pane swaps without a full reload.
// Observe DOM changes and (debounced) re-inject / keep the button in place.
let debounce = null;
const observer = new MutationObserver(() => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(inject, 300);
});
function start() {
inject();
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.body) {
start();
} else {
document.addEventListener('DOMContentLoaded', start);
}
})();
+29
View File
@@ -0,0 +1,29 @@
{
"manifest_version": 3,
"name": "Bewerbungs-Tracker Indeed Import",
"version": "1.0.0",
"description": "Fügt auf Indeed einen Button neben der Stellenbeschreibung ein und sendet die Stelle an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
"permissions": ["storage"],
"host_permissions": [
"*://*.indeed.com/*",
"http://localhost/*",
"http://127.0.0.1/*",
"http://*/*",
"https://*/*"
],
"content_scripts": [
{
"matches": ["*://*.indeed.com/*"],
"js": ["content.js"],
"css": ["content.css"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Bewerbungs-Tracker"
}
}
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
width: 320px;
margin: 0;
padding: 16px;
color: #1f2937;
background: #fff;
}
h1 { font-size: 15px; margin: 0 0 4px; }
p.hint { font-size: 12px; color: #6b7280; margin: 0 0 12px; }
label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; }
input {
width: 100%; box-sizing: border-box;
padding: 8px 10px; font-size: 13px;
border: 1px solid #d1d5db; border-radius: 6px;
}
.row { display: flex; gap: 8px; margin-top: 12px; }
button {
flex: 1; padding: 8px 10px; font-size: 13px; font-weight: 600;
border: none; border-radius: 6px; cursor: pointer;
}
.save { background: #2563eb; color: #fff; }
.save:hover { background: #1d4ed8; }
.test { background: #e5e7eb; color: #1f2937; }
.test:hover { background: #d1d5db; }
#status { font-size: 12px; margin-top: 10px; min-height: 16px; }
.ok { color: #16a34a; }
.err { color: #dc2626; }
</style>
</head>
<body>
<h1>Bewerbungs-Tracker</h1>
<p class="hint">Adresse deines Bewerbungs-Trackers. Von hier werden importierte Indeed-Stellen verarbeitet.</p>
<label for="trackerUrl">Tracker-Adresse</label>
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
<div class="row">
<button class="save" id="saveBtn">Speichern</button>
<button class="test" id="testBtn">Verbindung testen</button>
</div>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
const input = document.getElementById('trackerUrl');
const statusEl = document.getElementById('status');
function normalize(url) {
return (url || '').trim().replace(/\/+$/, '');
}
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = cls || '';
}
// Load stored value
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
input.value = items.trackerUrl || DEFAULT_TRACKER_URL;
});
document.getElementById('saveBtn').addEventListener('click', () => {
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
chrome.storage.sync.set({ trackerUrl: url }, () => {
input.value = url;
setStatus('Gespeichert.', 'ok');
});
});
document.getElementById('testBtn').addEventListener('click', async () => {
const url = normalize(input.value) || DEFAULT_TRACKER_URL;
setStatus('Teste Verbindung…', '');
try {
const res = await fetch(url + '/api/settings', { method: 'GET' });
if (res.ok) {
setStatus('Verbindung erfolgreich ✓', 'ok');
} else {
setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
}
} catch (err) {
setStatus('Nicht erreichbar: ' + err.message, 'err');
}
});
+259
View File
@@ -0,0 +1,259 @@
// Document generation: tailors application documents to a job description with
// the help of an LLM (Ollama Cloud), then renders them to PDF.
//
// The AI *rewrites* the user's own, previously provided base documents
// (Basis-Dokumente) so the result stays grounded in real facts — it must not
// invent experience the applicant doesn't have.
const { jsPDF } = require('jspdf');
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed
// (e.g. point OLLAMA_HOST at a local Ollama instance).
const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000);
// ----- Ollama call ---------------------------------------------------------
// Returns { anschreiben: string, lebenslauf: string }.
// Throws if no API key is configured or the API call fails.
async function generateTailoredTexts({ job, basisDokumente, settings }) {
const apiKey = process.env.OLLAMA_API_KEY;
if (!apiKey) {
throw new Error(
'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' +
'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' +
'Bewerbungsunterlagen generiert werden können.'
);
}
if (!basisDokumente || basisDokumente.length === 0) {
throw new Error(
'Es sind keine Basis-Unterlagen hinterlegt. Bitte zuerst unter "Vorlagen" ' +
'mindestens ein Basis-Dokument (z. B. Anschreiben und Lebenslauf) bereitstellen.'
);
}
const bewerber = [
settings && settings.name ? `Name: ${settings.name}` : null,
settings && settings.adresse ? `Adresse: ${settings.adresse}` : null,
].filter(Boolean).join('\n') || 'Keine Angaben';
const basisText = basisDokumente
.map((d, i) => `### Basis-Dokument ${i + 1} — Typ: ${d.typ || 'Sonstiges'} (${d.name || 'ohne Titel'})\n${d.inhalt}`)
.join('\n\n');
const stelleText = [
job.stelle ? `Stellenbezeichnung: ${job.stelle}` : null,
job.firma ? `Unternehmen: ${job.firma}` : null,
job.ort ? `Ort: ${job.ort}` : null,
job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null,
job.quelle_url ? `Quelle: ${job.quelle_url}` : null,
'',
'Stellenbeschreibung:',
job.stellenbeschreibung || '(keine Beschreibung übermittelt)',
].filter((l) => l !== null).join('\n');
const system =
'Du bist ein erfahrener Bewerbungscoach und schreibst professionelle, ' +
'deutschsprachige Bewerbungsunterlagen. Du passt die BEREITGESTELLTEN ' +
'Basis-Unterlagen des Bewerbers auf eine konkrete Stellenausschreibung an. ' +
'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse ' +
'oder Berufserfahrungen. Verwende ausschließlich Informationen, die in den ' +
'Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' +
'gewichten, relevante Punkte hervorheben und auf die Stelle zuschneiden — ' +
'aber nichts hinzudichten. Schreibe natürlich, überzeugend und ohne Floskeln.';
const userPrompt =
`# Bewerberdaten\n${bewerber}\n\n` +
`# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` +
`# Zielstelle\n${stelleText}\n\n` +
`# Aufgabe\n` +
`Erstelle zwei zugeschnittene Dokumente:\n` +
`1. "anschreiben": Ein vollständiges, individuelles Anschreiben für genau diese Stelle. ` +
`Beginne mit einer passenden Anrede und schließe mit einer Grußformel und dem Namen des Bewerbers. ` +
`Betone die zur Stellenbeschreibung passenden Stärken aus den Basis-Unterlagen. ` +
`Keine Platzhalter wie [Firma] — nutze die echten Angaben. Kein Briefkopf/Adressblock, nur der Brieftext.\n` +
`2. "lebenslauf": Eine auf die Stelle zugeschnittene, gut strukturierte Fassung des Lebenslaufs ` +
`(relevante Stationen und Kompetenzen zuerst), rein auf Basis der vorhandenen Angaben. ` +
`Falls keine Lebenslauf-Informationen in den Basis-Unterlagen vorhanden sind, gib einen leeren String zurück.\n` +
`Antworte ausschließlich im geforderten JSON-Format.`;
// Structured output: ask Ollama to return exactly this JSON shape.
const format = {
type: 'object',
properties: {
anschreiben: { type: 'string' },
lebenslauf: { type: 'string' },
},
required: ['anschreiben', 'lebenslauf'],
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
let res;
try {
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: OLLAMA_MODEL,
stream: false,
format,
options: { temperature: 0.4 },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userPrompt },
],
}),
signal: controller.signal,
});
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`);
}
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`);
}
const data = await res.json();
const text = ((data && data.message && data.message.content) || '').trim();
if (!text) {
throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).');
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (e) {
// Fallback: try to extract the first JSON object; otherwise treat the
// whole answer as the cover letter so nothing is lost.
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ }
}
if (!parsed) parsed = { anschreiben: text, lebenslauf: '' };
}
return {
anschreiben: (parsed.anschreiben || '').trim(),
lebenslauf: (parsed.lebenslauf || '').trim(),
};
}
// ----- PDF rendering -------------------------------------------------------
// Renders a text document to a PDF Buffer with a simple, clean A4 layout.
function textToPdf({ title, headerLines = [], bodyText = '' }) {
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const marginX = 20;
const marginTop = 20;
const marginBottom = 20;
const maxWidth = pageWidth - marginX * 2;
let y = marginTop;
function ensureSpace(lineHeight) {
if (y + lineHeight > pageHeight - marginBottom) {
doc.addPage();
y = marginTop;
}
}
function writeBlock(text, { fontSize = 11, fontStyle = 'normal', gapAfter = 4, lineHeight } = {}) {
doc.setFont('helvetica', fontStyle);
doc.setFontSize(fontSize);
const lh = lineHeight || fontSize * 0.5; // mm per line
// Preserve intentional blank lines from the source text.
const paragraphs = String(text).split('\n');
paragraphs.forEach((para) => {
if (para.trim() === '') {
ensureSpace(lh);
y += lh;
return;
}
const lines = doc.splitTextToSize(para, maxWidth);
lines.forEach((line) => {
ensureSpace(lh);
doc.text(line, marginX, y);
y += lh;
});
});
y += gapAfter;
}
if (title) {
writeBlock(title, { fontSize: 16, fontStyle: 'bold', gapAfter: 6 });
}
if (headerLines.length) {
writeBlock(headerLines.join('\n'), { fontSize: 10, fontStyle: 'normal', gapAfter: 8 });
}
if (bodyText) {
writeBlock(bodyText, { fontSize: 11, fontStyle: 'normal', gapAfter: 4, lineHeight: 6 });
}
const arrayBuffer = doc.output('arraybuffer');
return Buffer.from(arrayBuffer);
}
// Build the list of attachment documents (PDF buffers) for a job.
// Returns [{ name, filename, mime, buffer }].
async function generateApplicationDocuments({ job, basisDokumente, settings }) {
const { anschreiben, lebenslauf } = await generateTailoredTexts({ job, basisDokumente, settings });
const today = new Date().toLocaleDateString('de-DE');
const bewerberName = (settings && settings.name) || '';
const bewerberAdresse = (settings && settings.adresse) || '';
const safe = (s) => String(s || '').replace(/[^a-zA-Z0-9äöüÄÖÜß _-]/g, '').replace(/\s+/g, '_').slice(0, 60) || 'Bewerbung';
const suffix = safe(job.firma || job.stelle || 'Bewerbung');
const documents = [];
if (anschreiben) {
const headerLines = [
bewerberName,
bewerberAdresse,
'',
job.firma || '',
job.ort || '',
'',
today,
'',
`Bewerbung als ${job.stelle || ''}`.trim(),
].filter((l) => l !== null);
documents.push({
name: `Anschreiben ${job.stelle || job.firma || ''}`.trim(),
filename: `Anschreiben_${suffix}.pdf`,
mime: 'application/pdf',
buffer: textToPdf({ title: '', headerLines, bodyText: anschreiben }),
});
}
if (lebenslauf) {
const headerLines = [bewerberName, bewerberAdresse].filter(Boolean);
documents.push({
name: `Lebenslauf ${job.stelle || job.firma || ''}`.trim(),
filename: `Lebenslauf_${suffix}.pdf`,
mime: 'application/pdf',
buffer: textToPdf({ title: 'Lebenslauf', headerLines, bodyText: lebenslauf }),
});
}
if (documents.length === 0) {
throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.');
}
return documents;
}
module.exports = { generateApplicationDocuments, generateTailoredTexts, textToPdf };
+2 -1
View File
@@ -353,10 +353,11 @@ function generatePdfDocument(settings, applications, month, year) {
yPos += 6;
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Einstellung', 'Absage', 'Keine Rückmeldung'];
// Accent colour per status (RGB) reused by the chart and the list headings
const statusColors = {
'Entwurf': [168, 85, 247],
'Gesendet': [59, 130, 246],
'Eingangsbestätigung': [14, 165, 233],
'Vorstellungsgespräch': [245, 158, 11],
+334 -4
View File
@@ -3,6 +3,31 @@ const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
// (git-ignored) .env file into process.env without overwriting existing vars.
(function loadEnv() {
try {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key && !(key in process.env)) process.env[key] = val;
}
} catch (e) {
console.warn('Konnte .env nicht laden:', e.message);
}
})();
const { generateApplicationDocuments } = require('./lib/documents');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -13,15 +38,27 @@ const ART_OPTIONS = [
'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung'
];
// Base document types the user can provide as a foundation for AI tailoring
const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges'];
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// Allow the browser extension (running on indeed.com) to call the import API.
// Kept narrow: only the extension-facing endpoints need cross-origin access.
app.use('/api/indeed-import', (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
@@ -32,6 +69,12 @@ if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Directory for generated attachment files (application documents)
const anhaengeDir = path.join(dataDir, 'anhaenge');
if (!fs.existsSync(anhaengeDir)) {
fs.mkdirSync(anhaengeDir, { recursive: true });
}
// Database setup
const dbPath = path.join(dataDir, 'bewerbungen.db');
const db = new sqlite3.Database(dbPath);
@@ -96,6 +139,51 @@ async function attachVerlauf(applications) {
return applications;
}
// Run the AI document generation for one application (async, fire-and-forget).
// Loads the base documents + user settings, asks the LLM to tailor them to the
// job, writes the resulting PDFs to disk and links them as attachments.
async function runGeneration(bewerbungId) {
try {
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
if (!bewerbung) return;
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
const documents = await generateApplicationDocuments({
job: {
firma: bewerbung.firma,
stelle: bewerbung.stelle,
ort: bewerbung.ort,
quelle_url: bewerbung.quelle_url,
stellenbeschreibung: bewerbung.stellenbeschreibung,
},
basisDokumente,
settings,
});
for (const doc of documents) {
const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`;
fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer);
await dbRun(
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
[bewerbungId, doc.name, doc.filename, doc.mime, stored]
);
}
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[bewerbungId]
);
console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`);
} catch (error) {
console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message);
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[String(error.message || 'Unbekannter Fehler'), bewerbungId]
).catch(() => {});
}
}
// Initialize database - create tables and default settings in one operation
function initializeDatabase() {
return new Promise((resolve, reject) => {
@@ -119,8 +207,13 @@ function initializeDatabase() {
`, (err) => {
if (err) return reject(err);
// Migration: add interne_notizen to pre-existing databases (ignore "duplicate column")
// Migration: add columns to pre-existing databases (ignore "duplicate column")
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => {
db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => {
db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => {
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => {
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => {
// Chronological status changes, each with an optional comment
db.run(`
@@ -136,6 +229,33 @@ function initializeDatabase() {
`, (err) => {
if (err) return reject(err);
// Base documents (Basis-Unterlagen) — the foundation the AI tailors from
db.run(`
CREATE TABLE IF NOT EXISTS basis_dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT,
typ TEXT,
name TEXT,
inhalt TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) return reject(err);
// Generated attachment files linked to an application
db.run(`
CREATE TABLE IF NOT EXISTS anhaenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bewerbung_id INTEGER NOT NULL,
name TEXT,
dateiname TEXT NOT NULL,
mime TEXT,
pfad TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`, (err) => {
if (err) return reject(err);
db.run(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -164,6 +284,13 @@ function initializeDatabase() {
}
});
});
});
});
});
});
});
});
});
});
});
});
@@ -280,6 +407,88 @@ initializeDatabase().then(() => {
}
});
// ----- Indeed import (called by the browser extension) -----
app.post('/api/indeed-import', async (req, res) => {
try {
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url } = req.body || {};
if (!firma || !stelle) {
return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' });
}
const datum = new Date().toISOString().split('T')[0];
// Keep the extra details (location, salary, source) visible in the notes too.
const notizParts = [
ort ? `Ort: ${ort}` : null,
gehalt ? `Gehalt: ${gehalt}` : null,
quelle_url ? `Quelle: ${quelle_url}` : null,
].filter(Boolean);
const notizen = notizParts.join('\n');
// Stored raw: every view renders these through EJS `<%= %>` (auto-escaped),
// so this is XSS-safe — and it keeps the text clean for the AI and the PDFs
// (no HTML entities leaking into the generated documents).
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`,
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
);
// Record the initial "Entwurf" status in the timeline
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
);
// Kick off AI document generation in the background — respond immediately.
runGeneration(result.lastID);
res.json({
success: true,
id: result.lastID,
url: `/bewerbung/${result.lastID}`,
message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.',
});
} catch (error) {
console.error('Error importing job:', error);
res.status(500).json({ error: 'Serverfehler beim Import.' });
}
});
// Poll generation status + current attachments for one application
app.get('/api/bewerbungen/:id/generierung', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet(
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id]
);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id]
);
res.json({
status: bewerbung.generierung_status,
fehler: bewerbung.generierung_fehler,
anhaenge,
});
} catch (error) {
console.error('Error fetching generation status:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Base documents (Basis-Unterlagen / Vorlagen) -----
app.get('/api/basis-dokumente', async (req, res) => {
try {
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
res.json(docs);
} catch (error) {
console.error('Error listing base documents:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Create application
app.post('/api/bewerbungen', async (req, res) => {
try {
@@ -385,9 +594,17 @@ initializeDatabase().then(() => {
[id]
);
const anhaenge = await dbAll(
'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
);
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
res.render('bewerbung', {
application,
verlauf,
anhaenge,
basisCount: basisCountRow ? basisCountRow.count : 0,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
hideSettings: true
@@ -471,6 +688,119 @@ initializeDatabase().then(() => {
}
});
// ----- Vorlagen (Basis-Unterlagen) management page -----
app.get('/vorlagen', async (req, res) => {
try {
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
res.render('vorlagen', {
basisDokumente,
basisTypOptions: BASIS_TYP_OPTIONS,
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
hideSettings: true,
});
} catch (error) {
console.error('Error loading vorlagen:', error);
res.status(500).send('Serverfehler');
}
});
// Add a base document
app.post('/vorlagen', async (req, res) => {
try {
const { typ, name, inhalt } = req.body;
if (inhalt && inhalt.trim()) {
await dbRun(
'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)',
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt]
);
}
res.redirect('/vorlagen');
} catch (error) {
console.error('Error adding base document:', error);
res.status(500).send('Serverfehler');
}
});
// Update a base document
app.post('/vorlagen/:id', async (req, res) => {
try {
const { id } = req.params;
const { typ, name, inhalt } = req.body;
await dbRun(
'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?',
[sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id]
);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error updating base document:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a base document
app.post('/vorlagen/:id/delete', async (req, res) => {
try {
await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]);
res.redirect('/vorlagen');
} catch (error) {
console.error('Error deleting base document:', error);
res.status(500).send('Serverfehler');
}
});
// Download a generated attachment
app.get('/anhaenge/:id/download', async (req, res) => {
try {
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]);
if (!anhang) return res.status(404).send('Anhang nicht gefunden');
const filePath = path.join(anhaengeDir, anhang.pfad);
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
res.download(filePath, anhang.dateiname);
} catch (error) {
console.error('Error downloading attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a generated attachment
app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => {
try {
const { id, anhangId } = req.params;
const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]);
if (anhang) {
const filePath = path.join(anhaengeDir, anhang.pfad);
fs.promises.unlink(filePath).catch(() => {});
await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error deleting attachment:', error);
res.status(500).send('Serverfehler');
}
});
// Re-run the AI generation for an application (removes old generated files first)
app.post('/bewerbung/:id/regenerate', async (req, res) => {
try {
const { id } = req.params;
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
for (const a of alte) {
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
}
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]);
runGeneration(id);
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error regenerating documents:', error);
res.status(500).send('Serverfehler');
}
});
// Start server
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
+126
View File
@@ -8,6 +8,7 @@
<%
const statusColor = {
'Entwurf': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
'Gesendet': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
'Eingangsbestätigung': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
@@ -93,6 +94,113 @@
</form>
</section>
<!-- Bewerbungsunterlagen (KI-generiert) -->
<% const genStatus = application.generierung_status; %>
<% if (genStatus || (anhaenge && anhaenge.length) || (application.stellenbeschreibung && application.stellenbeschreibung.trim())) { %>
<section id="unterlagen" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"
data-status="<%= genStatus || '' %>">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungsunterlagen</h2>
<form action="/bewerbung/<%= application.id %>/regenerate" method="POST"
onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');">
<button type="submit"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Neu generieren
</button>
</form>
</div>
<!-- Generation status banner -->
<div id="genBanner">
<% if (genStatus === 'ausstehend') { %>
<div class="flex items-center gap-3 rounded-lg bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="text-sm text-blue-800 dark:text-blue-200">Die KI erstellt gerade individuelle Bewerbungsunterlagen … Diese Seite aktualisiert sich automatisch.</span>
</div>
<% } else if (genStatus === 'fehler') { %>
<div class="rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 mb-4">
<div class="flex items-center gap-2 mb-1">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span class="text-sm font-semibold text-red-800 dark:text-red-200">Generierung fehlgeschlagen</span>
</div>
<p class="text-sm text-red-700 dark:text-red-300 ml-7"><%= application.generierung_fehler || 'Unbekannter Fehler.' %></p>
</div>
<% } else if (genStatus === 'fertig') { %>
<div class="flex items-center gap-2 rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-sm text-green-800 dark:text-green-200">Unterlagen wurden erstellt. Bitte vor dem Versand prüfen und ggf. anpassen.</span>
</div>
<% } %>
</div>
<!-- Attachments -->
<div id="anhaengeListe">
<% if (anhaenge && anhaenge.length) { %>
<ul class="space-y-2">
<% anhaenge.forEach(a => { %>
<li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-6 h-6 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= a.name || a.dateiname %></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= a.dateiname %></p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<a href="/anhaenge/<%= a.id %>/download"
class="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download
</a>
<form action="/bewerbung/<%= application.id %>/anhaenge/<%= a.id %>/delete" method="POST"
onsubmit="return confirm('Diesen Anhang löschen?');">
<button type="submit" class="text-red-500 hover:text-red-700 dark:text-red-400" aria-label="Anhang löschen">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</form>
</div>
</li>
<% }); %>
</ul>
<% } else if (genStatus !== 'ausstehend') { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine generierten Unterlagen vorhanden.</p>
<% } %>
</div>
<!-- Job description from the import -->
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
<summary class="cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300">
Importierte Stellenbeschreibung anzeigen
</summary>
<div class="px-4 pb-4">
<% if (application.quelle_url) { %>
<a href="<%= application.quelle_url %>" target="_blank" rel="noopener"
class="inline-block mb-3 text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"><%= application.quelle_url %></a>
<% } %>
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= application.stellenbeschreibung %></p>
</div>
</details>
<% } %>
</section>
<% } %>
<!-- Status timeline -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Status-Verlauf</h2>
@@ -214,6 +322,24 @@
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// While documents are being generated, poll for completion and
// reload the page once the status changes.
const unterlagen = document.getElementById('unterlagen');
if (unterlagen && unterlagen.dataset.status === 'ausstehend') {
const appId = <%= application.id %>;
const poll = setInterval(async () => {
try {
const res = await fetch('/api/bewerbungen/' + appId + '/generierung', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data.status && data.status !== 'ausstehend') {
clearInterval(poll);
window.location.reload();
}
} catch (e) { /* keep polling */ }
}, 4000);
}
})();
</script>
</body>
+3 -1
View File
@@ -144,9 +144,10 @@
<% if (applications.length > 0) { %>
<%
// Status-Reihenfolge synchron zu public/js/main.js halten
const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Einstellung', 'Absage', 'Keine Rückmeldung'];
const statusDot = {
'Entwurf': 'bg-purple-500',
'Gesendet': 'bg-indigo-500',
'Eingangsbestätigung': 'bg-blue-500',
'Vorstellungsgespräch': 'bg-yellow-500',
@@ -204,6 +205,7 @@
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
<%= app.status || '-' %>
</span>
+11 -1
View File
@@ -9,8 +9,18 @@
</a>
<div class="flex items-center space-x-4">
<!-- Vorlagen (base documents) link -->
<a href="/vorlagen"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Basis-Unterlagen für die KI-Generierung verwalten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span class="hidden sm:inline">Vorlagen</span>
</a>
<!-- Dark mode toggle -->
<button
<button
id="darkModeToggle"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
aria-label="Dark Mode umschalten"
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header', { hideSettings: true }) %>
<main class="container mx-auto px-4 py-8 max-w-3xl">
<!-- Back link -->
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zur Übersicht
</a>
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
werden daraus automatisch zugeschnittene Bewerbungsunterlagen erstellt es werden ausschließlich die
hier hinterlegten Informationen verwendet, es wird nichts hinzuerfunden. Füge z. B. dein
Standard-Anschreiben und deinen Lebenslauf als Text ein.
</p>
<!-- API key warning -->
<% if (!hasApiKey) { %>
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-4 py-3 mb-6">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div class="text-sm text-amber-800 dark:text-amber-200">
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
<p>Setze die Umgebungsvariable <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">OLLAMA_API_KEY</code>
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
</div>
</div>
</div>
<% } %>
<!-- Existing base documents -->
<section class="space-y-4 mb-8">
<% if (basisDokumente.length === 0) { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 text-center text-gray-500 dark:text-gray-400">
Noch keine Basis-Unterlagen hinterlegt. Füge unten dein erstes Dokument hinzu.
</div>
<% } %>
<% basisDokumente.forEach(doc => { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<form action="/vorlagen/<%= doc.id %>" method="POST" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Typ</label>
<select name="typ" class="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">
<% basisTypOptions.forEach(opt => { %>
<option value="<%= opt %>" <%= doc.typ === opt ? 'selected' : '' %>><%= opt %></option>
<% }); %>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung</label>
<input type="text" name="name" value="<%= doc.name || '' %>"
class="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"
placeholder="z. B. Standard-Anschreiben">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Inhalt (Text)</label>
<textarea name="inhalt" rows="8"
class="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 leading-relaxed font-mono text-sm"><%= doc.inhalt %></textarea>
</div>
<div class="flex justify-end gap-2">
<button type="submit"
class="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Speichern
</button>
</div>
</form>
<form action="/vorlagen/<%= doc.id %>/delete" method="POST" class="mt-1 text-right"
onsubmit="return confirm('Dieses Basis-Dokument löschen?');">
<button type="submit"
class="text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:underline">
Dokument löschen
</button>
</form>
</div>
<% }); %>
</section>
<!-- Add new base document -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Basis-Dokument hinzufügen</h2>
<form action="/vorlagen" method="POST" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Typ</label>
<select name="typ" class="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">
<% basisTypOptions.forEach(opt => { %>
<option value="<%= opt %>"><%= opt %></option>
<% }); %>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung</label>
<input type="text" name="name"
class="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"
placeholder="z. B. Standard-Anschreiben">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Inhalt (Text)</label>
<textarea name="inhalt" rows="10" required
class="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 leading-relaxed font-mono text-sm"
placeholder="Füge hier den vollständigen Text deines Anschreibens oder Lebenslaufs ein…"></textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Hinzufügen
</button>
</div>
</form>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
})();
</script>
</body>
</html>