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'
', 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