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/ + diff --git a/app/templates/index.html b/app/templates/index.html index f86ccf5..c464d9b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,42 +2,7 @@ {% import "_icons.html" as icons %} {% block title %}Meine Links · LinkVault{% endblock %} {% block body %} -
-
LinkVault
-
- {% if not ai_enabled %} - - {{ icons.alert(size=14) }} KI aus - - {% endif %} - - - -
- - - - -
-
-
+{% include "_topbar.html" %}