Compare commits

..

No commits in common. "de5f5330963cbe9ca0dc7f7af3e438fd8d0e5c89" and "3ebadf08f34cf66ee61bed58f1808a0cc4ab8e80" have entirely different histories.

17 changed files with 115 additions and 1105 deletions

View file

@ -14,15 +14,8 @@
| FünfVorAcht / Telegram KI-Poster | fuenfvoracht/STATE.md | | FünfVorAcht / Telegram KI-Poster | fuenfvoracht/STATE.md |
| Server / Container / Proxmox | infrastructure/STATE.md | | Server / Container / Proxmox | infrastructure/STATE.md |
| Telegram Bots allgemein | infrastructure/STATE.md | | Telegram Bots allgemein | infrastructure/STATE.md |
| TODOs / Aufgaben / Was steht an | Forgejo Issues (siehe unten) |
| Alle Projekte / Übersicht | MASTER_INDEX.md | | 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) ## Server-Zugang (immer verfügbar)
- pve-hetzner: `ssh root@100.88.230.59` | PW: Astral-Proxmox!2026 - pve-hetzner: `ssh root@100.88.230.59` | PW: Astral-Proxmox!2026
- pve1 Kambodscha: `ssh root@192.168.0.197` | PW: astral66 - pve1 Kambodscha: `ssh root@192.168.0.197` | PW: astral66

View file

@ -8,32 +8,20 @@
|---|---|---|---| |---|---|---|---|
| **Arakava News** (WordPress + RSS + KI) | Orbitalo/Wordpress-V3-MCP-Projekt | arakava-news/STATE.md | arakava-news/src/ | | **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/ | | **Edelmetall Dashboard** (Gold/Silber) | — (in diesem Repo) | edelmetall/STATE.md | edelmetall/src/ |
| **Smart Home** (ioBroker, Grafana) | — (in diesem Repo) | smart-home/STATE.md | smart-home/scripts/ | | **Smart Home Muldenstein** (ioBroker, Grafana) | — (in diesem Repo) | smart-home/STATE.md | smart-home/scripts/ |
| **ESP32 Projekte** (Heizung, Sensor) | — (in diesem Repo) | esp32/PLAN.md | — | | **ESP32 Projekte** (Heizung, Sensor) | — (in diesem Repo) | esp32/PLAN.md | — |
| **FünfVorAcht** (Telegram KI-Poster) | — (in diesem Repo) | fuenfvoracht/STATE.md | fuenfvoracht/src/ | | **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/ | | **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/ | | **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 | — | | **Infrastruktur** (alle Server + CTs) | — (in diesem Repo) | infrastructure/STATE.md | — |
## Prioritäten ## Server
1. **Arakava News** (WordPress + RSS-Manager) — Prio 1 | Server | Standort | Tailscale IP | Funktion |
2. **FünfVorAcht** (Telegram KI-Poster) — Prio 1 |---|---|---|---|
3. Rest — bei Bedarf | 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) |
## Physische Standorte | pve3 | Muldenstein, DE | 100.109.101.12 | Smart Home (CT 143, 134) |
| 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 ## Wichtigste Zugangsdaten
@ -41,28 +29,21 @@
|---|---| |---|---|
| pve-hetzner SSH | root / Astral-Proxmox!2026 | | pve-hetzner SSH | root / Astral-Proxmox!2026 |
| pve1 SSH | root / astral66 | | pve1 SSH | root / astral66 |
| helmut-pve SSH | root / astral66 |
| Alle lokalen CTs | root / astral66 | | Alle lokalen CTs | root / astral66 |
| WordPress Admin | admin / eJIyhW0p5PFacjvvKGufKeXS | | WordPress Admin | admin / astral66 |
| Seafile | admin@orbitalo.net / astral66 | | Seafile | admin@orbitalo.net / astral66 |
| Forgejo | orbitalo / astral66 | | n8n | wuttig@gmx.de / Astral66 |
| Dify | admin@orbitalo.net / astral66 |
| Grafana | admin / astral66 | | Grafana | admin / astral66 |
## Telegram Bots ## Telegram Bots
| Bot | Token | Chat-ID | Projekt | | Bot | Token | Chat-ID | Projekt |
|---|---|---|---| |---|---|---|---|
| @MutterbotAI_bot | 8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE | 674951792 | RSS-Manager / Allgemein | | @MutterbotAI_bot | (in infrastructure/STATE.md) | 674951792 | Moltbot allgemein |
| @Diendemleben_bot | 8799990587:AAEoQuohGdoJ2WudoOHs_j5Ns3iwft6OlFc | 674951792 | FünfVorAcht | | @DifyRagBot | 8390483455:AAEUyRWkvESSGQBtvjzAIQ5UKqmpoMTQZ00 | 674951792 | Dify RAG / Grafana Alerts |
| Edelmetall Bot | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 | 674951792 | Gold/Silber Preise | | Arakava Comments | 8551565940:AAHIUpZND-tCNGv9yEoNPRyPt4GxEPYBJdE | 674951792 | WordPress Kommentare |
| Edelmetall Bot | 8262992299:AAEf8YHPsz42ZdP85DV7JqC4822Ts75GqF4 | 674951792 | Gold/Silber Preise (CT 136) |
## 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 ## Auto-Sync
Die STATE.md Dateien werden täglich um 03:00 Uhr automatisch aktualisiert. Die STATE.md Dateien werden täglich um 03:00 Uhr automatisch aktualisiert.

