mindnet/app/frontend/ui.py

479 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import streamlit as st
import requests
import uuid
import os
import json
import re
import yaml
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
# --- CONFIGURATION ---
load_dotenv()
API_BASE_URL = os.getenv("MINDNET_API_URL", "http://localhost:8002")
CHAT_ENDPOINT = f"{API_BASE_URL}/chat"
FEEDBACK_ENDPOINT = f"{API_BASE_URL}/feedback"
INGEST_ANALYZE_ENDPOINT = f"{API_BASE_URL}/ingest/analyze"
INGEST_SAVE_ENDPOINT = f"{API_BASE_URL}/ingest/save"
HISTORY_FILE = Path("data/logs/search_history.jsonl")
# Timeout Strategy
timeout_setting = os.getenv("MINDNET_API_TIMEOUT") or os.getenv("MINDNET_LLM_TIMEOUT")
API_TIMEOUT = float(timeout_setting) if timeout_setting else 300.0
# --- PAGE SETUP ---
st.set_page_config(page_title="mindnet v2.3.6", page_icon="🧠", layout="wide")
# --- CSS STYLING ---
st.markdown("""
<style>
.block-container { padding-top: 2rem; max_width: 1000px; margin: auto; }
.intent-badge {
background-color: #e8f0fe; color: #1a73e8;
padding: 4px 10px; border-radius: 12px;
font-size: 0.8rem; font-weight: 600;
border: 1px solid #d2e3fc; display: inline-block; margin-bottom: 0.5rem;
}
.draft-box {
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px;
background-color: #f6f8fa;
margin-top: 10px;
margin-bottom: 10px;
}
.preview-box {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 24px;
background-color: white;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
}
.suggestion-card {
border-left: 3px solid #1a73e8;
background-color: #ffffff;
padding: 10px;
margin-bottom: 8px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
</style>
""", 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())
# --- HELPER FUNCTIONS ---
def normalize_meta_and_body(meta, body):
"""Sanitizer: Stellt sicher, dass nur erlaubte Felder im Frontmatter bleiben."""
ALLOWED_KEYS = {"title", "type", "status", "tags", "id", "created", "updated", "aliases", "lang"}
clean_meta = {}
extra_content = []
if "titel" in meta and "title" not in meta:
meta["title"] = meta.pop("titel")
tag_candidates = ["tags", "emotionale_keywords", "keywords", "schluesselwoerter"]
all_tags = []
for key in tag_candidates:
if key in meta:
val = meta[key]
if isinstance(val, list): all_tags.extend(val)
elif isinstance(val, str): all_tags.extend([t.strip() for t in val.split(",")])
for key, val in meta.items():
if key in ALLOWED_KEYS:
clean_meta[key] = val
elif key in tag_candidates:
pass
else:
if val and isinstance(val, str):
header = key.replace("_", " ").title()
extra_content.append(f"## {header}\n{val}\n")
if all_tags:
clean_meta["tags"] = list(set(all_tags))
if extra_content:
new_section = "\n".join(extra_content)
final_body = f"{new_section}\n{body}"
else:
final_body = body
return clean_meta, final_body
def parse_markdown_draft(full_text):
"""Robustes Parsing + Sanitization."""
clean_text = full_text
pattern_block = r"```(?:markdown|md)?\s*(.*?)\s*```"
match_block = re.search(pattern_block, full_text, re.DOTALL | re.IGNORECASE)
if match_block:
clean_text = match_block.group(1).strip()
parts = re.split(r"^---+\s*$", clean_text, maxsplit=2, flags=re.MULTILINE)
meta = {}
body = clean_text
if len(parts) >= 3:
yaml_str = parts[1]
body_candidate = parts[2]
try:
parsed = yaml.safe_load(yaml_str)
if isinstance(parsed, dict):
meta = parsed
body = body_candidate.strip()
except Exception:
pass
return normalize_meta_and_body(meta, body)
def build_markdown_doc(meta, body):
"""Baut das finale Dokument zusammen."""
if "id" not in meta or meta["id"] == "generated_on_save":
safe_title = re.sub(r'[^a-zA-Z0-9]', '-', meta.get('title', 'note')).lower()[:30]
meta["id"] = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}-{uuid.uuid4().hex[:4]}"
meta["updated"] = datetime.now().strftime("%Y-%m-%d")
ordered_meta = {}
prio_keys = ["id", "type", "title", "status", "tags"]
for k in prio_keys:
if k in meta: ordered_meta[k] = meta.pop(k)
ordered_meta.update(meta)
try:
yaml_str = yaml.dump(ordered_meta, default_flow_style=None, sort_keys=False, allow_unicode=True).strip()
except:
yaml_str = "error: generating_yaml"
return f"---\n{yaml_str}\n---\n\n{body}"
def load_history_from_logs(limit=10):
queries = []
if HISTORY_FILE.exists():
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in reversed(lines):
try:
entry = json.loads(line)
q = entry.get("query_text")
if q and q not in queries:
queries.append(q)
if len(queries) >= limit: break
except: continue
except: pass
return queries
# --- API CLIENT ---
def send_chat_message(message: str, top_k: int, explain: bool):
try:
response = requests.post(
CHAT_ENDPOINT,
json={"message": message, "top_k": top_k, "explain": explain},
timeout=API_TIMEOUT
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def analyze_draft_text(text: str, n_type: str):
"""Ruft den neuen Intelligence-Service (WP-11) auf."""
try:
response = requests.post(
INGEST_ANALYZE_ENDPOINT,
json={"text": text, "type": n_type},
timeout=15
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def save_draft_to_vault(markdown_content: str, filename: str = None):
"""Ruft den neuen Persistence-Service (WP-11) auf."""
try:
response = requests.post(
INGEST_SAVE_ENDPOINT,
json={"markdown_content": markdown_content, "filename": filename},
timeout=60 # Indizierung kann dauern
)
response.raise_for_status()
return response.json()
except Exception as e:
return {"error": str(e)}
def submit_feedback(query_id, node_id, score, comment=None):
try:
requests.post(FEEDBACK_ENDPOINT, json={"query_id": query_id, "node_id": node_id, "score": score, "comment": comment}, timeout=2)
st.toast(f"Feedback ({score}) gesendet!")
except: pass
# --- UI COMPONENTS ---
def render_sidebar():
with st.sidebar:
st.title("🧠 mindnet")
st.caption("v2.3.6 | WP-10b (Full)")
mode = st.radio("Modus", ["💬 Chat", "📝 Manueller Editor"], index=0)
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(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):
qid = msg.get('query_id', str(uuid.uuid4()))
key_base = f"draft_{qid}"
body_key = f"{key_base}_txt_body"
# --- CALLBACKS (Lösung für den State-Error) ---
def _append_text(k, text):
current = st.session_state.get(k, "")
st.session_state[k] = f"{current}\n\n{text}"
# Sync auch den generischen Key
st.session_state[f"{key_base}_body"] = st.session_state[k]
def _remove_text(k, text):
current = st.session_state.get(k, "")
# Einfaches Replace (könnte man robuster machen)
st.session_state[k] = current.replace(text, "").strip()
st.session_state[f"{key_base}_body"] = st.session_state[k]
# 1. Init (Nur beim allerersten Laden)
if f"{key_base}_init" not in st.session_state:
meta, body = parse_markdown_draft(msg["content"])
st.session_state[f"{key_base}_type"] = meta.get("type", "default")
st.session_state[f"{key_base}_title"] = meta.get("title", "")
tags_raw = meta.get("tags", [])
st.session_state[f"{key_base}_tags"] = ", ".join(tags_raw) if isinstance(tags_raw, list) else str(tags_raw)
# Initialisiere beide Keys
st.session_state[body_key] = body.strip()
st.session_state[f"{key_base}_body"] = body.strip()
st.session_state[f"{key_base}_meta"] = meta
st.session_state[f"{key_base}_suggestions"] = []
st.session_state[f"{key_base}_init"] = True
# 2. UI Layout
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
st.markdown("### 📝 Entwurf bearbeiten")
# Metadata
c1, c2 = st.columns([2, 1])
with c1:
new_title = st.text_input("Titel", key=f"{key_base}_inp_title", value=st.session_state.get(f"{key_base}_title", ""))
with c2:
known_types = ["concept", "project", "decision", "experience", "journal", "person", "value", "goal", "principle", "default"]
curr_type = st.session_state.get(f"{key_base}_type", "default")
if curr_type not in known_types: known_types.append(curr_type)
new_type = st.selectbox("Typ", known_types, index=known_types.index(curr_type), key=f"{key_base}_sel_type")
new_tags = st.text_input("Tags (kommagetrennt)", key=f"{key_base}_inp_tags", value=st.session_state.get(f"{key_base}_tags", ""))
# Tabs
tab_edit, tab_intel, tab_view = st.tabs(["✏️ Inhalt", "🧠 Intelligence", "👁️ Vorschau"])
# --- TAB 1: EDITOR ---
with tab_edit:
# Das Widget rendert HIER. Änderungen am State müssen VORHER (via Callback) passieren.
current_body = st.text_area(
"Body",
key=body_key,
height=500,
label_visibility="collapsed"
)
# Sync manueller Änderungen in den generischen Key
st.session_state[f"{key_base}_body"] = current_body
# --- TAB 2: INTELLIGENCE ---
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"):
with st.spinner("Analysiere..."):
text_to_analyze = st.session_state[body_key]
analysis = analyze_draft_text(text_to_analyze, new_type)
if "error" in analysis:
st.error(f"Fehler: {analysis['error']}")
else:
suggestions = analysis.get("suggestions", [])
st.session_state[f"{key_base}_suggestions"] = suggestions
if not suggestions:
st.warning("Keine Vorschläge gefunden.")
suggestions = st.session_state.get(f"{key_base}_suggestions", [])
if suggestions:
st.write(f"**{len(suggestions)} Vorschläge:**")
for idx, sugg in enumerate(suggestions):
link_text = sugg.get('suggested_markdown', '')
# Prüfe ob Text vorhanden (Case Insensitive Check wäre besser, hier simpel)
is_inserted = link_text in st.session_state[body_key]
# Card Styling
card_style = "border-left: 3px solid #28a745;" if is_inserted else "border-left: 3px solid #1a73e8;"
bg_color = "#e6fffa" if is_inserted else "#ffffff"
st.markdown(f"""
<div style="{card_style} background-color: {bg_color}; padding: 10px; margin-bottom: 8px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<b>{sugg.get('target_title', 'Unbekannt')}</b> <small>({sugg.get('type', 'semantic')})</small><br>
<i>{sugg.get('reason', 'N/A')}</i><br>
<code>{link_text}</code>
</div>
""", unsafe_allow_html=True)
# Button Logik mit CALLBACKS (on_click)
if is_inserted:
st.button(
f"❌ Entfernen",
key=f"del_{idx}_{key_base}",
on_click=_remove_text, # Callback
args=(body_key, link_text) # Argumente für Callback
)
else:
st.button(
f" Einfügen",
key=f"add_{idx}_{key_base}",
on_click=_append_text, # Callback
args=(body_key, link_text) # Argumente für Callback
)
# --- TAB 3: PREVIEW & SAVE ---
final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()]
final_meta = {
"id": "generated_on_save",
"type": new_type,
"title": new_title,
"status": "draft",
"tags": final_tags_list
}
# Nimm immer den aktuellsten Text aus dem Widget-State
final_body_content = st.session_state[body_key]
final_doc = build_markdown_doc(final_meta, final_body_content)
with tab_view:
st.markdown('<div class="preview-box">', unsafe_allow_html=True)
st.markdown(final_doc)
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("---")
# Save Action
b1, b2 = st.columns([1, 1])
with b1:
if st.button("💾 Speichern & Indizieren", type="primary", key=f"{key_base}_save"):
with st.spinner("Speichere im Vault..."):
safe_title = re.sub(r'[^a-zA-Z0-9]', '-', new_title).lower()[:30]
if not safe_title: safe_title = "draft"
fname = f"{datetime.now().strftime('%Y%m%d')}-{safe_title}.md"
# Speichern mit aktuellstem Inhalt
result = save_draft_to_vault(final_doc, filename=fname)
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("</div>", unsafe_allow_html=True)
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":
# Header
intent = msg.get("intent", "UNKNOWN")
src = msg.get("intent_source", "?")
icon = {"EMPATHY":"❤️", "DECISION":"⚖️", "CODING":"💻", "FACT":"📚", "INTERVIEW":"📝"}.get(intent, "🧠")
st.markdown(f'<div class="intent-badge">{icon} Intent: {intent} <span style="opacity:0.6; font-size:0.8em">({src})</span></div>', unsafe_allow_html=True)
with st.expander("🐞 Debug Raw Payload", expanded=False):
st.json(msg)
# Logic
if intent == "INTERVIEW":
render_draft_editor(msg)
else:
st.markdown(msg["content"])
# Sources
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():
# Wir nutzen eine Fake-Message, um die render_draft_editor Logik wiederzuverwenden
# Aber mit leeren Defaults
mock_msg = {
"content": "---\ntype: default\nstatus: draft\ntitle: Neue Notiz\ntags: []\n---\n# Titel\n",
"query_id": "manual_mode_v2" # Feste ID für manuellen Modus
}
render_draft_editor(mock_msg)
# --- MAIN ---
mode, top_k, explain = render_sidebar()
if mode == "💬 Chat":
render_chat_interface(top_k, explain)
else:
render_manual_editor()