Einstellungen-Seite: .env-Werte in DB (app_state), keine .env mehr
Neue /einstellungen-Seite (Zahnrad im Header) mit allen bisherigen .env-Werten (Ollama, E-Mail, CalDAV, REST-API), gespeichert in SQLite (app_state, cfg:-Prefix). Libs lesen per lib/config.js zur Laufzeit statt beim Start -> Aenderungen wirken sofort, kein Neustart. Bestehende .env wird beim ersten Start einmalig migriert. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+5
-2
@@ -1,5 +1,8 @@
|
||||
# Kopiere diese Datei nach .env und trage deinen Schlüssel ein.
|
||||
# .env wird NICHT eingecheckt (siehe .gitignore).
|
||||
# HINWEIS: Diese Werte werden seit dem Umzug auf die Datenbank-basierte
|
||||
# Konfiguration primär in der Weboberfläche unter „Einstellungen“ (/einstellungen)
|
||||
# gepflegt. Eine bestehende .env-Datei wird beim ersten Start einmalig in die
|
||||
# Datenbank migriert; danach ist die Datenbank die einzige Quelle und .env wird
|
||||
# nicht mehr benötigt. Diese Datei dient nur noch als Vorlage/Referenz.
|
||||
|
||||
# Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys
|
||||
OLLAMA_API_KEY=
|
||||
|
||||
@@ -30,20 +30,21 @@ Ablauf:
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Alle Einstellungen (Ollama, E-Mail, Kalender, REST-API) werden in der Weboberfläche
|
||||
unter **„Einstellungen“** (`/einstellungen`, Zahnrad-Symbol im Header) gepflegt und
|
||||
in der Datenbank gespeichert. Änderungen wirken sofort, ein Neustart ist nicht nötig.
|
||||
|
||||
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`:
|
||||
([ollama.com/settings/keys](https://ollama.com/settings/keys)) – trage ihn nach dem
|
||||
ersten Start unter **Einstellungen → Ollama Cloud** ein.
|
||||
|
||||
```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
|
||||
# dann im Browser http://localhost:3000/einstellungen öffnen
|
||||
```
|
||||
|
||||
Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`.
|
||||
Bestehende Installationen, die bisher eine `.env`-Datei nutzen, werden beim ersten
|
||||
Start einmalig in die Datenbank migriert; danach wird `.env` nicht mehr benötigt.
|
||||
|
||||
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
|
||||
@@ -217,10 +218,10 @@ das Rohdokument unter **`/swagger.json`**.
|
||||
### Authentifizierung
|
||||
|
||||
Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header
|
||||
`X-API-Key`. Der Schlüssel wird über die Umgebungsvariable `API_TOKEN` konfiguriert
|
||||
(z. B. in `.env`, siehe `.env.example`). Ist `API_TOKEN` nicht gesetzt, antwortet
|
||||
die API mit `503` – sie gibt nie ungeschützt Daten heraus. Swagger/UI sind
|
||||
auch ohne Token erreichbar (die Dokumentation enthält keine sensiblen Daten).
|
||||
`X-API-Key`. Der Schlüssel wird unter **Einstellungen → REST-API** (`API_TOKEN`)
|
||||
konfiguriert. Ist `API_TOKEN` nicht gesetzt, antwortet die API mit `503` – sie gibt
|
||||
nie ungeschützt Daten heraus. Swagger/UI sind auch ohne Token erreichbar (die
|
||||
Dokumentation enthält keine sensiblen Daten).
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications
|
||||
|
||||
+13
-6
@@ -1,9 +1,10 @@
|
||||
// Third-party REST API (v1) for the Bewerbungs-Tracker.
|
||||
//
|
||||
// Mounted under /api/v1 in server.js. All endpoints except /health require an
|
||||
// API key (env API_TOKEN) sent in the X-API-Key header. Reuses the server's
|
||||
// existing DB helpers, sanitizer, duplicate guard, generation runner and
|
||||
// attachment directories so behaviour stays consistent with the web UI.
|
||||
// API key (API_TOKEN, editable via /einstellungen) sent in the X-API-Key
|
||||
// header. Reuses the server's existing DB helpers, sanitizer, duplicate guard,
|
||||
// generation runner and attachment directories so behaviour stays consistent
|
||||
// with the web UI.
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
@@ -41,16 +42,22 @@ function createExternalApi(deps) {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// `apiToken` may be a string (static) or a function () => string (dynamic,
|
||||
// read from the DB on each request so an edit on /einstellungen takes effect
|
||||
// without a restart).
|
||||
const resolveToken = () => (typeof apiToken === 'function' ? apiToken() : apiToken);
|
||||
|
||||
// --- API key auth --------------------------------------------------
|
||||
// /health is public so monitoring tools can probe availability; everything
|
||||
// else returns 401 when the header is missing/wrong or the token isn't set.
|
||||
router.use((req, res, next) => {
|
||||
if (req.path === '/health') return next();
|
||||
if (!apiToken) {
|
||||
return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' });
|
||||
const token = resolveToken();
|
||||
if (!token) {
|
||||
return res.status(503).json({ error: 'API-Token nicht konfiguriert (in den Einstellungen setzen).' });
|
||||
}
|
||||
const provided = req.get('X-API-Key');
|
||||
if (!provided || provided !== apiToken) {
|
||||
if (!provided || provided !== token) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||
}
|
||||
next();
|
||||
|
||||
+8
-5
@@ -4,7 +4,7 @@
|
||||
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
||||
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
|
||||
//
|
||||
// Config (env):
|
||||
// Config (DB, editable via /einstellungen — see lib/config.js):
|
||||
// CALDAV_URL full URL of the calendar collection (must end with /)
|
||||
// MAIL_USER login (shared with mail)
|
||||
// MAIL_PASSWORD password (shared with mail)
|
||||
@@ -14,15 +14,18 @@
|
||||
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
|
||||
|
||||
const crypto = require('crypto');
|
||||
const config = require('./config');
|
||||
|
||||
const TZ = 'Europe/Berlin';
|
||||
|
||||
// Read fresh on every call so an edit on the /einstellungen page takes effect
|
||||
// immediately (values live in the DB now, not in process.env/.env).
|
||||
function cfg() {
|
||||
return {
|
||||
url: (process.env.CALDAV_URL || '').trim(),
|
||||
user: process.env.MAIL_USER || '',
|
||||
pass: process.env.MAIL_PASSWORD || '',
|
||||
alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
|
||||
url: (config.get('CALDAV_URL') || '').trim(),
|
||||
user: config.get('MAIL_USER') || '',
|
||||
pass: config.get('MAIL_PASSWORD') || '',
|
||||
alarmMin: Number(config.get('CALDAV_ALARM_MIN')) || 60,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -11,14 +11,12 @@
|
||||
// replies as text, token by token, so the UI can render progressively.
|
||||
|
||||
const promptStore = require('./prompts');
|
||||
const config = require('./config');
|
||||
|
||||
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 || 300000);
|
||||
const MAX_TOOL_ROUNDS = 4;
|
||||
|
||||
function isConfigured() {
|
||||
return Boolean(process.env.OLLAMA_API_KEY);
|
||||
return Boolean(config.ollama().apiKey);
|
||||
}
|
||||
|
||||
// Stream a single chat completion from Ollama. `messages` = [{role, content}]
|
||||
@@ -28,8 +26,8 @@ function isConfigured() {
|
||||
// fires with each incremental text chunk. Returns { content, toolCalls }.
|
||||
// Aborts cleanly via `signal`.
|
||||
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.');
|
||||
const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama();
|
||||
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).');
|
||||
|
||||
const allMessages = [];
|
||||
if (system) allMessages.push({ role: 'system', content: system });
|
||||
@@ -43,7 +41,7 @@ async function streamChat({ system, messages, tools, onToken, temperature = 0.6,
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: OLLAMA_MODEL,
|
||||
model: ollamaModel,
|
||||
stream: true,
|
||||
options: { temperature },
|
||||
messages: allMessages,
|
||||
@@ -52,7 +50,7 @@ async function streamChat({ system, messages, tools, onToken, temperature = 0.6,
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
||||
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify(body),
|
||||
@@ -216,4 +214,8 @@ function buildContextPrompt(ctx) {
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, streamChat, runChat, buildContextPrompt, OLLAMA_MODEL, MAX_TOOL_ROUNDS };
|
||||
module.exports = {
|
||||
isConfigured, streamChat, runChat, buildContextPrompt, MAX_TOOL_ROUNDS,
|
||||
// Dynamic so an edit on /einstellungen is reflected without a restart.
|
||||
get OLLAMA_MODEL() { return config.ollama().model; },
|
||||
};
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// Centralized configuration store.
|
||||
//
|
||||
// Replaces the .env file: all settings live in the SQLite app_state table
|
||||
// (rows prefixed "cfg:") and are editable via the /einstellungen page. The
|
||||
// libs read values through config.get() / config.ollama() at *call* time, so
|
||||
// an edit in the UI takes effect immediately — no restart, no .env file.
|
||||
//
|
||||
// On first start of an install that previously used .env, init() migrates any
|
||||
// still-present env value into the DB once, so existing config is not lost.
|
||||
// After that the database is the single source of truth; process.env is only a
|
||||
// fallback for keys that were never saved (and for the one-time migration).
|
||||
|
||||
const DEFAULTS = {
|
||||
// Ollama Cloud (KI text generation + chat).
|
||||
OLLAMA_API_KEY: '',
|
||||
OLLAMA_MODEL: 'gpt-oss:120b',
|
||||
OLLAMA_HOST: 'https://ollama.com',
|
||||
OLLAMA_TIMEOUT_MS: '300000',
|
||||
// E-Mail (SMTP submission + IMAP receive).
|
||||
MAIL_HOST: '',
|
||||
MAIL_SMTP_PORT: '587',
|
||||
MAIL_IMAP_PORT: '993',
|
||||
MAIL_USER: '',
|
||||
MAIL_PASSWORD: '',
|
||||
MAIL_FROM_NAME: '',
|
||||
MAIL_FROM: '',
|
||||
MAIL_IMAP_MAILBOX: 'INBOX',
|
||||
MAIL_POLL_MS: '180000',
|
||||
// Bewerbungskalender (CalDAV, z. B. SOGo).
|
||||
CALDAV_URL: '',
|
||||
CALDAV_ALARM_MIN: '60',
|
||||
CALDAV_POLL_MS: '300000',
|
||||
// REST-API für Drittanbietersoftware (/api/v1, Header X-API-Key).
|
||||
API_TOKEN: '',
|
||||
};
|
||||
|
||||
// UI metadata for the /einstellungen page: grouped sections with input types.
|
||||
// `secret` fields render as password inputs with a reveal toggle.
|
||||
const FIELDS = [
|
||||
{
|
||||
titel: 'Ollama Cloud (KI-Generierung + Chat)',
|
||||
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
|
||||
items: [
|
||||
{ key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' },
|
||||
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: gpt-oss:120b' },
|
||||
{ key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' },
|
||||
{ key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
|
||||
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert.',
|
||||
items: [
|
||||
{ key: 'MAIL_HOST', label: 'SMTP/IMAP Host' },
|
||||
{ key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' },
|
||||
{ key: 'MAIL_IMAP_PORT', label: 'IMAP-Port', help: 'Standard: 993 (implicit TLS)' },
|
||||
{ key: 'MAIL_USER', label: 'Benutzername (Login)', help: 'Auch CalDAV-Login' },
|
||||
{ key: 'MAIL_PASSWORD', label: 'Passwort', secret: true, help: 'Auch CalDAV-Passwort' },
|
||||
{ key: 'MAIL_FROM_NAME', label: 'Absendername' },
|
||||
{ key: 'MAIL_FROM', label: 'Absenderadresse', help: 'Leer = Benutzername' },
|
||||
{ key: 'MAIL_IMAP_MAILBOX', label: 'IMAP-Postfach', help: 'Standard: INBOX' },
|
||||
{ key: 'MAIL_POLL_MS', label: 'Abrufintervall (ms)', help: 'Standard: 180000 (3 Min.) — greift nach Neustart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: 'Bewerbungskalender (CalDAV)',
|
||||
beschreibung: 'Voll-URL der Kalender-Sammlung (mit abschließendem /). Authentifizierung läuft über MAIL_USER/MAIL_PASSWORD. Ohne URL sind die Kalender-Funktionen deaktiviert.',
|
||||
items: [
|
||||
{ key: 'CALDAV_URL', label: 'Kalender-URL', help: 'z. B. https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/' },
|
||||
{ key: 'CALDAV_ALARM_MIN', label: 'Erinnerung (Minuten vor Termin)', help: 'Standard: 60' },
|
||||
{ key: 'CALDAV_POLL_MS', label: 'Sync-Intervall (ms)', help: 'Standard: 300000 (5 Min.) — greift nach Neustart' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titel: 'REST-API für Drittanbietersoftware',
|
||||
beschreibung: 'Ist ein Token gesetzt, ist /api/v1 aktiv und erwartet den Wert im Header „X-API-Key“. Ohne Token antwortet die API (bis auf /health) mit 503. Swagger unter /swagger.',
|
||||
items: [
|
||||
{ key: 'API_TOKEN', label: 'API-Token (X-API-Key)', secret: true, help: 'Leer = API deaktiviert' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const PREFIX = 'cfg:';
|
||||
const cache = Object.create(null); // key -> string (only keys present in the DB)
|
||||
let dbAllFn = null;
|
||||
let dbRunFn = null;
|
||||
|
||||
function envOrDefault(key) {
|
||||
const e = process.env[key];
|
||||
return e && e.length ? e : DEFAULTS[key];
|
||||
}
|
||||
|
||||
// Synchronous read. Falls back to process.env (pre-migration / never saved)
|
||||
// then to the built-in default. After init() the DB value is cached and wins.
|
||||
function get(key) {
|
||||
const v = cache[key];
|
||||
if (v !== undefined) return v;
|
||||
return envOrDefault(key);
|
||||
}
|
||||
|
||||
// All keys with their effective values, keyed by name — used by the settings UI.
|
||||
function getAll() {
|
||||
const out = {};
|
||||
for (const key of Object.keys(DEFAULTS)) out[key] = get(key);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ollama bundle (shared by lib/documents.js + lib/chat.js).
|
||||
function ollama() {
|
||||
return {
|
||||
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
|
||||
model: get('OLLAMA_MODEL') || 'gpt-oss:120b',
|
||||
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
|
||||
apiKey: get('OLLAMA_API_KEY') || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!dbAllFn) return;
|
||||
const rows = await dbAllFn('SELECT key, value FROM app_state WHERE key LIKE ?', [PREFIX + '%']);
|
||||
for (const r of rows) cache[r.key.slice(PREFIX.length)] = r.value;
|
||||
}
|
||||
|
||||
// Wire up DB helpers, load the cached rows, then one-time-migrate any env
|
||||
// value that isn't yet in the DB (so an existing .env install keeps its config).
|
||||
async function init({ dbAll, dbRun }) {
|
||||
dbAllFn = dbAll;
|
||||
dbRunFn = dbRun;
|
||||
await load();
|
||||
const toMigrate = [];
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
if (cache[key] === undefined) {
|
||||
const e = process.env[key];
|
||||
if (e && e.length) toMigrate.push([key, e]);
|
||||
}
|
||||
}
|
||||
if (toMigrate.length) {
|
||||
for (const [k, v] of toMigrate) {
|
||||
await dbRunFn(
|
||||
'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[PREFIX + k, v]
|
||||
);
|
||||
cache[k] = v;
|
||||
}
|
||||
console.log(`Konfiguration aus .env in die Datenbank migriert (${toMigrate.length} Werte) — .env wird nicht mehr benötigt.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist every key (writes all rows, including empty strings, so a cleared
|
||||
// field is stored as empty and no longer falls back to env/default).
|
||||
async function saveAll(values) {
|
||||
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||||
for (const key of Object.keys(DEFAULTS)) {
|
||||
const v = values && values[key] != null ? String(values[key]) : '';
|
||||
await dbRunFn(
|
||||
'INSERT INTO app_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
[PREFIX + key, v]
|
||||
);
|
||||
cache[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DEFAULTS, FIELDS, get, getAll, ollama, init, load, saveAll };
|
||||
+16
-18
@@ -11,6 +11,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const promptStore = require('./prompts');
|
||||
const designStore = require('./design');
|
||||
const config = require('./config');
|
||||
|
||||
// Embedded typefaces (all SIL Open Font License — see lib/fonts/OFL*.txt):
|
||||
// Lato — the classic layout's workhorse
|
||||
@@ -47,10 +48,8 @@ function makeDoc(t) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed.
|
||||
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 || 300000);
|
||||
// Ollama Cloud API (https://ollama.com). Host/model/timeout come from the DB
|
||||
// (editable via /einstellungen) and are read fresh per call via config.ollama().
|
||||
|
||||
// ===========================================================================
|
||||
// 1. LLM call — produce tailored TEXT for the template
|
||||
@@ -177,12 +176,11 @@ function buildOutputSchema(dokumente) {
|
||||
|
||||
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null, dokumente = null }) {
|
||||
const doks = normalizeDokumente(dokumente);
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
const { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama();
|
||||
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.'
|
||||
'Kein Ollama-API-Schlüssel konfiguriert. Bitte unter „Einstellungen“ den ' +
|
||||
'API-Schlüssel eintragen, damit Bewerbungsunterlagen generiert werden können.'
|
||||
);
|
||||
}
|
||||
if (!basisDokumente || basisDokumente.length === 0) {
|
||||
@@ -428,15 +426,15 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
||||
`JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
||||
const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
||||
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
model: ollamaModel,
|
||||
stream: false,
|
||||
format: buildOutputSchema(doks),
|
||||
options: { temperature: 0.4 },
|
||||
@@ -449,7 +447,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
||||
});
|
||||
} 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(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(ollamaTimeoutMs / 1000)}s).`);
|
||||
}
|
||||
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
||||
} finally {
|
||||
@@ -1942,17 +1940,17 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
||||
|
||||
// Small shared Ollama JSON call (used by the reply drafter).
|
||||
async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.');
|
||||
const { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama();
|
||||
if (!apiKey) throw new Error('Kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
||||
const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
||||
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
model: ollamaModel,
|
||||
stream: false,
|
||||
format: schema,
|
||||
options: { temperature },
|
||||
@@ -1964,7 +1962,7 @@ async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
|
||||
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).`);
|
||||
if (err.name === 'AbortError') throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(ollamaTimeoutMs / 1000)}s).`);
|
||||
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
+11
-8
@@ -11,18 +11,21 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const { ImapFlow } = require('imapflow');
|
||||
const { simpleParser } = require('mailparser');
|
||||
const config = require('./config');
|
||||
|
||||
// Read fresh on every call so an edit on the /einstellungen page takes effect
|
||||
// immediately (values live in the DB now, not in process.env/.env).
|
||||
function cfg() {
|
||||
const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587);
|
||||
const smtpPort = Number(config.get('MAIL_SMTP_PORT') || 587);
|
||||
return {
|
||||
host: process.env.MAIL_HOST || '',
|
||||
host: config.get('MAIL_HOST') || '',
|
||||
smtpPort,
|
||||
imapPort: Number(process.env.MAIL_IMAP_PORT || 993),
|
||||
user: process.env.MAIL_USER || '',
|
||||
pass: process.env.MAIL_PASSWORD || '',
|
||||
fromName: process.env.MAIL_FROM_NAME || '',
|
||||
fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '',
|
||||
mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX',
|
||||
imapPort: Number(config.get('MAIL_IMAP_PORT') || 993),
|
||||
user: config.get('MAIL_USER') || '',
|
||||
pass: config.get('MAIL_PASSWORD') || '',
|
||||
fromName: config.get('MAIL_FROM_NAME') || '',
|
||||
fromAddr: config.get('MAIL_FROM') || config.get('MAIL_USER') || '',
|
||||
mailbox: config.get('MAIL_IMAP_MAILBOX') || 'INBOX',
|
||||
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
|
||||
smtpSecure: smtpPort === 465,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ const { buildOpenApiSpec } = require('./lib/openapi');
|
||||
const blacklist = require('./lib/blacklist');
|
||||
const caldav = require('./lib/caldav');
|
||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
|
||||
const config = require('./lib/config');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -1065,9 +1066,14 @@ function initializeDatabase() {
|
||||
}
|
||||
|
||||
// Initialize and start server
|
||||
initializeDatabase().then(() => {
|
||||
initializeDatabase().then(async () => {
|
||||
console.log('Database initialized successfully');
|
||||
|
||||
|
||||
// Load configuration from the DB (migrates any still-present .env values
|
||||
// once). Must run before the boot checks below (mailer/caldav configured?) and
|
||||
// before any route that reads config — values live in the DB now, not in .env.
|
||||
await config.init({ dbAll, dbRun });
|
||||
|
||||
// Routes
|
||||
app.get('/', async (req, res) => {
|
||||
try {
|
||||
@@ -1976,7 +1982,7 @@ initializeDatabase().then(() => {
|
||||
hasSignatur: Boolean(currentSignaturFile()),
|
||||
hasFoto: Boolean(currentFotoFile()),
|
||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
|
||||
hideSettings: true,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2653,6 +2659,34 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Einstellungen (früher .env — jetzt in der Datenbank) -----
|
||||
// Alle Konfigurationswerte (Ollama, E-Mail, CalDAV, REST-API) liegen in der
|
||||
// app_state-Tabelle und sind hier editierbar. Speichern wirkt sofort, ein
|
||||
// Neustart ist nicht nötig (die Libs lesen per config.get() zur Laufzeit).
|
||||
app.get('/einstellungen', (req, res) => {
|
||||
res.render('settings', {
|
||||
felder: config.FIELDS,
|
||||
werte: config.getAll(),
|
||||
hideSettings: true,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/einstellungen', async (req, res) => {
|
||||
try {
|
||||
const werte = {};
|
||||
for (const sektion of config.FIELDS) {
|
||||
for (const f of sektion.items) {
|
||||
werte[f.key] = req.body[f.key] != null ? String(req.body[f.key]) : '';
|
||||
}
|
||||
}
|
||||
await config.saveAll(werte);
|
||||
res.redirect('/einstellungen');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Conversational KI-Chat (Ollama, streaming) -----
|
||||
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
|
||||
// assistant answer is streamed back via Server-Sent Events.
|
||||
@@ -2804,7 +2838,7 @@ initializeDatabase().then(() => {
|
||||
|
||||
// Chat page: list threads + render the active thread (or a fresh empty one).
|
||||
app.get('/chat', async (req, res) => {
|
||||
if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – OLLAMA_API_KEY fehlt.');
|
||||
if (!chat.isConfigured()) return res.status(503).send('KI-Chat deaktiviert – kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
|
||||
try {
|
||||
const threads = await dbAll(
|
||||
'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC'
|
||||
@@ -2965,8 +2999,9 @@ initializeDatabase().then(() => {
|
||||
|
||||
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
|
||||
// API key for third-party software. When unset, the API responds 503 on
|
||||
// every endpoint except /health — it never silently exposes data.
|
||||
const apiToken = process.env.API_TOKEN || '';
|
||||
// every endpoint except /health — it never silently exposes data. Read from
|
||||
// the DB via config so an edit on /einstellungen is picked up live.
|
||||
const apiToken = () => config.get('API_TOKEN') || '';
|
||||
app.use('/api/v1', createExternalApi({
|
||||
dbGet,
|
||||
dbAll,
|
||||
@@ -3036,7 +3071,7 @@ initializeDatabase().then(() => {
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||
if (apiToken) console.log('REST-API (/api/v1) aktiv – Swagger unter /swagger');
|
||||
if (apiToken()) console.log('REST-API (/api/v1) aktiv – Swagger unter /swagger');
|
||||
else console.log('REST-API deaktiviert – API_TOKEN fehlt (Swagger unter /swagger weiterhin verfügbar)');
|
||||
});
|
||||
|
||||
@@ -3045,7 +3080,7 @@ initializeDatabase().then(() => {
|
||||
mailer.verify()
|
||||
.then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`))
|
||||
.catch((e) => console.warn('E-Mail SMTP-Verbindung nicht verifizierbar:', e.message));
|
||||
const pollMs = Math.max(60000, Number(process.env.MAIL_POLL_MS) || 180000);
|
||||
const pollMs = Math.max(60000, Number(config.get('MAIL_POLL_MS')) || 180000);
|
||||
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
|
||||
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
|
||||
} else {
|
||||
@@ -3055,7 +3090,7 @@ initializeDatabase().then(() => {
|
||||
// Calendar: reconcile our appointments with the SOGo CalDAV calendar.
|
||||
if (caldav.isConfigured()) {
|
||||
console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`);
|
||||
const calPoll = Math.max(60000, Number(process.env.CALDAV_POLL_MS) || 300000);
|
||||
const calPoll = Math.max(60000, Number(config.get('CALDAV_POLL_MS')) || 300000);
|
||||
setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot
|
||||
setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
|
||||
} else {
|
||||
|
||||
@@ -52,6 +52,17 @@
|
||||
<span class="hidden sm:inline">Vorlagen</span>
|
||||
</a>
|
||||
|
||||
<!-- Settings (formerly .env — now in the database) link -->
|
||||
<a href="/einstellungen"
|
||||
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="Einstellungen (Ollama, E-Mail, Kalender, REST-API)">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Einstellungen</span>
|
||||
</a>
|
||||
|
||||
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
||||
<div class="relative" id="notifWrap">
|
||||
<button id="notifBtn" type="button"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<%- include('partials/head') %>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- 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>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-gray-500 dark:text-gray-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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Einstellungen</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
|
||||
Hier liegen alle Werte, die früher über die <code class="px-1 rounded bg-gray-100 dark:bg-gray-700">.env</code>-Datei
|
||||
gesetzt wurden – Ollama, E-Mail, Kalender und die REST-API. Gespeichert wird in der Datenbank;
|
||||
Änderungen wirken <em>sofort</em>, ein Neustart ist nicht nötig. Lediglich die Abruf-/Sync-Intervalle
|
||||
(E-Mail & Kalender) greifen erst nach einem Neustart.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/einstellungen" method="POST" class="space-y-6">
|
||||
<% felder.forEach(function(sektion){ %>
|
||||
<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"><%= sektion.titel %></h3>
|
||||
<% if (sektion.beschreibung) { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5 max-w-2xl"><%= sektion.beschreibung %></p>
|
||||
<% } else { %>
|
||||
<div class="mb-5"></div>
|
||||
<% } %>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<% sektion.items.forEach(function(f){
|
||||
var val = werte[f.key] != null ? werte[f.key] : '';
|
||||
var inputType = f.secret ? 'password' : 'text';
|
||||
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
|
||||
%>
|
||||
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
|
||||
<%= f.label %>
|
||||
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input id="cfg-<%= f.key %>" name="<%= f.key %>" type="<%= inputType %>" autocomplete="off"
|
||||
spellcheck="false" class="<%= inputClass %>" value="<%= val %>">
|
||||
<% if (f.secret) { %>
|
||||
<button type="button" data-toggle="cfg-<%= f.key %>"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
title="Wert anzeigen/verbergen" tabindex="-1">
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (f.help) { %>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</section>
|
||||
<% }); %>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/einstellungen" class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||
Verwerfen
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 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="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const cy = document.getElementById('currentYear');
|
||||
if (cy) cy.textContent = new Date().getFullYear();
|
||||
|
||||
// Toggle reveal for secret (password) fields.
|
||||
document.querySelectorAll('[data-toggle]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
const input = document.getElementById(btn.getAttribute('data-toggle'));
|
||||
if (input) input.type = input.type === 'password' ? 'text' : 'password';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+3
-3
@@ -32,9 +32,9 @@
|
||||
</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>
|
||||
<p>Trage den Ollama-API-Schlüssel unter
|
||||
<a href="/einstellungen" class="underline font-medium hover:no-underline">Einstellungen</a>
|
||||
ein, damit Bewerbungsunterlagen automatisch generiert werden können.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user