Compare commits

..

2 commits

Author SHA1 Message Date
root
de5f533096 docs: redakteur STATE.md aktualisiert 03.03.2026
Made-with: Cursor
2026-03-03 16:19:53 +07:00
root
82e0850df2 docs: Update STATE.md mit neuesten Features
Made-with: Cursor
2026-03-03 16:19:53 +07:00
17 changed files with 1105 additions and 115 deletions

View file

@ -14,8 +14,15 @@
| FünfVorAcht / Telegram KI-Poster | fuenfvoracht/STATE.md |
| Server / Container / Proxmox | infrastructure/STATE.md |
| Telegram Bots allgemein | infrastructure/STATE.md |
| TODOs / Aufgaben / Was steht an | Forgejo Issues (siehe unten) |
| Alle Projekte / Übersicht | MASTER_INDEX.md |
## TODO-Liste (Forgejo)
- Lesen: `curl -s -H "Authorization: token b874766bdf357bd4c32fa4369d0c588fc6193336" http://100.89.246.60:3000/api/v1/repos/orbitalo/homelab-brain/issues?state=open`
- Web: http://100.89.246.60:3000/orbitalo/homelab-brain/issues
- Neues TODO: POST an `/api/v1/repos/orbitalo/homelab-brain/issues` mit `{"title":"...","body":"...","labels":[ID]}`
- Erledigt: PATCH mit `{"state":"closed"}`
## Server-Zugang (immer verfügbar)
- pve-hetzner: `ssh root@100.88.230.59` | PW: Astral-Proxmox!2026
- pve1 Kambodscha: `ssh root@192.168.0.197` | PW: astral66

View file

@ -8,20 +8,32 @@
|---|---|---|---|
| **Arakava News** (WordPress + RSS + KI) | Orbitalo/Wordpress-V3-MCP-Projekt | arakava-news/STATE.md | arakava-news/src/ |
| **Edelmetall Dashboard** (Gold/Silber) | — (in diesem Repo) | edelmetall/STATE.md | edelmetall/src/ |
| **Smart Home Muldenstein** (ioBroker, Grafana) | — (in diesem Repo) | smart-home/STATE.md | smart-home/scripts/ |
| **Smart Home** (ioBroker, Grafana) | — (in diesem Repo) | smart-home/STATE.md | smart-home/scripts/ |
| **ESP32 Projekte** (Heizung, Sensor) | — (in diesem Repo) | esp32/PLAN.md | — |
| **FünfVorAcht** (Telegram KI-Poster) | — (in diesem Repo) | fuenfvoracht/STATE.md | fuenfvoracht/src/ |
| **Redakteur** (WordPress KI-Autor) | git.orbitalo.net/orbitalo/redakteur | redakteur/STATE.md | redakteur/src/ |
| **Flugpreisscanner** (FRA→PNH, Selenium, KI) | git.orbitalo.net/orbitalo/flugpreisscanner | flugpreisscanner/STATE.md | flugpreisscanner/src/ |
| **Infrastruktur** (alle Server + CTs) | — (in diesem Repo) | infrastructure/STATE.md | — |
## Server
## Prioritäten
| Server | Standort | Tailscale IP | Funktion |
|---|---|---|---|
| pve-hetzner | Deutschland | 100.88.230.59 | Hauptserver (CT 100-110, 144, 999) |
| pve1 | Kambodscha | 192.168.0.197 (lokal) / 100.122.56.60 (TS) | Heimserver (CT 136, 888, 999-Mirror) |
| pve3 | Muldenstein, DE | 100.109.101.12 | Smart Home (CT 143, 134) |
1. **Arakava News** (WordPress + RSS-Manager) — Prio 1
2. **FünfVorAcht** (Telegram KI-Poster) — Prio 1
3. Rest — bei Bedarf
## Physische Standorte
| Standort | Server | Hardware | Tailscale IP | Funktion |
|---|---|---|---|---|
| Hetzner Cloud | pve-hetzner | Dedicated Server | 100.88.230.59 | Hauptserver, alle Projekte |
| Kambodscha | pve1 | Dell Optiplex Mini | 100.122.56.60 | Heimserver, Edelmetall, Smart Home |
| Bei Helmut (Kumpel) | helmut-pve | Dell Optiplex Mini | 100.87.235.11 | Backup-Agent, Filebrowser |
| Muldenstein | pve2-1 | Dell Optiplex Mini | 100.99.101.37 | Pizza-Shops, PC-Shops, Taxi, Tools (22 CTs aktiv) |
| Muldenstein | pve3 | Dell Optiplex Mini | 100.109.101.12 | Syncthing, WireGuard, Flugscanner-Node, MQTT |
| Muldenstein | pbs-1 | PBS Server | 100.99.139.22 | Proxmox Backup Server (aktiv, 20 GB Traffic) |
| Muldenstein | KI-Tower | Tower + RTX 3090 | — | Geplant: Lokaler KI-Server (Ollama) |
**Aktueller Aufenthalt:** Kambodscha (bis ca. Ende März 2026)
## Wichtigste Zugangsdaten
@ -29,21 +41,28 @@
|---|---|
| pve-hetzner SSH | root / Astral-Proxmox!2026 |
| pve1 SSH | root / astral66 |
| helmut-pve SSH | root / astral66 |
| Alle lokalen CTs | root / astral66 |
| WordPress Admin | admin / astral66 |
| WordPress Admin | admin / eJIyhW0p5PFacjvvKGufKeXS |
| Seafile | admin@orbitalo.net / astral66 |
| n8n | wuttig@gmx.de / Astral66 |
| Dify | admin@orbitalo.net / astral66 |
| Forgejo | orbitalo / astral66 |
| Grafana | admin / astral66 |
## Telegram Bots
| Bot | Token | Chat-ID | Projekt |
|---|---|---|---|
| @MutterbotAI_bot | (in infrastructure/STATE.md) | 674951792 | Moltbot allgemein |
| @DifyRagBot | 8390483455:AAEUyRWkvESSGQBtvjzAIQ5UKqmpoMTQZ00 | 674951792 | Dify RAG / Grafana Alerts |
| Arakava Comments | 8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE | 674951792 | WordPress Kommentare |
| Edelmetall Bot | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 | 674951792 | Gold/Silber Preise (CT 136) |
| @MutterbotAI_bot | 8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE | 674951792 | RSS-Manager / Allgemein |
| @Diendemleben_bot | 8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc | 674951792 | FünfVorAcht |
| Edelmetall Bot | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 | 674951792 | Gold/Silber Preise |
## TODO-Liste
**Zentral in Forgejo (Repo `orbitalo/homelab-brain`):**
- Web: http://100.89.246.60:3000/orbitalo/homelab-brain/issues
- API-Token: `b874766bdf357bd4c32fa4369d0c588fc6193336`
**Labels:** prio-1, wordpress, fuenfvoracht, infrastruktur, flugscanner, ki-tower, wartung, nice-to-have
## Auto-Sync
Die STATE.md Dateien werden täglich um 03:00 Uhr automatisch aktualisiert.

View file

