From bbfa2049eb7f35316fc430f2d973421db24b0f2c Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 14 Dec 2025 12:50:54 +0100 Subject: [PATCH] neue Aufteilung der scripte --- app/frontend/ui.py | 56 ++-- app/frontend/ui_callbacks.py | 37 +++ app/frontend/ui_chat.py | 56 ++++ app/frontend/ui_components.py | 519 ---------------------------------- app/frontend/ui_editor.py | 189 +++++++++++++ app/frontend/ui_graph.py | 126 +++++++++ app/frontend/ui_sidebar.py | 31 ++ 7 files changed, 458 insertions(+), 556 deletions(-) create mode 100644 app/frontend/ui_callbacks.py create mode 100644 app/frontend/ui_chat.py delete mode 100644 app/frontend/ui_components.py create mode 100644 app/frontend/ui_editor.py create mode 100644 app/frontend/ui_graph.py create mode 100644 app/frontend/ui_sidebar.py diff --git a/app/frontend/ui.py b/app/frontend/ui.py index 954519e..943dc1b 100644 --- a/app/frontend/ui.py +++ b/app/frontend/ui.py @@ -1,55 +1,37 @@ import streamlit as st import uuid +# --- CONFIG & STYLING --- +st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide") +st.markdown(""" + +""", unsafe_allow_html=True) + # --- MODULE IMPORTS --- try: from ui_config import QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX from ui_graph_service import GraphExplorerService - from ui_components import render_sidebar, render_chat_interface, render_manual_editor, render_graph_explorer + + # Neue modulare Komponenten + from ui_sidebar import render_sidebar + from ui_chat import render_chat_interface + from ui_editor import render_manual_editor + from ui_graph import render_graph_explorer + except ImportError as e: - st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im selben Ordner liegen.") + st.error(f"Import Error: {e}. Bitte stelle sicher, dass alle UI-Dateien im Ordner liegen.") st.stop() -# --- PAGE SETUP --- -st.set_page_config(page_title="mindnet v2.6", page_icon="🧠", layout="wide") - -# --- CSS STYLING --- -st.markdown(""" - -""", unsafe_allow_html=True) - # --- SESSION STATE --- if "messages" not in st.session_state: st.session_state.messages = [] if "user_id" not in st.session_state: st.session_state.user_id = str(uuid.uuid4()) # --- SERVICE INIT --- -# Initialisiert den Graph Service einmalig graph_service = GraphExplorerService(QDRANT_URL, QDRANT_KEY, COLLECTION_PREFIX) # --- MAIN ROUTING --- diff --git a/app/frontend/ui_callbacks.py b/app/frontend/ui_callbacks.py new file mode 100644 index 0000000..dff6cbf --- /dev/null +++ b/app/frontend/ui_callbacks.py @@ -0,0 +1,37 @@ +import streamlit as st +from ui_utils import build_markdown_doc + +def switch_to_editor_callback(note_payload): + """ + Callback für den 'Bearbeiten'-Button im Graphen. + Bereitet den Session-State vor, damit der Editor im Update-Modus startet. + """ + # 1. Inhalt extrahieren (Fulltext bevorzugt, sonst Fallback) + content = note_payload.get('fulltext', '') + if not content: + content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") + + # 2. Single Source of Truth: 'path' Feld (Absoluter Pfad) + origin_fname = note_payload.get('path') + + # Fallback: Falls 'path' leer ist (Legacy Daten) + if not origin_fname: + origin_fname = note_payload.get('file_path') or note_payload.get('filename') + + # Notfall-Fallback: Konstruktion aus ID + if not origin_fname and 'note_id' in note_payload: + origin_fname = f"{note_payload['note_id']}.md" + + # 3. Message in den Chat-Verlauf injecten + # Diese Nachricht dient als Datencontainer für den Editor im "Manuellen Modus" + st.session_state.messages.append({ + "role": "assistant", + "intent": "INTERVIEW", + "content": content, + "query_id": f"edit_{note_payload['note_id']}", + "origin_filename": origin_fname, + "origin_note_id": note_payload['note_id'] + }) + + # 4. Modus umschalten (erzwingt Wechsel zum Editor-Tab beim nächsten Re-Run) + st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" \ No newline at end of file diff --git a/app/frontend/ui_chat.py b/app/frontend/ui_chat.py new file mode 100644 index 0000000..3b5c56d --- /dev/null +++ b/app/frontend/ui_chat.py @@ -0,0 +1,56 @@ +import streamlit as st +from ui_api import send_chat_message, submit_feedback +from ui_editor import render_draft_editor + +def render_chat_interface(top_k, explain): + for idx, msg in enumerate(st.session_state.messages): + with st.chat_message(msg["role"]): + if msg["role"] == "assistant": + intent = msg.get("intent", "UNKNOWN") + st.markdown(f'
Intent: {intent}
', unsafe_allow_html=True) + + with st.expander("🐞 Payload", expanded=False): + st.json(msg) + + if intent == "INTERVIEW": + render_draft_editor(msg) + else: + st.markdown(msg["content"]) + + if "sources" in msg and msg["sources"]: + for hit in msg["sources"]: + with st.expander(f"📄 {hit.get('note_id', '?')} ({hit.get('total_score', 0):.2f})"): + st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") + if hit.get('explanation'): + st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") + + def _cb(qid=msg.get("query_id"), nid=hit.get('node_id')): + val = st.session_state.get(f"fb_src_{qid}_{nid}") + if val is not None: submit_feedback(qid, nid, val+1) + st.feedback("faces", key=f"fb_src_{msg.get('query_id')}_{hit.get('node_id')}", on_change=_cb) + + if "query_id" in msg: + qid = msg["query_id"] + st.feedback("stars", key=f"fb_glob_{qid}", on_change=lambda: submit_feedback(qid, "generated_answer", st.session_state[f"fb_glob_{qid}"]+1)) + else: + st.markdown(msg["content"]) + + if prompt := st.chat_input("Frage Mindnet..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + st.rerun() + + if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user": + with st.chat_message("assistant"): + with st.spinner("Thinking..."): + resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain) + if "error" in resp: + st.error(resp["error"]) + else: + st.session_state.messages.append({ + "role": "assistant", + "content": resp.get("answer"), + "intent": resp.get("intent", "FACT"), + "sources": resp.get("sources", []), + "query_id": resp.get("query_id") + }) + st.rerun() \ No newline at end of file diff --git a/app/frontend/ui_components.py b/app/frontend/ui_components.py deleted file mode 100644 index 7662773..0000000 --- a/app/frontend/ui_components.py +++ /dev/null @@ -1,519 +0,0 @@ -import streamlit as st -import uuid -import re -from datetime import datetime -from streamlit_agraph import agraph, Config -from qdrant_client import models - -# Importe aus den anderen Modulen -from ui_utils import parse_markdown_draft, build_markdown_doc, load_history_from_logs, slugify -from ui_api import save_draft_to_vault, analyze_draft_text, send_chat_message, submit_feedback -from ui_config import HISTORY_FILE, COLLECTION_PREFIX, GRAPH_COLORS - -# --- CALLBACKS --- -# Diese müssen zwingend VOR dem Aufruf definiert sein. - -def switch_to_editor_callback(note_payload): - """ - Callback für den Edit-Button im Graphen. - Lädt die Notiz in den Editor und setzt den Modus. - Nutzt den 'path'-Parameter aus Qdrant als Single Source of Truth für Updates. - """ - # 1. Inhalt extrahieren (Fulltext oder Fallback) - content = note_payload.get('fulltext', '') - if not content: - content = build_markdown_doc(note_payload, "Inhalt konnte nicht geladen werden (nur Metadaten verfügbar).") - - # 2. Single Source of Truth Bestimmung (Dateipfad) - # Priorität 1: Der absolute Pfad aus dem Ingest-Prozess ('path') - origin_fname = note_payload.get('path') - - # Priorität 2: 'file_path' oder 'filename' (Legacy Felder, Fallback) - if not origin_fname: - origin_fname = note_payload.get('file_path') or note_payload.get('filename') - - # Priorität 3: Konstruktion aus ID (Notlösung, falls Metadaten unvollständig) - if not origin_fname and 'note_id' in note_payload: - # Annahme: Datei heißt {note_id}.md im Vault Root - origin_fname = f"{note_payload['note_id']}.md" - - # 3. Message in den Chat-Verlauf injecten (dient als Datencontainer für den Editor) - # Wir fügen eine "künstliche" Assistant-Nachricht hinzu, die der Editor dann ausliest. - st.session_state.messages.append({ - "role": "assistant", - "intent": "INTERVIEW", - "content": content, - "query_id": f"edit_{note_payload['note_id']}", - "origin_filename": origin_fname, # Pfad für Speicher-Logik - "origin_note_id": note_payload['note_id'] - }) - - # 4. Modus umschalten - # Wir setzen den Key des Radio-Buttons im Session State - st.session_state["sidebar_mode_selection"] = "📝 Manueller Editor" - -# --- UI RENDERER --- - -def render_sidebar(): - """ - Rendert die Seitenleiste mit Navigation und Einstellungen. - """ - with st.sidebar: - st.title("🧠 mindnet") - st.caption("v2.6 | WP-19 Graph View") - - # Modus-Auswahl mit State-Key für programmatische Umschaltung - if "sidebar_mode_selection" not in st.session_state: - st.session_state["sidebar_mode_selection"] = "💬 Chat" - - mode = st.radio( - "Modus", - ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], - key="sidebar_mode_selection" - ) - - st.divider() - st.subheader("⚙️ Settings") - top_k = st.slider("Quellen (Top-K)", 1, 10, 5) - explain = st.toggle("Explanation Layer", True) - - st.divider() - st.subheader("🕒 Verlauf") - # Suchhistorie laden - for q in load_history_from_logs(HISTORY_FILE, 8): - if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True): - st.session_state.messages.append({"role": "user", "content": q}) - st.rerun() - - return mode, top_k, explain - -def render_draft_editor(msg): - """ - Der Markdown-Editor. - Wird für neue Entwürfe UND für das Bearbeiten bestehender Notizen genutzt. - """ - # ID Generierung für Unique Keys - if "query_id" not in msg or not msg["query_id"]: - msg["query_id"] = str(uuid.uuid4()) - - qid = msg["query_id"] - key_base = f"draft_{qid}" - - # State Keys definieren - data_meta_key = f"{key_base}_data_meta" - data_sugg_key = f"{key_base}_data_suggestions" - widget_body_key = f"{key_base}_widget_body" - data_body_key = f"{key_base}_data_body" - - # --- INITIALISIERUNG --- - if f"{key_base}_init" not in st.session_state: - # Markdown parsen - meta, body = parse_markdown_draft(msg["content"]) - if "type" not in meta: meta["type"] = "default" - if "title" not in meta: meta["title"] = "" - tags = meta.get("tags", []) - meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) - - # Daten in Session State schreiben - st.session_state[data_meta_key] = meta - st.session_state[data_sugg_key] = [] - st.session_state[data_body_key] = body.strip() - - # Widget-Werte vorbelegen - st.session_state[f"{key_base}_wdg_title"] = meta["title"] - st.session_state[f"{key_base}_wdg_type"] = meta["type"] - st.session_state[f"{key_base}_wdg_tags"] = meta["tags_str"] - - # WICHTIG: Original-Pfad aus der Message übernehmen (für Update-Logik) - st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename") - - st.session_state[f"{key_base}_init"] = True - - # --- STATE RESURRECTION (falls Streamlit rerunt) --- - if widget_body_key not in st.session_state and data_body_key in st.session_state: - st.session_state[widget_body_key] = st.session_state[data_body_key] - - # --- SYNC FUNKTIONEN --- - def _sync_meta(): - meta = st.session_state[data_meta_key] - meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "") - meta["type"] = st.session_state.get(f"{key_base}_wdg_type", "default") - meta["tags_str"] = st.session_state.get(f"{key_base}_wdg_tags", "") - st.session_state[data_meta_key] = meta - - def _sync_body(): - st.session_state[data_body_key] = st.session_state[widget_body_key] - - def _insert_text(text_to_insert): - current = st.session_state.get(widget_body_key, "") - new_text = f"{current}\n\n{text_to_insert}" - st.session_state[widget_body_key] = new_text - st.session_state[data_body_key] = new_text - - def _remove_text(text_to_remove): - current = st.session_state.get(widget_body_key, "") - new_text = current.replace(text_to_remove, "").strip() - st.session_state[widget_body_key] = new_text - st.session_state[data_body_key] = new_text - - # --- LAYOUT HEADER --- - origin_fname = st.session_state.get(f"{key_base}_origin_filename") - - if origin_fname: - # Update Modus - display_name = str(origin_fname).split("/")[-1] # Nur Dateiname anzeigen - st.success(f"📂 **Datei-Modus**: `{origin_fname}`") # Voller Pfad zur Sicherheit - st.markdown(f'
', unsafe_allow_html=True) - else: - # Create Modus - st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.") - st.markdown(f'
', unsafe_allow_html=True) - - st.markdown("### Editor") - - # Metadaten-Editor - meta_ref = st.session_state[data_meta_key] - c1, c2 = st.columns([2, 1]) - with c1: - st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta) - with c2: - known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle", "risk", "belief"] - curr_type = st.session_state.get(f"{key_base}_wdg_type", meta_ref["type"]) - if curr_type not in known_types: known_types.append(curr_type) - st.selectbox("Typ", known_types, key=f"{key_base}_wdg_type", on_change=_sync_meta) - - st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta) - - # Tabs - tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) - - with tab_edit: - st.text_area("Body", key=widget_body_key, height=600, on_change=_sync_body, label_visibility="collapsed") - - with tab_intel: - st.info("Klicke auf 'Analysieren', um Verknüpfungen für den AKTUELLEN Text zu finden.") - - if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): - st.session_state[data_sugg_key] = [] - text_to_analyze = st.session_state.get(widget_body_key, st.session_state.get(data_body_key, "")) - current_doc_type = st.session_state.get(f"{key_base}_wdg_type", "concept") - - with st.spinner("Analysiere..."): - analysis = analyze_draft_text(text_to_analyze, current_doc_type) - if "error" in analysis: - st.error(f"Fehler: {analysis['error']}") - else: - suggestions = analysis.get("suggestions", []) - st.session_state[data_sugg_key] = suggestions - if not suggestions: st.warning("Keine Vorschläge gefunden.") - else: st.success(f"{len(suggestions)} Vorschläge gefunden.") - - # Vorschläge rendern - suggestions = st.session_state[data_sugg_key] - if suggestions: - current_text_state = st.session_state.get(widget_body_key, "") - for idx, sugg in enumerate(suggestions): - link_text = sugg.get('suggested_markdown', '') - is_inserted = link_text in current_text_state - - bg_color = "#e6fffa" if is_inserted else "#ffffff" - border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" - - st.markdown(f""" -
- {sugg.get('target_title')} ({sugg.get('type')})
- {sugg.get('reason')}
- {link_text} -
- """, unsafe_allow_html=True) - - if is_inserted: - st.button("❌ Entfernen", key=f"del_{idx}_{key_base}", on_click=_remove_text, args=(link_text,)) - else: - st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) - - # Dokument für Vorschau/Save bauen - final_tags_str = st.session_state.get(f"{key_base}_wdg_tags", "") - final_tags = [t.strip() for t in final_tags_str.split(",") if t.strip()] - - final_meta = { - "id": "generated_on_save", - "type": st.session_state.get(f"{key_base}_wdg_type", "default"), - "title": st.session_state.get(f"{key_base}_wdg_title", "").strip(), - "status": "draft", - "tags": final_tags - } - - # ID behalten wenn vorhanden (Wichtig für Source of Truth) - if "origin_note_id" in msg: - final_meta["id"] = msg["origin_note_id"] - - final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key]) - - # Fallback Titel aus H1 - if not final_meta["title"]: - h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE) - if h1_match: final_meta["title"] = h1_match.group(1).strip() - - final_doc = build_markdown_doc(final_meta, final_body) - - with tab_view: - st.markdown('
', unsafe_allow_html=True) - st.markdown(final_doc) - st.markdown('
', unsafe_allow_html=True) - - st.markdown("---") - - # Footer Actions - b1, b2 = st.columns([1, 1]) - with b1: - # Label dynamisch - save_label = "💾 Speichern (Überschreiben)" if origin_fname else "💾 Neu anlegen & Indizieren" - - if st.button(save_label, type="primary", key=f"{key_base}_save"): - with st.spinner("Speichere im Vault..."): - - # ENTSCHEIDUNG: Update oder Neu? - if origin_fname: - # UPDATE: Wir nutzen den exakten Pfad aus Qdrant - target_filename = origin_fname - else: - # NEU: Wir generieren einen Namen - raw_title = final_meta.get("title", "draft") - target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md" - target_filename = target_file - - # Senden an API - result = save_draft_to_vault(final_doc, filename=target_filename) - - if "error" in result: - st.error(f"Fehler: {result['error']}") - else: - st.success(f"Gespeichert: {result.get('file_path')}") - st.balloons() - - with b2: - if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"): - st.code(final_doc, language="markdown") - - st.markdown("
", unsafe_allow_html=True) - -def render_chat_interface(top_k, explain): - """ - Rendert das Chat-Interface. - """ - for idx, msg in enumerate(st.session_state.messages): - with st.chat_message(msg["role"]): - if msg["role"] == "assistant": - intent = msg.get("intent", "UNKNOWN") - src = msg.get("intent_source", "?") - icon = {"EMPATHY":"❤️", "DECISION":"⚖️", "CODING":"💻", "FACT":"📚", "INTERVIEW":"📝"}.get(intent, "🧠") - st.markdown(f'
{icon} Intent: {intent} ({src})
', unsafe_allow_html=True) - - with st.expander("🐞 Debug Raw Payload", expanded=False): - st.json(msg) - - if intent == "INTERVIEW": - render_draft_editor(msg) - else: - st.markdown(msg["content"]) - - if "sources" in msg and msg["sources"]: - for hit in msg["sources"]: - with st.expander(f"📄 {hit.get('note_id', '?')} ({hit.get('total_score', 0):.2f})"): - st.markdown(f"_{hit.get('source', {}).get('text', '')[:300]}..._") - if hit.get('explanation'): - st.caption(f"Grund: {hit['explanation']['reasons'][0]['message']}") - - def _cb(qid=msg.get("query_id"), nid=hit.get('node_id')): - val = st.session_state.get(f"fb_src_{qid}_{nid}") - if val is not None: submit_feedback(qid, nid, val+1) - st.feedback("faces", key=f"fb_src_{msg.get('query_id')}_{hit.get('node_id')}", on_change=_cb) - - if "query_id" in msg: - qid = msg["query_id"] - st.feedback("stars", key=f"fb_glob_{qid}", on_change=lambda: submit_feedback(qid, "generated_answer", st.session_state[f"fb_glob_{qid}"]+1)) - else: - st.markdown(msg["content"]) - - if prompt := st.chat_input("Frage Mindnet..."): - st.session_state.messages.append({"role": "user", "content": prompt}) - st.rerun() - - if len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user": - with st.chat_message("assistant"): - with st.spinner("Thinking..."): - resp = send_chat_message(st.session_state.messages[-1]["content"], top_k, explain) - if "error" in resp: - st.error(resp["error"]) - else: - st.session_state.messages.append({ - "role": "assistant", - "content": resp.get("answer"), - "intent": resp.get("intent", "FACT"), - "intent_source": resp.get("intent_source", "Unknown"), - "sources": resp.get("sources", []), - "query_id": resp.get("query_id") - }) - st.rerun() - -def render_manual_editor(): - """ - Wrapper für den manuellen Editor-Modus (Startet mit leerem Template). - """ - mock_msg = { - "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", - "query_id": "manual_mode_v2" - } - render_draft_editor(mock_msg) - -# --- GRAPH EXPLORER --- - -def render_graph_explorer(graph_service): - st.header("🕸️ Graph Explorer") - - # Session State initialisieren - if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None - - # Defaults speichern für Persistenz - st.session_state.setdefault("graph_depth", 2) - st.session_state.setdefault("graph_show_labels", True) - # Defaults angepasst für BarnesHut (Skalierung angepasst) - st.session_state.setdefault("graph_spacing", 150) - st.session_state.setdefault("graph_gravity", -3000) - - col_ctrl, col_graph = st.columns([1, 4]) - - with col_ctrl: - st.subheader("Fokus") - - # Suche - search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") - - options = {} - if search_term: - hits, _ = graph_service.client.scroll( - collection_name=f"{COLLECTION_PREFIX}_notes", - scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]), - limit=10 - ) - options = {h.payload['title']: h.payload['note_id'] for h in hits} - - if options: - selected_title = st.selectbox("Ergebnisse:", list(options.keys())) - if st.button("Laden", use_container_width=True): - st.session_state.graph_center_id = options[selected_title] - st.rerun() - - st.divider() - - # View Settings - with st.expander("👁️ Ansicht & Layout", expanded=True): - st.session_state.graph_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.graph_depth) - st.session_state.graph_show_labels = st.checkbox("Kanten-Beschriftung", st.session_state.graph_show_labels) - - st.markdown("**Physik (BarnesHut)**") - # ACHTUNG: BarnesHut reagiert anders. Spring Length ist wichtig. - st.session_state.graph_spacing = st.slider("Federlänge (Abstand)", 50, 500, st.session_state.graph_spacing, help="Wie lang sollen die Verbindungen sein?") - st.session_state.graph_gravity = st.slider("Abstoßung (Gravity)", -20000, -1000, st.session_state.graph_gravity, help="Wie stark sollen sich Knoten abstoßen?") - - if st.button("Reset Layout"): - st.session_state.graph_spacing = 150 - st.session_state.graph_gravity = -3000 - st.rerun() - - st.divider() - st.caption("Legende (Top Typen)") - for k, v in list(GRAPH_COLORS.items())[:8]: - st.markdown(f" {k}", unsafe_allow_html=True) - - with col_graph: - center_id = st.session_state.graph_center_id - - if center_id: - # Container für Action Bar OBERHALB des Graphen (Layout Fix) - action_container = st.container() - - # Graph Laden - with st.spinner(f"Lade Graph..."): - # Daten laden (Cache wird genutzt) - nodes, edges = graph_service.get_ego_graph( - center_id, - depth=st.session_state.graph_depth, - show_labels=st.session_state.graph_show_labels - ) - - # Fetch Note Data für Button & Debug - note_data = graph_service._fetch_note_cached(center_id) - - # --- ACTION BAR RENDEREN --- - with action_container: - c_act1, c_act2 = st.columns([3, 1]) - with c_act1: - st.caption(f"Aktives Zentrum: **{center_id}**") - with c_act2: - if note_data: - st.button("📝 Bearbeiten", - use_container_width=True, - on_click=switch_to_editor_callback, - args=(note_data,)) - else: - st.error("Daten nicht verfügbar") - - # DATA INSPECTOR (Payload Debug) - with st.expander("🕵️ Data Inspector (Payload Debug)", expanded=False): - if note_data: - st.json(note_data) - if 'path' not in note_data: - st.error("ACHTUNG: Feld 'path' fehlt im Qdrant-Payload! Update-Modus wird nicht funktionieren.") - else: - st.success(f"Pfad gefunden: {note_data['path']}") - else: - st.info("Keine Daten geladen.") - - if not nodes: - st.warning("Keine Daten gefunden.") - else: - # --- CONFIGURATION (BarnesHut) --- - # Height-Trick für Re-Render (da key-Parameter nicht funktioniert) - # Ändere Height minimal basierend auf Gravity - dyn_height = 800 + (abs(st.session_state.graph_gravity) % 3) - - config = Config( - width=1000, - height=dyn_height, - directed=True, - physics={ - "enabled": True, - # BarnesHut ist der Standard und stabilste Solver für Agraph - "solver": "barnesHut", - "barnesHut": { - "gravitationalConstant": st.session_state.graph_gravity, - "centralGravity": 0.3, - "springLength": st.session_state.graph_spacing, - "springConstant": 0.04, - "damping": 0.09, - "avoidOverlap": 0.1 - }, - "stabilization": {"enabled": True, "iterations": 600} - }, - hierarchical=False, - nodeHighlightBehavior=True, - highlightColor="#F7A7A6", - collapsible=False - ) - - return_value = agraph(nodes=nodes, edges=edges, config=config) - - # Interaktions-Logik - if return_value: - if return_value != center_id: - # Navigation: Neues Zentrum setzen - st.session_state.graph_center_id = return_value - st.rerun() - else: - # Klick auf das Zentrum selbst - st.toast(f"Zentrum: {return_value}") - - else: - st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file diff --git a/app/frontend/ui_editor.py b/app/frontend/ui_editor.py new file mode 100644 index 0000000..43acedc --- /dev/null +++ b/app/frontend/ui_editor.py @@ -0,0 +1,189 @@ +import streamlit as st +import uuid +import re +from datetime import datetime + +from ui_utils import parse_markdown_draft, build_markdown_doc, slugify +from ui_api import save_draft_to_vault, analyze_draft_text + +def render_draft_editor(msg): + """ + Rendert den Markdown-Editor. + Nutzt 'origin_filename' aus der Message, um zwischen Update und Neu zu unterscheiden. + """ + if "query_id" not in msg or not msg["query_id"]: + msg["query_id"] = str(uuid.uuid4()) + + qid = msg["query_id"] + key_base = f"draft_{qid}" + + # State Keys + data_meta_key = f"{key_base}_data_meta" + data_sugg_key = f"{key_base}_data_suggestions" + widget_body_key = f"{key_base}_widget_body" + data_body_key = f"{key_base}_data_body" + + # --- INIT STATE --- + if f"{key_base}_init" not in st.session_state: + meta, body = parse_markdown_draft(msg["content"]) + if "type" not in meta: meta["type"] = "default" + if "title" not in meta: meta["title"] = "" + tags = meta.get("tags", []) + meta["tags_str"] = ", ".join(tags) if isinstance(tags, list) else str(tags) + + st.session_state[data_meta_key] = meta + st.session_state[data_sugg_key] = [] + st.session_state[data_body_key] = body.strip() + + st.session_state[f"{key_base}_wdg_title"] = meta["title"] + st.session_state[f"{key_base}_wdg_type"] = meta["type"] + st.session_state[f"{key_base}_wdg_tags"] = meta["tags_str"] + + # Pfad übernehmen (Source of Truth) + st.session_state[f"{key_base}_origin_filename"] = msg.get("origin_filename") + st.session_state[f"{key_base}_init"] = True + + # --- RESURRECTION --- + if widget_body_key not in st.session_state and data_body_key in st.session_state: + st.session_state[widget_body_key] = st.session_state[data_body_key] + + # --- SYNC HELPER --- + def _sync_meta(): + meta = st.session_state[data_meta_key] + meta["title"] = st.session_state.get(f"{key_base}_wdg_title", "") + meta["type"] = st.session_state.get(f"{key_base}_wdg_type", "default") + meta["tags_str"] = st.session_state.get(f"{key_base}_wdg_tags", "") + st.session_state[data_meta_key] = meta + + def _sync_body(): + st.session_state[data_body_key] = st.session_state[widget_body_key] + + def _insert_text(t): + st.session_state[widget_body_key] = f"{st.session_state.get(widget_body_key, '')}\n\n{t}" + st.session_state[data_body_key] = st.session_state[widget_body_key] + + def _remove_text(t): + st.session_state[widget_body_key] = st.session_state.get(widget_body_key, '').replace(t, "").strip() + st.session_state[data_body_key] = st.session_state[widget_body_key] + + # --- UI LAYOUT --- + + # Header Info + origin_fname = st.session_state.get(f"{key_base}_origin_filename") + if origin_fname: + display_name = str(origin_fname).split("/")[-1] + st.success(f"📂 **Update-Modus**: `{origin_fname}`") + st.markdown(f'
', unsafe_allow_html=True) + else: + st.info("✨ **Erstell-Modus**: Neue Datei wird angelegt.") + st.markdown(f'
', unsafe_allow_html=True) + + st.markdown("### Editor") + + # Meta Felder + meta_ref = st.session_state[data_meta_key] + c1, c2 = st.columns([2, 1]) + with c1: + st.text_input("Titel", key=f"{key_base}_wdg_title", on_change=_sync_meta) + with c2: + known_types = ["concept", "project", "decision", "experience", "journal", "value", "goal", "principle", "risk", "belief"] + curr_type = st.session_state.get(f"{key_base}_wdg_type", meta_ref["type"]) + if curr_type not in known_types: known_types.append(curr_type) + st.selectbox("Typ", known_types, key=f"{key_base}_wdg_type", on_change=_sync_meta) + + st.text_input("Tags", key=f"{key_base}_wdg_tags", on_change=_sync_meta) + + # Tabs + tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"]) + + with tab_edit: + st.text_area("Body", key=widget_body_key, height=600, on_change=_sync_body, label_visibility="collapsed") + + with tab_intel: + st.info("Analysiert den Text auf Verknüpfungsmöglichkeiten.") + if st.button("🔍 Analyse starten", key=f"{key_base}_analyze"): + st.session_state[data_sugg_key] = [] + text_to_analyze = st.session_state.get(widget_body_key, st.session_state.get(data_body_key, "")) + with st.spinner("Analysiere..."): + analysis = analyze_draft_text(text_to_analyze, st.session_state.get(f"{key_base}_wdg_type", "concept")) + if "error" in analysis: + st.error(f"Fehler: {analysis['error']}") + else: + suggestions = analysis.get("suggestions", []) + st.session_state[data_sugg_key] = suggestions + if not suggestions: st.warning("Keine Vorschläge.") + else: st.success(f"{len(suggestions)} Vorschläge gefunden.") + + suggestions = st.session_state[data_sugg_key] + if suggestions: + current_text = st.session_state.get(widget_body_key, "") + for idx, sugg in enumerate(suggestions): + link_text = sugg.get('suggested_markdown', '') + is_inserted = link_text in current_text + bg_color = "#e6fffa" if is_inserted else "#ffffff" + border = "3px solid #28a745" if is_inserted else "3px solid #1a73e8" + st.markdown(f"
{sugg.get('target_title')} ({sugg.get('type')})
{sugg.get('reason')}
{link_text}
", unsafe_allow_html=True) + if is_inserted: + st.button("❌ Entfernen", key=f"del_{idx}_{key_base}", on_click=_remove_text, args=(link_text,)) + else: + st.button("➕ Einfügen", key=f"add_{idx}_{key_base}", on_click=_insert_text, args=(link_text,)) + + # Save Logic Preparation + final_tags = [t.strip() for t in st.session_state.get(f"{key_base}_wdg_tags", "").split(",") if t.strip()] + final_meta = { + "id": "generated_on_save", + "type": st.session_state.get(f"{key_base}_wdg_type", "default"), + "title": st.session_state.get(f"{key_base}_wdg_title", "").strip(), + "status": "draft", + "tags": final_tags + } + if "origin_note_id" in msg: + final_meta["id"] = msg["origin_note_id"] + + final_body = st.session_state.get(widget_body_key, st.session_state[data_body_key]) + if not final_meta["title"]: + h1_match = re.search(r"^#\s+(.*)$", final_body, re.MULTILINE) + if h1_match: final_meta["title"] = h1_match.group(1).strip() + + final_doc = build_markdown_doc(final_meta, final_body) + + with tab_view: + st.markdown('
', unsafe_allow_html=True) + st.markdown(final_doc) + st.markdown('
', unsafe_allow_html=True) + + st.markdown("---") + + # Save Actions + b1, b2 = st.columns([1, 1]) + with b1: + save_label = "💾 Update speichern" if origin_fname else "💾 Neu anlegen & Indizieren" + + if st.button(save_label, type="primary", key=f"{key_base}_save"): + with st.spinner("Speichere im Vault..."): + if origin_fname: + # UPDATE: Ziel ist der exakte Pfad + target_file = origin_fname + else: + # CREATE: Neuer Dateiname + raw_title = final_meta.get("title", "draft") + target_file = f"{datetime.now().strftime('%Y%m%d')}-{slugify(raw_title)[:60]}.md" + + result = save_draft_to_vault(final_doc, filename=target_file) + if "error" in result: + st.error(f"Fehler: {result['error']}") + else: + st.success(f"Gespeichert: {result.get('file_path')}") + st.balloons() + with b2: + if st.button("📋 Code anzeigen", key=f"{key_base}_btn_copy"): + st.code(final_doc, language="markdown") + + st.markdown("
", unsafe_allow_html=True) + +def render_manual_editor(): + mock_msg = { + "content": "---\ntype: concept\ntitle: Neue Notiz\nstatus: draft\ntags: []\n---\n# Titel\n", + "query_id": "manual_mode_v2" + } + render_draft_editor(mock_msg) \ No newline at end of file diff --git a/app/frontend/ui_graph.py b/app/frontend/ui_graph.py new file mode 100644 index 0000000..6d438e3 --- /dev/null +++ b/app/frontend/ui_graph.py @@ -0,0 +1,126 @@ +import streamlit as st +from streamlit_agraph import agraph, Config +from qdrant_client import models +from ui_config import COLLECTION_PREFIX, GRAPH_COLORS +from ui_callbacks import switch_to_editor_callback + +def render_graph_explorer(graph_service): + st.header("🕸️ Graph Explorer") + + if "graph_center_id" not in st.session_state: st.session_state.graph_center_id = None + + # Defaults + st.session_state.setdefault("graph_depth", 2) + st.session_state.setdefault("graph_show_labels", True) + st.session_state.setdefault("graph_spacing", 150) + st.session_state.setdefault("graph_gravity", -3000) + + col_ctrl, col_graph = st.columns([1, 4]) + + with col_ctrl: + st.subheader("Fokus") + search_term = st.text_input("Suche Notiz", placeholder="Titel eingeben...") + + if search_term: + hits, _ = graph_service.client.scroll( + collection_name=f"{COLLECTION_PREFIX}_notes", + scroll_filter=models.Filter(must=[models.FieldCondition(key="title", match=models.MatchText(text=search_term))]), + limit=10 + ) + options = {h.payload['title']: h.payload['note_id'] for h in hits} + if options: + selected_title = st.selectbox("Ergebnisse:", list(options.keys())) + if st.button("Laden", use_container_width=True): + st.session_state.graph_center_id = options[selected_title] + st.rerun() + + st.divider() + with st.expander("👁️ Ansicht & Layout", expanded=True): + st.session_state.graph_depth = st.slider("Tiefe (Tier)", 1, 3, st.session_state.graph_depth) + st.session_state.graph_show_labels = st.checkbox("Kanten-Beschriftung", st.session_state.graph_show_labels) + + st.markdown("**Physik (BarnesHut)**") + st.session_state.graph_spacing = st.slider("Federlänge", 50, 500, st.session_state.graph_spacing) + st.session_state.graph_gravity = st.slider("Abstoßung", -20000, -500, st.session_state.graph_gravity) + + if st.button("Reset Layout"): + st.session_state.graph_spacing = 150 + st.session_state.graph_gravity = -3000 + st.rerun() + + st.divider() + st.caption("Legende (Top Typen)") + for k, v in list(GRAPH_COLORS.items())[:8]: + st.markdown(f" {k}", unsafe_allow_html=True) + + with col_graph: + center_id = st.session_state.graph_center_id + + if center_id: + # Action Container oben + action_container = st.container() + + with st.spinner(f"Lade Graph..."): + nodes, edges = graph_service.get_ego_graph( + center_id, + depth=st.session_state.graph_depth, + show_labels=st.session_state.graph_show_labels + ) + note_data = graph_service._fetch_note_cached(center_id) + + # Action Bar rendern + with action_container: + c1, c2 = st.columns([3, 1]) + with c1: st.caption(f"Aktives Zentrum: **{center_id}**") + with c2: + if note_data: + st.button("📝 Bearbeiten", use_container_width=True, on_click=switch_to_editor_callback, args=(note_data,)) + else: + st.error("Datenfehler") + + with st.expander("🕵️ Data Inspector", expanded=False): + if note_data: + st.json(note_data) + if 'path' in note_data: st.success(f"Pfad OK: {note_data['path']}") + else: st.error("Pfad fehlt!") + else: st.info("Leer.") + + if not nodes: + st.warning("Keine Daten gefunden.") + else: + # Physik Config für BarnesHut + Height-Trick + dyn_height = 800 + (abs(st.session_state.graph_gravity) % 5) + + config = Config( + width=1000, + height=dyn_height, + directed=True, + physics={ + "enabled": True, + "solver": "barnesHut", + "barnesHut": { + "gravitationalConstant": st.session_state.graph_gravity, + "centralGravity": 0.3, + "springLength": st.session_state.graph_spacing, + "springConstant": 0.04, + "damping": 0.09, + "avoidOverlap": 0.1 + }, + "stabilization": {"enabled": True, "iterations": 600} + }, + hierarchical=False, + nodeHighlightBehavior=True, + highlightColor="#F7A7A6", + collapsible=False + ) + + return_value = agraph(nodes=nodes, edges=edges, config=config) + + if return_value: + if return_value != center_id: + st.session_state.graph_center_id = return_value + st.rerun() + else: + st.toast(f"Zentrum: {return_value}") + else: + st.info("👈 Bitte wähle links eine Notiz aus.") \ No newline at end of file diff --git a/app/frontend/ui_sidebar.py b/app/frontend/ui_sidebar.py new file mode 100644 index 0000000..4691358 --- /dev/null +++ b/app/frontend/ui_sidebar.py @@ -0,0 +1,31 @@ +import streamlit as st +from ui_utils import load_history_from_logs +from ui_config import HISTORY_FILE + +def render_sidebar(): + with st.sidebar: + st.title("🧠 mindnet") + st.caption("v2.6 | WP-19 Graph View") + + if "sidebar_mode_selection" not in st.session_state: + st.session_state["sidebar_mode_selection"] = "💬 Chat" + + mode = st.radio( + "Modus", + ["💬 Chat", "📝 Manueller Editor", "🕸️ Graph Explorer"], + key="sidebar_mode_selection" + ) + + st.divider() + st.subheader("⚙️ Settings") + top_k = st.slider("Quellen (Top-K)", 1, 10, 5) + explain = st.toggle("Explanation Layer", True) + + st.divider() + st.subheader("🕒 Verlauf") + for q in load_history_from_logs(HISTORY_FILE, 8): + if st.button(f"🔎 {q[:25]}...", key=f"hist_{q}", use_container_width=True): + st.session_state.messages.append({"role": "user", "content": q}) + st.rerun() + + return mode, top_k, explain \ No newline at end of file