View file

@ -1,20 +1,5 @@
# Infrastruktur — Live State # Infrastruktur — Live State
> Aktualisiert: 2026-03-01 > Auto-generiert: 2026-03-03 10:15
## 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 ## pve-hetzner Disk
| Mount | Belegt | | Mount | Belegt |
@ -23,109 +8,17 @@
| /var/lib/vz (VMs/CTs) | 2% von 2.9T | | /var/lib/vz (VMs/CTs) | 2% von 2.9T |
## Aktive Container auf pve-hetzner ## Aktive Container auf pve-hetzner
| CT | Name | Tailscale IP | Dienste | | CT | Name | Tailscale IP | Dienste |
|---|---|---|---| |---|---|---|---|
| 101 | wordpress-v2 | 100.91.212.19 | WordPress + MySQL (Docker) + **CF Tunnel** | | 101 | wordpress-v2 | 100.91.212.19 | WordPress + MySQL (Docker) |
| 103 | seafile | 100.75.247.60 | Seafile (seafile.orbitalo.net) | | 103 | seafile | 100.75.247.60 | Seafile (seafile.orbitalo.net) |
| 109 | rss-manager | 100.113.244.101 | RSS Manager + Matomo + **CF Tunnel** | | 109 | rss-manager | 100.113.244.101 | RSS Manager + Matomo |
| 110 | portainer | 100.109.206.43 | Portainer Docker UI | | 110 | portainer | 100.109.206.43 | Portainer Docker UI |
| 111 | forgejo | 100.89.246.60 | Forgejo Git (http://100.89.246.60:3000) | | 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 | | 144 | muldenstein-backup | — | Backup-Archiv |
| 999 | cluster-docu | 100.79.8.49 | Dokumentation (http://100.79.8.49:8080) | | 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) ## Gelöschte Container (24.02.2026)
| CT | Name | Grund | | CT | Name | Grund |
|---|---|---| |---|---|---|
| 100 | traefik | Abgelöst durch Cloudflare Tunnel | | 100 | traefik | Abgelöst durch Cloudflare Tunnel |
@ -133,62 +26,27 @@
| 104 | n8n | Nicht aktiv genutzt | | 104 | n8n | Nicht aktiv genutzt |
| 105 | debian-12 | Nicht genutzt | | 105 | debian-12 | Nicht genutzt |
| 106 | wordpress-news | Abgelöst durch CT 101 | | 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 |
|---|---|---| |---|---|---|
| 101 | 0231beb8-193e-46df-a6ef-4154cf04f374 | arakava-news-2.orbitalo.net → localhost:80 | | 136 | gold-silber-v3 | Edelmetall-Bot (Tailscale: 100.72.230.87) |
| 109 | 486454a9-4812-4422-b30b-abd5ada71ce1 | matomo.orbitalo.net → localhost:80 | | 143 | smart-home | ioBroker + Grafana + InfluxDB |
| 112 | ba4f6f84-45db-4369-a588-c231f9d559ce | fuenfvoracht.orbitalo.net → localhost:8080 |
### pve2-1 (Muldenstein) ## Routing
| CT | Tunnel-ID | Public Hostnames | - Cloudflare Tunnel CT 101: arakava-news-2.orbitalo.net → :80
|---|---|---| - Cloudflare Tunnel CT 109: matomo.orbitalo.net → :80
| 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) | - Kein Traefik, kein PBS-Gateway mehr
**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 ## Zugangsdaten
- pve-hetzner: root / Astral-Proxmox!2026
| System | Login | Zugang | - pve1: root / astral66
|---|---|---| - Alle CTs: root / astral66
| pve-hetzner | root / Astral-Proxmox!2026 | SSH: 100.88.230.59<br>GUI: https://100.88.230.59:8006 | - Seafile: admin@orbitalo.net / astral66
| pve1 (Kambodscha) | root / astral66 | SSH: 100.122.56.60<br>GUI: https://100.122.56.60:8006 | - Forgejo: orbitalo / astral66
| 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 ## 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 |
|---|---|---| |---|---|---|
| OpenRouter (GPT-4o-mini) | ~$0,35/Tag | RSS-Manager + Flugscanner Vision | | Mutter (@MutterbotAI_bot) | 8551565940:... | 674951792 |
| Cursor Ultra | $200/Monat | Entwicklung |

View file

@ -1,11 +1,11 @@
# STATE: Redax-WP # STATE: Redax-WP
**Stand: 03.03.2026** **Stand: 28.02.2026**
--- ---
## Status ## Status
✅ **Vollständig in Betrieb — 03.03.2026** ✅ **Vollständig in Betrieb — 28.02.2026**
--- ---
@ -87,40 +87,18 @@ Docker Container:
| Feature | Beschreibung | | Feature | Beschreibung |
|---------|-------------| |---------|-------------|
| Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) | | Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) |
| Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) | | WP-Entwurf | Artikel direkt als Draft auf Primary pushen + Vorschau-Link |
| **Text-Import** | Quelle-Feld als Textarea — komplette Artikel einfügbar | | Redaktionsplan | 7-Tage-Kalender mit Status, Umplanen, Löschen |
| **Markdown→HTML** | KI-Output zu WordPress-HTML konvertiert | | Multi-Publish | Beim Veröffentlichen: Primary + alle aktiven Mirrors |
| **YouTube-Bilder** | YouTube-URL in Artikel → Thumbnail + Featured Image | | Publish-Ziele | Checkboxen zum Ein-/Ausschalten pro Mirror + Links zu Website & WP-Admin + Zugangsdaten |
| **KI-Chat** | Freie Texteingabe mit Artikelkontext | | Mirror-Status | Pro Artikel: welche Sites wurden bespielt (✅/❌) |
| 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 | | RSS-Queue | Feed-Artikel verwalten, KI-Rewrite, Auto-Publish |
| Duplikat-Schutz | Mirror überspringt existierende Artikel | | Duplikat-Schutz | Mirror überspringt Artikel die bereits vorhanden sind |
--- ---
## Changelog ## 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 ### 28.02.2026
- Multi-Publish implementiert: `WordPressMirrorClient` in `wordpress.py` - Multi-Publish implementiert: `WordPressMirrorClient` in `wordpress.py`
- `mirror_posts` Tabelle in SQLite für Mirror-Tracking - `mirror_posts` Tabelle in SQLite für Mirror-Tracking