@ -1,5 +1,20 @@
# Infrastruktur — Live State
> Auto-generiert: 2026-03-03 10:15
> Aktualisiert: 2026-03-01
## Physische Standorte
| Standort | Hardware | Tailscale IP | Funktion | Status |
|----------|----------|-------------|----------|--------|
| **Hetzner Cloud** | Dedicated Server | 100.88.230.59 | pve-hetzner — Hauptserver, alle Projekte | ✅ Läuft |
| **Kambodscha** | Dell Optiplex Mini | 100.122.56.60 | pve1 — Heimserver, Edelmetall, Smart Home | ✅ Läuft |
| **Bei Helmut** (Kumpel) | Dell Optiplex Mini | 100.87.235.11 | helmut-pve — Backup-Agent, Filebrowser | ✅ Läuft |
| **Muldenstein** | Dell Optiplex Minis | 100.99.101.37 / 100.109.101.12 | pve2-1 + pve3 — Pizza-Shops, Scraper, Tools | ✅ Läuft |
| **Muldenstein** | PBS Server | 100.99.139.22 | pbs-1 — Proxmox Backup Server | ✅ Läuft (20 GB Traffic) |
| **Muldenstein** | Tower, RTX 3090 | — | KI-Tower — geplant als lokaler KI-Server | ⚠️ Windows, Netzwerkprobleme, Neuaufsetzen geplant |
**Aktueller Aufenthalt:** Kambodscha (noch ~4 Wochen, bis ca. Ende März 2026)
---
## pve-hetzner Disk
| Mount | Belegt |
@ -8,17 +23,109 @@
| /var/lib/vz (VMs/CTs) | 2% von 2.9T |
## Aktive Container auf pve-hetzner
| CT | Name | Tailscale IP | Dienste |
|---|---|---|---|
| 101 | wordpress-v2 | 100.91.212.19 | WordPress + MySQL (Docker) |
| 101 | wordpress-v2 | 100.91.212.19 | WordPress + MySQL (Docker) + **CF Tunnel** |
| 103 | seafile | 100.75.247.60 | Seafile (seafile.orbitalo.net) |
| 109 | rss-manager | 100.113.244.101 | RSS Manager + Matomo |
| 109 | rss-manager | 100.113.244.101 | RSS Manager + Matomo + **CF Tunnel** |
| 110 | portainer | 100.109.206.43 | Portainer Docker UI |
| 111 | forgejo | 100.89.246.60 | Forgejo Git (http://100.89.246.60:3000) |
| 112 | fuenfvoracht | 100.73.171.62 | FünfVorAcht Telegram KI-Poster + **CF Tunnel** |
| 113 | redax-wp | 100.69.243.16 | Redax-WP KI-Redakteur |
| 115 | flugscanner-hub | 100.92.161.97 | Flugscanner Scheduler + Web-Dashboard |
| 144 | muldenstein-backup | — | Backup-Archiv |
| 999 | cluster-docu | 100.79.8.49 | Dokumentation (http://100.79.8.49:8080) |
## Container auf pve1 (Kambodscha)
| CT | Name | Tailscale IP | Dienste |
|---|---|---|---|
| 115 | flugscanner-asia | 100.112.190.22 | Flugscanner Scraping-Node Asia |
| 136 | gold-silber-v3 | 100.72.230.87 | Edelmetall-Bot |
| 143 | smart-home | — | ioBroker + Grafana + InfluxDB |
## Container auf helmut-pve (bei Kumpel)
| CT | Name | Tailscale IP | Dienste |
|---|---|---|---|
| 145 | flugscanner-mu | 100.75.182.15 | Flugscanner Scraping-Node DE (derzeit inaktiv) |
| — | — | — | Backup-Agent + Filebrowser |
## Container auf pve2-1 (Muldenstein) — Pizza-Shops & Tools
| CT | Name | Status | Tailscale IP | Dienste |
|---|---|---|---|---|
| 111 | uptimekuma | running | — | Uptime Monitoring |
| 112 | myspeed | running | — | Speedtest Tracker |
| 113 | pve-scripts-local | running | — | Lokale Scripts |
| 114 | djangoadmin | running | — | Django Admin Tools |
| 115 | Takeo-PC-Shop-Engl | running | — | PC-Shop Takeo (englisch) |
| 116 | Pulse | running | — | **Pulse Monitoring + Cloudflare Tunnel** |
| 117 | Intercity-Taxi | running | — | Taxi-Buchung |
| 123 | Kofi-Shop-PP | running | — | Kofi Shop Phnom Penh |
| 128 | rustdeskserver | running | — | RustDesk Remote Desktop |
| 129 | debian | running | — | Allgemein |
| 130 | PC-Shop-Takeo | running | 100.70.158.12 | PC-Shop Takeo |
| 131 | PC-Shopp-PP | running | 100.98.199.9 | PC-Shop Phnom Penh |
| 136 | Seleniumbase | running | — | SeleniumBase Scraper |
| 140 | Alfredo-Pizza | running | 100.118.43.100 | Django Pizza-Shop |
| 150 | Pizza-Express-Wolfen | running | 100.105.246.18 | Django Pizza-Shop |
| 160 | Red-Pizza | running | 100.69.66.101 | Django Pizza-Shop |
| 180 | Mellensa-Pizza | running | 100.76.173.1 | Django Pizza-Shop |
| 190 | Ali-Baba | running | 100.126.45.101 | Django Pizza-Shop |
| 200 | Pizza-Di-Angelo | running | 100.66.182.58 | Django Pizza-Shop |
| 500 | Test-Shop | running | 100.98.217.121 | Test-Umgebung |
| 501 | Test-Shop-Prod | running | — | Test-Umgebung |
| 502 | Test-Shop-2 | running | — | Test-Umgebung |
**Gestoppt:** CT 110, 118, 119, 120, 121, 122, 124, 125, 126, 132, 133 (alte Klone/Templates)
**Stack aller Shops:** Django 5.2 + PostgreSQL + Gunicorn + Nginx + Telegram Bot (projektscan2000)
**Cloudflare Tunnel:** CT 116 (Pulse) — Tunnel-ID `f98f666c-73b8-487b-8327-9aa1edc2145e`
- Aktive Routes:
- `pulse.orbitalo.info``http://192.168.178.200:7655` (Pulse Monitoring)
- `pve2-muldenstein.orbitalo.net``https://192.168.178.123:8006` (Proxmox pve2-1, TLS Verify: OFF)
- Alle Container auf pve2-1 können über diesen Tunnel erreichbar gemacht werden
## Container auf pve3 (Muldenstein)
| CT | Name | Status | Tailscale IP | Dienste |
|---|---|---|---|---|
| 139 | Syncthing-Muldenstein | running | — | Datei-Synchronisation |
| 141 | syncthing | running | — | Datei-Synchronisation |
| 142 | WG-easy | running | — | WireGuard VPN |
| 143 | Raspi-Broker | running | 100.66.78.56 | MQTT Broker (Smart Home) |
| 145 | flugscanner-mu | running | 100.75.182.15 | Flugscanner Scraping-Node DE |
| 504 | projektscan-template | running | — | Shop-Template |
| 144 | BT-Bridge | running (VM) | — | Bluetooth Bridge |
**Gestoppt:** CT 137 (Template), 138 (SeleniumBase2), 503 (Schawarma-Cursor)
## Proxmox Backup Server
| Server | Tailscale IP | Standort | Version | Status |
|---|---|---|---|---|
| pbs-1 | 100.99.139.22 | Muldenstein | PBS 3.4.0 | ✅ Aktiv (20 GB Traffic) |
| pbs | 100.82.175.23 | ? | PBS 3.4.0 | ✅ Online |
| pbs-hetzner | 100.126.237.22 | Hetzner | ? | ⚠️ Offline/Auth |
## KI-Tower (Muldenstein) — geplant
| Eigenschaft | Wert |
|---|---|
| Hardware | Tower-Gehäuse, ~1.200€ (ohne GPU) |
| GPU | NVIDIA RTX 3090 (24 GB VRAM) |
| OS aktuell | Windows (Netzwerkprobleme) |
| OS geplant | Neu aufsetzen (Ubuntu Server oder Proxmox) |
| Ziel | Lokaler KI-Server (Ollama), Ersatz für OpenRouter/GPT-4o-mini |
| Status | ⚠️ Wartet auf Rückkehr aus Kambodscha |
---
## Gelöschte Container (24.02.2026)
| CT | Name | Grund |
|---|---|---|
| 100 | traefik | Abgelöst durch Cloudflare Tunnel |
@ -26,27 +133,62 @@
| 104 | n8n | Nicht aktiv genutzt |
| 105 | debian-12 | Nicht genutzt |
| 106 | wordpress-news | Abgelöst durch CT 101 |
| 113 | matomo | Integriert in CT 109 |
## Container auf pve1 (Kambodscha)
| CT | Name | Dienste |
---
## Cloudflare Tunnels & Routing
### pve-hetzner
| CT | Tunnel-ID | Public Hostnames |
|---|---|---|
| 136 | gold-silber-v3 | Edelmetall-Bot (Tailscale: 100.72.230.87) |
| 143 | smart-home | ioBroker + Grafana + InfluxDB |
| 101 | 0231beb8-193e-46df-a6ef-4154cf04f374 | arakava-news-2.orbitalo.net → localhost:80 |
| 109 | 486454a9-4812-4422-b30b-abd5ada71ce1 | matomo.orbitalo.net → localhost:80 |
| 112 | ba4f6f84-45db-4369-a588-c231f9d559ce | fuenfvoracht.orbitalo.net → localhost:8080 |
## Routing
- Cloudflare Tunnel CT 101: arakava-news-2.orbitalo.net → :80
- Cloudflare Tunnel CT 109: matomo.orbitalo.net → :80
- Kein Traefik, kein PBS-Gateway mehr
### pve2-1 (Muldenstein)
| CT | Tunnel-ID | Public Hostnames |
|---|---|---|
| 116 (Pulse) | f98f666c-73b8-487b-8327-9aa1edc2145e | pulse.orbitalo.info → http://192.168.178.200:7655<br>pve2-muldenstein.orbitalo.net → https://192.168.178.123:8006 (TLS Verify: OFF) |
**Lokale IPs:**
- pve2-1: 192.168.178.123
- pve3: 192.168.178.250
**Best Practice:**
- Tunnel immer auf Host oder dediziertem Tunnel-CT, nie in Dienst-Containern
- Ein Tunnel pro Proxmox-Host kann alle Container bedienen
- Service-URLs immer mit lokaler IP + Port: `http://192.168.178.xxx:port`
- Bei HTTPS-Services: "No TLS Verify" aktivieren (Self-Signed Certificates)
**Wichtige URLs:**
- Pulse Monitoring: https://pulse.orbitalo.info
- pve2-1 Proxmox GUI: https://pve2-muldenstein.orbitalo.net
- pve3 Proxmox GUI: https://100.109.101.12:8006 (Tailscale, kein Tunnel)
## Zugangsdaten
- pve-hetzner: root / Astral-Proxmox!2026
- pve1: root / astral66
- Alle CTs: root / astral66
- Seafile: admin@orbitalo.net / astral66
- Forgejo: orbitalo / astral66
| System | Login | Zugang |
|---|---|---|
| pve-hetzner | root / Astral-Proxmox!2026 | SSH: 100.88.230.59<br>GUI: https://100.88.230.59:8006 |
| pve1 (Kambodscha) | root / astral66 | SSH: 100.122.56.60<br>GUI: https://100.122.56.60:8006 |
| pve2-1 (Muldenstein) | root / astral66 | SSH: 100.99.101.37<br>GUI: https://pve2-muldenstein.orbitalo.net |
| pve3 (Muldenstein) | root / astral66 | SSH: 100.109.101.12<br>GUI: https://100.109.101.12:8006 (Tailscale) |
| helmut-pve (Kumpel) | root / astral66 | SSH: 100.87.235.11<br>GUI: https://100.87.235.11:8006 |
| Alle CTs | root / astral66 | — |
| Seafile | admin@orbitalo.net / astral66 | https://seafile.orbitalo.net |
| Forgejo | orbitalo / astral66 | http://100.89.246.60:3000 |
## Telegram Bots
| Bot | Token (Auszug) | Chat-ID |
| Bot | Token (Auszug) | Chat-ID | Projekt |
|---|---|---|---|
| @MutterbotAI_bot | 8551565940:... | 674951792 | RSS-Manager / Allgemein |
| @Diendemleben_bot | 8799990587:... | 674951792 | FünfVorAcht |
| Edelmetall Bot | 8262992299:... | 674951792 | Gold/Silber Preise |
## KI-API Kosten
| Service | Kosten | Verbrauch |
|---|---|---|
| Mutter (@MutterbotAI_bot) | 8551565940:... | 674951792 |
| OpenRouter (GPT-4o-mini) | ~$0,35/Tag | RSS-Manager + Flugscanner Vision |
| Cursor Ultra | $200/Monat | Entwicklung |

View file

@ -1,11 +1,11 @@
# STATE: Redax-WP
**Stand: 28.02.2026**
**Stand: 03.03.2026**
---
## Status
✅ **Vollständig in Betrieb — 28.02.2026**
✅ **Vollständig in Betrieb — 03.03.2026**
---
@ -87,18 +87,40 @@ Docker Container:
| Feature | Beschreibung |
|---------|-------------|
| Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) |
| WP-Entwurf | Artikel direkt als Draft auf Primary pushen + Vorschau-Link |
| Redaktionsplan | 7-Tage-Kalender mit Status, Umplanen, Löschen |
| Multi-Publish | Beim Veröffentlichen: Primary + alle aktiven Mirrors |
| Publish-Ziele | Checkboxen zum Ein-/Ausschalten pro Mirror + Links zu Website & WP-Admin + Zugangsdaten |
| Mirror-Status | Pro Artikel: welche Sites wurden bespielt (✅/❌) |
| Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) |
| **Text-Import** | Quelle-Feld als Textarea — komplette Artikel einfügbar |
| **Markdown→HTML** | KI-Output zu WordPress-HTML konvertiert |
| **YouTube-Bilder** | YouTube-URL in Artikel → Thumbnail + Featured Image |
| **KI-Chat** | Freie Texteingabe mit Artikelkontext |
| WP-Entwurf | Artikel als Draft pushen |
| Redaktionsplan | 7-Tage-Kalender mit Drag & Drop |
| Multi-Publish | Primary + alle aktiven Mirrors |
| Publish-Ziele | Einklappbar mit Links zu Website & WP-Admin |
| Mirror-Status | Pro Artikel: welche Sites bespielt (✅/❌) |
| RSS-Queue | Feed-Artikel verwalten, KI-Rewrite, Auto-Publish |
| Duplikat-Schutz | Mirror überspringt Artikel die bereits vorhanden sind |
| Duplikat-Schutz | Mirror überspringt existierende Artikel |
---
## Changelog
### 03.03.2026 (Latest)
- **Text-Import**: Quelle-Feld von `<input>` zu `<textarea>` — ganze Artikel einfügbar
- **Neuer Default-Prompt**: „Artikel formatieren & SEO" — formatiert Text UND URLs zu WP-tauglichem Markdown
- **Markdown→HTML**: `_to_html()` in `app.py` konvertiert KI-Output automatisch zu HTML beim WP-Push
- **YouTube-Thumbnail im Content**: YouTube-URL alleine in eigenem Absatz → anklickbares `<img>` Vorschaubild
- **YouTube als Featured Image**: YouTube-Thumbnail wird automatisch als Beitragsbild gesetzt (wenn kein anderes Bild vorhanden)
- **Gunicorn-Fix**: `generate()` + `generate_chat()` in `openrouter.py` auf `requests` umgestellt (kein asyncio-Timeout)
- `markdown==3.10.2` zu `requirements.txt` hinzugefügt
- `docker-compose.yml`: `openrouter.py` + `rss_fetcher.py` als Volume-mounts (persistente Änderungen)
### 02.03.2026
- **KI-Chat** implementiert (freie Promptwahl, Artikelkontext, Übernehmen-Button)
- Layout: Redaktionsplan unten, Studio volle Breite
- Publish-Ziele einklappbar
- Entwurf: WP-Editor-Link + Vorschau-Link
- Editor + Vorschau größer
### 28.02.2026
- Multi-Publish implementiert: `WordPressMirrorClient` in `wordpress.py`
- `mirror_posts` Tabelle in SQLite für Mirror-Tracking

