feat: AI-Prompts flexibilisierung - Backend & Admin UI (Issue #28, Part 1)
Backend complete: - Migration 017: Add category column to ai_prompts - placeholder_resolver.py: 20+ placeholders with resolver functions - Extended routers/prompts.py with CRUD endpoints: * POST /api/prompts (create) * PUT /api/prompts/:id (update) * DELETE /api/prompts/:id (delete) * POST /api/prompts/:id/duplicate * PUT /api/prompts/reorder * POST /api/prompts/preview * GET /api/prompts/placeholders * POST /api/prompts/generate (KI-assisted generation) * POST /api/prompts/:id/optimize (KI analysis) - Extended models.py with PromptCreate, PromptUpdate, PromptGenerateRequest Frontend: - AdminPromptsPage.jsx: Full CRUD UI with category filter, reordering Meta-Features: - KI generates prompts from goal description + example data - KI analyzes and optimizes existing prompts Next: PromptEditModal, PromptGenerator, api.js integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5796c6a21a
commit
500de132b9
22
backend/migrations/017_ai_prompts_flexibilisierung.sql
Normal file
22
backend/migrations/017_ai_prompts_flexibilisierung.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- Migration 017: AI Prompts Flexibilisierung (Issue #28)
|
||||
-- Add category column to ai_prompts for better organization and filtering
|
||||
|
||||
-- Add category column
|
||||
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS category VARCHAR(20) DEFAULT 'ganzheitlich';
|
||||
|
||||
-- Create index for category filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON COLUMN ai_prompts.category IS 'Prompt category: körper, ernährung, training, schlaf, vitalwerte, ziele, ganzheitlich';
|
||||
|
||||
-- Update existing prompts with appropriate categories
|
||||
-- Based on slug patterns and content
|
||||
UPDATE ai_prompts SET category = 'körper' WHERE slug IN ('koerperkomposition', 'gewichtstrend', 'umfaenge', 'caliper');
|
||||
UPDATE ai_prompts SET category = 'ernährung' WHERE slug IN ('ernaehrung', 'kalorienbilanz', 'protein', 'makros');
|
||||
UPDATE ai_prompts SET category = 'training' WHERE slug IN ('aktivitaet', 'trainingsanalyse', 'erholung', 'leistung');
|
||||
UPDATE ai_prompts SET category = 'schlaf' WHERE slug LIKE '%schlaf%';
|
||||
UPDATE ai_prompts SET category = 'vitalwerte' WHERE slug IN ('vitalwerte', 'herzfrequenz', 'ruhepuls', 'hrv');
|
||||
UPDATE ai_prompts SET category = 'ziele' WHERE slug LIKE '%ziel%' OR slug LIKE '%goal%';
|
||||
|
||||
-- Pipeline prompts remain 'ganzheitlich' (default)
|
||||
|
|
@ -127,3 +127,30 @@ class AdminProfileUpdate(BaseModel):
|
|||
ai_enabled: Optional[int] = None
|
||||
ai_limit_day: Optional[int] = None
|
||||
export_enabled: Optional[int] = None
|
||||
|
||||
|
||||
# ── Prompt Models (Issue #28) ────────────────────────────────────────────────
|
||||
|
||||
class PromptCreate(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
template: str
|
||||
category: str = 'ganzheitlich'
|
||||
active: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class PromptUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
template: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class PromptGenerateRequest(BaseModel):
|
||||
goal: str
|
||||
data_categories: list[str]
|
||||
example_output: Optional[str] = None
|
||||
|
|
|
|||
308
backend/placeholder_resolver.py
Normal file
308
backend/placeholder_resolver.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""
|
||||
Placeholder Resolver for AI Prompts
|
||||
|
||||
Provides a registry of placeholder functions that resolve to actual user data.
|
||||
Used for prompt templates and preview functionality.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
|
||||
# ── Helper Functions ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_profile_data(profile_id: str) -> Dict:
|
||||
"""Load profile data for a user."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
|
||||
return r2d(cur.fetchone()) if cur.rowcount > 0 else {}
|
||||
|
||||
|
||||
def get_latest_weight(profile_id: str) -> Optional[str]:
|
||||
"""Get latest weight entry."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 1",
|
||||
(profile_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return f"{row['weight']:.1f} kg" if row else "nicht verfügbar"
|
||||
|
||||
|
||||
def get_weight_trend(profile_id: str, days: int = 28) -> str:
|
||||
"""Calculate weight trend description."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
"""SELECT weight, date FROM weight_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
if len(rows) < 2:
|
||||
return "nicht genug Daten"
|
||||
|
||||
first = rows[0]['weight']
|
||||
last = rows[-1]['weight']
|
||||
delta = last - first
|
||||
|
||||
if abs(delta) < 0.3:
|
||||
return "stabil"
|
||||
elif delta > 0:
|
||||
return f"steigend (+{delta:.1f} kg in {days} Tagen)"
|
||||
else:
|
||||
return f"sinkend ({delta:.1f} kg in {days} Tagen)"
|
||||
|
||||
|
||||
def get_latest_bf(profile_id: str) -> Optional[str]:
|
||||
"""Get latest body fat percentage from caliper."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT bf_jpl FROM caliper_log
|
||||
WHERE profile_id=%s AND bf_jpl IS NOT NULL
|
||||
ORDER BY date DESC LIMIT 1""",
|
||||
(profile_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return f"{row['bf_jpl']:.1f}%" if row else "nicht verfügbar"
|
||||
|
||||
|
||||
def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str:
|
||||
"""Calculate average nutrition value."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
f"""SELECT AVG({field}) as avg FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND {field} IS NOT NULL""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row['avg']:
|
||||
if field == 'kcal':
|
||||
return f"{int(row['avg'])} kcal/Tag (Ø {days} Tage)"
|
||||
else:
|
||||
return f"{int(row['avg'])}g/Tag (Ø {days} Tage)"
|
||||
return "nicht verfügbar"
|
||||
|
||||
|
||||
def get_activity_summary(profile_id: str, days: int = 14) -> str:
|
||||
"""Get activity summary for recent period."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count,
|
||||
SUM(duration_min) as total_min,
|
||||
SUM(kcal_active) as total_kcal
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND date >= %s""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
row = r2d(cur.fetchone())
|
||||
|
||||
if row['count'] == 0:
|
||||
return f"Keine Aktivitäten in den letzten {days} Tagen"
|
||||
|
||||
avg_min = int(row['total_min'] / row['count']) if row['total_min'] else 0
|
||||
return f"{row['count']} Einheiten in {days} Tagen (Ø {avg_min} min/Einheit, {int(row['total_kcal'] or 0)} kcal gesamt)"
|
||||
|
||||
|
||||
def calculate_age(dob: Optional[str]) -> str:
|
||||
"""Calculate age from date of birth."""
|
||||
if not dob:
|
||||
return "unbekannt"
|
||||
try:
|
||||
birth = datetime.strptime(dob, '%Y-%m-%d')
|
||||
today = datetime.now()
|
||||
age = today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
|
||||
return str(age)
|
||||
except:
|
||||
return "unbekannt"
|
||||
|
||||
|
||||
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
||||
"""Get training type distribution."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cur.execute(
|
||||
"""SELECT training_category, COUNT(*) as count
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND date >= %s AND training_category IS NOT NULL
|
||||
GROUP BY training_category
|
||||
ORDER BY count DESC""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
if not rows:
|
||||
return "Keine kategorisierten Trainings"
|
||||
|
||||
total = sum(r['count'] for r in rows)
|
||||
parts = [f"{r['training_category']}: {int(r['count']/total*100)}%" for r in rows[:3]]
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
# ── Placeholder Registry ──────────────────────────────────────────────────────
|
||||
|
||||
PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||
# Profil
|
||||
'{{name}}': lambda pid: get_profile_data(pid).get('name', 'Nutzer'),
|
||||
'{{age}}': lambda pid: calculate_age(get_profile_data(pid).get('dob')),
|
||||
'{{height}}': lambda pid: str(get_profile_data(pid).get('height', 'unbekannt')),
|
||||
'{{geschlecht}}': lambda pid: 'männlich' if get_profile_data(pid).get('sex') == 'm' else 'weiblich',
|
||||
|
||||
# Körper
|
||||
'{{weight_aktuell}}': get_latest_weight,
|
||||
'{{weight_trend}}': get_weight_trend,
|
||||
'{{kf_aktuell}}': get_latest_bf,
|
||||
'{{bmi}}': lambda pid: calculate_bmi(pid),
|
||||
|
||||
# Ernährung
|
||||
'{{kcal_avg}}': lambda pid: get_nutrition_avg(pid, 'kcal', 30),
|
||||
'{{protein_avg}}': lambda pid: get_nutrition_avg(pid, 'protein', 30),
|
||||
'{{carb_avg}}': lambda pid: get_nutrition_avg(pid, 'carb', 30),
|
||||
'{{fat_avg}}': lambda pid: get_nutrition_avg(pid, 'fat', 30),
|
||||
|
||||
# Training
|
||||
'{{activity_summary}}': get_activity_summary,
|
||||
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
|
||||
|
||||
# Zeitraum
|
||||
'{{datum_heute}}': lambda pid: datetime.now().strftime('%d.%m.%Y'),
|
||||
'{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage',
|
||||
'{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage',
|
||||
'{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage',
|
||||
}
|
||||
|
||||
|
||||
def calculate_bmi(profile_id: str) -> str:
|
||||
"""Calculate BMI from latest weight and profile height."""
|
||||
profile = get_profile_data(profile_id)
|
||||
if not profile.get('height'):
|
||||
return "nicht verfügbar"
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 1",
|
||||
(profile_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "nicht verfügbar"
|
||||
|
||||
height_m = profile['height'] / 100
|
||||
bmi = row['weight'] / (height_m ** 2)
|
||||
return f"{bmi:.1f}"
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def resolve_placeholders(template: str, profile_id: str) -> str:
|
||||
"""
|
||||
Replace all {{placeholders}} in template with actual user data.
|
||||
|
||||
Args:
|
||||
template: Prompt template with placeholders
|
||||
profile_id: User profile ID
|
||||
|
||||
Returns:
|
||||
Resolved template with placeholders replaced by values
|
||||
"""
|
||||
result = template
|
||||
|
||||
for placeholder, resolver in PLACEHOLDER_MAP.items():
|
||||
if placeholder in result:
|
||||
try:
|
||||
value = resolver(profile_id)
|
||||
result = result.replace(placeholder, str(value))
|
||||
except Exception as e:
|
||||
# On error, replace with error message
|
||||
result = result.replace(placeholder, f"[Fehler: {placeholder}]")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_unknown_placeholders(template: str) -> List[str]:
|
||||
"""
|
||||
Find all placeholders in template that are not in PLACEHOLDER_MAP.
|
||||
|
||||
Args:
|
||||
template: Prompt template
|
||||
|
||||
Returns:
|
||||
List of unknown placeholder names (without {{}})
|
||||
"""
|
||||
# Find all {{...}} patterns
|
||||
found = re.findall(r'\{\{(\w+)\}\}', template)
|
||||
|
||||
# Filter to only unknown ones
|
||||
known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()}
|
||||
unknown = [p for p in found if p not in known_names]
|
||||
|
||||
return list(set(unknown)) # Remove duplicates
|
||||
|
||||
|
||||
def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Get available placeholders, optionally filtered by categories.
|
||||
|
||||
Args:
|
||||
categories: Optional list of categories to filter (körper, ernährung, training, etc.)
|
||||
|
||||
Returns:
|
||||
Dict mapping category to list of placeholders
|
||||
"""
|
||||
placeholder_categories = {
|
||||
'profil': [
|
||||
'{{name}}', '{{age}}', '{{height}}', '{{geschlecht}}'
|
||||
],
|
||||
'körper': [
|
||||
'{{weight_aktuell}}', '{{weight_trend}}', '{{kf_aktuell}}', '{{bmi}}'
|
||||
],
|
||||
'ernährung': [
|
||||
'{{kcal_avg}}', '{{protein_avg}}', '{{carb_avg}}', '{{fat_avg}}'
|
||||
],
|
||||
'training': [
|
||||
'{{activity_summary}}', '{{trainingstyp_verteilung}}'
|
||||
],
|
||||
'zeitraum': [
|
||||
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
|
||||
]
|
||||
}
|
||||
|
||||
if not categories:
|
||||
return placeholder_categories
|
||||
|
||||
# Filter to requested categories
|
||||
return {k: v for k, v in placeholder_categories.items() if k in categories}
|
||||
|
||||
|
||||
def get_placeholder_example_values(profile_id: str) -> Dict[str, str]:
|
||||
"""
|
||||
Get example values for all placeholders using real user data.
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
|
||||
Returns:
|
||||
Dict mapping placeholder to example value
|
||||
"""
|
||||
examples = {}
|
||||
|
||||
for placeholder, resolver in PLACEHOLDER_MAP.items():
|
||||
try:
|
||||
examples[placeholder] = resolver(profile_id)
|
||||
except Exception as e:
|
||||
examples[placeholder] = f"[Fehler: {str(e)}]"
|
||||
|
||||
return examples
|
||||
|
|
@ -3,10 +3,26 @@ AI Prompts Management Endpoints for Mitai Jinkendo
|
|||
|
||||
Handles prompt template configuration (admin-editable).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_admin
|
||||
from models import PromptCreate, PromptUpdate, PromptGenerateRequest
|
||||
from placeholder_resolver import (
|
||||
resolve_placeholders,
|
||||
get_unknown_placeholders,
|
||||
get_placeholder_example_values,
|
||||
get_available_placeholders
|
||||
)
|
||||
|
||||
# Environment variables
|
||||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
|
||||
|
||||
router = APIRouter(prefix="/api/prompts", tags=["prompts"])
|
||||
|
||||
|
|
@ -32,29 +48,434 @@ def list_prompts(session: dict=Depends(require_auth)):
|
|||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)):
|
||||
"""Create new AI prompt (admin only)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if slug already exists
|
||||
cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,))
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail=f"Prompt with slug '{p.slug}' already exists")
|
||||
|
||||
prompt_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""INSERT INTO ai_prompts (id, name, slug, description, template, category, active, sort_order, created, updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
|
||||
(prompt_id, p.name, p.slug, p.description, p.template, p.category, p.active, p.sort_order)
|
||||
)
|
||||
|
||||
return {"id": prompt_id, "slug": p.slug}
|
||||
|
||||
|
||||
@router.put("/{prompt_id}")
|
||||
def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)):
|
||||
def update_prompt(prompt_id: str, p: PromptUpdate, session: dict=Depends(require_admin)):
|
||||
"""Update AI prompt template (admin only)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Build dynamic UPDATE query
|
||||
updates = []
|
||||
values = []
|
||||
if 'name' in data:
|
||||
updates.append('name=%s')
|
||||
values.append(data['name'])
|
||||
if 'description' in data:
|
||||
updates.append('description=%s')
|
||||
values.append(data['description'])
|
||||
if 'template' in data:
|
||||
updates.append('template=%s')
|
||||
values.append(data['template'])
|
||||
if 'active' in data:
|
||||
updates.append('active=%s')
|
||||
# Convert to boolean (accepts true/false, 1/0)
|
||||
values.append(bool(data['active']))
|
||||
|
||||
if updates:
|
||||
cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
|
||||
values + [prompt_id])
|
||||
if p.name is not None:
|
||||
updates.append('name=%s')
|
||||
values.append(p.name)
|
||||
if p.description is not None:
|
||||
updates.append('description=%s')
|
||||
values.append(p.description)
|
||||
if p.template is not None:
|
||||
updates.append('template=%s')
|
||||
values.append(p.template)
|
||||
if p.category is not None:
|
||||
updates.append('category=%s')
|
||||
values.append(p.category)
|
||||
if p.active is not None:
|
||||
updates.append('active=%s')
|
||||
values.append(p.active)
|
||||
if p.sort_order is not None:
|
||||
updates.append('sort_order=%s')
|
||||
values.append(p.sort_order)
|
||||
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
|
||||
cur.execute(
|
||||
f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
|
||||
values + [prompt_id]
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{prompt_id}")
|
||||
def delete_prompt(prompt_id: str, session: dict=Depends(require_admin)):
|
||||
"""Delete AI prompt (admin only)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("DELETE FROM ai_prompts WHERE id=%s", (prompt_id,))
|
||||
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{prompt_id}/duplicate")
|
||||
def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)):
|
||||
"""Duplicate an existing prompt (admin only)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Load original prompt
|
||||
cur.execute("SELECT * FROM ai_prompts WHERE id=%s", (prompt_id,))
|
||||
original = r2d(cur.fetchone())
|
||||
|
||||
if not original:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||
|
||||
# Create duplicate with new ID and modified name/slug
|
||||
new_id = str(uuid.uuid4())
|
||||
new_name = f"{original['name']} (Kopie)"
|
||||
new_slug = f"{original['slug']}_copy_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO ai_prompts (id, name, slug, description, template, category, active, sort_order, created, updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
|
||||
(new_id, new_name, new_slug, original['description'], original['template'],
|
||||
original.get('category', 'ganzheitlich'), original['active'], original['sort_order'])
|
||||
)
|
||||
|
||||
return {"id": new_id, "slug": new_slug, "name": new_name}
|
||||
|
||||
|
||||
@router.put("/reorder")
|
||||
def reorder_prompts(order: list[str], session: dict=Depends(require_admin)):
|
||||
"""
|
||||
Reorder prompts by providing list of IDs in desired order.
|
||||
|
||||
Args:
|
||||
order: List of prompt IDs in new order
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
for idx, prompt_id in enumerate(order):
|
||||
cur.execute(
|
||||
"UPDATE ai_prompts SET sort_order=%s WHERE id=%s",
|
||||
(idx, prompt_id)
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
def preview_prompt(data: dict, session: dict=Depends(require_auth)):
|
||||
"""
|
||||
Preview a prompt template with real user data (without calling AI).
|
||||
|
||||
Args:
|
||||
data: {"template": "Your template with {{placeholders}}"}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"resolved": "Template with replaced placeholders",
|
||||
"unknown_placeholders": ["list", "of", "unknown"]
|
||||
}
|
||||
"""
|
||||
template = data.get('template', '')
|
||||
profile_id = session['profile_id']
|
||||
|
||||
resolved = resolve_placeholders(template, profile_id)
|
||||
unknown = get_unknown_placeholders(template)
|
||||
|
||||
return {
|
||||
"resolved": resolved,
|
||||
"unknown_placeholders": unknown
|
||||
}
|
||||
|
||||
|
||||
@router.get("/placeholders")
|
||||
def list_placeholders(session: dict=Depends(require_auth)):
|
||||
"""
|
||||
Get list of available placeholders with example values.
|
||||
|
||||
Returns:
|
||||
Dict mapping placeholder to example value using current user's data
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
return get_placeholder_example_values(profile_id)
|
||||
|
||||
|
||||
# ── KI-Assisted Prompt Engineering ───────────────────────────────────────────
|
||||
|
||||
async def call_openrouter(prompt: str, max_tokens: int = 1500) -> str:
|
||||
"""Call OpenRouter API to get AI response."""
|
||||
if not OPENROUTER_KEY:
|
||||
raise HTTPException(status_code=500, detail="OpenRouter API key not configured")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
|
||||
json={
|
||||
"model": OPENROUTER_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": max_tokens
|
||||
},
|
||||
timeout=60.0
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=resp.status_code, detail=f"OpenRouter API error: {resp.text}")
|
||||
|
||||
return resp.json()['choices'][0]['message']['content'].strip()
|
||||
|
||||
|
||||
def collect_example_data(profile_id: str, data_categories: list[str]) -> dict:
|
||||
"""Collect example data from user's profile for specified categories."""
|
||||
example_data = {}
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Profil
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
|
||||
profile = r2d(cur.fetchone())
|
||||
example_data['profil'] = {
|
||||
'name': profile.get('name', 'Nutzer'),
|
||||
'age': profile.get('dob', 'unbekannt'),
|
||||
'height': profile.get('height', 'unbekannt'),
|
||||
'sex': profile.get('sex', 'unbekannt')
|
||||
}
|
||||
|
||||
# Körper
|
||||
if 'körper' in data_categories:
|
||||
cur.execute(
|
||||
"SELECT weight, date FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 3",
|
||||
(profile_id,)
|
||||
)
|
||||
weights = [r2d(r) for r in cur.fetchall()]
|
||||
example_data['körper'] = {
|
||||
'weight_entries': weights,
|
||||
'latest_weight': f"{weights[0]['weight']:.1f} kg" if weights else "nicht verfügbar"
|
||||
}
|
||||
|
||||
# Ernährung
|
||||
if 'ernährung' in data_categories:
|
||||
cur.execute(
|
||||
"""SELECT kcal, protein, carb, fat, date FROM nutrition_log
|
||||
WHERE profile_id=%s ORDER BY date DESC LIMIT 3""",
|
||||
(profile_id,)
|
||||
)
|
||||
nutrition = [r2d(r) for r in cur.fetchall()]
|
||||
example_data['ernährung'] = {
|
||||
'recent_entries': nutrition
|
||||
}
|
||||
|
||||
# Training
|
||||
if 'training' in data_categories:
|
||||
cur.execute(
|
||||
"""SELECT activity_type, duration_min, kcal_active, date FROM activity_log
|
||||
WHERE profile_id=%s ORDER BY date DESC LIMIT 5""",
|
||||
(profile_id,)
|
||||
)
|
||||
activities = [r2d(r) for r in cur.fetchall()]
|
||||
example_data['training'] = {
|
||||
'recent_activities': activities
|
||||
}
|
||||
|
||||
return example_data
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_prompt(req: PromptGenerateRequest, session: dict=Depends(require_admin)):
|
||||
"""
|
||||
Generate AI prompt using KI based on user's goal description.
|
||||
|
||||
This is a meta-feature: KI helps create better prompts for KI analysis.
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# Collect example data
|
||||
example_data = collect_example_data(profile_id, req.data_categories)
|
||||
|
||||
# Get available placeholders for selected categories
|
||||
available_placeholders = get_available_placeholders(req.data_categories)
|
||||
placeholders_list = []
|
||||
for cat, phs in available_placeholders.items():
|
||||
placeholders_list.extend(phs)
|
||||
|
||||
# Build meta-prompt for prompt generation
|
||||
meta_prompt = f"""Du bist ein Experte für Prompt-Engineering im Bereich Fitness & Gesundheit.
|
||||
|
||||
**Aufgabe:**
|
||||
Erstelle einen optimalen KI-Prompt für folgendes Analyseziel:
|
||||
"{req.goal}"
|
||||
|
||||
**Verfügbare Datenbereiche:**
|
||||
{', '.join(req.data_categories)}
|
||||
|
||||
**Beispieldaten (aktuelle Werte des Nutzers):**
|
||||
```json
|
||||
{json.dumps(example_data, indent=2, ensure_ascii=False)}
|
||||
```
|
||||
|
||||
**Verfügbare Platzhalter:**
|
||||
{', '.join(placeholders_list)}
|
||||
|
||||
**Anforderungen an den Prompt:**
|
||||
1. Nutze relevante Platzhalter ({{{{platzhalter_name}}}}) - diese werden durch echte Daten ersetzt
|
||||
2. Sei spezifisch und klar in den Anweisungen
|
||||
3. Fordere strukturierte Antworten (z.B. Abschnitte, Bullet Points)
|
||||
4. Gib der KI Kontext über ihre Rolle/Expertise (z.B. "Du bist ein Sportwissenschaftler")
|
||||
5. Fordere konkrete, umsetzbare Handlungsempfehlungen
|
||||
6. Sprache: Deutsch
|
||||
7. Der Prompt sollte 150-300 Wörter lang sein
|
||||
|
||||
{f'**Gewünschtes Antwort-Format:**\\n{req.example_output}' if req.example_output else ''}
|
||||
|
||||
**Generiere jetzt NUR den Prompt-Text (keine Erklärung, keine Metakommentare):**
|
||||
"""
|
||||
|
||||
# Call AI to generate prompt
|
||||
generated_prompt = await call_openrouter(meta_prompt, max_tokens=1000)
|
||||
|
||||
# Extract placeholders used
|
||||
import re
|
||||
placeholders_used = list(set(re.findall(r'\{\{(\w+)\}\}', generated_prompt)))
|
||||
|
||||
# Generate title from goal
|
||||
title = generate_title_from_goal(req.goal)
|
||||
|
||||
# Infer category
|
||||
category = infer_category(req.data_categories)
|
||||
|
||||
return {
|
||||
"template": generated_prompt,
|
||||
"placeholders_used": placeholders_used,
|
||||
"example_data": example_data,
|
||||
"suggested_title": title,
|
||||
"suggested_category": category
|
||||
}
|
||||
|
||||
|
||||
def generate_title_from_goal(goal: str) -> str:
|
||||
"""Generate a title from the goal description."""
|
||||
goal_lower = goal.lower()
|
||||
|
||||
# Simple keyword matching
|
||||
if 'protein' in goal_lower:
|
||||
return 'Protein-Analyse'
|
||||
elif 'gewicht' in goal_lower or 'abnehmen' in goal_lower:
|
||||
return 'Gewichtstrend-Analyse'
|
||||
elif 'training' in goal_lower or 'aktivität' in goal_lower:
|
||||
return 'Trainingsanalyse'
|
||||
elif 'schlaf' in goal_lower:
|
||||
return 'Schlaf-Analyse'
|
||||
elif 'regeneration' in goal_lower or 'erholung' in goal_lower:
|
||||
return 'Regenerations-Analyse'
|
||||
elif 'kraft' in goal_lower or 'muskel' in goal_lower:
|
||||
return 'Kraftentwicklung'
|
||||
elif 'ausdauer' in goal_lower or 'cardio' in goal_lower:
|
||||
return 'Ausdauer-Analyse'
|
||||
else:
|
||||
return 'Neue Analyse'
|
||||
|
||||
|
||||
def infer_category(data_categories: list[str]) -> str:
|
||||
"""Infer prompt category from selected data categories."""
|
||||
if len(data_categories) == 1:
|
||||
return data_categories[0]
|
||||
elif len(data_categories) > 2:
|
||||
return 'ganzheitlich'
|
||||
else:
|
||||
# 2 categories: prefer the first one
|
||||
return data_categories[0] if data_categories else 'ganzheitlich'
|
||||
|
||||
|
||||
@router.post("/{prompt_id}/optimize")
|
||||
async def optimize_prompt(prompt_id: str, session: dict=Depends(require_admin)):
|
||||
"""
|
||||
Analyze and optimize an existing prompt using KI.
|
||||
|
||||
Returns suggestions for improvement with score, strengths, weaknesses,
|
||||
and an optimized version of the prompt.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM ai_prompts WHERE id=%s", (prompt_id,))
|
||||
prompt = r2d(cur.fetchone())
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||
|
||||
# Build meta-prompt for optimization
|
||||
meta_prompt = f"""Du bist ein Experte für Prompt-Engineering.
|
||||
|
||||
**Analysiere folgenden KI-Prompt und schlage Verbesserungen vor:**
|
||||
|
||||
```
|
||||
{prompt['template']}
|
||||
```
|
||||
|
||||
**Analysiere folgende Aspekte:**
|
||||
1. **Klarheit & Präzision:** Ist die Anweisung klar und eindeutig?
|
||||
2. **Struktur & Lesbarkeit:** Ist der Prompt gut strukturiert?
|
||||
3. **Platzhalter-Nutzung:** Werden relevante Platzhalter genutzt? Fehlen wichtige Daten?
|
||||
4. **Antwort-Format:** Wird eine strukturierte Ausgabe gefordert?
|
||||
5. **Kontext:** Hat die KI genug Rollenkontext (z.B. "Du bist ein Ernährungsexperte")?
|
||||
6. **Handlungsempfehlungen:** Werden konkrete, umsetzbare Schritte gefordert?
|
||||
|
||||
**Gib deine Analyse als JSON zurück (NUR das JSON, keine zusätzlichen Kommentare):**
|
||||
|
||||
```json
|
||||
{{
|
||||
"score": 0-100,
|
||||
"strengths": ["Stärke 1", "Stärke 2", "Stärke 3"],
|
||||
"weaknesses": ["Schwäche 1", "Schwäche 2"],
|
||||
"optimized_prompt": "Vollständig optimierte Version des Prompts",
|
||||
"changes_summary": "Kurze Zusammenfassung was verbessert wurde (2-3 Sätze)"
|
||||
}}
|
||||
```
|
||||
|
||||
**Wichtig:**
|
||||
- Die optimierte Version sollte alle Platzhalter beibehalten und ggf. ergänzen
|
||||
- Sprache: Deutsch
|
||||
- Der optimierte Prompt sollte 150-400 Wörter lang sein
|
||||
"""
|
||||
|
||||
# Call AI for optimization
|
||||
response = await call_openrouter(meta_prompt, max_tokens=1500)
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
# Extract JSON from markdown code blocks if present
|
||||
if '```json' in response:
|
||||
json_start = response.find('```json') + 7
|
||||
json_end = response.find('```', json_start)
|
||||
json_str = response[json_start:json_end].strip()
|
||||
elif '```' in response:
|
||||
json_start = response.find('```') + 3
|
||||
json_end = response.find('```', json_start)
|
||||
json_str = response[json_start:json_end].strip()
|
||||
else:
|
||||
json_str = response
|
||||
|
||||
analysis = json.loads(json_str)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to parse AI response as JSON: {str(e)}. Response: {response[:200]}"
|
||||
)
|
||||
|
||||
# Ensure required fields
|
||||
if not all(k in analysis for k in ['score', 'strengths', 'weaknesses', 'optimized_prompt', 'changes_summary']):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI response missing required fields. Got: {list(analysis.keys())}"
|
||||
)
|
||||
|
||||
return analysis
|
||||
|
|
|
|||
293
frontend/src/pages/AdminPromptsPage.jsx
Normal file
293
frontend/src/pages/AdminPromptsPage.jsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import PromptEditModal from '../components/PromptEditModal'
|
||||
|
||||
export default function AdminPromptsPage() {
|
||||
const [prompts, setPrompts] = useState([])
|
||||
const [filteredPrompts, setFilteredPrompts] = useState([])
|
||||
const [category, setCategory] = useState('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [editingPrompt, setEditingPrompt] = useState(null)
|
||||
const [showNewPrompt, setShowNewPrompt] = useState(false)
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'Alle Kategorien' },
|
||||
{ id: 'körper', label: 'Körper' },
|
||||
{ id: 'ernährung', label: 'Ernährung' },
|
||||
{ id: 'training', label: 'Training' },
|
||||
{ id: 'schlaf', label: 'Schlaf' },
|
||||
{ id: 'vitalwerte', label: 'Vitalwerte' },
|
||||
{ id: 'ziele', label: 'Ziele' },
|
||||
{ id: 'ganzheitlich', label: 'Ganzheitlich' }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
loadPrompts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (category === 'all') {
|
||||
setFilteredPrompts(prompts)
|
||||
} else {
|
||||
setFilteredPrompts(prompts.filter(p => p.category === category))
|
||||
}
|
||||
}, [category, prompts])
|
||||
|
||||
const loadPrompts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.listAdminPrompts()
|
||||
setPrompts(data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (prompt) => {
|
||||
try {
|
||||
await api.updatePrompt(prompt.id, { active: !prompt.active })
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (prompt) => {
|
||||
if (!confirm(`Prompt "${prompt.name}" wirklich löschen?`)) return
|
||||
|
||||
try {
|
||||
await api.deletePrompt(prompt.id)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async (prompt) => {
|
||||
try {
|
||||
await api.duplicatePrompt(prompt.id)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveUp = async (prompt) => {
|
||||
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
|
||||
if (idx === 0) return // Already at top
|
||||
|
||||
const above = filteredPrompts[idx - 1]
|
||||
const newOrder = filteredPrompts.map(p => p.id)
|
||||
newOrder[idx] = above.id
|
||||
newOrder[idx - 1] = prompt.id
|
||||
|
||||
try {
|
||||
await api.reorderPrompts(newOrder)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveDown = async (prompt) => {
|
||||
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
|
||||
if (idx === filteredPrompts.length - 1) return // Already at bottom
|
||||
|
||||
const below = filteredPrompts[idx + 1]
|
||||
const newOrder = filteredPrompts.map(p => p.id)
|
||||
newOrder[idx] = below.id
|
||||
newOrder[idx + 1] = prompt.id
|
||||
|
||||
try {
|
||||
await api.reorderPrompts(newOrder)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePrompt = async () => {
|
||||
await loadPrompts()
|
||||
setEditingPrompt(null)
|
||||
setShowNewPrompt(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div style={{textAlign:'center', padding:40}}>
|
||||
<div className="spinner"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:24}}>
|
||||
<h1 className="page-title">KI-Prompts Verwaltung</h1>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowNewPrompt(true)}
|
||||
>
|
||||
+ Neuer Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{padding:12, background:'#fee', color:'#c00', borderRadius:8, marginBottom:16}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Filter */}
|
||||
<div style={{marginBottom:24}}>
|
||||
<label style={{fontSize:13, fontWeight:600, marginBottom:8, display:'block'}}>
|
||||
Kategorie filtern:
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
style={{maxWidth:300}}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Prompts Table */}
|
||||
<div className="card">
|
||||
<table style={{width:'100%', borderCollapse:'collapse'}}>
|
||||
<thead>
|
||||
<tr style={{borderBottom:'2px solid var(--border)', textAlign:'left'}}>
|
||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
||||
Titel
|
||||
</th>
|
||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
||||
Kategorie
|
||||
</th>
|
||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
||||
Aktiv
|
||||
</th>
|
||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
||||
Reihenfolge
|
||||
</th>
|
||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPrompts.map((prompt, idx) => (
|
||||
<tr key={prompt.id} style={{borderBottom:'1px solid var(--border)'}}>
|
||||
<td style={{padding:'12px 8px'}}>
|
||||
<div style={{fontWeight:500, fontSize:14}}>{prompt.name}</div>
|
||||
{prompt.description && (
|
||||
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
|
||||
{prompt.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
|
||||
{prompt.slug}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{padding:'12px 8px'}}>
|
||||
<span style={{
|
||||
padding:'4px 8px', borderRadius:4, fontSize:11, fontWeight:500,
|
||||
background:'var(--surface2)', color:'var(--text2)'
|
||||
}}>
|
||||
{prompt.category || 'ganzheitlich'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{padding:'12px 8px'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prompt.active}
|
||||
onChange={() => handleToggleActive(prompt)}
|
||||
style={{cursor:'pointer'}}
|
||||
/>
|
||||
</td>
|
||||
<td style={{padding:'12px 8px'}}>
|
||||
<div style={{display:'flex', gap:4}}>
|
||||
<button
|
||||
onClick={() => handleMoveUp(prompt)}
|
||||
disabled={idx === 0}
|
||||
style={{
|
||||
padding:'4px 8px', fontSize:12, cursor: idx === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: idx === 0 ? 0.5 : 1
|
||||
}}
|
||||
title="Nach oben"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(prompt)}
|
||||
disabled={idx === filteredPrompts.length - 1}
|
||||
style={{
|
||||
padding:'4px 8px', fontSize:12,
|
||||
cursor: idx === filteredPrompts.length - 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: idx === filteredPrompts.length - 1 ? 0.5 : 1
|
||||
}}
|
||||
title="Nach unten"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{padding:'12px 8px'}}>
|
||||
<div style={{display:'flex', gap:6}}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setEditingPrompt(prompt)}
|
||||
style={{fontSize:12, padding:'6px 12px'}}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => handleDuplicate(prompt)}
|
||||
style={{fontSize:12, padding:'6px 12px'}}
|
||||
>
|
||||
Duplizieren
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => handleDelete(prompt)}
|
||||
style={{fontSize:12, padding:'6px 12px', color:'#D85A30'}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredPrompts.length === 0 && (
|
||||
<div style={{padding:40, textAlign:'center', color:'var(--text3)'}}>
|
||||
Keine Prompts in dieser Kategorie
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{(editingPrompt || showNewPrompt) && (
|
||||
<PromptEditModal
|
||||
prompt={editingPrompt}
|
||||
onSave={handleSavePrompt}
|
||||
onClose={() => {
|
||||
setEditingPrompt(null)
|
||||
setShowNewPrompt(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user