View file

@ -1,148 +0,0 @@
# 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

@ -1,126 +0,0 @@
# 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

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

View file

@ -1,18 +0,0 @@
#!/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,9 +46,6 @@ services:
volumes: volumes:
- ./data/db:/data - ./data/db:/data
- ./logs:/logs - ./logs:/logs
- ./src/app.py:/app/app.py
- ./src/database.py:/app/database.py
- ./src/templates:/app/templates
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:

View file

@ -1,7 +0,0 @@
{
"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,9 +272,8 @@ def api_generate():
data = request.json data = request.json
source = data.get('source', '') source = data.get('source', '')
tone = data.get('tone', 'informativ') tone = data.get('tone', 'informativ')
prompt_id = data.get('prompt_id')
prompt = db.get_prompt_by_id(prompt_id) if prompt_id else db.get_default_prompt() prompt = db.get_default_prompt()
if not prompt: if not prompt:
return jsonify({'error': 'Kein Prompt konfiguriert'}), 400 return jsonify({'error': 'Kein Prompt konfiguriert'}), 400
@ -303,127 +302,6 @@ def api_generate():
return jsonify({'error': str(e)}), 500 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']) @app.route('/api/article/save', methods=['POST'])
def api_save_article(): def api_save_article():
data = request.json data = request.json
@ -460,24 +338,12 @@ def api_save_article():
db.update_article(article_id, {'wp_post_id': wp_post_id}) db.update_article(article_id, {'wp_post_id': wp_post_id})
wp_base = os.getenv('WP_URL', '').rstrip('/') 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_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) flog.info('article_saved_as_draft', article_id=article_id, wp_post_id=wp_post_id)
except Exception as e: except Exception as e:
flog.warn('draft_push_failed', article_id=article_id, error=str(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({ return jsonify({'success': True, 'id': article_id, 'wp_post_id': wp_post_id, 'wp_preview_url': wp_preview_url})
'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']) @app.route('/api/article/schedule', methods=['POST'])
@ -668,18 +534,14 @@ def api_save_prompt():
"UPDATE prompts SET name=?, system_prompt=? WHERE id=?", "UPDATE prompts SET name=?, system_prompt=? WHERE id=?",
(data['name'], data['system_prompt'], data['id']) (data['name'], data['system_prompt'], data['id'])
) )
conn.commit()
conn.close()
return jsonify({'success': True, 'id': data['id']})
else: else:
cur = conn.execute( conn.execute(
"INSERT INTO prompts (name, system_prompt) VALUES (?,?)", "INSERT INTO prompts (name, system_prompt) VALUES (?,?)",
(data['name'], data['system_prompt']) (data['name'], data['system_prompt'])
) )
new_id = cur.lastrowid
conn.commit() conn.commit()
conn.close() conn.close()
return jsonify({'success': True, 'id': new_id}) return jsonify({'success': True})
@app.route('/api/prompts/<int:pid>/default', methods=['POST']) @app.route('/api/prompts/<int:pid>/default', methods=['POST'])