View file

@ -0,0 +1,148 @@
# Plan: Chat-Fenster mit freier Promptwahl
**Stand:** 02.03.2026
**Status:** ✅ **Umgesetzt**
**Ziel:** Schnelle, direkte KI-Interaktion ohne Umweg über feste Prompts
---
## 1. Übersicht
| Element | Beschreibung |
|--------|--------------|
| **Chatfenster** | Unter dem Editor, freie Texteingabe an die KI |
| **Kontext** | Aktueller Artikel (Titel, Inhalt, SEO) wird automatisch mitgegeben |
| **Prompts** | Feste Prompts bleiben optional (für "KI generieren" aus Quelle) |
| **Priorität** | Chat = Hauptweg, feste Prompts = Nebenweg |
---
## 2. Layout
```
┌─────────────────────────────────────────────────────────────┐
│ Nav, Status, Publish-Ziele │
├──────────────────────────────┬──────────────────────────────┤
│ Inhalt (Editor) │ Vorschau │
│ │ │
├──────────────────────────────┴──────────────────────────────┤
│ 💬 KI-Chat (freie Eingabe) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Nachrichtenverlauf (User/KI) │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ [Eingabefeld] [Senden] │ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ SEO, Medien, Buttons │
├─────────────────────────────────────────────────────────────┤
│ Redaktionsplan (nach Scrollen) │
└─────────────────────────────────────────────────────────────┘
```
- Chat direkt unter Editor + Vorschau
- **Standard: offen** (schnelle Interaktion), einklappbar mit Chevron „▸ KI-Chat“
- Eingabefeld groß genug für längere Anweisungen
---
## 3. API
### Endpoint: `POST /api/chat`
**Request:**
```json
{
"message": "Mach den ersten Absatz knackiger",
"history": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
],
"context": {
"title": "Aktueller Titel",
"content": "Aktueller Inhalt...",
"seo_title": "...",
"seo_description": "...",
"focus_keyword": "..."
}
}
```
**Response:**
```json
{
"reply": "Antwort der KI...",
"suggested_content": "Optional: überarbeiteter Text zum Übernehmen"
}
```
- Backend: OpenRouter wie bei `/api/polish`, aber mit Chat-System-Prompt
- System-Prompt: „Du bist ein Redakteurs-Assistent. Der User arbeitet an einem Artikel. Kontext: [Titel, Inhalt, SEO]. Antworte kurz und handlungsorientiert. Wenn der User Änderungen will, gib den überarbeiteten Text als `suggested_content` zurück.“
---
## 4. Chat-UI (Frontend)
| Element | Beschreibung |
|---------|--------------|
| Nachrichtenliste | User-Nachrichten rechts/unterschiedlich, KI links/anders |
| Eingabefeld | Textarea oder input, Enter zum Senden |
| Senden-Button | Expliziter Klick |
| Spinner | Während die KI antwortet |
| Übernehmen-Button | Bei Antworten mit `suggested_content` übernimmt in Editor/SEO |
| History | Nur Session (im Speicher), max. 6 Nachrichtenpaare, kein DB-Persistenz (v1) |
---
## 5. Ablauf
1. User tippt im Chat: „Kürze den zweiten Absatz“
2. Frontend sendet: message + context (aktueller Artikel) + history (falls vorhanden)
3. Backend ruft OpenRouter auf, KI antwortet
4. Antwort erscheint im Chat
5. Optional: KI liefert `suggested_content` → Button „In Editor übernehmen“
---
## 6. Feste Prompts (Anpassung)
| Änderung | Beschreibung |
|----------|--------------|
| Quelle + Ton + Prompt | Bleiben für „KI generieren“ (Artikel aus URL/Text erzeugen) |
| Position | Können kompakter werden (z.B. eine Zeile), Chat ist prominenter |
| Optional | Wenn User nur chatten will: Quelle/Prompt ignorierbar |
---
## 7. Aufwand (grobe Schätzung)
| Teil | Zeilen / Aufwand |
|------|------------------|
| `POST /api/chat` in app.py | ~40 Zeilen |
| Chat-UI in index.html | ~80 Zeilen HTML/JS |
| Integration Kontext (getArticleData) | ~5 Zeilen |
| **Gesamt** | ~23 Stunden |
---
## 8. Entscheidungen (festgelegt)
| Punkt | Entscheidung |
|-------|--------------|
| **Chat Standard** | Offen (schnelle Interaktion), einklappbar optional |
| **History persistieren** | Nein nur Session, bei Artikel-Wechsel/Reload neu |
| **Übernehmen-Button** | Zeigen, wenn `suggested_content` vorhanden |
| **Max. History** | 6 Nachrichtenpaare (12 Nachrichten) |
---
## 9. Reihenfolge (bei Umsetzung)
1. API-Endpoint `/api/chat`
2. Chat-UI (Eingabe + Senden + Anzeige)
3. Kontext-Anbindung (Artikel mitschicken)
4. Übernehmen-Button (optional)
5. Feinschliff (Spinner, Fehlerbehandlung, Einklappbar)
---
*Plan erstellt für Redax-WP. Umsetzung nur nach explizitem OK.*

View file

