diff --git a/.env.example b/.env.example
index e5f0c81..b4b614e 100644
--- a/.env.example
+++ b/.env.example
@@ -14,5 +14,5 @@ EMBEDDING_MODEL=text-embedding-3-small
# Datenbank (Standard: lokale SQLite-Datei)
DATABASE_URL=sqlite:///./linkvault.db
-# Versionsangabe für die Fußzeile (Standard: 1.0.0)
-APP_VERSION=1.0.0
+# Versionsangabe für die Fußzeile (Standard: 2.0.0)
+APP_VERSION=2.0.0
diff --git a/README.md b/README.md
index 67afae7..3d18e16 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,40 @@
# LinkVault
-KI-gestützte Link-Sammlung: Links einfügen → automatisch laden, **zusammenfassen**,
-in **Kategorien / Hersteller** einsortieren und schnell **wiederfinden**.
+KI-gestützte Sammlung für **Links** und **Prompts**: eingeben → automatisch
+**kategorisieren**, in **Kategorien / Hersteller / Tags** einsortieren und
+schnell **wiederfinden**. Beide Sammlungen laufen in derselben App und
+Datenbank, per Menü umschaltbar.
## Funktionen
- 🔗 **Link hinzufügen** – Seite wird geladen und der Textinhalt extrahiert.
-- 🤖 **Automatische KI-Analyse** (OpenAI) – Titel, Zusammenfassung, Kategorie,
- Hersteller/Quelle und Tags werden erzeugt. Bestehende Kategorien werden
- wiederverwendet, damit die Sammlung konsistent bleibt.
-- ✎ **Bearbeiten** – Titel, Zusammenfassung, Kategorie, Hersteller und Tags
- lassen sich jederzeit manuell anpassen. Alternativ kann die KI den Link
- **neu beschreiben**: die Seite wird erneut geladen und alle Felder werden
- neu erzeugt.
-- 🗂️ **Filtern** nach Kategorie und Hersteller über die Seitenleiste.
-- 🔍 **Suche** – klassische Textsuche und optionale **semantische KI-Suche**
- (findet auch sinnverwandte Treffer via Embeddings).
+- ✨ **Prompt hinzufügen** – eigener Bereich für KI-Prompts, per Kopf-Menü
+ zwischen „Links" und „Prompts" umschaltbar. Prompts werden ebenfalls
+ automatisch kategorisiert und getaggt, lassen sich per Kategorie/Volltext
+ durchsuchen und mit einem Klick in die Zwischenablage kopieren. Der
+ Prompt-Text unterstützt **Markdown** (Überschriften, Listen, Fett/Kursiv,
+ Codeblöcke, Zitate) – in der Karten-Vorschau wird das gerendert, beim
+ Kopieren landet der rohe Markdown-Text (für Struktur in der Ziel-KI) in
+ der Zwischenablage.
+- 🤖 **Automatische KI-Analyse** (OpenAI) – Titel, Zusammenfassung/Kategorie
+ und Tags werden erzeugt. Bestehende Kategorien werden wiederverwendet,
+ damit die Sammlung konsistent bleibt.
+- ✎ **Bearbeiten** – alle Felder lassen sich jederzeit manuell anpassen.
+ Alternativ kann die KI Links/Prompts **neu beschreiben bzw. neu
+ kategorisieren** lassen.
+- 🗂️ **Filtern** nach Kategorie (Links zusätzlich nach Hersteller) über die
+ Seitenleiste.
+- 🔍 **Suche** – klassische Textsuche, bei Links zusätzlich optionale
+ **semantische KI-Suche** (findet auch sinnverwandte Treffer via
+ Embeddings).
- ↕️ **Sortieren** nach Datum (neueste/älteste zuerst) oder Name (A–Z / Z–A).
-- 🕓 **Hinzufügedatum** wird pro Link angezeigt.
-- 👥 **Mehrbenutzer** – Registrierung/Login, jeder Nutzer sieht nur seine Links.
+- 🔖 **Merkliste** – Links lassen sich zur späteren Durchsicht markieren.
+- 🕓 **Hinzufügedatum** wird pro Eintrag angezeigt.
+- 📤 **Export/Import** (Einstellungen) – Links als CSV oder SQL-Dump
+ exportieren, SQL-Dump auch wieder importieren.
+- 🌗 **Dark/Light-Modus** über den Umschalter im Kopfbereich.
+- 👥 **Mehrbenutzer** – Registrierung/Login, jeder Nutzer sieht nur seine
+ eigenen Links und Prompts.
Die Fußzeile zeigt Autor, Version und den Hostnamen des Servers. Die Version
lässt sich über `APP_VERSION` in der `.env` setzen.
@@ -59,14 +75,16 @@ uvicorn app.main:app --reload --port 8000
```
app/
- main.py FastAPI-App, Routen, Auth-Sessions
+ main.py FastAPI-App, Routen (Links & Prompts), Auth-Sessions
config.py Konfiguration aus .env
database.py SQLAlchemy Engine/Session
- models.py User- und Link-Modelle
+ models.py User-, Link- und Prompt-Modelle
security.py Passwort-Hashing (bcrypt)
- scraper.py Seiteninhalt laden & extrahieren
- ai.py OpenAI: Kategorisierung, Zusammenfassung, Embeddings
- search.py Semantische Suche (Kosinus-Ähnlichkeit)
+ scraper.py Seiteninhalt laden & extrahieren (nur Links)
+ ai.py OpenAI: Kategorisierung (Links & Prompts), Embeddings
+ search.py Semantische Suche (Kosinus-Ähnlichkeit, nur Links)
+ backup.py CSV-/SQL-Export und sicherer SQL-Import (nur Links)
+ mdrender.py Sicheres Markdown-Rendering für Prompt-Inhalte
templates/ Jinja2 + HTMX Oberfläche
```
diff --git a/app/ai.py b/app/ai.py
index 5a6efad..a1292fe 100644
--- a/app/ai.py
+++ b/app/ai.py
@@ -89,6 +89,62 @@ Gib ein JSON-Objekt mit genau diesen Feldern zurück:
}
+PROMPT_SYSTEM_PROMPT = (
+ "Du bist ein Assistent, der eine Sammlung von KI-Prompts organisiert. "
+ "Analysiere den gegebenen Prompt-Text und liefere strukturierte Metadaten "
+ "als JSON. Verwende möglichst eine der bereits existierenden Kategorien, "
+ "wenn sie passt; sonst erstelle eine kurze, allgemeingültige neue "
+ "Bezeichnung (z.B. Schreiben, Code, Bild, Analyse, Sonstiges). Antworte "
+ "ausschließlich auf Deutsch."
+)
+
+
+def categorize_prompt(content: str, existing_categories: list[str]) -> dict:
+ """Erzeuge Titel, Kategorie und Tags für einen gespeicherten Prompt."""
+ fallback_title = (content.strip().splitlines() or [""])[0].strip()[:80] or "Prompt"
+ fallback = {"title": fallback_title, "category": "Sonstiges", "tags": []}
+
+ client = _client_or_none()
+ if client is None:
+ return fallback
+
+ user_prompt = f"""Prompt-Text:
+{content[:4000]}
+
+Bereits vorhandene Kategorien: {", ".join(existing_categories) or "(noch keine)"}
+
+Gib ein JSON-Objekt mit genau diesen Feldern zurück:
+- "title": kurzer, aussagekräftiger Titel (max. 80 Zeichen)
+- "category": eine passende Kategorie
+- "tags": Array aus 3-6 kurzen Schlagwörtern
+"""
+
+ try:
+ resp = client.chat.completions.create(
+ model=config.OPENAI_MODEL,
+ messages=[
+ {"role": "system", "content": PROMPT_SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ],
+ response_format={"type": "json_object"},
+ temperature=0.2,
+ )
+ data = json.loads(resp.choices[0].message.content)
+ except Exception:
+ return fallback
+
+ tags = data.get("tags") or []
+ if not isinstance(tags, list):
+ tags = []
+
+ return {
+ "title": (str(data.get("title") or fallback_title)).strip()[:120] or fallback_title,
+ "category": (str(data.get("category") or "Sonstiges")).strip()[:120]
+ or "Sonstiges",
+ "tags": [str(t).strip()[:40] for t in tags if str(t).strip()][:8],
+ }
+
+
def embed(text: str) -> list[float]:
"""Erzeuge einen Embedding-Vektor für die semantische Suche."""
client = _client_or_none()
diff --git a/app/config.py b/app/config.py
index f847091..86ecf05 100644
--- a/app/config.py
+++ b/app/config.py
@@ -6,7 +6,7 @@ from dotenv import load_dotenv
load_dotenv()
APP_AUTHOR = "Erik Thiele"
-APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
+APP_VERSION = os.getenv("APP_VERSION", "2.0.0")
APP_HOST = socket.gethostname()
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-secret-change-me")
diff --git a/app/main.py b/app/main.py
index 3769e52..88525cd 100644
--- a/app/main.py
+++ b/app/main.py
@@ -6,13 +6,14 @@ from urllib.parse import quote, urlparse
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
+from markupsafe import Markup
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
-from . import ai, backup, config, scraper, search
+from . import ai, backup, config, mdrender, scraper, search
from .database import Base, engine, get_db
-from .models import Link, User, utcnow
+from .models import Link, Prompt, User, utcnow
from .security import hash_password, verify_password
Base.metadata.create_all(bind=engine)
@@ -53,14 +54,24 @@ def domain_of(url: str) -> str:
return host[4:] if host.startswith("www.") else host
+def _hash_hue(value: str) -> int:
+ return sum(ord(c) for c in (value or "")) % 360
+
+
def avatar_hue(url: str) -> int:
- """Stabile Farbe pro Domain, damit Karten wiedererkennbar sind."""
- return sum(ord(c) for c in domain_of(url)) % 360
+ """Stabile Farbe pro Domain, damit Link-Karten wiedererkennbar sind."""
+ return _hash_hue(domain_of(url))
+
+
+def render_markdown(text: str) -> Markup:
+ return Markup(mdrender.render(text))
templates.env.filters["datum"] = format_datum
templates.env.filters["domain"] = domain_of
templates.env.filters["hue"] = avatar_hue
+templates.env.filters["texthue"] = _hash_hue
+templates.env.filters["markdown"] = render_markdown
# ---------------------------------------------------------------------------
@@ -170,6 +181,51 @@ def query_links(
return sort_links(matches, sort)
+def prompt_facets(db: Session, user: User) -> dict:
+ cat_rows = db.execute(
+ select(Prompt.category, func.count())
+ .where(Prompt.user_id == user.id, Prompt.category != "")
+ .group_by(Prompt.category)
+ .order_by(func.count().desc())
+ ).all()
+ return {"categories": [(name, count) for name, count in cat_rows]}
+
+
+def sort_prompts(prompts: list[Prompt], sort: str) -> list[Prompt]:
+ if sort == "date_asc":
+ return sorted(prompts, key=lambda p: p.created_at)
+ if sort == "name_asc":
+ return sorted(prompts, key=lambda p: (p.title or "").lower())
+ if sort == "name_desc":
+ return sorted(prompts, key=lambda p: (p.title or "").lower(), reverse=True)
+ return sorted(prompts, key=lambda p: p.created_at, reverse=True)
+
+
+def query_prompts(
+ db: Session,
+ user: User,
+ q: str = "",
+ category: str = "",
+ sort: str = DEFAULT_SORT,
+) -> list[Prompt]:
+ stmt = select(Prompt).where(Prompt.user_id == user.id)
+ if category:
+ stmt = stmt.where(Prompt.category == category)
+ prompts = list(db.scalars(stmt.order_by(Prompt.created_at.desc())).all())
+
+ q = (q or "").strip()
+ if not q:
+ return sort_prompts(prompts, sort)
+
+ ql = q.lower()
+ matches = [
+ p
+ for p in prompts
+ if ql in " ".join([p.title, p.content, p.tags, p.category]).lower()
+ ]
+ return sort_prompts(matches, sort)
+
+
# ---------------------------------------------------------------------------
# Authentifizierung
# ---------------------------------------------------------------------------
@@ -531,6 +587,217 @@ def reanalyze_link(
)
+# ---------------------------------------------------------------------------
+# Prompts
+# ---------------------------------------------------------------------------
+@app.get("/prompts", response_class=HTMLResponse)
+def prompts_index(
+ request: Request,
+ q: str = "",
+ category: str = "",
+ sort: str = DEFAULT_SORT,
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return RedirectResponse("/login", status_code=303)
+
+ if sort not in SORT_OPTIONS:
+ sort = DEFAULT_SORT
+ prompts = query_prompts(db, user, q, category, sort)
+ return templates.TemplateResponse(
+ "prompts.html",
+ {
+ "request": request,
+ "user": user,
+ "prompts": prompts,
+ "facets": prompt_facets(db, user),
+ "q": q,
+ "active_category": category,
+ "ai_enabled": config.AI_ENABLED,
+ "sort": sort,
+ "sort_options": SORT_OPTIONS,
+ },
+ )
+
+
+@app.get("/prompts/search", response_class=HTMLResponse)
+def search_prompts(
+ request: Request,
+ q: str = "",
+ category: str = "",
+ sort: str = DEFAULT_SORT,
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+
+ if sort not in SORT_OPTIONS:
+ sort = DEFAULT_SORT
+ prompts = query_prompts(db, user, q, category, sort)
+ return templates.TemplateResponse(
+ "_prompts_list.html", {"request": request, "prompts": prompts}
+ )
+
+
+@app.post("/prompts", response_class=HTMLResponse)
+def add_prompt(
+ request: Request,
+ content: str = Form(...),
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+
+ content = content.strip()
+ if not content:
+ return Response(status_code=400)
+
+ existing = prompt_facets(db, user)
+ meta = ai.categorize_prompt(content, [name for name, _ in existing["categories"]])
+
+ prompt = Prompt(
+ user_id=user.id,
+ title=meta["title"],
+ content=content,
+ category=meta["category"],
+ tags=json.dumps(meta["tags"], ensure_ascii=False),
+ )
+ db.add(prompt)
+ db.commit()
+ db.refresh(prompt)
+
+ return templates.TemplateResponse(
+ "_prompt_card.html", {"request": request, "prompt": prompt}
+ )
+
+
+@app.delete("/prompts/{prompt_id}", response_class=HTMLResponse)
+def delete_prompt(
+ prompt_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+ prompt = db.get(Prompt, prompt_id)
+ if prompt and prompt.user_id == user.id:
+ db.delete(prompt)
+ db.commit()
+ return Response(status_code=200)
+
+
+def _get_owned_prompt(db: Session, user: User, prompt_id: int) -> Prompt | None:
+ prompt = db.get(Prompt, prompt_id)
+ if prompt and prompt.user_id == user.id:
+ return prompt
+ return None
+
+
+def _prompt_edit_context(request: Request, db: Session, user: User, prompt: Prompt) -> dict:
+ existing = prompt_facets(db, user)
+ return {
+ "request": request,
+ "prompt": prompt,
+ "ai_enabled": config.AI_ENABLED,
+ "all_categories": [name for name, _ in existing["categories"]],
+ }
+
+
+@app.get("/prompts/{prompt_id}/edit", response_class=HTMLResponse)
+def edit_prompt_form(
+ prompt_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+ prompt = _get_owned_prompt(db, user, prompt_id)
+ if not prompt:
+ return Response(status_code=404)
+ return templates.TemplateResponse(
+ "_prompt_card_edit.html", _prompt_edit_context(request, db, user, prompt)
+ )
+
+
+@app.get("/prompts/{prompt_id}/view", response_class=HTMLResponse)
+def view_prompt_card(
+ prompt_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+ prompt = _get_owned_prompt(db, user, prompt_id)
+ if not prompt:
+ return Response(status_code=404)
+ return templates.TemplateResponse(
+ "_prompt_card.html", {"request": request, "prompt": prompt}
+ )
+
+
+@app.put("/prompts/{prompt_id}", response_class=HTMLResponse)
+def update_prompt(
+ prompt_id: int,
+ request: Request,
+ title: str = Form(""),
+ content: str = Form(""),
+ category: str = Form(""),
+ tags: str = Form(""),
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+ prompt = _get_owned_prompt(db, user, prompt_id)
+ if not prompt:
+ return Response(status_code=404)
+
+ prompt.title = title.strip()[:120] or "Prompt"
+ prompt.content = content.strip()
+ prompt.category = category.strip()[:120]
+ prompt.tags = json.dumps(_parse_tags(tags), ensure_ascii=False)
+
+ db.commit()
+ db.refresh(prompt)
+ return templates.TemplateResponse(
+ "_prompt_card.html", {"request": request, "prompt": prompt}
+ )
+
+
+@app.post("/prompts/{prompt_id}/reanalyze", response_class=HTMLResponse)
+def reanalyze_prompt(
+ prompt_id: int,
+ request: Request,
+ db: Session = Depends(get_db),
+):
+ user = current_user(request, db)
+ if not user:
+ return Response(status_code=401, headers={"HX-Redirect": "/login"})
+ prompt = _get_owned_prompt(db, user, prompt_id)
+ if not prompt:
+ return Response(status_code=404)
+
+ existing = prompt_facets(db, user)
+ meta = ai.categorize_prompt(
+ prompt.content, [name for name, _ in existing["categories"]]
+ )
+ prompt.title = meta["title"]
+ prompt.category = meta["category"]
+ prompt.tags = json.dumps(meta["tags"], ensure_ascii=False)
+
+ db.commit()
+ db.refresh(prompt)
+ return templates.TemplateResponse(
+ "_prompt_card_edit.html", _prompt_edit_context(request, db, user, prompt)
+ )
+
+
# ---------------------------------------------------------------------------
# Einstellungen: Export / Import
# ---------------------------------------------------------------------------
@@ -551,6 +818,7 @@ def settings_page(request: Request, db: Session = Depends(get_db)):
"request": request,
"user": user,
"link_count": link_count or 0,
+ "ai_enabled": config.AI_ENABLED,
"imported": request.query_params.get("imported"),
"import_error": request.query_params.get("import_error"),
},
diff --git a/app/mdrender.py b/app/mdrender.py
new file mode 100644
index 0000000..7a1c568
--- /dev/null
+++ b/app/mdrender.py
@@ -0,0 +1,53 @@
+"""Sicheres Markdown-Rendering für Prompt-Inhalte.
+
+Nutzer geben rohes Markdown ein (**fett**, Listen, Codeblöcke ...), das in
+der Karten-Vorschau als HTML dargestellt werden soll. Zwei Schutzschichten
+gegen Stored-XSS:
+
+1. Der Eingabetext wird vor der Konvertierung HTML-escaped. Echte
+ Markdown-Syntax verwendet nur ASCII-Satzzeichen (*, #, -, `, [] ...),
+ keine spitzen Klammern – jeder Versuch, rohes HTML/
+