View file

@ -403,13 +403,6 @@ def get_default_prompt():
return dict(row) if row else None 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 ────────────────────────────────────────────────────────────────── # ── Settings ──────────────────────────────────────────────────────────────────
def get_setting(key, default=None): def get_setting(key, default=None):

View file

@ -88,32 +88,7 @@ def generate(system_prompt: str, user_message: str) -> str:
"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
timeout = _aiohttp.ClientTimeout(total=90) async with _aiohttp.ClientSession() as session:
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: async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp:
data = await resp.json() data = await resp.json()
if resp.status != 200: if resp.status != 200:

View file

@ -1,13 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Redax-WP — Studio{% endblock %} {% 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 %} {% block content %}
<div class="max-w-7xl mx-auto px-6 py-6" data-wp-admin="{{ wp_admin_direct }}" data-wp-url="{{ wp_url }}"> <div class="max-w-7xl mx-auto px-6 py-6">
<!-- Status-Bar --> <!-- Status-Bar -->
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500 flex-wrap"> <div class="flex items-center gap-4 mb-4 text-xs text-slate-500 flex-wrap">
@ -19,13 +14,10 @@
{% endif %} {% endif %}
</div> </div>
<!-- WordPress-Targets (einklappbar) --> <!-- WordPress-Targets -->
<div class="mb-3"> <div class="mb-6">
<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')?'▸':'▾'" <div class="text-xs text-slate-500 mb-2">📡 Publish-Ziele</div>
class="text-xs text-slate-500 hover:text-slate-300 flex items-center gap-1.5 py-1"> <div class="flex flex-wrap gap-3">
<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 %} {% 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"> <div style="background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
@ -71,20 +63,20 @@
</div> </div>
</div> </div>
<div class="space-y-6"> <div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- ═══ STUDIO (oben, volle Breite) ═══ --> <!-- ═══ STUDIO (links, 2/3) ═══ -->
<div class="space-y-4"> <div class="xl:col-span-2 space-y-4">
<!-- Artikel-Generator --> <!-- Artikel-Generator -->
<div class="card p-5"> <div class="card p-5">
<h2 class="text-base font-semibold text-white mb-4">✍️ Artikel-Studio</h2> <h2 class="text-base font-semibold text-white mb-4">✍️ Artikel-Studio</h2>
<!-- Quelle + Ton + Prompt --> <!-- Quelle + Ton -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="text-xs text-slate-400 block mb-1">Quelle (URL oder Text)</label> <label class="text-xs text-slate-400 block mb-1">Quelle (URL oder Text)</label>
<input type="text" id="source-input" placeholder="https://... oder Text einfügen" class="w-full"> <input type="url" id="source-input" placeholder="https://..." class="w-full">
</div> </div>
<div> <div>
<label class="text-xs text-slate-400 block mb-1">Ton</label> <label class="text-xs text-slate-400 block mb-1">Ton</label>
@ -94,14 +86,6 @@
<option value="reportage">Reportage</option> <option value="reportage">Reportage</option>
</select> </select>
</div> </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> </div>
<!-- Titel --> <!-- Titel -->
@ -110,53 +94,25 @@
<input type="text" id="article-title" placeholder="Artikel-Titel" class="w-full"> <input type="text" id="article-title" placeholder="Artikel-Titel" class="w-full">
</div> </div>
<!-- Zwei-Spalten Editor + Vorschau (große Designfläche) --> <!-- Zwei-Spalten Editor -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4" style="min-height: 65vh"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div class="flex flex-col min-h-0"> <div>
<label class="text-xs text-slate-400 block mb-1">Inhalt (HTML)</label> <label class="text-xs text-slate-400 block mb-1">Inhalt (HTML)</label>
<textarea id="article-content" placeholder="Artikel-Inhalt..." <textarea id="article-content" rows="14" placeholder="Artikel-Inhalt..."
oninput="updatePreview()" class="flex-1 min-h-[320px]" oninput="updatePreview()"></textarea>
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> <div>
<div class="flex flex-col min-h-0"> <label class="text-xs text-slate-400 block mb-1">WordPress-Vorschau</label>
<label class="text-xs text-slate-400 block mb-1">Vorschau</label>
<div id="wp-preview" <div id="wp-preview"
class="rounded-lg p-5 overflow-y-auto border border-slate-600 flex-1" class="bg-white text-slate-900 rounded-lg p-4 text-sm overflow-y-auto"
style="min-height: 50vh; max-height: 75vh; font-family: Georgia, serif; line-height: 1.7; font-size: 1rem; background: #f8fafc; color: #0f172a;"> style="min-height: 14rem; max-height: 24rem; font-family: Georgia, serif; line-height: 1.8;">
<span class="text-slate-500 italic">Vorschau erscheint beim Tippen...</span> <span class="text-slate-400 italic text-xs">Vorschau erscheint beim Tippen...</span>
</div> </div>
<div id="wp-draft-link" class="hidden"></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 id="mirror-status-box" class="hidden mt-2 text-xs space-y-1"></div>
</div> </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 --> <!-- SEO-Panel -->
<div class="bg-slate-900/50 border border-slate-700 rounded-lg p-3 mb-4"> <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> <div class="text-xs text-slate-400 font-semibold mb-2">🔍 SEO</div>
@ -194,19 +150,14 @@
</div> </div>
<!-- Aktions-Buttons --> <!-- Aktions-Buttons -->
<div class="flex flex-col gap-3">
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<button id="btn-generate" onclick="generateArticle()" class="btn btn-primary">🤖 KI generieren</button> <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="saveDraft()" class="btn btn-ghost">💾 Entwurf</button>
<button onclick="publishNow()" class="btn btn-success">🚀 Sofort veröffentlichen</button> <button onclick="publishNow()" class="btn btn-success">🚀 Sofort veröffentlichen</button>
<button onclick="toggleSchedulePanel()" class="btn" style="background:#4c1d95;color:#fff">📅 Einplanen</button> <button onclick="toggleSchedulePanel()" class="btn" style="background:#4c1d95;color:#fff">📅 Einplanen</button>
</div> </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 --> <!-- Einplan-Panel -->
<div id="schedule-panel" class="hidden mt-4 bg-slate-900 border border-slate-700 rounded-lg p-4"> <div id="schedule-panel" class="hidden mt-4 bg-slate-900 border border-slate-700 rounded-lg p-4">
@ -230,7 +181,7 @@
</div> </div>
<!-- ═══ REDAKTIONSPLAN (unten, volle Breite, scrollen) ═══ --> <!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ -->
<div class="space-y-4"> <div class="space-y-4">
{% set draft_arts = [] %} {% set draft_arts = [] %}
@ -390,166 +341,18 @@ function updatePreview() {
preview.innerHTML = (title ? `<h1 style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">${title}</h1>` : '') + content; 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() { async function generateArticle() {
const source = document.getElementById('source-input').value.trim(); const source = document.getElementById('source-input').value.trim();
const tone = document.getElementById('tone-select').value; const tone = document.getElementById('tone-select').value;
const promptId = document.getElementById('prompt-select')?.value || null;
if (!source) { showToast('⚠️ Bitte Quelle eingeben'); return; } if (!source) { showToast('⚠️ Bitte Quelle eingeben'); return; }
setButtonLoading('btn-generate', true); document.getElementById('gen-spinner').classList.remove('hidden');
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', { const r = await fetch('/api/generate', {
method: 'POST', headers: {'Content-Type':'application/json'}, method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, tone, prompt_id: promptId ? parseInt(promptId, 10) : null}), body: JSON.stringify({source, tone})
signal: ctrl.signal
}); });
clearTimeout(t); document.getElementById('gen-spinner').classList.add('hidden');
const d = await r.json().catch(() => ({})); const d = await r.json();
if (d.error) { showToast('❌ ' + d.error); return; } if (d.error) { showToast('❌ ' + d.error); return; }
document.getElementById('article-title').value = d.title || ''; document.getElementById('article-title').value = d.title || '';
@ -559,14 +362,11 @@ async function generateArticle() {
document.getElementById('focus-keyword').value = d.focus_keyword || ''; document.getElementById('focus-keyword').value = d.focus_keyword || '';
updatePreview(); updatePreview();
if (source.startsWith('http')) fetchOgImage(source); // og:image automatisch holen
showToast('✅ Artikel generiert'); if (source.startsWith('http')) {
} catch (e) { fetchOgImage(source);
showToast('❌ ' + (e.name === 'AbortError' ? 'Timeout (>2 Min)' : (e.message || 'Fehler')));
} finally {
clearTimeout(backup);
setButtonLoading('btn-generate', false);
} }
showToast('✅ Artikel generiert');
} }
async function fetchOgImage(url) { async function fetchOgImage(url) {
@ -606,19 +406,13 @@ async function saveDraft() {
currentArticleId = d.id; currentArticleId = d.id;
if (d.wp_post_id) currentWpPostId = d.wp_post_id; if (d.wp_post_id) currentWpPostId = d.wp_post_id;
// Vorschau- und Editor-Links anzeigen // Vorschau-Link anzeigen
const linkBox = document.getElementById('wp-draft-link'); const linkBox = document.getElementById('wp-draft-link');
if ((d.wp_preview_url || d.wp_edit_url) && linkBox) { if (d.wp_preview_url && linkBox) {
const links = []; linkBox.innerHTML = `<a href="${d.wp_preview_url}" target="_blank"
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; 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"> color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-top:8px">
&#128065; Vorschau ansehen</a>`); &#128065; Entwurf in WordPress ansehen &rarr;</a>`;
linkBox.innerHTML = links.join('');
linkBox.classList.remove('hidden'); linkBox.classList.remove('hidden');
} }
showToast('💾 Entwurf gespeichert & nach WordPress gepusht'); showToast('💾 Entwurf gespeichert & nach WordPress gepusht');
@ -688,7 +482,6 @@ async function confirmSchedule() {
} }
async function loadArticle(id) { 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 r = await fetch(`/api/article/${id}`);
const d = await r.json(); const d = await r.json();
currentArticleId = id; currentArticleId = id;
@ -702,22 +495,14 @@ async function loadArticle(id) {
document.getElementById('featured-image').value = d.featured_image_url || ''; document.getElementById('featured-image').value = d.featured_image_url || '';
if (d.category_id) document.getElementById('category-select').value = d.category_id; if (d.category_id) document.getElementById('category-select').value = d.category_id;
// WP-Links wiederherstellen wenn vorhanden // WP-Vorschau-Link wiederherstellen wenn vorhanden
const linkBox = document.getElementById('wp-draft-link'); const linkBox = document.getElementById('wp-draft-link');
if (d.wp_post_id && linkBox) { if (d.wp_post_id && linkBox) {
const wrap = document.querySelector('[data-wp-admin]'); const wpBase = '{{ wp_url }}';
const wpAdmin = wrap ? wrap.dataset.wpAdmin : ''; linkBox.innerHTML = `<a href="${wpBase}/?p=${d.wp_post_id}&preview=true" target="_blank"
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; 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"> color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-top:8px">
&#128065; Vorschau ansehen</a>`); &#128065; Entwurf in WordPress ansehen &rarr;</a>`;
linkBox.innerHTML = links.join('');
linkBox.classList.remove('hidden'); linkBox.classList.remove('hidden');
} else if (linkBox) { } else if (linkBox) {
linkBox.classList.add('hidden'); linkBox.classList.add('hidden');

View file

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

View file

@ -1,27 +0,0 @@
# 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.