@ -0,0 +1,126 @@
# Redax Prompt-Experimente
Prompts unter **https://redax.orbitalo.net/prompts** bearbeiten. Neue erstellen, testen, als Standard setzen.
---
## Variablen (werden ersetzt)
- `{tone}` — informativ | meinungsstark | reportage
- `{date}` — aktuelles Datum (dd.mm.yyyy)
**Quelle** kommt als User-Eingabe (URL oder Text).
---
## A) Standard (aktuell)
```
Du bist ein erfahrener Redakteur. Schreibe einen vollständigen, gut strukturierten Artikel auf Basis der folgenden Quelle.
Ton: {tone}
Datum: {date}
Formatierung:
- Titel als erste Zeile (ohne #)
- Dann den Artikeltext in HTML (H2, H3, <p>, <ul>, <strong>)
- Am Ende: SEO_TITLE: [max 60 Zeichen]
- SEO_DESC: [max 155 Zeichen]
- KEYWORD: [1 Fokus-Keyword]
Quelle:
{source}
```
---
## B) Mit Links & Quellen
```
Du bist ein erfahrener Redakteur. Schreibe einen vollständigen, gut strukturierten Artikel auf Basis der folgenden Quelle.
Ton: {tone}
Datum: {date}
WICHTIG Quellen und Links:
- Behalte wichtige Quellen-Links im Text als <a href="URL">Linktext</a>
- Wenn die Quelle eine URL hat: am Ende des Artikels einen Absatz „Weiterlesen beim Original“ mit Link einbauen
- Externe Links mit target="_blank" rel="noopener"
Formatierung:
- Titel als erste Zeile (ohne #)
- Dann den Artikeltext in HTML (H2, H3, <p>, <ul>, <strong>)
- Am Ende: SEO_TITLE: [max 60 Zeichen]
- SEO_DESC: [max 155 Zeichen]
- KEYWORD: [1 Fokus-Keyword]
Quelle:
{source}
```
---
## C) Mit Links & Videos
```
Du bist ein erfahrener Redakteur. Schreibe einen vollständigen, gut strukturierten Artikel auf Basis der folgenden Quelle.
Ton: {tone}
Datum: {date}
WICHTIG:
- Links: Wichtige Quellen als <a href="URL" target="_blank" rel="noopener">Linktext</a> einbauen. Am Ende: „Weiterlesen“-Link zur Originalquelle wenn URL vorhanden.
- Videos: Wenn die Quelle ein YouTube- oder Vimeo-Video enthält, den passenden Embed einbauen:
YouTube: <iframe src="https://www.youtube.com/embed/VIDEO_ID" width="560" height="315" frameborder="0" allowfullscreen></iframe>
Vimeo: <iframe src="https://player.vimeo.com/video/VIDEO_ID" width="560" height="315" frameborder="0" allowfullscreen></iframe>
- Nur einbinden wenn das Video den Artikel sinnvoll ergänzt.
Formatierung:
- Titel als erste Zeile (ohne #)
- Dann den Artikeltext in HTML (H2, H3, <p>, <ul>, <strong>)
- Am Ende: SEO_TITLE: [max 60 Zeichen]
- SEO_DESC: [max 155 Zeichen]
- KEYWORD: [1 Fokus-Keyword]
Quelle:
{source}
```
---
## D) Kompakt & klar
```
Schreibe einen Artikel basierend auf der Quelle. Ton: {tone}. Datum: {date}.
Output-Format:
1. Erste Zeile = Titel (ohne #)
2. Artikel in HTML: <p>, <h2>, <h3>, <ul>, <strong>, <a href="...">
3. Quellen-Links im Text beibehalten; am Ende „Weiterlesen“-Link wenn URL vorhanden
4. Bei Video-Quellen: YouTube/Vimeo iframe einbetten
5. Abschluss: SEO_TITLE: [60 Zeichen] / SEO_DESC: [155 Zeichen] / KEYWORD: [1 Wort]
Quelle:
{source}
```
---
## Testablauf
1. https://redax.orbitalo.net/prompts öffnen
2. „+ Neuer Prompt“ → Name z.B. „Mit Links“
3. Prompt B oder C einfügen → Speichern → „Standard“ klicken
4. Zurück zum Studio, Quelle eingeben (z.B. Artikel-URL), „KI generieren“
5. Ergebnis prüfen: Sind Links/Videos drin?
6. Bei Bedarf Prompt anpassen und erneut testen
---
## Tipp: Quelle = was du eingibst
Redax fetcht **keinen** Web-Inhalt. Die KI bekommt exakt das, was du in „Quelle“ eintippst.
- **Nur URL**: KI hat wenig Kontext (kennt evtl. bekannte Seiten, aber unzuverlässig)
- **Besser**: Artikeltext kopieren und einfügen — dann hat die KI den vollen Inhalt
- Bei RSS: Redax nutzt Titel + URL + Summary aus dem Feed (mehr Kontext)

View file

@ -0,0 +1,61 @@
# Redax: Angepasster Prompt + Kambodscha-Quelle zum Selbsttesten
---
## 1. Prompt (unter https://redax.orbitalo.net/prompts einfügen)
**Name:** Sachlich mit SEO
```
Du bist ein erfahrener Redakteur. Schreibe einen vollständigen, gut strukturierten Artikel auf Basis der folgenden Quelle.
Ton: {tone}
Datum: {date}
REGELN:
- Keine konkreten Agenten-, Firmen- oder Personennamen nennen (nur allgemein: "eine Agentur", "Anbieter im Riverside")
- Keine nummerierten Listen (1. 2. 3.) und keine Bulletpoints im Fließtext stattdessen Absätze
- Keine Tabellen Unterschiede im Fließtext erklären
- Keine Zusatzinhalte wie "Grafik-Ideen", "Tipps" oder Ähnliches am Ende
- Output nur: Titel, Artikel, SEO-Zeilen
FORMATIERUNG:
- Erste Zeile = Titel (ohne #)
- Artikel in HTML: <p>, <h2>, <h3>, <strong> keine <ul>/<ol>
- Am Ende des Artikels exakt diese drei Zeilen (ohne ### oder andere Präfixe):
SEO_TITLE: [max 60 Zeichen]
SEO_DESC: [max 155 Zeichen]
KEYWORD: [1 Fokus-Keyword]
Quelle:
{source}
```
---
## 2. Quelle (ins Feld "Quelle" im Studio einfügen)
```
Titel: Ohne Visum kein Kambodscha warum dein erster Schritt nicht Hausbau oder Business, sondern Immigration sein sollte
Zielgruppe: Auswanderungsinteressierte, die länger als touristisch in Kambodscha bleiben wollen.
Ton: nüchtern, erfahren, realistisch. Keine Romantisierung. Kein Werbestil. Keine Rechtsberatung. Keine Preisgarantien. Keine konkreten Agentennamen.
Inhalt: Einstieg über stabiles Aufenthaltsmodell. Viele Visa-Agenturen im Riverside-Bereich Phnom Penh. Unterschied: Tourist Visa, Ordinary (Business) Visa, Jahres-Extension. Praxisbeispiel: Eine Visa-Agentur im Riverside organisierte ca. 10 Tage eine saubere Jahresverlängerung. Ablauf: Passabgabe, Bearbeitungszeit, Rückgabe mit gültigem Eintrag. System: Rolle von Agenten, persönliche Netzwerke, Regeln können sich ändern. Strategisch: Aufenthaltsstatus Grundlage für Mietverträge, Geschäftsaufbau, Planung, Verhandlungsposition. Abschluss: Visa-Regelungen ändern sich, jeder prüft selbst aktuelle Infos.
Länge: 9001200 Wörter. Zwischenüberschriften. Sachliche Sprache. Keine Dramatisierung.
```
---
## 3. Schritte zum Testen
1. https://redax.orbitalo.net öffnen und einloggen
2. Zu **Prompts** gehen → "+ Neuer Prompt"
3. Prompt aus Abschnitt 1 einfügen → Speichern → "Standard" klicken
4. Zurück zum **Studio**
5. Quelle aus Abschnitt 2 ins Feld "Quelle (URL oder Text)" einfügen
6. Ton: **Informativ**
7. "🤖 KI generieren" klicken
8. Vorschau erscheint rechts; bei Bedarf "💾 Entwurf" für WordPress-Vorschau

View file

@ -1,11 +1,11 @@
# STATE: Redax-WP (Redakteur)
**Stand: 28.02.2026**
**Stand: 02.03.2026**
---
## Status
✅ **Vollständig in Betrieb — Multi-Publish, KI-Serie, animierte Grafiken**
✅ **Vollständig in Betrieb — Multi-Publish, KI-Chat, KI-Serie**
---
@ -38,7 +38,7 @@
│ ├── app.py Flask-App, Scheduler, alle Routes
│ ├── wordpress.py WordPressClient + WordPressMirrorClient
│ ├── database.py SQLite Schema + Helpers
│ ├── openrouter.py OpenRouter API (sync wrapper)
│ ├── openrouter.py OpenRouter API (generate + generate_chat für KI-Chat)
│ ├── rss_fetcher.py RSS Feed Parser
│ ├── logger.py JSON Logging
│ ├── Dockerfile.web Docker Image Build
@ -63,7 +63,8 @@ redax-db MySQL 8
### KI-Artikel
- Quelle eingeben → Ton wählen → KI generiert Artikel + SEO-Felder automatisch
- Zwei-Spalten-Editor: Markdown links / WordPress-Vorschau rechts
- Zwei-Spalten-Editor: Markdown links / WordPress-Vorschau rechts (große Designfläche 50vh/75vh)
- **KI-Chat** unter Editor: Freie Texteingabe an die KI, Artikelkontext wird automatisch mitgegeben, max. 6 Nachrichtenpaare History, Button „In Editor übernehmen“ bei Änderungsvorschlägen
- Featured Image aus og:image der Quelle automatisch
- Kategorie + Tags aus WordPress live geladen
- Publish / Entwurf / Einplanen (15-Minuten-Slots)
@ -79,7 +80,8 @@ redax-db MySQL 8
- Credentials (User/PW) direkt im Dashboard sichtbar
- WP-Admin Direktzugang via socat-Proxy (bypass Cloudflare WAF)
### Redaktionsplan (verbessert)
### Redaktionsplan
- **Layout:** Unten (volle Breite), nach Scrollen sichtbar — Studio nimmt oben gesamte Breite
- 7-Tage-Kalender mit KI + RSS gemeinsam
- Badge: 🤖 KI / 📡 RSS
- **Drag & Drop** zum Umplanen zwischen Tagen
@ -88,6 +90,10 @@ redax-db MySQL 8
- **Entwürfe ohne Datum** in separater Sektion sichtbar
- WP-Editor-Link für Arakava News via socat-Proxy: `http://100.88.230.59:8101/wp-admin/`
### Entwurf-Speicher
- Zwei Links nach Push: **Im WP-Editor bearbeiten** (WP_ADMIN_DIRECT_URL) + **Vorschau ansehen**
- Publish-Ziele (WP-Targets) einklappbar, Standard eingeklappt
### RSS-Feeds
- Beliebig viele Feeds konfigurierbar
- Modi: Manuell / Auto-Publish (Teaser) / KI-Rewrite + Publish
@ -134,7 +140,7 @@ Direkt-URL: `http://100.88.230.59:8101/wp-admin/`
| Was | Pfad |
|-----|------|
| App | /opt/redax-wp/ |
| Datenbank | /opt/redax-wp/data/redax.db |
| Datenbank | /opt/redax-wp/data/db/redax.db |
| WordPress-Dateien | /opt/redax-wp/data/wordpress/ |
| MySQL-Daten | /opt/redax-wp/data/mysql/ |
| Logs | /opt/redax-wp/logs/ |
@ -184,6 +190,14 @@ TELEGRAM_CHANNEL_ID=...
## Changelog
### 02.03.2026 — KI-Chat + Layout
- **KI-Chat** unter Editor: Freie Texteingabe, Artikelkontext, History (6 Paare), „In Editor übernehmen“
- **API** `/api/chat` + `openrouter.generate_chat(messages)`
- **Layout:** Redaktionsplan nach unten (volle Breite), Studio oben volle Breite
- **Publish-Ziele** einklappbar
- **Editor + Vorschau** größer (50vh / 75vh)
- **Entwurf:** Zwei Links (WP-Editor bearbeiten + Vorschau)
### 28.02.2026 — ESP32-Serie Teil 2 + Animiertes Hydraulikschema
- **ESP32-Serie Teil 2** als WP-Entwurf erstellt (Post 1340 auf Arakava News)
- Titel: "70 Euro gegen Heizungschaos: Die Hardware für mein Smart-Home-Projekt"

