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.
|
# HINWEIS: Diese Werte werden seit dem Umzug auf die Datenbank-basierte
|
||||||
# .env wird NICHT eingecheckt (siehe .gitignore).
|
# 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 Cloud API-Schlüssel – https://ollama.com/settings/keys
|
||||||
OLLAMA_API_KEY=
|
OLLAMA_API_KEY=
|
||||||
|
|||||||
@@ -30,20 +30,21 @@ Ablauf:
|
|||||||
|
|
||||||
### Konfiguration
|
### 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
|
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
|
([ollama.com/settings/keys](https://ollama.com/settings/keys)) – trage ihn nach dem
|
||||||
eine (nicht eingecheckte) `.env`-Datei – kopiere `.env.example` nach `.env`:
|
ersten Start unter **Einstellungen → Ollama Cloud** ein.
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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
|
schlägt dann kontrolliert mit einem Hinweis fehl und kann später per
|
||||||
@@ -217,10 +218,10 @@ das Rohdokument unter **`/swagger.json`**.
|
|||||||
### Authentifizierung
|
### Authentifizierung
|
||||||
|
|
||||||
Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header
|
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
|
`X-API-Key`. Der Schlüssel wird unter **Einstellungen → REST-API** (`API_TOKEN`)
|
||||||
(z. B. in `.env`, siehe `.env.example`). Ist `API_TOKEN` nicht gesetzt, antwortet
|
konfiguriert. Ist `API_TOKEN` nicht gesetzt, antwortet die API mit `503` – sie gibt
|
||||||
die API mit `503` – sie gibt nie ungeschützt Daten heraus. Swagger/UI sind
|
nie ungeschützt Daten heraus. Swagger/UI sind auch ohne Token erreichbar (die
|
||||||
auch ohne Token erreichbar (die Dokumentation enthält keine sensiblen Daten).
|
Dokumentation enthält keine sensiblen Daten).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications
|
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.
|
// Third-party REST API (v1) for the Bewerbungs-Tracker.
|
||||||
//
|
//
|
||||||
// Mounted under /api/v1 in server.js. All endpoints except /health require an
|
// 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
|
// API key (API_TOKEN, editable via /einstellungen) sent in the X-API-Key
|
||||||
// existing DB helpers, sanitizer, duplicate guard, generation runner and
|
// header. Reuses the server's existing DB helpers, sanitizer, duplicate guard,
|
||||||
// attachment directories so behaviour stays consistent with the web UI.
|
// generation runner and attachment directories so behaviour stays consistent
|
||||||
|
// with the web UI.
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -41,16 +42,22 @@ function createExternalApi(deps) {
|
|||||||
|
|
||||||
const router = express.Router();
|
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 --------------------------------------------------
|
// --- API key auth --------------------------------------------------
|
||||||
// /health is public so monitoring tools can probe availability; everything
|
// /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.
|
// else returns 401 when the header is missing/wrong or the token isn't set.
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
if (req.path === '/health') return next();
|
if (req.path === '/health') return next();
|
||||||
if (!apiToken) {
|
const token = resolveToken();
|
||||||
return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' });
|
if (!token) {
|
||||||
|
return res.status(503).json({ error: 'API-Token nicht konfiguriert (in den Einstellungen setzen).' });
|
||||||
}
|
}
|
||||||
const provided = req.get('X-API-Key');
|
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).' });
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
|||||||
+8
-5
@@ -4,7 +4,7 @@
|
|||||||
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
||||||
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
|
// 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 /)
|
// CALDAV_URL full URL of the calendar collection (must end with /)
|
||||||
// MAIL_USER login (shared with mail)
|
// MAIL_USER login (shared with mail)
|
||||||
// MAIL_PASSWORD password (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().
|
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
const TZ = 'Europe/Berlin';
|
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() {
|
function cfg() {
|
||||||
return {
|
return {
|
||||||
url: (process.env.CALDAV_URL || '').trim(),
|
url: (config.get('CALDAV_URL') || '').trim(),
|
||||||
user: process.env.MAIL_USER || '',
|
user: config.get('MAIL_USER') || '',
|
||||||
pass: process.env.MAIL_PASSWORD || '',
|
pass: config.get('MAIL_PASSWORD') || '',
|
||||||
alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
|
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.
|
// replies as text, token by token, so the UI can render progressively.
|
||||||
|
|
||||||
const promptStore = require('./prompts');
|
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;
|
const MAX_TOOL_ROUNDS = 4;
|
||||||
|
|
||||||
function isConfigured() {
|
function isConfigured() {
|
||||||
return Boolean(process.env.OLLAMA_API_KEY);
|
return Boolean(config.ollama().apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream a single chat completion from Ollama. `messages` = [{role, content}]
|
// 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 }.
|
// fires with each incremental text chunk. Returns { content, toolCalls }.
|
||||||
// Aborts cleanly via `signal`.
|
// Aborts cleanly via `signal`.
|
||||||
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
|
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
|
||||||
const apiKey = process.env.OLLAMA_API_KEY;
|
const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama();
|
||||||
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.');
|
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).');
|
||||||
|
|
||||||
const allMessages = [];
|
const allMessages = [];
|
||||||
if (system) allMessages.push({ role: 'system', content: system });
|
if (system) allMessages.push({ role: 'system', content: system });
|
||||||
@@ -43,7 +41,7 @@ async function streamChat({ system, messages, tools, onToken, temperature = 0.6,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model: OLLAMA_MODEL,
|
model: ollamaModel,
|
||||||
stream: true,
|
stream: true,
|
||||||
options: { temperature },
|
options: { temperature },
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
@@ -52,7 +50,7 @@ async function streamChat({ system, messages, tools, onToken, temperature = 0.6,
|
|||||||
|
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -216,4 +214,8 @@ function buildContextPrompt(ctx) {
|
|||||||
return parts.join('\n\n');
|
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 path = require('path');
|
||||||
const promptStore = require('./prompts');
|
const promptStore = require('./prompts');
|
||||||
const designStore = require('./design');
|
const designStore = require('./design');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
// Embedded typefaces (all SIL Open Font License — see lib/fonts/OFL*.txt):
|
// Embedded typefaces (all SIL Open Font License — see lib/fonts/OFL*.txt):
|
||||||
// Lato — the classic layout's workhorse
|
// Lato — the classic layout's workhorse
|
||||||
@@ -47,10 +48,8 @@ function makeDoc(t) {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed.
|
// Ollama Cloud API (https://ollama.com). Host/model/timeout come from the DB
|
||||||
const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
|
// (editable via /einstellungen) and are read fresh per call via config.ollama().
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
|
|
||||||
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000);
|
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 1. LLM call — produce tailored TEXT for the template
|
// 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 }) {
|
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null, dokumente = null }) {
|
||||||
const doks = normalizeDokumente(dokumente);
|
const doks = normalizeDokumente(dokumente);
|
||||||
const apiKey = process.env.OLLAMA_API_KEY;
|
const { host: ollamaHost, model: ollamaModel, timeoutMs: ollamaTimeoutMs, apiKey } = config.ollama();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' +
|
'Kein Ollama-API-Schlüssel konfiguriert. Bitte unter „Einstellungen“ den ' +
|
||||||
'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' +
|
'API-Schlüssel eintragen, damit Bewerbungsunterlagen generiert werden können.'
|
||||||
'Bewerbungsunterlagen generiert werden können.'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!basisDokumente || basisDokumente.length === 0) {
|
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.`;
|
`JSON-Objekt, ohne Markdown, ohne Code-Fences, ohne weiteren Text.`;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs);
|
||||||
|
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: OLLAMA_MODEL,
|
model: ollamaModel,
|
||||||
stream: false,
|
stream: false,
|
||||||
format: buildOutputSchema(doks),
|
format: buildOutputSchema(doks),
|
||||||
options: { temperature: 0.4 },
|
options: { temperature: 0.4 },
|
||||||
@@ -449,7 +447,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') {
|
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}`);
|
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1942,17 +1940,17 @@ async function generateApplicationDocuments({ job, basisDokumente, settings, zus
|
|||||||
|
|
||||||
// Small shared Ollama JSON call (used by the reply drafter).
|
// Small shared Ollama JSON call (used by the reply drafter).
|
||||||
async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
|
async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
|
||||||
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.');
|
if (!apiKey) throw new Error('Kein Ollama-API-Schlüssel konfiguriert (unter „Einstellungen“ eintragen).');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), ollamaTimeoutMs);
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${OLLAMA_HOST}/api/chat`, {
|
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: OLLAMA_MODEL,
|
model: ollamaModel,
|
||||||
stream: false,
|
stream: false,
|
||||||
format: schema,
|
format: schema,
|
||||||
options: { temperature },
|
options: { temperature },
|
||||||
@@ -1964,7 +1962,7 @@ async function ollamaChatJSON({ system, user, schema, temperature = 0.5 }) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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}`);
|
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
+11
-8
@@ -11,18 +11,21 @@
|
|||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { ImapFlow } = require('imapflow');
|
const { ImapFlow } = require('imapflow');
|
||||||
const { simpleParser } = require('mailparser');
|
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() {
|
function cfg() {
|
||||||
const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587);
|
const smtpPort = Number(config.get('MAIL_SMTP_PORT') || 587);
|
||||||
return {
|
return {
|
||||||
host: process.env.MAIL_HOST || '',
|
host: config.get('MAIL_HOST') || '',
|
||||||
smtpPort,
|
smtpPort,
|
||||||
imapPort: Number(process.env.MAIL_IMAP_PORT || 993),
|
imapPort: Number(config.get('MAIL_IMAP_PORT') || 993),
|
||||||
user: process.env.MAIL_USER || '',
|
user: config.get('MAIL_USER') || '',
|
||||||
pass: process.env.MAIL_PASSWORD || '',
|
pass: config.get('MAIL_PASSWORD') || '',
|
||||||
fromName: process.env.MAIL_FROM_NAME || '',
|
fromName: config.get('MAIL_FROM_NAME') || '',
|
||||||
fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '',
|
fromAddr: config.get('MAIL_FROM') || config.get('MAIL_USER') || '',
|
||||||
mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX',
|
mailbox: config.get('MAIL_IMAP_MAILBOX') || 'INBOX',
|
||||||
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
|
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
|
||||||
smtpSecure: smtpPort === 465,
|
smtpSecure: smtpPort === 465,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const { buildOpenApiSpec } = require('./lib/openapi');
|
|||||||
const blacklist = require('./lib/blacklist');
|
const blacklist = require('./lib/blacklist');
|
||||||
const caldav = require('./lib/caldav');
|
const caldav = require('./lib/caldav');
|
||||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
|
||||||
|
const config = require('./lib/config');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -1065,9 +1066,14 @@ function initializeDatabase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize and start server
|
// Initialize and start server
|
||||||
initializeDatabase().then(() => {
|
initializeDatabase().then(async () => {
|
||||||
console.log('Database initialized successfully');
|
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
|
// Routes
|
||||||
app.get('/', async (req, res) => {
|
app.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1976,7 +1982,7 @@ initializeDatabase().then(() => {
|
|||||||
hasSignatur: Boolean(currentSignaturFile()),
|
hasSignatur: Boolean(currentSignaturFile()),
|
||||||
hasFoto: Boolean(currentFotoFile()),
|
hasFoto: Boolean(currentFotoFile()),
|
||||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
hasApiKey: Boolean(config.get('OLLAMA_API_KEY')),
|
||||||
hideSettings: true,
|
hideSettings: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) -----
|
// ----- Conversational KI-Chat (Ollama, streaming) -----
|
||||||
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
|
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
|
||||||
// assistant answer is streamed back via Server-Sent Events.
|
// 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).
|
// Chat page: list threads + render the active thread (or a fresh empty one).
|
||||||
app.get('/chat', async (req, res) => {
|
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 {
|
try {
|
||||||
const threads = await dbAll(
|
const threads = await dbAll(
|
||||||
'SELECT id, titel, updated_at FROM chat_threads ORDER BY updated_at DESC'
|
'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 -----
|
// ----- Third-party REST API (/api/v1) + OpenAPI/Swagger -----
|
||||||
// API key for third-party software. When unset, the API responds 503 on
|
// API key for third-party software. When unset, the API responds 503 on
|
||||||
// every endpoint except /health — it never silently exposes data.
|
// every endpoint except /health — it never silently exposes data. Read from
|
||||||
const apiToken = process.env.API_TOKEN || '';
|
// the DB via config so an edit on /einstellungen is picked up live.
|
||||||
|
const apiToken = () => config.get('API_TOKEN') || '';
|
||||||
app.use('/api/v1', createExternalApi({
|
app.use('/api/v1', createExternalApi({
|
||||||
dbGet,
|
dbGet,
|
||||||
dbAll,
|
dbAll,
|
||||||
@@ -3036,7 +3071,7 @@ initializeDatabase().then(() => {
|
|||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server läuft auf http://localhost:${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)');
|
else console.log('REST-API deaktiviert – API_TOKEN fehlt (Swagger unter /swagger weiterhin verfügbar)');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3045,7 +3080,7 @@ initializeDatabase().then(() => {
|
|||||||
mailer.verify()
|
mailer.verify()
|
||||||
.then(() => console.log(`E-Mail aktiv: Versand über ${mailer.config().host} als ${mailer.fromAddress()}`))
|
.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));
|
.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
|
setTimeout(() => { pollInbox().catch(() => {}); }, 8000); // initial fetch after boot
|
||||||
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
|
setInterval(() => { pollInbox().catch(() => {}); }, pollMs); // periodic fetch
|
||||||
} else {
|
} else {
|
||||||
@@ -3055,7 +3090,7 @@ initializeDatabase().then(() => {
|
|||||||
// Calendar: reconcile our appointments with the SOGo CalDAV calendar.
|
// Calendar: reconcile our appointments with the SOGo CalDAV calendar.
|
||||||
if (caldav.isConfigured()) {
|
if (caldav.isConfigured()) {
|
||||||
console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`);
|
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
|
setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot
|
||||||
setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
|
setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -52,6 +52,17 @@
|
|||||||
<span class="hidden sm:inline">Vorlagen</span>
|
<span class="hidden sm:inline">Vorlagen</span>
|
||||||
</a>
|
</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) -->
|
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
||||||
<div class="relative" id="notifWrap">
|
<div class="relative" id="notifWrap">
|
||||||
<button id="notifBtn" type="button"
|
<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>
|
</svg>
|
||||||
<div class="text-sm text-amber-800 dark:text-amber-200">
|
<div class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
|
<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>
|
<p>Trage den Ollama-API-Schlüssel unter
|
||||||
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
|
<a href="/einstellungen" class="underline font-medium hover:no-underline">Einstellungen</a>
|
||||||
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
|
ein, damit Bewerbungsunterlagen automatisch generiert werden können.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user