mindnet/app/frontend/ui.py
2025-12-10 17:14:51 +01:00

342 lines
13 KiB
Python

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"
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.2", 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;
}
/* Editor Box Styling */
.draft-box {
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px;
background-color: #f6f8fa;
margin-top: 10px;
margin-bottom: 10px;
}
/* Preview Styling mimics GitHub Markdown */
.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;
}
</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 parse_markdown_draft(full_text):
"""
Zerlegt die LLM-Antwort in Frontmatter (Dict) und Body (String).
Robust gegen Einleitungstexte vor dem Codeblock.
"""
# 1. Extrahiere Codeblock
pattern_block = r"```markdown\s*(.*?)\s*```"
match_block = re.search(pattern_block, full_text, re.DOTALL)
# Fallback: Wenn kein Codeblock, nimm ganzen Text
clean_text = match_block.group(1).strip() if match_block else full_text
# 2. Trenne Frontmatter (YAML) vom Body
# Sucht nach --- am Anfang, gefolgt von YAML, gefolgt von ---
pattern_fm = r"^---\s+(.*?)\s+---\s*(.*)$"
match_fm = re.search(pattern_fm, clean_text, re.DOTALL)
if match_fm:
yaml_str = match_fm.group(1)
body = match_fm.group(2)
try:
meta = yaml.safe_load(yaml_str) or {}
except Exception:
meta = {} # Fallback bei kaputtem YAML
return meta, body
else:
# Kein Frontmatter gefunden -> Alles ist Body
return {}, clean_text
def generate_filename(meta):
"""Generiert einen Dateinamen basierend auf Typ und Datum."""
date_str = datetime.now().strftime("%Y%m%d")
n_type = meta.get("type", "note")
# Versuche Titel aus Body zu raten wäre zu komplex, wir nehmen Generic
return f"{date_str}-{n_type}-draft.md"
def build_markdown_doc(meta, body):
"""Baut das finale Dokument zusammen."""
# ID Generierung beim 'Speichern' (hier simuliert für Download)
if "id" not in meta or meta["id"] == "generated_on_save":
meta["id"] = f"{datetime.now().strftime('%Y%m%d')}-{meta.get('type', 'note')}-{uuid.uuid4().hex[:6]}"
# Updated timestamp
meta["updated"] = datetime.now().strftime("%Y-%m-%d")
yaml_str = yaml.dump(meta, default_flow_style=None, sort_keys=False, allow_unicode=True).strip()
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
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 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)
target = "Antwort" if node_id == "generated_answer" else "Quelle"
st.toast(f"Feedback ({score}) gesendet!")
except: pass
# --- UI COMPONENTS ---
def render_sidebar():
with st.sidebar:
st.title("🧠 mindnet")
st.caption("v2.3.2 | WP-10 UI")
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):
"""
Rendert den Split-Screen Editor für INTERVIEW Drafts.
"""
qid = msg.get('query_id', str(uuid.uuid4()))
key_base = f"draft_{qid}"
# 1. State Initialisierung (Nur einmal pro Nachricht)
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")
# Tags Behandlung (Liste oder String)
tags_raw = meta.get("tags", [])
if isinstance(tags_raw, list):
st.session_state[f"{key_base}_tags"] = ", ".join(tags_raw)
else:
st.session_state[f"{key_base}_tags"] = str(tags_raw)
st.session_state[f"{key_base}_body"] = body.strip()
st.session_state[f"{key_base}_init"] = True
# 2. Editor Container
st.markdown(f'<div class="draft-box">', unsafe_allow_html=True)
st.markdown("### 📝 Entwurf bearbeiten")
st.caption("Das System hat diesen Entwurf vorbereitet. Ergänze die fehlenden [TODO] Bereiche.")
# 3. Metadaten (Grid Layout)
c1, c2 = st.columns([1, 2])
with c1:
# Typen müssen mit types.yaml synchron sein
known_types = ["concept", "project", "decision", "experience", "journal", "person", "value", "goal", "principle"]
curr_type = st.session_state[f"{key_base}_type"]
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")
with c2:
new_tags = st.text_input("Tags (kommagetrennt)", value=st.session_state[f"{key_base}_tags"], key=f"{key_base}_inp_tags")
# 4. Inhalt (Tabs)
tab_edit, tab_view = st.tabs(["✏️ Editor", "👁️ Vorschau"])
with tab_edit:
# Hier landet der Body mit den ## Headings und [TODO]s
new_body = st.text_area(
"Inhalt",
value=st.session_state[f"{key_base}_body"],
height=500,
key=f"{key_base}_txt_body",
label_visibility="collapsed",
help="Hier kannst du Markdown schreiben."
)
# Live-Zusammenbau für Vorschau & Download
final_tags_list = [t.strip() for t in new_tags.split(",") if t.strip()]
final_meta = {
"id": "generated_on_save", # Placeholder, wird beim Download ersetzt
"type": new_type,
"status": "draft",
"tags": final_tags_list
}
final_doc = build_markdown_doc(final_meta, new_body)
with tab_view:
st.markdown('<div class="preview-box">', unsafe_allow_html=True)
st.markdown(final_doc) # Rendered Markdown
st.markdown('</div>', unsafe_allow_html=True)
st.markdown("---")
# 5. Actions (Writeback Simulation)
b1, b2 = st.columns([1, 1])
with b1:
# Download Button (Client-Side)
st.download_button(
label="💾 Als .md Datei speichern",
data=final_doc,
file_name=generate_filename(final_meta),
mime="text/markdown"
)
with b2:
# Copy Button Logic
if st.button("📋 Code anzeigen (Copy)", 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 msg in st.session_state.messages:
with st.chat_message(msg["role"]):
if msg["role"] == "assistant":
# Intent Badge
if "intent" in msg:
intent = msg["intent"]
icon = {"EMPATHY": "❤️", "DECISION": "⚖️", "CODING": "💻", "FACT": "📚", "INTERVIEW": "📝"}.get(intent, "🧠")
src = msg.get("intent_source", "?")
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)
# INTERVIEW Logic vs NORMAL Chat
if msg.get("intent") == "INTERVIEW":
render_draft_editor(msg)
else:
st.markdown(msg["content"])
# Sources & Feedback (nur bei RAG sinnvoll)
if "sources" in msg and msg["sources"]:
for hit in msg["sources"]:
score = hit.get('total_score', 0)
icon = "🟢" if score > 0.8 else "🟡" if score > 0.5 else ""
with st.expander(f"{icon} {hit.get('note_id', '?')} ({score:.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["query_id"], nid=hit['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['query_id']}_{hit['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"])
# Input Loop
if prompt := st.chat_input("Frage Mindnet... (z.B. 'Neues Projekt anlegen')"):
st.session_state.messages.append({"role": "user", "content": prompt})
st.rerun()
last_msg_is_user = len(st.session_state.messages) > 0 and st.session_state.messages[-1]["role"] == "user"
if last_msg_is_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():
st.header("📝 Manueller Editor")
st.info("Hier kannst du eine Notiz komplett von Null erstellen.")
# Wiederverwendung der Editor-Logik wäre ideal, hier vereinfacht:
c1, c2 = st.columns([1, 2])
n_type = c1.selectbox("Typ", ["concept", "project", "decision", "experience", "value", "goal"])
tags = c2.text_input("Tags")
body = st.text_area("Inhalt", height=400, placeholder="# Titel\n\nText...")
if st.button("Generieren & Download"):
meta = {"type": n_type, "status": "draft", "tags": [t.strip() for t in tags.split(",")]}
doc = build_markdown_doc(meta, body)
st.code(doc, language="markdown")
# --- MAIN ---
mode, top_k, explain = render_sidebar()
if mode == "💬 Chat":
render_chat_interface(top_k, explain)
else:
render_manual_editor()