18
redax-wp/check-wp-push.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/bash
# Prüft ob Redax-Entwürfe auf Arakava News 2 landen
# Auf pve-hetzner: ssh root@100.88.230.59, dann:
# pct exec 113 -- bash -c 'cd /opt/redax-wp && source .env 2>/dev/null; echo "WP_URL=$WP_URL"'
echo "=== 1. WP_URL in .env (CT 113) ==="
sshpass -p 'Astral-Proxmox!2026' ssh -o StrictHostKeyChecking=no root@100.88.230.59 \
"pct exec 113 -- bash -c 'grep -E \"^WP_URL=\" /opt/redax-wp/.env 2>/dev/null || echo \"Keine .env gefunden\"'"
echo ""
echo "=== 2. Letzte Redax-Logs (draft_push) ==="
sshpass -p 'Astral-Proxmox!2026' ssh -o StrictHostKeyChecking=no root@100.88.230.59 \
"pct exec 113 -- tail -20 /opt/redax-wp/logs/redax.log 2>/dev/null | grep -E 'draft|push|article'" 2>/dev/null || echo "Keine passenden Log-Einträge"
echo ""
echo "=== 3. Letzter Artikel in Redax-DB (wp_post_id) ==="
sshpass -p 'Astral-Proxmox!2026' ssh -o StrictHostKeyChecking=no root@100.88.230.59 \
"pct exec 113 -- sqlite3 /opt/redax-wp/data/db/redax.db 'SELECT id, title, wp_post_id, status FROM articles ORDER BY id DESC LIMIT 3'" 2>/dev/null || echo "DB-Zugriff fehlgeschlagen"

View file

@ -46,6 +46,9 @@ services:
volumes:
- ./data/db:/data
- ./logs:/logs
- ./src/app.py:/app/app.py
- ./src/database.py:/app/database.py
- ./src/templates:/app/templates
ports:
- "8080:8080"
networks:

View file

@ -0,0 +1,7 @@
{
"title": "Ohne Visum kein Kambodscha warum dein erster Schritt nicht Hausbau oder Business, sondern Immigration sein sollte",
"content": "In der Überlegung, nach Kambodscha auszuwandern, gibt es eine zentrale Fragestellung: Wie stellt man sicher, dass der Aufenthalt rechtlich abgesichert ist? Bevor du über den Bau eines Hauses oder die Gründung eines Unternehmens nachdenkst, ist es unerlässlich, die verschiedenen Möglichkeiten des Aufenthalts rechtlich zu klären. In diesem Artikel beleuchte ich die verschiedenen Visa-Optionen sowie den Ablauf der Beantragung und die Rolle von Agenturen in Phnom Penh.\n\n## Stabiles Aufenthaltsmodell\n\nKambodscha bietet eine Vielzahl von Aufenthaltsmodellen, die auf verschiedene Bedürfnisse abgestimmt sind. Die gängigsten Visa sind das Touristenvisum, das Ordinary Business Visa (EB-Visa) und die Jahresverlängerung. Je nach deinem Vorhaben in Kambodscha kann die Wahl des richtigen Visas entscheidend für die Planung deiner Zukunft hier sein.\n\n### Die Visa-Optionen im Detail\n\nTouristenvisum: Bis zu 30 Tage, 1 Monat einmal verlängerbar, touristische Aufenthalte.\nOrdinary Business Visa: 30 Tage bis 1 Jahr, Jahresverlängerung möglich, geschäftliche Aktivitäten.\nJahresverlängerung: 1 Jahr, kann jährlich erneuert werden, langfristiger Aufenthalt.\n\n### Agenturen im Riverside Phnom Penh\n\nIn Phnom Penh, besonders im Riverside-Gebiet, gibt es zahlreiche Visa-Agenturen, die den Prozess der Visa-Beantragung und -Verlängerung erleichtern. Diese Agenturen sind oft mit dem lokalen System vertraut und können helfen, die Formalitäten schnell und unkompliziert zu erledigen. Im Riverside-Bereich finden sich mehrere Anbieter.\n\n## Praxisbeispiel: Ablauf einer Jahresverlängerung\n\nEine Visa-Agentur im Riverside-Bereich organisierte innerhalb von etwa 10 Tagen eine saubere Jahresverlängerung im Reisepass. Der Ablauf gliedert sich in drei Schritte: Zunächst übergibst du deinen Reisepass bei der Agentur. Während der Bearbeitungszeit von rund 10 Tagen erledigt die Agentur die notwendigen Formalitäten. Anschließend erhältst du deinen Pass mit dem gültigen Eintrag zurück.\n\n## Die Rolle der Agenten und persönliche Netzwerke\n\nDie Agenten spielen eine entscheidende Rolle im Visasystem. Sie sind oft gut vernetzt und kennen die lokalen Behörden, was den Prozess wesentlich beschleunigen kann. Hierbei ist es hilfreich, ein persönliches Netzwerk aufzubauen. Wichtig: Die Vorschriften und Anforderungen für Visa in Kambodscha können sich häufig ändern. Daher ist es ratsam, sich regelmäßig über die aktuellen Bestimmungen zu informieren.\n\n## Strategische Überlegungen zum Aufenthaltsstatus\n\nDer Aufenthaltsstatus ist Grundlage für Mietverträge, Geschäftsaufbau und langfristige Planung. Ohne gültiges Visum ist es nahezu unmöglich, legal einen Mietvertrag abzuschließen oder ein Geschäft zu gründen. Ein rechtmäßiger Aufenthalt stärkt zudem die Verhandlungsposition gegenüber Vermietern und Geschäftspartnern.\n\n## Abschluss\n\nJeder, der über eine Auswanderung nach Kambodscha nachdenkt, sollte sich intensiv mit den Visa-Möglichkeiten auseinandersetzen. Visa-Regelungen können sich ändern. Informiere dich selbst über aktuelle Bestimmungen, um unangenehme Überraschungen zu vermeiden.",
"seo_title": "Ohne Visum kein Kambodscha Visa-Grundlagen für Auswanderer",
"seo_description": "Warum Immigration vor Hausbau oder Business: Aufenthaltsmodelle, Visa-Optionen und die Rolle von Agenturen im Riverside Phnom Penh.",
"focus_keyword": "Kambodscha Visum"
}

View file

