Generate it / Publish it: Claude Haiku integration + Generate page redesign
- GenerateIt page: objects sidebar, readOnly canvas, collapsible prompt bar - Generate it: calls Claude Haiku, saves questions/words to Directus as draft - Publish it: promotes draft questions/words to published - Deduplication: links existing words/questions instead of duplicating - GenerateObjectsList: tree view with user_notes labels - DrawCanvas: readOnly prop to disable mouse interaction - api.ts: generateQuestions + publishQuestions endpoints - requirements.txt: anthropic==0.40.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,28 @@
|
|||||||
"Bash(docker build *)",
|
"Bash(docker build *)",
|
||||||
"mcp__coolify__private-keys",
|
"mcp__coolify__private-keys",
|
||||||
"Bash(pkill -f \"python app.py\")",
|
"Bash(pkill -f \"python app.py\")",
|
||||||
"Bash(curl -s http://localhost:8000/)"
|
"Bash(curl -s http://localhost:8000/)",
|
||||||
|
"mcp__coolify__applications",
|
||||||
|
"Bash(ssh-keygen -t ed25519 -C \"coolify-content-mentor\" -f /tmp/coolify_content_mentor -N \"\" -q)",
|
||||||
|
"Bash(ssh-keygen -lf /tmp/coolify_content_mentor.pub)",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(ssh -i /tmp/coolify_content_mentor -o StrictHostKeyChecking=no -o ConnectTimeout=10 -T git@git.hyggecraftery.com)",
|
||||||
|
"Bash(ssh -i /tmp/coolify_content_mentor -o StrictHostKeyChecking=no -o ConnectTimeout=10 -vvv git@git.hyggecraftery.com)",
|
||||||
|
"Bash(nc -zv git.hyggecraftery.com 2222)",
|
||||||
|
"Bash(nc -zv git.hyggecraftery.com 3000)",
|
||||||
|
"mcp__coolify__deployments",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"mcp__directus__read-items",
|
||||||
|
"mcp__directus__system-prompt",
|
||||||
|
"Read(//Users/tim/.claude/**)",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers',{}\\).get\\('directus',{}\\), indent=2\\)\\)\")",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); mcps=d.get\\('mcpServers',{}\\); print\\(json.dumps\\(mcps.get\\('directus', mcps.get\\('mcp__directus',{}\\)\\), indent=2\\)\\)\")",
|
||||||
|
"mcp__directus__read-fields",
|
||||||
|
"mcp__directus__create-field",
|
||||||
|
"mcp__directus__update-field",
|
||||||
|
"mcp__directus__read-collections"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
295
app.py
295
app.py
@@ -2,13 +2,18 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from flask import Flask, send_from_directory, request, jsonify
|
from flask import Flask, send_from_directory, request, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import ollama
|
import ollama
|
||||||
|
import anthropic as _anthropic_sdk
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
DIRECTUS_URL = "https://db.hejyou.com"
|
DIRECTUS_URL = "https://db.hejyou.com"
|
||||||
|
|
||||||
@@ -878,8 +883,294 @@ def crop_image():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Claude / Directus Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ensure_junction(collection: str, field1: str, field2: str, token: str):
|
||||||
|
"""Create a simple M2M junction collection in Directus if it doesn't exist."""
|
||||||
|
_, status = _directus("GET", f"/collections/{collection}", token)
|
||||||
|
if status == 200:
|
||||||
|
return
|
||||||
|
body = {
|
||||||
|
"collection": collection,
|
||||||
|
"schema": {},
|
||||||
|
"meta": {"hidden": True, "icon": "import_export"},
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"field": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"schema": {"is_primary_key": True, "has_auto_increment": True},
|
||||||
|
"meta": {"hidden": True},
|
||||||
|
},
|
||||||
|
{"field": field1, "type": "uuid", "schema": {}, "meta": {"hidden": True}},
|
||||||
|
{"field": field2, "type": "uuid", "schema": {}, "meta": {"hidden": True}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_directus("POST", "/collections", token, body)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_link(collection: str, match: dict, payload: dict, token: str):
|
||||||
|
"""Insert a junction row only if it doesn't already exist."""
|
||||||
|
qs = "&".join(
|
||||||
|
f"filter[{k}][_eq]={urllib.parse.quote(str(v), safe='')}"
|
||||||
|
for k, v in match.items()
|
||||||
|
)
|
||||||
|
data, status = _directus("GET", f"/items/{collection}?{qs}&limit=1", token)
|
||||||
|
if status == 200 and data.get("data"):
|
||||||
|
return # already linked
|
||||||
|
_directus("POST", f"/items/{collection}", token, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create_word(title_de: str, level: int, token: str):
|
||||||
|
"""Return (word_id, is_new). Creates word with status=draft if missing."""
|
||||||
|
enc = urllib.parse.quote(title_de, safe="")
|
||||||
|
data, status = _directus(
|
||||||
|
"GET", f"/items/words?filter[title_de][_eq]={enc}&fields=id&limit=1", token
|
||||||
|
)
|
||||||
|
if status == 200 and data.get("data"):
|
||||||
|
return data["data"][0]["id"], False
|
||||||
|
|
||||||
|
body = {"status": "draft", "title_de": title_de, "level": level}
|
||||||
|
data, status = _directus("POST", "/items/words", token, body)
|
||||||
|
if status in (200, 201):
|
||||||
|
return data["data"]["id"], True
|
||||||
|
raise RuntimeError(f"Word creation failed ({status}): {data}")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_or_create_question(question_de: str, answer_de: str, level: int,
|
||||||
|
short_answer_id, obj_id: str, token: str):
|
||||||
|
"""Return (question_id, is_new). Creates question with status=draft if missing."""
|
||||||
|
enc = urllib.parse.quote(question_de, safe="")
|
||||||
|
data, status = _directus(
|
||||||
|
"GET",
|
||||||
|
f"/items/questions?filter[question_de][_eq]={enc}&fields=id&limit=1",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if status == 200 and data.get("data"):
|
||||||
|
return data["data"][0]["id"], False
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"status": "draft",
|
||||||
|
"question_de": question_de,
|
||||||
|
"answer_de": answer_de,
|
||||||
|
"level": level,
|
||||||
|
"object": obj_id,
|
||||||
|
}
|
||||||
|
if short_answer_id:
|
||||||
|
body["short_answer"] = short_answer_id
|
||||||
|
|
||||||
|
data, status = _directus("POST", "/items/questions", token, body)
|
||||||
|
if status in (200, 201):
|
||||||
|
return data["data"]["id"], True
|
||||||
|
raise RuntimeError(f"Question creation failed ({status}): {data}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Generate & Publish endpoints ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/object/<obj_id>/generate_questions", methods=["POST"])
|
||||||
|
def generate_questions(obj_id: str):
|
||||||
|
"""
|
||||||
|
1. Holt Objekt + Elternobjekt aus Directus
|
||||||
|
2. Füllt Prompt-Platzhalter
|
||||||
|
3. Ruft Claude Haiku auf
|
||||||
|
4. Speichert Wörter + Fragen als Entwurf in Directus
|
||||||
|
"""
|
||||||
|
token = request.headers.get("Authorization", "")
|
||||||
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
|
prompt_template = (body.get("prompt") or "").strip()
|
||||||
|
|
||||||
|
if not prompt_template:
|
||||||
|
return jsonify({"error": "Missing prompt"}), 400
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
return jsonify({"error": "ANTHROPIC_API_KEY not configured on server"}), 500
|
||||||
|
|
||||||
|
# Objekt laden
|
||||||
|
obj_resp, s = _directus("GET", f"/items/objects/{obj_id}?fields=id,user_notes,parent", token)
|
||||||
|
if s != 200:
|
||||||
|
return jsonify({"error": "Object not found"}), 404
|
||||||
|
obj = obj_resp.get("data") or {}
|
||||||
|
|
||||||
|
# Elternobjekt laden
|
||||||
|
parent_notes = ""
|
||||||
|
if obj.get("parent"):
|
||||||
|
p_resp, _ = _directus("GET", f"/items/objects/{obj['parent']}?fields=id,user_notes", token)
|
||||||
|
parent_notes = ((p_resp.get("data") or {}).get("user_notes") or "")
|
||||||
|
|
||||||
|
# Platzhalter ersetzen
|
||||||
|
prompt = (
|
||||||
|
prompt_template
|
||||||
|
.replace("{user-notes_object}", obj.get("user_notes") or "")
|
||||||
|
.replace("{user-notes_parentobject}", parent_notes)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Claude Haiku aufrufen
|
||||||
|
try:
|
||||||
|
client = _anthropic_sdk.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||||
|
msg = client.messages.create(
|
||||||
|
model="claude-haiku-4-5-20251001",
|
||||||
|
max_tokens=4096,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
raw = msg.content[0].text
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": f"Claude API error: {e}"}), 500
|
||||||
|
|
||||||
|
# JSON parsen
|
||||||
|
try:
|
||||||
|
ai = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
start = raw.index("{")
|
||||||
|
end = raw.rindex("}") + 1
|
||||||
|
ai = json.loads(raw[start:end])
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"error": "Invalid JSON from AI", "raw": raw[:400]}), 500
|
||||||
|
|
||||||
|
levels = ai.get("levels", [])
|
||||||
|
if not levels:
|
||||||
|
return jsonify({"error": "No levels in AI response"}), 500
|
||||||
|
|
||||||
|
# Junction-Collections sicherstellen
|
||||||
|
for col, f1, f2 in [
|
||||||
|
("words_objects", "words_id", "objects_id"),
|
||||||
|
("questions_objects", "questions_id", "objects_id"),
|
||||||
|
("questions_distractor_words", "questions_id", "words_id"),
|
||||||
|
]:
|
||||||
|
_ensure_junction(col, f1, f2, token)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"words_created": 0,
|
||||||
|
"words_linked": 0,
|
||||||
|
"questions_created": 0,
|
||||||
|
"questions_linked": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for lvl in levels:
|
||||||
|
level = int(lvl.get("level") or 1)
|
||||||
|
q_de = (lvl.get("question") or "").strip()
|
||||||
|
a_de = (lvl.get("answer") or "").strip()
|
||||||
|
short_text = (lvl.get("short_answer") or "").strip()
|
||||||
|
words_list = [str(w).strip() for w in lvl.get("words", []) if str(w).strip()]
|
||||||
|
distractor_list = [str(w).strip() for w in lvl.get("distractor_words", []) if str(w).strip()]
|
||||||
|
|
||||||
|
if not q_de:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Kurzantwort-Wort
|
||||||
|
short_answer_id = None
|
||||||
|
if short_text:
|
||||||
|
try:
|
||||||
|
wid, is_new = _find_or_create_word(short_text, level, token)
|
||||||
|
short_answer_id = wid
|
||||||
|
_ensure_link(
|
||||||
|
"words_objects",
|
||||||
|
{"words_id": wid, "objects_id": obj_id},
|
||||||
|
{"words_id": wid, "objects_id": obj_id},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
stats["words_created" if is_new else "words_linked"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[generate_questions] short_answer error: {e}")
|
||||||
|
|
||||||
|
# Alle einzigartigen Wörter verarbeiten
|
||||||
|
all_unique = list(dict.fromkeys(words_list + distractor_list))
|
||||||
|
word_map: dict[str, str] = {} # title_de → id
|
||||||
|
|
||||||
|
for w in all_unique:
|
||||||
|
try:
|
||||||
|
wid, is_new = _find_or_create_word(w, level, token)
|
||||||
|
word_map[w] = wid
|
||||||
|
_ensure_link(
|
||||||
|
"words_objects",
|
||||||
|
{"words_id": wid, "objects_id": obj_id},
|
||||||
|
{"words_id": wid, "objects_id": obj_id},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
stats["words_created" if is_new else "words_linked"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[generate_questions] word error '{w}': {e}")
|
||||||
|
|
||||||
|
# Frage anlegen / verknüpfen
|
||||||
|
try:
|
||||||
|
q_id, q_is_new = _find_or_create_question(q_de, a_de, level, short_answer_id, obj_id, token)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[generate_questions] question error level {level}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
stats["questions_created" if q_is_new else "questions_linked"] += 1
|
||||||
|
|
||||||
|
# Frage ↔ Objekt
|
||||||
|
_ensure_link(
|
||||||
|
"questions_objects",
|
||||||
|
{"questions_id": q_id, "objects_id": obj_id},
|
||||||
|
{"questions_id": q_id, "objects_id": obj_id},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
|
||||||
|
# related_words (bestehende Junction questions_words)
|
||||||
|
for w in words_list:
|
||||||
|
if w in word_map:
|
||||||
|
_ensure_link(
|
||||||
|
"questions_words",
|
||||||
|
{"questions_id": q_id, "words_id": word_map[w]},
|
||||||
|
{"questions_id": q_id, "words_id": word_map[w]},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
|
||||||
|
# distractor_words
|
||||||
|
for w in distractor_list:
|
||||||
|
if w in word_map:
|
||||||
|
_ensure_link(
|
||||||
|
"questions_distractor_words",
|
||||||
|
{"questions_id": q_id, "words_id": word_map[w]},
|
||||||
|
{"questions_id": q_id, "words_id": word_map[w]},
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[generate_questions] obj={obj_id} stats={stats}")
|
||||||
|
return jsonify({"ok": True, "object_id": obj_id, "stats": stats})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/object/<obj_id>/publish_questions", methods=["POST"])
|
||||||
|
def publish_questions(obj_id: str):
|
||||||
|
"""Ändert Status aller verknüpften Entwurfs-Fragen und -Wörter auf 'published'."""
|
||||||
|
token = request.headers.get("Authorization", "")
|
||||||
|
|
||||||
|
# Verknüpfte Fragen
|
||||||
|
q_resp, _ = _directus(
|
||||||
|
"GET",
|
||||||
|
f"/items/questions_objects?filter[objects_id][_eq]={obj_id}&fields=questions_id&limit=200",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
q_ids = [e["questions_id"] for e in (q_resp.get("data") or [])]
|
||||||
|
|
||||||
|
# Verknüpfte Wörter
|
||||||
|
w_resp, _ = _directus(
|
||||||
|
"GET",
|
||||||
|
f"/items/words_objects?filter[objects_id][_eq]={obj_id}&fields=words_id&limit=2000",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
w_ids = [e["words_id"] for e in (w_resp.get("data") or [])]
|
||||||
|
|
||||||
|
published_q = 0
|
||||||
|
published_w = 0
|
||||||
|
|
||||||
|
for q_id in q_ids:
|
||||||
|
_, s = _directus("PATCH", f"/items/questions/{q_id}", token, {"status": "published"})
|
||||||
|
if s == 200:
|
||||||
|
published_q += 1
|
||||||
|
|
||||||
|
for w_id in w_ids:
|
||||||
|
_, s = _directus("PATCH", f"/items/words/{w_id}", token, {"status": "published"})
|
||||||
|
if s == 200:
|
||||||
|
published_w += 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"ok": True,
|
||||||
|
"published_questions": published_q,
|
||||||
|
"published_words": published_w,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Du kannst den Port hier anpassen, z.B. 8000 oder 5000.
|
|
||||||
# Für externen Zugriff später einfach in Nginx/Reverse Proxy weiterleiten.
|
|
||||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||||
|
|
||||||
|
|||||||
@@ -195,3 +195,38 @@ export async function generateSentence(
|
|||||||
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Sentence')
|
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Sentence')
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateStats {
|
||||||
|
words_created: number
|
||||||
|
words_linked: number
|
||||||
|
questions_created: number
|
||||||
|
questions_linked: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateQuestions(
|
||||||
|
objId: string,
|
||||||
|
prompt: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ ok: boolean; stats: GenerateStats }> {
|
||||||
|
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_questions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ prompt }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Fehler bei Generate')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishQuestions(
|
||||||
|
objId: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ ok: boolean; published_questions: number; published_words: number }> {
|
||||||
|
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/publish_questions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Fehler bei Publish')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
|
||||||
import GenerateObjectsList from '../components/GenerateObjectsList'
|
import GenerateObjectsList from '../components/GenerateObjectsList'
|
||||||
import SentencesList from '../components/SentencesList'
|
|
||||||
import Topbar from '../components/Topbar'
|
import Topbar from '../components/Topbar'
|
||||||
import {
|
import {
|
||||||
getDirectusPictures,
|
getDirectusPictures,
|
||||||
directusAssetUrl,
|
directusAssetUrl,
|
||||||
type DirectusPicture,
|
type DirectusPicture,
|
||||||
getDirectusObjects,
|
getDirectusObjects,
|
||||||
generateDetails,
|
generateQuestions,
|
||||||
generateSentence,
|
publishQuestions,
|
||||||
getSentences,
|
type GenerateStats,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import type { DirectusObject, CanvasObject } from '../types'
|
import type { DirectusObject, CanvasObject } from '../types'
|
||||||
import type { Sentence } from '../types'
|
|
||||||
|
|
||||||
const ChevronLeftIcon = () => (
|
const ChevronLeftIcon = () => (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -28,15 +27,15 @@ const ChevronRightIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const SparkleIcon = () => (
|
const GenerateIcon = () => (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ChatIcon = () => (
|
const PublishIcon = () => (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
<path d="M12 19V5M5 12l7-7 7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,18 +105,6 @@ function persistLayouts(layouts: PromptLayout[]): void {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Word extraction ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function extractWords(sentences: Sentence[]): string[] {
|
|
||||||
const allText = sentences
|
|
||||||
.flatMap(s => [s.question_simple_en, s.answer_simple_en, s.question_advanced_en, s.answer_advanced_en])
|
|
||||||
.join(' ')
|
|
||||||
const words = allText
|
|
||||||
.split(/\s+/)
|
|
||||||
.map(w => w.replace(/[^a-zA-ZäöüÄÖÜßéèêàâùûîô'-]/g, ''))
|
|
||||||
.filter(w => w.length > 1)
|
|
||||||
return [...new Set(words)].sort((a, b) => a.localeCompare(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -129,9 +116,11 @@ export default function GenerateIt() {
|
|||||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||||
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
|
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
|
||||||
const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
|
const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
|
||||||
const [sentences, setSentences] = useState<Sentence[]>([])
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
const [isGeneratingSentence, setIsGeneratingSentence] = useState(false)
|
const [generateResult, setGenerateResult] = useState<GenerateStats | null>(null)
|
||||||
|
const [generateError, setGenerateError] = useState<string | null>(null)
|
||||||
|
const [publishResult, setPublishResult] = useState<{ q: number; w: number } | null>(null)
|
||||||
|
|
||||||
// Prompt layouts
|
// Prompt layouts
|
||||||
const [layouts, setLayouts] = useState<PromptLayout[]>(loadLayouts)
|
const [layouts, setLayouts] = useState<PromptLayout[]>(loadLayouts)
|
||||||
@@ -144,8 +133,6 @@ export default function GenerateIt() {
|
|||||||
const currentPicture: DirectusPicture | null =
|
const currentPicture: DirectusPicture | null =
|
||||||
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
|
||||||
|
|
||||||
const words = extractWords(sentences)
|
|
||||||
|
|
||||||
const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({
|
const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
visible: true,
|
visible: true,
|
||||||
@@ -163,44 +150,46 @@ export default function GenerateIt() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentPicture || !token) {
|
if (!currentPicture || !token) {
|
||||||
setDirectusObjects([]); setSelectedObjId(null); setSentences([])
|
setDirectusObjects([]); setSelectedObjId(null)
|
||||||
|
setGenerateResult(null); setPublishResult(null); setGenerateError(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
getDirectusObjects(currentPicture.id, token)
|
getDirectusObjects(currentPicture.id, token)
|
||||||
.then(objs => {
|
.then(objs => {
|
||||||
setDirectusObjects(objs)
|
setDirectusObjects(objs)
|
||||||
if (objs.length > 0) { setSelectedObjId(objs[0].id); loadSentences(objs[0].id) }
|
if (objs.length > 0) setSelectedObjId(objs[0].id)
|
||||||
else { setSelectedObjId(null); setSentences([]) }
|
else setSelectedObjId(null)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
}, [currentPicture?.id, token])
|
}, [currentPicture?.id, token])
|
||||||
|
|
||||||
const loadSentences = async (objId: string) => {
|
const handleGenerate = async () => {
|
||||||
try { setSentences(await getSentences(objId)) } catch (e) { console.error(e) }
|
if (!selectedObjId || !token) return
|
||||||
}
|
setIsGenerating(true)
|
||||||
|
setGenerateResult(null)
|
||||||
const handleGenerateDetails = async () => {
|
setGenerateError(null)
|
||||||
if (!selectedObjId) return
|
setPublishResult(null)
|
||||||
setIsGeneratingDetails(true)
|
|
||||||
try {
|
try {
|
||||||
await generateDetails(selectedObjId)
|
const res = await generateQuestions(selectedObjId, promptText, token)
|
||||||
|
setGenerateResult(res.stats)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler bei KI-Details')
|
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Generieren')
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingDetails(false)
|
setIsGenerating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateSentence = async () => {
|
const handlePublish = async () => {
|
||||||
if (!selectedObjId) return
|
if (!selectedObjId || !token) return
|
||||||
setIsGeneratingSentence(true)
|
setIsPublishing(true)
|
||||||
|
setPublishResult(null)
|
||||||
try {
|
try {
|
||||||
const data = await generateSentence(selectedObjId)
|
const res = await publishQuestions(selectedObjId, token)
|
||||||
setSentences(prev => [...prev, data.sentence])
|
setPublishResult({ q: res.published_questions, w: res.published_words })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler bei KI-Sentence')
|
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Veröffentlichen')
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingSentence(false)
|
setIsPublishing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,14 +247,40 @@ export default function GenerateIt() {
|
|||||||
|
|
||||||
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
|
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
|
||||||
|
|
||||||
<button className="btn-ghost btn-sm" onClick={handleGenerateDetails} disabled={isGeneratingDetails || !selectedObjId}>
|
<button
|
||||||
<SparkleIcon />
|
className="btn-ghost btn-sm"
|
||||||
{isGeneratingDetails ? 'Generiere…' : 'KI-Details'}
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating || isPublishing || !selectedObjId}
|
||||||
|
style={isGenerating ? { opacity: 0.7 } : undefined}
|
||||||
|
>
|
||||||
|
<GenerateIcon />
|
||||||
|
{isGenerating ? 'Generiere…' : 'Generate it'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-ghost btn-sm" onClick={handleGenerateSentence} disabled={isGeneratingSentence || !selectedObjId}>
|
<button
|
||||||
<ChatIcon />
|
className="btn-ghost btn-sm"
|
||||||
{isGeneratingSentence ? 'Generiere…' : 'KI-Sentence'}
|
onClick={handlePublish}
|
||||||
|
disabled={isPublishing || isGenerating || !selectedObjId || !generateResult}
|
||||||
|
title={!generateResult ? 'Erst Generate it ausführen' : undefined}
|
||||||
|
>
|
||||||
|
<PublishIcon />
|
||||||
|
{isPublishing ? 'Veröffentliche…' : 'Publish it'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{generateResult && !isGenerating && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--success)', background: 'var(--success-bg)', border: '1px solid var(--success)', borderRadius: 'var(--r-full)', padding: '2px 8px' }}>
|
||||||
|
✓ {generateResult.questions_created}F +{generateResult.words_created}W neu
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{publishResult && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--primary-muted-fg)', background: 'var(--primary-muted)', border: '1px solid var(--primary)', borderRadius: 'var(--r-full)', padding: '2px 8px' }}>
|
||||||
|
↑ {publishResult.q}F {publishResult.w}W
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{generateError && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--danger)', background: 'var(--danger-bg)', border: '1px solid var(--danger)', borderRadius: 'var(--r-full)', padding: '2px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{generateError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,7 +299,7 @@ export default function GenerateIt() {
|
|||||||
<GenerateObjectsList
|
<GenerateObjectsList
|
||||||
objects={directusObjects}
|
objects={directusObjects}
|
||||||
selectedId={selectedObjId}
|
selectedId={selectedObjId}
|
||||||
onSelect={id => { setSelectedObjId(id); loadSentences(id) }}
|
onSelect={id => setSelectedObjId(id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -367,33 +382,26 @@ export default function GenerateIt() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Words */}
|
{/* Right: Generate results */}
|
||||||
<aside className="sidebar sidebar--words">
|
|
||||||
<div className="sidebar-panel" style={{ flex: 1 }}>
|
|
||||||
<h3 className="sidebar-heading">
|
|
||||||
Wörter
|
|
||||||
{words.length > 0 && <span className="badge">{words.length}</span>}
|
|
||||||
</h3>
|
|
||||||
{words.length === 0 ? (
|
|
||||||
<div className="empty-state">Noch keine Wörter.</div>
|
|
||||||
) : (
|
|
||||||
<div className="words-cloud">
|
|
||||||
{words.map(w => (
|
|
||||||
<span key={w} className="word-chip">{w}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Right: Sentences */}
|
|
||||||
<aside className="sidebar sidebar--right">
|
<aside className="sidebar sidebar--right">
|
||||||
<div className="sidebar-panel" style={{ flex: 1 }}>
|
<div className="sidebar-panel" style={{ flex: 1 }}>
|
||||||
<h3 className="sidebar-heading">
|
<h3 className="sidebar-heading">Ergebnis</h3>
|
||||||
Sätze
|
{generateResult ? (
|
||||||
{sentences.length > 0 && <span className="badge">{sentences.length}</span>}
|
<div style={{ padding: '8px 12px', fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
</h3>
|
<div><strong>{generateResult.questions_created}</strong> Fragen erstellt</div>
|
||||||
<SentencesList sentences={sentences} />
|
<div><strong>{generateResult.questions_linked}</strong> Fragen verknüpft</div>
|
||||||
|
<div><strong>{generateResult.words_created}</strong> Wörter erstellt</div>
|
||||||
|
<div><strong>{generateResult.words_linked}</strong> Wörter verknüpft</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">Klicke „Generate it".</div>
|
||||||
|
)}
|
||||||
|
{publishResult && (
|
||||||
|
<div style={{ padding: '0 12px 8px', fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6, color: 'var(--primary)' }}>
|
||||||
|
<div>↑ {publishResult.q} Fragen veröffentlicht</div>
|
||||||
|
<div>↑ {publishResult.w} Wörter veröffentlicht</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ Flask-Cors==4.0.1
|
|||||||
gunicorn==22.0.0
|
gunicorn==22.0.0
|
||||||
Pillow==11.0.0
|
Pillow==11.0.0
|
||||||
ollama==0.3.0
|
ollama==0.3.0
|
||||||
|
anthropic==0.40.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user