docs: redakteur STATE.md aktualisiert 03.03.2026

Made-with: Cursor
This commit is contained in:
root 2026-03-03 16:19:53 +07:00
parent 82e0850df2
commit de5f533096
5 changed files with 391 additions and 23 deletions

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

@ -1,11 +1,11 @@
# STATE: Redax-WP (Redakteur) # STATE: Redax-WP (Redakteur)
**Stand: 28.02.2026** **Stand: 02.03.2026**
--- ---
## Status ## 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 │ ├── 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 (sync wrapper) │ ├── openrouter.py OpenRouter API (generate + generate_chat für KI-Chat)
│ ├── 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,7 +63,8 @@ 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 - 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 - 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)
@ -79,7 +80,8 @@ 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 (verbessert) ### Redaktionsplan
- **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
@ -88,6 +90,10 @@ 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
@ -134,7 +140,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/redax.db | | Datenbank | /opt/redax-wp/data/db/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/ |
@ -184,6 +190,14 @@ 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

@ -345,6 +345,85 @@ KEYWORD: [1 Wort]"""
return jsonify({'error': str(e)}), 500 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

View file

@ -96,3 +96,27 @@ def generate(system_prompt: str, user_message: str) -> str:
raise Exception(f"OpenRouter Fehler {resp.status}: {data}") raise Exception(f"OpenRouter Fehler {resp.status}: {data}")
return data["choices"][0]["message"]["content"].strip() return data["choices"][0]["message"]["content"].strip()
return asyncio.run(_gen()) 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:
raise Exception(f"OpenRouter Fehler {resp.status}: {data}")
return data["choices"][0]["message"]["content"].strip()
return asyncio.run(_gen())

View file

@ -19,10 +19,13 @@
{% endif %} {% endif %}
</div> </div>
<!-- WordPress-Targets --> <!-- WordPress-Targets (einklappbar) -->
<div class="mb-6"> <div class="mb-3">
<div class="text-xs text-slate-500 mb-2">📡 Publish-Ziele</div> <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="flex flex-wrap gap-3"> 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 %} {% 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">
@ -68,10 +71,10 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6"> <div class="space-y-6">
<!-- ═══ STUDIO (links, 2/3) ═══ --> <!-- ═══ STUDIO (oben, volle Breite) ═══ -->
<div class="xl:col-span-2 space-y-4"> <div class="space-y-4">
<!-- Artikel-Generator --> <!-- Artikel-Generator -->
<div class="card p-5"> <div class="card p-5">
@ -107,22 +110,23 @@
<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 + KI verbessern --> <!-- Zwei-Spalten Editor + Vorschau (große Designfläche) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4" style="min-height: 65vh">
<div> <div class="flex flex-col min-h-0">
<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" rows="16" placeholder="Artikel-Inhalt..." <textarea id="article-content" placeholder="Artikel-Inhalt..."
oninput="updatePreview()"></textarea> 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"> <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"> <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> <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="min-h-[420px]"> <div class="flex flex-col min-h-0">
<label class="text-xs text-slate-400 block mb-1">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" class="rounded-lg p-5 overflow-y-auto border border-slate-600 flex-1"
style="min-height: 380px; max-height: 60vh; font-family: Georgia, serif; line-height: 1.7; font-size: 1rem; background: #f8fafc; color: #0f172a;"> 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> <span class="text-slate-500 italic">Vorschau erscheint beim Tippen...</span>
</div> </div>
<div id="wp-draft-link" class="hidden"></div> <div id="wp-draft-link" class="hidden"></div>
@ -130,6 +134,29 @@
</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>
@ -203,7 +230,7 @@
</div> </div>
<!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ --> <!-- ═══ REDAKTIONSPLAN (unten, volle Breite, scrollen) ═══ -->
<div class="space-y-4"> <div class="space-y-4">
{% set draft_arts = [] %} {% set draft_arts = [] %}
@ -380,16 +407,91 @@ function insertImageAtCursor() {
function setButtonLoading(btnId, loading) { function setButtonLoading(btnId, loading) {
const btn = document.getElementById(btnId); const btn = document.getElementById(btnId);
if (!btn) return; if (!btn) return;
const labels = { 'btn-generate': '🤖 KI generieren', 'btn-polish': 'Verbessern', 'btn-chat': 'Senden' };
if (loading) { if (loading) {
btn.dataset.origText = btn.textContent; btn.dataset.origText = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Bitte warten...'; btn.textContent = 'Bitte warten...';
} else { } else {
btn.disabled = false; btn.disabled = false;
btn.textContent = btn.dataset.origText || (btnId === 'btn-generate' ? '🤖 KI generieren' : 'Verbessern'); 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() { async function polishArticle() {
const instruction = document.getElementById('polish-instruction').value.trim(); const instruction = document.getElementById('polish-instruction').value.trim();
const title = document.getElementById('article-title').value; const title = document.getElementById('article-title').value;
@ -586,6 +688,7 @@ 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;