@ -272,8 +272,9 @@ def api_generate():
data = request.json
source = data.get('source', '')
tone = data.get('tone', 'informativ')
prompt_id = data.get('prompt_id')
prompt = db.get_default_prompt()
prompt = db.get_prompt_by_id(prompt_id) if prompt_id else db.get_default_prompt()
if not prompt:
return jsonify({'error': 'Kein Prompt konfiguriert'}), 400
@ -302,6 +303,127 @@ def api_generate():
return jsonify({'error': str(e)}), 500
@app.route('/api/polish', methods=['POST'])
def api_polish():
"""KI-gestützte Textverbesserung: Nutzer gibt Anweisung, KI poliert Titel + Inhalt."""
data = request.json
title = data.get('title', '')
content = data.get('content', '')
instruction = data.get('instruction', '').strip()
if not instruction:
return jsonify({'error': 'Bitte Anweisung eingeben (z.B. kürzer, lockerer, Einstieg packender)'}), 400
if not content and not title:
return jsonify({'error': 'Kein Inhalt zum Verbessern'}), 400
system = """Du bist ein Redakteur. Deine Aufgabe: Artikel bearbeiten nach Anweisung. Gib IMMER den bearbeiteten Text zurück keine Ablehnung, kein "Ich kann nicht helfen".
Erledige die Anweisung (Stil ändern, kürzen, Bild einfügen, etc.) und gib NUR den Ergebnis-Text zurück.
Format: Erste Zeile = Titel (ohne #). Dann HTML (<p>, <h2>, <h3>, <strong>, <img src="..." />).
Am Ende: SEO_TITLE: [max 60]
SEO_DESC: [max 155]
KEYWORD: [1 Wort]"""
user_msg = f"Anweisung: {instruction}\n\n---\nTitel: {title}\n\nInhalt:\n{content}"
try:
raw = openrouter.generate(system, user_msg)
parsed_content, seo_title, seo_desc, keyword = rss_fetcher._parse_ki_output(raw)
lines = parsed_content.strip().split('\n')
new_title = lines[0].lstrip('#').strip() if lines else title
new_content = '\n'.join(lines[1:]).strip() if len(lines) > 1 else parsed_content
flog.info('article_polished', instruction=instruction[:50])
return jsonify({
'title': new_title,
'content': new_content,
'seo_title': seo_title or data.get('seo_title'),
'seo_description': seo_desc or data.get('seo_description'),
'focus_keyword': keyword or data.get('focus_keyword'),
})
except Exception as e:
flog.error('polish_failed', error=str(e))
return jsonify({'error': str(e)}), 500
@app.route('/api/chat', methods=['POST'])
def api_chat():
"""Freier KI-Chat mit Artikelkontext. History max 6 Paare."""
data = request.json
message = (data.get('message') or '').strip()
history = data.get('history') or []
ctx = data.get('context') or {}
if not message:
return jsonify({'error': 'Bitte Nachricht eingeben'}), 400
# History auf 6 Paare begrenzen
history = history[-12:] # max 12 messages (6 user + 6 assistant)
title = ctx.get('title', '')
content = ctx.get('content', '')
seo_title = ctx.get('seo_title', '')
seo_desc = ctx.get('seo_description', '')
keyword = ctx.get('focus_keyword', '')
system = """Du bist ein Redakteurs-Assistent. Der User arbeitet an einem WordPress-Artikel.
Kontext des aktuellen Artikels:
- Titel: """ + (title or '(leer)') + """
- Inhalt: """ + (content[:3000] + '...' if len(content or '') > 3000 else (content or '(leer)')) + """
- SEO-Titel: """ + (seo_title or '(leer)') + """
- SEO-Beschreibung: """ + (seo_desc or '(leer)') + """
- Fokus-Keyword: """ + (keyword or '(leer)') + """
Antworte kurz und handlungsorientiert. Wenn der User Änderungen am Artikel wünscht (kürzen, umschreiben, Teaser, etc.):
Gib deine Antwort, und am Ende in diesem Format den überarbeiteten Inhalt:
===APPLY===
TITEL: [neuer Titel falls geändert]
INHALT: [HTML-Inhalt]
SEO_TITLE: [optional, max 60 Zeichen]
SEO_DESC: [optional, max 155 Zeichen]
KEYWORD: [optional]
===ENDAPPLY===
Nur die Felder angeben die sich ändern. Ohne ===APPLY=== wenn du keinen Text zum Übernehmen lieferst."""
messages = [{"role": "system", "content": system}]
for h in history:
if h.get('role') in ('user', 'assistant') and h.get('content'):
messages.append({"role": h["role"], "content": h["content"]})
messages.append({"role": "user", "content": message})
try:
raw = openrouter.generate_chat(messages)
suggested = None
if '===APPLY===' in raw and '===ENDAPPLY===' in raw:
try:
block = raw.split('===APPLY===')[1].split('===ENDAPPLY===')[0].strip()
parts = {}
for line in block.split('\n'):
if ':' in line:
k, v = line.split(':', 1)
parts[k.strip().upper()] = v.strip()
suggested = {
'title': parts.get('TITEL') or title,
'content': parts.get('INHALT') or content,
'seo_title': parts.get('SEO_TITLE') or seo_title,
'seo_description': parts.get('SEO_DESC') or seo_desc,
'focus_keyword': parts.get('KEYWORD') or keyword,
}
reply = raw.split('===APPLY===')[0].strip()
except Exception:
reply = raw
else:
reply = raw
flog.info('chat_message', msg_len=len(message))
out = {'reply': reply}
if suggested:
out['suggested_content'] = suggested
return jsonify(out)
except Exception as e:
flog.error('chat_failed', error=str(e))
return jsonify({'error': str(e)}), 500
@app.route('/api/article/save', methods=['POST'])
def api_save_article():
data = request.json
@ -338,12 +460,24 @@ def api_save_article():
db.update_article(article_id, {'wp_post_id': wp_post_id})
wp_base = os.getenv('WP_URL', '').rstrip('/')
admin_base = os.getenv('WP_ADMIN_DIRECT_URL', wp_base).rstrip('/')
wp_preview_url = f"{wp_base}/?p={wp_post_id}&preview=true"
wp_edit_url = f"{admin_base}/wp-admin/post.php?post={wp_post_id}&action=edit"
flog.info('article_saved_as_draft', article_id=article_id, wp_post_id=wp_post_id)
except Exception as e:
flog.warn('draft_push_failed', article_id=article_id, error=str(e))
if wp_post_id:
wp_base = os.getenv('WP_URL', '').rstrip('/')
admin_base = os.getenv('WP_ADMIN_DIRECT_URL', wp_base).rstrip('/')
wp_edit_url = f"{admin_base}/wp-admin/post.php?post={wp_post_id}&action=edit"
else:
wp_edit_url = None
return jsonify({'success': True, 'id': article_id, 'wp_post_id': wp_post_id, 'wp_preview_url': wp_preview_url})
return jsonify({
'success': True, 'id': article_id, 'wp_post_id': wp_post_id,
'wp_preview_url': wp_preview_url,
'wp_edit_url': wp_edit_url if wp_post_id else None
})
@app.route('/api/article/schedule', methods=['POST'])
@ -534,14 +668,18 @@ def api_save_prompt():
"UPDATE prompts SET name=?, system_prompt=? WHERE id=?",
(data['name'], data['system_prompt'], data['id'])
)
conn.commit()
conn.close()
return jsonify({'success': True, 'id': data['id']})
else:
conn.execute(
cur = conn.execute(
"INSERT INTO prompts (name, system_prompt) VALUES (?,?)",
(data['name'], data['system_prompt'])
)
conn.commit()
conn.close()
return jsonify({'success': True})
new_id = cur.lastrowid
conn.commit()
conn.close()
return jsonify({'success': True, 'id': new_id})
@app.route('/api/prompts/<int:pid>/default', methods=['POST'])

View file

@ -403,6 +403,13 @@ def get_default_prompt():
return dict(row) if row else None
def get_prompt_by_id(pid: int):
conn = get_conn()
row = conn.execute("SELECT * FROM prompts WHERE id=?", (pid,)).fetchone()
conn.close()
return dict(row) if row else None
# ── Settings ──────────────────────────────────────────────────────────────────
def get_setting(key, default=None):

View file

@ -88,7 +88,32 @@ def generate(system_prompt: str, user_message: str) -> str:
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
async with _aiohttp.ClientSession() as session:
timeout = _aiohttp.ClientTimeout(total=90)
async with _aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp:
data = await resp.json()
if resp.status != 200:
raise Exception(f"OpenRouter Fehler {resp.status}: {data}")
return data["choices"][0]["message"]["content"].strip()
return asyncio.run(_gen())
def generate_chat(messages: list) -> str:
"""Chat mit History. messages: [{"role":"system/user/assistant","content":"..."}, ...]"""
import asyncio, aiohttp as _aiohttp
async def _gen():
payload = {
"model": DEFAULT_MODEL,
"messages": messages,
"max_tokens": 2500,
"temperature": 0.7
}
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
timeout = _aiohttp.ClientTimeout(total=90)
async with _aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp:
data = await resp.json()
if resp.status != 200:

View file

@ -1,8 +1,13 @@
{% extends "base.html" %}
{% block title %}Redax-WP — Studio{% endblock %}
{% block extra_head %}
<style>
#wp-preview, #wp-preview p, #wp-preview h1, #wp-preview h2, #wp-preview h3, #wp-preview li, #wp-preview span { color: #0f172a !important; }
#wp-preview a { color: #2563eb !important; }
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-6 py-6">
<div class="max-w-7xl mx-auto px-6 py-6" data-wp-admin="{{ wp_admin_direct }}" data-wp-url="{{ wp_url }}">
<!-- Status-Bar -->
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500 flex-wrap">
@ -14,10 +19,13 @@
{% endif %}
</div>
<!-- WordPress-Targets -->
<div class="mb-6">
<div class="text-xs text-slate-500 mb-2">📡 Publish-Ziele</div>
<div class="flex flex-wrap gap-3">
<!-- WordPress-Targets (einklappbar) -->
<div class="mb-3">
<button type="button" onclick="var b=document.getElementById('wp-targets-box');b.classList.toggle('hidden');document.getElementById('wp-targets-chevron').textContent=b.classList.contains('hidden')?'▸':'▾'"
class="text-xs text-slate-500 hover:text-slate-300 flex items-center gap-1.5 py-1">
<span id="wp-targets-chevron"></span> 📡 Publish-Ziele
</button>
<div id="wp-targets-box" class="hidden mt-2" style="display:flex;flex-wrap:wrap;gap:0.75rem">
{% for t in wp_targets %}
<div style="background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
@ -63,20 +71,20 @@
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div class="space-y-6">
<!-- ═══ STUDIO (links, 2/3) ═══ -->
<div class="xl:col-span-2 space-y-4">
<!-- ═══ STUDIO (oben, volle Breite) ═══ -->
<div class="space-y-4">
<!-- Artikel-Generator -->
<div class="card p-5">
<h2 class="text-base font-semibold text-white mb-4">✍️ Artikel-Studio</h2>
<!-- Quelle + Ton -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<!-- Quelle + Ton + Prompt -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
<div class="md:col-span-2">
<label class="text-xs text-slate-400 block mb-1">Quelle (URL oder Text)</label>
<input type="url" id="source-input" placeholder="https://..." class="w-full">
<input type="text" id="source-input" placeholder="https://... oder Text einfügen" class="w-full">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Ton</label>
@ -86,6 +94,14 @@
<option value="reportage">Reportage</option>
</select>
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Prompt</label>
<select id="prompt-select" class="w-full">
{% for p in prompts %}
<option value="{{ p.id }}" {% if p.is_default %}selected{% endif %}>{{ p.name }}{% if p.is_default %} ✓{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Titel -->
@ -94,25 +110,53 @@
<input type="text" id="article-title" placeholder="Artikel-Titel" class="w-full">
</div>
<!-- Zwei-Spalten Editor -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
<!-- Zwei-Spalten Editor + Vorschau (große Designfläche) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4" style="min-height: 65vh">
<div class="flex flex-col min-h-0">
<label class="text-xs text-slate-400 block mb-1">Inhalt (HTML)</label>
<textarea id="article-content" rows="14" placeholder="Artikel-Inhalt..."
oninput="updatePreview()"></textarea>
<textarea id="article-content" placeholder="Artikel-Inhalt..."
oninput="updatePreview()" class="flex-1 min-h-[320px]"
style="min-height: 50vh; resize: vertical"></textarea>
<div class="mt-2 flex gap-2 items-center flex-wrap">
<input type="text" id="inline-image-url" placeholder="Bild-URL für Inhalt..." class="flex-1 min-w-[120px] text-xs">
<button type="button" onclick="insertImageAtCursor()" class="text-xs px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-200">🖼️ Hier einfügen</button>
</div>
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">WordPress-Vorschau</label>
<div class="flex flex-col min-h-0">
<label class="text-xs text-slate-400 block mb-1">Vorschau</label>
<div id="wp-preview"
class="bg-white text-slate-900 rounded-lg p-4 text-sm overflow-y-auto"
style="min-height: 14rem; max-height: 24rem; font-family: Georgia, serif; line-height: 1.8;">
<span class="text-slate-400 italic text-xs">Vorschau erscheint beim Tippen...</span>
class="rounded-lg p-5 overflow-y-auto border border-slate-600 flex-1"
style="min-height: 50vh; max-height: 75vh; font-family: Georgia, serif; line-height: 1.7; font-size: 1rem; background: #f8fafc; color: #0f172a;">
<span class="text-slate-500 italic">Vorschau erscheint beim Tippen...</span>
</div>
<div id="wp-draft-link" class="hidden"></div>
<div id="mirror-status-box" class="hidden mt-2 text-xs space-y-1"></div>
</div>
</div>
<!-- KI-Chat (freie Eingabe) -->
<div class="card p-4 mb-4">
<div class="text-sm font-semibold text-white mb-2 flex items-center gap-2">
<span>💬 KI-Chat</span>
<button type="button" id="chat-toggle" onclick="var b=document.getElementById('chat-box');b.classList.toggle('hidden');this.textContent=b.classList.contains('hidden')?'▸ aufklappen':'▾ einklappen'"
class="text-xs text-slate-500 hover:text-slate-300">▾ einklappen</button>
</div>
<div id="chat-box">
<div id="chat-messages" class="space-y-3 mb-3 max-h-[220px] overflow-y-auto rounded-lg border border-slate-600 p-3 bg-slate-900/50 text-sm"
style="min-height: 80px">
<span class="text-slate-500 italic text-xs">Schreibe eine Nachricht die KI kennt deinen Artikel.</span>
</div>
<div class="flex gap-2">
<input type="text" id="chat-input" placeholder="z.B. Kürze den ersten Absatz, schreib einen Teaser..."
class="flex-1 text-sm" onkeydown="if(event.key==='Enter')sendChat()">
<button type="button" id="btn-chat" onclick="sendChat()" class="btn btn-primary">Senden</button>
</div>
<div id="chat-apply-row" class="hidden mt-2">
<button type="button" onclick="applyChatSuggestion()" class="btn btn-success text-xs">✓ In Editor übernehmen</button>
</div>
</div>
</div>
<!-- SEO-Panel -->
<div class="bg-slate-900/50 border border-slate-700 rounded-lg p-3 mb-4">
<div class="text-xs text-slate-400 font-semibold mb-2">🔍 SEO</div>
@ -150,13 +194,18 @@
</div>
<!-- Aktions-Buttons -->
<div class="flex gap-2 flex-wrap">
<button onclick="generateArticle()" class="btn btn-primary">
<span id="gen-spinner" class="spinner hidden"></span> 🤖 KI generieren
</button>
<button onclick="saveDraft()" class="btn btn-ghost">💾 Entwurf</button>
<button onclick="publishNow()" class="btn btn-success">🚀 Sofort veröffentlichen</button>
<button onclick="toggleSchedulePanel()" class="btn" style="background:#4c1d95;color:#fff">📅 Einplanen</button>
<div class="flex flex-col gap-3">
<div class="flex gap-2 flex-wrap">
<button id="btn-generate" onclick="generateArticle()" class="btn btn-primary">🤖 KI generieren</button>
<button onclick="saveDraft()" class="btn btn-ghost">💾 Entwurf</button>
<button onclick="publishNow()" class="btn btn-success">🚀 Sofort veröffentlichen</button>
<button onclick="toggleSchedulePanel()" class="btn" style="background:#4c1d95;color:#fff">📅 Einplanen</button>
</div>
<div class="flex gap-2 flex-wrap items-center p-3 rounded-lg border border-slate-600 bg-slate-800/60">
<span class="text-sm font-medium text-slate-300">✨ KI verbessern:</span>
<input type="text" id="polish-instruction" placeholder="z.B. kürzer, lockerer — oder: Bild https://... nach Absatz 2 einfügen" class="flex-1 min-w-[200px] text-sm">
<button id="btn-polish" type="button" onclick="polishArticle()" class="btn btn-primary">Verbessern</button>
</div>
</div>
<!-- Einplan-Panel -->
@ -181,7 +230,7 @@
</div>
<!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ -->
<!-- ═══ REDAKTIONSPLAN (unten, volle Breite, scrollen) ═══ -->
<div class="space-y-4">
{% set draft_arts = [] %}
@ -341,32 +390,183 @@ function updatePreview() {
preview.innerHTML = (title ? `<h1 style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">${title}</h1>` : '') + content;
}
function insertImageAtCursor() {
const url = document.getElementById('inline-image-url').value.trim();
const ta = document.getElementById('article-content');
if (!url) { showToast('⚠️ Bild-URL eingeben'); return; }
const img = `<p><img src="${url.replace(/"/g, '&quot;')}" alt="Bild" style="max-width:100%;height:auto;"></p>`;
const start = ta.selectionStart, end = ta.selectionEnd;
const before = ta.value.substring(0, start), after = ta.value.substring(end);
ta.value = before + img + after;
ta.selectionStart = ta.selectionEnd = start + img.length;
ta.focus();
updatePreview();
showToast('🖼️ Bild eingefügt');
}
function setButtonLoading(btnId, loading) {
const btn = document.getElementById(btnId);
if (!btn) return;
const labels = { 'btn-generate': '🤖 KI generieren', 'btn-polish': 'Verbessern', 'btn-chat': 'Senden' };
if (loading) {
btn.dataset.origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Bitte warten...';
} else {
btn.disabled = false;
btn.textContent = btn.dataset.origText || labels[btnId] || 'OK';
}
}
let chatHistory = [];
let lastSuggested = null;
function renderChatMessages() {
const el = document.getElementById('chat-messages');
if (!el) return;
if (chatHistory.length === 0) {
el.innerHTML = '<span class="text-slate-500 italic text-xs">Schreibe eine Nachricht die KI kennt deinen Artikel.</span>';
return;
}
el.innerHTML = chatHistory.map(m => {
const isUser = m.role === 'user';
return `<div class="flex ${isUser ? 'justify-end' : 'justify-start'}">
<div class="max-w-[85%] rounded-lg px-3 py-2 text-xs ${isUser ? 'bg-blue-900/50 border border-blue-700' : 'bg-slate-800 border border-slate-600'}">
${(m.content || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}
</div>
</div>`;
}).join('');
el.scrollTop = el.scrollHeight;
}
async function sendChat() {
const input = document.getElementById('chat-input');
const msg = (input?.value || '').trim();
if (!msg) return;
const ctx = getArticleData();
const history = chatHistory.slice(-12).map(m => ({ role: m.role, content: m.content }));
chatHistory.push({ role: 'user', content: msg });
input.value = '';
renderChatMessages();
document.getElementById('chat-apply-row').classList.add('hidden');
lastSuggested = null;
setButtonLoading('btn-chat', true);
try {
const r = await fetch('/api/chat', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
message: msg,
history: history,
context: { title: ctx.title, content: ctx.content, seo_title: ctx.seo_title, seo_description: ctx.seo_description, focus_keyword: ctx.focus_keyword }
})
});
const d = await r.json().catch(() => ({}));
if (d.error) { showToast('❌ ' + d.error); chatHistory.pop(); renderChatMessages(); return; }
chatHistory.push({ role: 'assistant', content: d.reply });
if (chatHistory.length > 12) chatHistory = chatHistory.slice(-12);
if (d.suggested_content) { lastSuggested = d.suggested_content; document.getElementById('chat-apply-row').classList.remove('hidden'); }
renderChatMessages();
} catch (e) {
showToast('❌ ' + (e.message || 'Fehler')); chatHistory.pop(); renderChatMessages();
} finally {
setButtonLoading('btn-chat', false);
}
}
function applyChatSuggestion() {
if (!lastSuggested) return;
if (lastSuggested.title) document.getElementById('article-title').value = lastSuggested.title;
if (lastSuggested.content) document.getElementById('article-content').value = lastSuggested.content;
if (lastSuggested.seo_title) document.getElementById('seo-title').value = lastSuggested.seo_title;
if (lastSuggested.seo_description) document.getElementById('seo-description').value = lastSuggested.seo_description;
if (lastSuggested.focus_keyword) document.getElementById('focus-keyword').value = lastSuggested.focus_keyword;
const c1 = document.getElementById('seo-title-count');
if (c1) c1.textContent = (lastSuggested.seo_title || '').length + '/60';
const c2 = document.getElementById('seo-desc-count');
if (c2) c2.textContent = (lastSuggested.seo_description || '').length + '/155';
updatePreview();
document.getElementById('chat-apply-row').classList.add('hidden');
lastSuggested = null;
showToast('✓ Übernommen');
}
async function polishArticle() {
const instruction = document.getElementById('polish-instruction').value.trim();
const title = document.getElementById('article-title').value;
const content = document.getElementById('article-content').value;
if (!instruction) { showToast('⚠️ Anweisung eingeben'); return; }
if (!content && !title) { showToast('⚠️ Kein Inhalt zum Verbessern'); return; }
setButtonLoading('btn-polish', true);
const backup = setTimeout(() => setButtonLoading('btn-polish', false), 130000);
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 120000);
const r = await fetch('/api/polish', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ title, content, instruction, seo_title: document.getElementById('seo-title').value, seo_description: document.getElementById('seo-description').value, focus_keyword: document.getElementById('focus-keyword').value }),
signal: ctrl.signal
});
clearTimeout(t);
const d = await r.json().catch(() => ({}));
if (d.error) { showToast('❌ ' + d.error); return; }
document.getElementById('article-title').value = d.title || '';
document.getElementById('article-content').value = d.content || '';
if (d.seo_title) document.getElementById('seo-title').value = d.seo_title;
if (d.seo_description) document.getElementById('seo-description').value = d.seo_description;
if (d.focus_keyword) document.getElementById('focus-keyword').value = d.focus_keyword;
const c1 = document.getElementById('seo-title-count');
if (c1) c1.textContent = (d.seo_title || '').length + '/60';
const c2 = document.getElementById('seo-desc-count');
if (c2) c2.textContent = (d.seo_description || '').length + '/155';
updatePreview();
document.getElementById('polish-instruction').value = '';
showToast('✨ Text verbessert');
} catch (e) {
showToast('❌ ' + (e.name === 'AbortError' ? 'Timeout' : (e.message || 'Fehler')));
} finally {
clearTimeout(backup);
setButtonLoading('btn-polish', false);
}
}
async function generateArticle() {
const source = document.getElementById('source-input').value.trim();
const tone = document.getElementById('tone-select').value;
const promptId = document.getElementById('prompt-select')?.value || null;
if (!source) { showToast('⚠️ Bitte Quelle eingeben'); return; }
document.getElementById('gen-spinner').classList.remove('hidden');
const r = await fetch('/api/generate', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, tone})
});
document.getElementById('gen-spinner').classList.add('hidden');
const d = await r.json();
if (d.error) { showToast('❌ ' + d.error); return; }
setButtonLoading('btn-generate', true);
const backup = setTimeout(() => setButtonLoading('btn-generate', false), 130000);
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 120000);
const r = await fetch('/api/generate', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, tone, prompt_id: promptId ? parseInt(promptId, 10) : null}),
signal: ctrl.signal
});
clearTimeout(t);
const d = await r.json().catch(() => ({}));
if (d.error) { showToast('❌ ' + d.error); return; }
document.getElementById('article-title').value = d.title || '';
document.getElementById('article-content').value = d.content || '';
document.getElementById('seo-title').value = d.seo_title || '';
document.getElementById('seo-description').value = d.seo_description || '';
document.getElementById('focus-keyword').value = d.focus_keyword || '';
updatePreview();
document.getElementById('article-title').value = d.title || '';
document.getElementById('article-content').value = d.content || '';
document.getElementById('seo-title').value = d.seo_title || '';
document.getElementById('seo-description').value = d.seo_description || '';
document.getElementById('focus-keyword').value = d.focus_keyword || '';
updatePreview();
// og:image automatisch holen
if (source.startsWith('http')) {
fetchOgImage(source);
if (source.startsWith('http')) fetchOgImage(source);
showToast('✅ Artikel generiert');
} catch (e) {
showToast('❌ ' + (e.name === 'AbortError' ? 'Timeout (>2 Min)' : (e.message || 'Fehler')));
} finally {
clearTimeout(backup);
setButtonLoading('btn-generate', false);
}
showToast('✅ Artikel generiert');
}
async function fetchOgImage(url) {
@ -406,13 +606,19 @@ async function saveDraft() {
currentArticleId = d.id;
if (d.wp_post_id) currentWpPostId = d.wp_post_id;
// Vorschau-Link anzeigen
// Vorschau- und Editor-Links anzeigen
const linkBox = document.getElementById('wp-draft-link');
if (d.wp_preview_url && linkBox) {
linkBox.innerHTML = `<a href="${d.wp_preview_url}" target="_blank"
if ((d.wp_preview_url || d.wp_edit_url) && linkBox) {
const links = [];
if (d.wp_edit_url) links.push(`<a href="${d.wp_edit_url}" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;background:#e0f2fe;border:1px solid #7dd3fc;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-right:8px">
&#9998; Im WP-Editor bearbeiten &rarr;</a>`);
if (d.wp_preview_url) links.push(`<a href="${d.wp_preview_url}" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;background:#f0f9ff;border:1px solid #bae6fd;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-top:8px">
&#128065; Entwurf in WordPress ansehen &rarr;</a>`;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none">
&#128065; Vorschau ansehen</a>`);
linkBox.innerHTML = links.join('');
linkBox.classList.remove('hidden');
}
showToast('💾 Entwurf gespeichert & nach WordPress gepusht');
@ -482,6 +688,7 @@ async function confirmSchedule() {
}
async function loadArticle(id) {
if (currentArticleId !== id) { chatHistory = []; lastSuggested = null; if (document.getElementById('chat-apply-row')) document.getElementById('chat-apply-row').classList.add('hidden'); renderChatMessages(); }
const r = await fetch(`/api/article/${id}`);
const d = await r.json();
currentArticleId = id;
@ -495,14 +702,22 @@ async function loadArticle(id) {
document.getElementById('featured-image').value = d.featured_image_url || '';
if (d.category_id) document.getElementById('category-select').value = d.category_id;
// WP-Vorschau-Link wiederherstellen wenn vorhanden
// WP-Links wiederherstellen wenn vorhanden
const linkBox = document.getElementById('wp-draft-link');
if (d.wp_post_id && linkBox) {
const wpBase = '{{ wp_url }}';
linkBox.innerHTML = `<a href="${wpBase}/?p=${d.wp_post_id}&preview=true" target="_blank"
const wrap = document.querySelector('[data-wp-admin]');
const wpAdmin = wrap ? wrap.dataset.wpAdmin : '';
const wpBase = wrap ? wrap.dataset.wpUrl : '';
const links = [];
if (wpAdmin) links.push(`<a href="${wpAdmin}/wp-admin/post.php?post=${d.wp_post_id}&action=edit" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;background:#e0f2fe;border:1px solid #7dd3fc;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-right:8px">
&#9998; Im WP-Editor bearbeiten &rarr;</a>`);
if (wpBase) links.push(`<a href="${wpBase}/?p=${d.wp_post_id}&preview=true" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;background:#f0f9ff;border:1px solid #bae6fd;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-top:8px">
&#128065; Entwurf in WordPress ansehen &rarr;</a>`;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none">
&#128065; Vorschau ansehen</a>`);
linkBox.innerHTML = links.join('');
linkBox.classList.remove('hidden');
} else if (linkBox) {
linkBox.classList.add('hidden');

View file

@ -6,6 +6,7 @@
<h1 class="text-xl font-bold text-white">🧠 Prompt-Bibliothek</h1>
<button onclick="newPrompt()" class="btn btn-primary text-sm">+ Neuer Prompt</button>
</div>
<p class="text-xs text-slate-500 mb-4">Klicke auf einen Prompt, um ihn zu bearbeiten oder als Standard zu setzen. Neuer Prompt: Speichern setzt ihn automatisch als Standard.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-3">
{% for p in prompts %}
@ -58,15 +59,25 @@ function newPrompt() {
document.getElementById('prompt-text').value = '';
}
async function savePrompt() {
const currentId = document.getElementById('prompt-id').value || null;
const r = await fetch('/api/prompts/save', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({id: document.getElementById('prompt-id').value || null,
body: JSON.stringify({id: currentId,
name: document.getElementById('prompt-name').value,
system_prompt: document.getElementById('prompt-text').value})});
if ((await r.json()).success) { showToast('💾 Gespeichert'); location.reload(); }
const d = await r.json();
if (d.success) {
showToast('💾 Gespeichert');
// Neuer Prompt: automatisch als Standard setzen
if (!currentId && d.id) {
await fetch(`/api/prompts/${d.id}/default`, {method:'POST'});
showToast('💾 Gespeichert & ⭐ als Standard gesetzt');
}
location.reload();
}
}
async function setDefault() {
const id = document.getElementById('prompt-id').value;
if (!id) { showToast('⚠️ Prompt zuerst speichern'); return; }
if (!id) { showToast('⚠️ Prompt zuerst auswählen (klicken) und ggf. speichern'); return; }
await fetch(`/api/prompts/${id}/default`, {method:'POST'});
showToast('⭐ Als Standard gesetzt'); location.reload();
}

View file

@ -0,0 +1,27 @@
# Brief für Blogartikel Redax-Quelle
Titel: Ohne Visum kein Kambodscha warum dein erster Schritt nicht Hausbau oder Business, sondern Immigration sein sollte
Zielgruppe: Auswanderungsinteressierte, die länger als touristisch in Kambodscha bleiben wollen.
Ton: nüchtern, erfahren, realistisch. Keine Romantisierung. Kein Werbestil. Keine Rechtsberatung. Keine Preisgarantien. Keine konkreten Agentennamen.
---
Inhaltliche Anforderungen:
Einstieg: Wer langfristig bleiben will, braucht ein stabiles Aufenthaltsmodell. Viele Visa-Agenturen sind im Riverside-Bereich von Phnom Penh tätig.
Unterschied erklären: Tourist Visa, Ordinary (Business) Visa, Jahres-Extension.
Praxisbeispiel: Eine Visa-Agentur im Riverside-Bereich organisierte innerhalb von ca. 10 Tagen eine saubere Jahresverlängerung im Reisepass. Ablauf: Passabgabe, Bearbeitungszeit, Rückgabe mit gültigem Eintrag.
System: Rolle von Agenten in Kambodscha, Bedeutung persönlicher Netzwerke, warum sich Regeln ändern können.
Strategisch: Warum Aufenthaltsstatus Grundlage ist für Mietverträge, Geschäftsaufbau, langfristige Planung, Verhandlungsposition.
Abschluss: Visa-Regelungen können sich ändern, jeder sollte selbst aktuelle Informationen prüfen.
---
Länge: 900 bis 1.200 Wörter. Zwischenüberschriften verwenden. Keine Bulletpoints im Fließtext. Sachliche Sprache. Keine dramatisierenden Formulierungen.