diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json new file mode 100644 index 0000000..4b5a294 --- /dev/null +++ b/app/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda" +} \ No newline at end of file diff --git a/app/app.py b/app/app.py deleted file mode 100644 index 3e7dfec..0000000 --- a/app/app.py +++ /dev/null @@ -1,121 +0,0 @@ -import streamlit as st -from utils.db import init_db, get_stats, get_meetings -from utils.brain import get_exec_list -from utils.auth import render_sidebar -from utils.theme import inject_css, meeting_card_html, time_group_header -import json -from datetime import date, timedelta - -st.set_page_config( - page_title="Medulla", - page_icon="๐Ÿง ", - layout="wide", - initial_sidebar_state="expanded" -) - -inject_css() -init_db() - -if "current_user" not in st.session_state: - # โ”€โ”€ Centered login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - _, col, _ = st.columns([1, 1.2, 1]) - with col: - st.markdown("

", unsafe_allow_html=True) - import os - if os.path.exists("/app/static/logo.png"): - logo_c, _ = st.columns([1, 4]) - with logo_c: - st.image("/app/static/logo.png", width=56) - st.markdown("## Medulla") - st.caption("Private, local meeting intelligence. No cloud. No data leaves your machine.") - st.markdown("
", unsafe_allow_html=True) - with st.form("login", border=True): - st.subheader("Sign in") - email = st.text_input("Email", placeholder="you@company.com", label_visibility="collapsed") - submitted = st.form_submit_button("Enter", type="primary", use_container_width=True) - if submitted and email: - st.session_state["current_user"] = email.strip().lower() - st.rerun() - elif submitted: - st.warning("Please enter your email.") - -else: - render_sidebar() - user = st.session_state["current_user"] - exec_list = get_exec_list() - - # โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - h_col, btn_col = st.columns([5, 1]) - with h_col: - st.markdown("## Dashboard") - with btn_col: - if st.button("+ Upload Recording", type="primary", use_container_width=True): - st.switch_page("pages/1_transcribe.py") - - # โ”€โ”€ Stats row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - stats = get_stats(user, exec_list) - total_fus = stats["open_fus"] + stats["done_fus"] - completion_pct = int(stats["done_fus"] / total_fus * 100) if total_fus else 0 - - m1, m2, m3, m4 = st.columns(4) - m1.metric("Total Meetings", stats["total_meetings"]) - m2.metric("In Medulla", stats["in_brain"]) - m3.metric("Open Follow-ups", stats["open_fus"]) - m4.metric("Follow-up Completion", f"{completion_pct}%") - - st.markdown("
", unsafe_allow_html=True) - - # โ”€โ”€ Meetings grouped by time โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - meetings = get_meetings(user, exec_list) - if not meetings: - st.info("No meetings yet. Hit **+ Upload Recording** to get started.") - else: - today = date.today() - week_ago = today - timedelta(days=7) - two_weeks_ago = today - timedelta(days=14) - month_ago = today - timedelta(days=30) - - groups = {"This Week": [], "Last Week": [], "Earlier This Month": [], "Older": []} - for m in sorted(meetings, key=lambda x: x.get("date", ""), reverse=True): - try: - d = date.fromisoformat(m["date"]) - except Exception: - groups["Older"].append(m) - continue - if d >= week_ago: - groups["This Week"].append(m) - elif d >= two_weeks_ago: - groups["Last Week"].append(m) - elif d >= month_ago: - groups["Earlier This Month"].append(m) - else: - groups["Older"].append(m) - - for group_name, group_meetings in groups.items(): - if not group_meetings: - continue - st.markdown(time_group_header(f"{group_name} ยท {len(group_meetings)}"), unsafe_allow_html=True) - cols = st.columns(3) - for i, m in enumerate(group_meetings): - participants = json.loads(m["participants"]) if isinstance(m["participants"], str) else m["participants"] - summary = json.loads(m["summary"]) if isinstance(m["summary"], str) else m["summary"] - desc = summary[0][:80] + "โ€ฆ" if summary and summary[0] else "No summary available" - with cols[i % 3]: - st.markdown( - meeting_card_html( - m["title"], desc, m["date"], - len(participants), 0, bool(m.get("in_brain")) - ), - unsafe_allow_html=True - ) - if st.button("Open", key=f"open_{m['id']}", use_container_width=True): - decisions = json.loads(m["decisions"]) if isinstance(m["decisions"], str) else m["decisions"] - st.session_state.update({ - "meeting_id": m["id"], - "transcript": m["transcript"], - "meeting_title": m["title"], - "meeting_date": m["date"], - "participants": participants, - "analysis": {"summary": summary, "decisions": decisions}, - }) - st.switch_page("pages/2_meeting_detail.py") diff --git a/app/pages/0_meetings.py b/app/pages/0_meetings.py deleted file mode 100644 index c9264c2..0000000 --- a/app/pages/0_meetings.py +++ /dev/null @@ -1,43 +0,0 @@ -import streamlit as st -import json -from utils.db import get_meetings -from utils.brain import get_exec_list -from utils.auth import render_sidebar -from utils.theme import inject_css - -st.set_page_config(page_title="Medulla | Past Meetings", layout="wide") -st.title("๐Ÿ“… Past Meetings") - -if "current_user" not in st.session_state: - st.warning("Please log in from the home page first.") - st.stop() - -inject_css() -render_sidebar() -current_user = st.session_state["current_user"] -exec_list = get_exec_list() - -meetings = get_meetings(current_user, exec_list) - -if not meetings: - st.info("No meetings found. Go to **Transcribe Meeting** to add one.") - st.stop() - -for m in sorted(meetings, key=lambda x: x.get("date", ""), reverse=True): - participants = json.loads(m["participants"]) if isinstance(m["participants"], str) else m["participants"] - col_info, col_btn = st.columns([5, 1]) - with col_info: - st.markdown(f"**{m['title']}**") - st.caption(f"{m['date']} ยท {', '.join(participants)}") - with col_btn: - if st.button("View", key=f"view_{m['id']}"): - summary = json.loads(m["summary"]) if isinstance(m["summary"], str) else m["summary"] - decisions = json.loads(m["decisions"]) if isinstance(m["decisions"], str) else m["decisions"] - st.session_state["meeting_id"] = m["id"] - st.session_state["transcript"] = m["transcript"] - st.session_state["meeting_title"] = m["title"] - st.session_state["meeting_date"] = m["date"] - st.session_state["participants"] = participants - st.session_state["analysis"] = {"summary": summary, "decisions": decisions} - st.switch_page("pages/2_meeting_detail.py") - st.divider() diff --git a/app/pages/1_transcribe.py b/app/pages/1_transcribe.py deleted file mode 100644 index 85c2600..0000000 --- a/app/pages/1_transcribe.py +++ /dev/null @@ -1,80 +0,0 @@ -import streamlit as st -import uuid -from datetime import date -from utils.transcribe import transcribe_file -from utils.analyze import analyze_transcript -from utils.db import save_meeting -from utils.auth import render_sidebar -from utils.theme import inject_css - -st.set_page_config(page_title="Medulla | Transcribe", layout="wide") -st.title("๐ŸŽ™๏ธ Transcribe Meeting") - -if "current_user" not in st.session_state: - st.warning("Please log in from the home page first.") - st.stop() - - -def run_pipeline(audio_bytes, filename, title, participants_raw): - meeting_id = str(uuid.uuid4())[:8] - participants = [p.strip().lower() for p in participants_raw.split(",") if p.strip()] - current_user = st.session_state["current_user"] - if current_user not in participants: - participants.append(current_user) - - with st.status("Processing meeting...", expanded=True) as status: - st.write("Transcribing audio with Whisper...") - transcript = transcribe_file(audio_bytes, filename) - st.session_state["transcript"] = transcript - - st.write("Analyzing with Granite...") - analysis = analyze_transcript(transcript) - - st.write("Saving...") - save_meeting(meeting_id, title, str(date.today()), participants, transcript, analysis) - - status.update(label="Done!", state="complete") - - st.session_state["meeting_id"] = meeting_id - st.session_state["meeting_title"] = title - st.session_state["participants"] = participants - st.session_state["analysis"] = analysis - st.session_state["meeting_date"] = str(date.today()) - # clear so Record Now tab can trigger again next time - if "last_audio_id" in st.session_state: - del st.session_state["last_audio_id"] - - st.switch_page("pages/2_meeting_detail.py") - - -tab1, tab2 = st.tabs(["Upload File", "Record Now"]) - -with tab1: - uploaded = st.file_uploader( - "Upload audio or video", - type=["mp3", "mp4", "wav", "m4a", "ogg"] - ) - title = st.text_input("Meeting title", placeholder="Q3 Planning Sync", key="title1") - participants_raw = st.text_input( - "Participants (comma-separated emails)", - placeholder="alice@co.com, bob@co.com", - key="p1" - ) - if uploaded and st.button("Transcribe", type="primary", key="btn1"): - run_pipeline(uploaded.read(), uploaded.name, title or "Untitled Meeting", participants_raw) - -with tab2: - st.info("Join your meeting first (Zoom, Meet, Teams โ€” any). Then hit Record. Stop when the meeting ends โ€” analysis runs automatically.") - audio = st.audio_input("Record meeting audio") - title2 = st.text_input("Meeting title", placeholder="Recorded Meeting", key="title2") - participants_raw2 = st.text_input( - "Participants (comma-separated emails)", - placeholder="alice@co.com, bob@co.com", - key="p2" - ) - - if audio: - audio_id = id(audio) - if st.session_state.get("last_audio_id") != audio_id: - st.session_state["last_audio_id"] = audio_id - run_pipeline(audio.read(), "recording.wav", title2 or "Recorded Meeting", participants_raw2) diff --git a/app/pages/2_meeting_detail.py b/app/pages/2_meeting_detail.py deleted file mode 100644 index 2483c37..0000000 --- a/app/pages/2_meeting_detail.py +++ /dev/null @@ -1,141 +0,0 @@ -import streamlit as st -import json -import os -from utils.db import get_follow_ups, update_follow_up_status, update_follow_up_assignee, share_meeting, set_in_brain, get_meeting -from utils.brain import contribute, share_in_brain, get_exec_list -from utils.auth import render_sidebar -from utils.theme import inject_css - -st.set_page_config(page_title="Medulla | Meeting", layout="wide") - -if "current_user" not in st.session_state: - st.warning("Please log in from the home page first.") - st.stop() - -if "meeting_id" not in st.session_state: - st.warning("No meeting loaded.") - st.page_link("pages/0_meetings.py", label="โ† View Past Meetings") - st.stop() - -inject_css() -render_sidebar() - -current_user = st.session_state["current_user"] -exec_list = get_exec_list() -meeting_id = st.session_state["meeting_id"] - -# Fall back to DB if session_state is incomplete (e.g. after re-login) -if "transcript" not in st.session_state: - row = get_meeting(meeting_id) - if row: - participants_raw = row["participants"] - summary_raw = row["summary"] - decisions_raw = row["decisions"] - st.session_state["transcript"] = row["transcript"] - st.session_state["meeting_title"] = row["title"] - st.session_state["meeting_date"] = row["date"] - st.session_state["participants"] = json.loads(participants_raw) if isinstance(participants_raw, str) else participants_raw - st.session_state["analysis"] = { - "summary": json.loads(summary_raw) if isinstance(summary_raw, str) else summary_raw, - "decisions": json.loads(decisions_raw) if isinstance(decisions_raw, str) else decisions_raw, - } - -transcript = st.session_state.get("transcript", "") -analysis = st.session_state.get("analysis", {}) -participants = st.session_state.get("participants", []) -title = st.session_state.get("meeting_title", "Meeting") -date_str = st.session_state.get("meeting_date", "") - -is_exec = current_user in exec_list -is_owner = current_user in participants - -# Header -col_title, col_badge = st.columns([6, 1]) -with col_title: - st.title(f"๐Ÿ“‹ {title}") - st.caption(f"{date_str} ยท {', '.join(participants)}") -with col_badge: - if is_exec: - st.markdown("๐Ÿ”ด **Executive**") - elif is_owner: - st.markdown("๐Ÿ”ต **Owner**") - else: - st.markdown("โšช **Viewer**") - -st.divider() - -col1, col2, col3 = st.columns([3, 4, 3]) - -with col1: - st.subheader("Transcript") - st.text_area("", transcript, height=500, disabled=True, label_visibility="collapsed") - -with col2: - st.subheader("Summary") - for point in analysis.get("summary", []): - st.markdown(f"- {point}") - - decisions = analysis.get("decisions", []) - if decisions: - st.subheader("Key Decisions") - for d in decisions: - st.info(d) - -with col3: - st.subheader("Follow-ups") - follow_ups = get_follow_ups(current_user, exec_list, meeting_id) - if follow_ups: - for fu in follow_ups: - checked = fu["status"] == "done" - label = fu["task"][:60] + ("..." if len(fu["task"]) > 60 else "") - new_val = st.checkbox(label, value=checked, key=f"fu_{fu['id']}") - if new_val != checked: - update_follow_up_status(fu["id"], "done" if new_val else "open") - st.rerun() - - a_col, d_col = st.columns([3, 2]) - with a_col: - new_assignee = st.text_input( - "Assignee", - value=fu["assignee"], - key=f"assignee_{fu['id']}", - placeholder="name or email", - label_visibility="collapsed" - ) - if new_assignee != fu["assignee"]: - update_follow_up_assignee(fu["id"], new_assignee) - with d_col: - st.caption(f"๐Ÿ“… {fu['due']}") - - with st.expander("Source quote"): - st.markdown(f"> *{fu['source_quote']}*") - else: - st.caption("No follow-ups found.") - -# Owner / exec controls -if is_owner or is_exec: - st.divider() - st.subheader("Medulla") - col_a, col_b = st.columns(2) - - with col_a: - brain_toggle = st.toggle( - "Contribute to Medulla", - help="Makes this meeting searchable by eligible team members" - ) - if brain_toggle and st.button("Save to Medulla", type="primary"): - contribute(meeting_id, transcript, title, date_str, participants) - set_in_brain(meeting_id) - st.success("Added to Medulla!") - - with col_b: - share_input = st.text_input( - "Share with (comma-separated emails)", - placeholder="carol@co.com, dave@co.com", - help="These people will see this meeting in Medulla" - ) - if share_input and st.button("Share Meeting"): - viewer_emails = [e.strip().lower() for e in share_input.split(",") if e.strip()] - share_meeting(meeting_id, viewer_emails) - share_in_brain(meeting_id, viewer_emails) - st.success(f"Shared with {', '.join(viewer_emails)}") diff --git a/app/pages/3_company_brain.py b/app/pages/3_company_brain.py deleted file mode 100644 index 866950f..0000000 --- a/app/pages/3_company_brain.py +++ /dev/null @@ -1,125 +0,0 @@ -import streamlit as st -from utils.brain import query, get_exec_list -from utils.db import get_all_decisions -from utils.auth import render_sidebar -from utils.theme import inject_css - -st.set_page_config(page_title="Medulla", layout="wide") - -if "current_user" not in st.session_state: - st.warning("Please log in from the home page first.") - st.stop() - -inject_css() -render_sidebar() - -current_user = st.session_state["current_user"] -exec_list = get_exec_list() -is_exec = current_user in exec_list - -st.markdown("## Medulla") -if is_exec: - st.caption("Executive access โ€” search across all meetings.") -else: - st.caption("Search meetings you attended or that were shared with you.") - -st.markdown("
", unsafe_allow_html=True) - -tab_ask, tab_decisions, tab_topics = st.tabs(["Ask Anything", "All Decisions", "Explore Topics"]) - -# โ”€โ”€ Tab 1: Ask Anything โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -with tab_ask: - st.markdown("
", unsafe_allow_html=True) - question = st.text_input( - "Ask anything about past meetings", - placeholder="What did we decide about the tech stack?", - label_visibility="collapsed", - key="brain_question", - ) - - if question: - with st.spinner("Searching meetings..."): - result = query(question, current_user) - - st.markdown("**Answer**") - st.markdown(result["answer"]) - - if result["sources"]: - st.divider() - with st.expander("Sources", expanded=True): - for s in result["sources"]: - st.markdown(f"- **{s['title']}** โ€” {s['date']}") - -# โ”€โ”€ Tab 2: All Decisions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -with tab_decisions: - st.markdown("
", unsafe_allow_html=True) - decision_groups = get_all_decisions(current_user, exec_list) - - if not decision_groups: - st.info("No decisions found. Transcribe a meeting to get started.") - else: - total_decisions = sum(len(g["decisions"]) for g in decision_groups) - st.markdown(f"**{total_decisions} decisions** across {len(decision_groups)} meetings") - st.markdown("
", unsafe_allow_html=True) - - for group in decision_groups: - with st.expander(f"**{group['title']}** โ€” {group['date']} ({len(group['decisions'])} decisions)"): - for decision in group["decisions"]: - st.markdown( - f"
{decision}
", - unsafe_allow_html=True, - ) - -# โ”€โ”€ Tab 3: Explore Topics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -with tab_topics: - st.markdown("
", unsafe_allow_html=True) - st.markdown("**Click a topic to explore what was discussed across all meetings.**") - st.markdown("
", unsafe_allow_html=True) - - TOPICS = [ - ("โš™๏ธ Tech Stack", "What technology stack did we choose and why?"), - ("๐Ÿ”’ Privacy & Security", "What privacy or security decisions were made?"), - ("๐ŸŽจ Product & Design", "What product or design decisions were made?"), - ("๐Ÿ“ฃ Marketing & Demo", "What were the key marketing and demo decisions?"), - ("๐Ÿ› Bugs & Fixes", "What bugs were identified and how were they resolved?"), - ("๐Ÿš€ Deployment", "What deployment or infrastructure decisions were made?"), - ("๐Ÿ‘ฅ Team & Roles", "What was decided about team structure and responsibilities?"), - ("๐Ÿ“… Timeline & Milestones", "What timelines or milestones were set?"), - ("๐Ÿ’ก Ideas & Opportunities", "What new ideas or opportunities were discussed?"), - ("๐Ÿ† Wins & Retrospective", "What went well and what lessons did we learn?"), - ] - - if "topic_question" not in st.session_state: - st.session_state["topic_question"] = None - if "topic_answer" not in st.session_state: - st.session_state["topic_answer"] = None - - # Render topic pills as buttons in a 2-column grid - col_a, col_b = st.columns(2) - for idx, (label, question_text) in enumerate(TOPICS): - col = col_a if idx % 2 == 0 else col_b - with col: - if st.button(label, key=f"topic_{idx}", use_container_width=True): - st.session_state["topic_question"] = question_text - st.session_state["topic_answer"] = None - - # Show result below pills - if st.session_state.get("topic_question"): - st.markdown("
", unsafe_allow_html=True) - st.markdown(f"*Searching: {st.session_state['topic_question']}*") - - if st.session_state.get("topic_answer") is None: - with st.spinner("Searching meetings..."): - result = query(st.session_state["topic_question"], current_user) - st.session_state["topic_answer"] = result - - result = st.session_state["topic_answer"] - st.markdown("**Answer**") - st.markdown(result["answer"]) - - if result.get("sources"): - with st.expander("Sources", expanded=False): - for s in result["sources"]: - st.markdown(f"- **{s['title']}** โ€” {s['date']}") diff --git a/app/pages/4_follow_ups.py b/app/pages/4_follow_ups.py deleted file mode 100644 index 7d45a07..0000000 --- a/app/pages/4_follow_ups.py +++ /dev/null @@ -1,73 +0,0 @@ -import streamlit as st -from utils.db import get_follow_ups, update_follow_up_status -from utils.brain import get_exec_list -from utils.auth import render_sidebar -from utils.theme import inject_css - -st.set_page_config(page_title="Medulla | Follow-ups", layout="wide") -st.title("โœ… Follow-ups") - -if "current_user" not in st.session_state: - st.warning("Please log in from the home page first.") - st.stop() - -current_user = st.session_state["current_user"] -exec_list = get_exec_list() -follow_ups = get_follow_ups(current_user, exec_list) - -if not follow_ups: - st.info("No follow-ups yet. Transcribe a meeting to get started.") - st.stop() - -# Velocity summary -total = len(follow_ups) -done = sum(1 for fu in follow_ups if fu["status"] == "done") -pct = int(done / total * 100) if total else 0 -meetings_with_fus = len(set(fu["meeting_id"] for fu in follow_ups)) -st.markdown(f"**{done} of {total} tasks done** across {meetings_with_fus} meetings โ€” {pct}% complete") -st.progress(pct / 100) -st.divider() - -# Filters -col1, col2 = st.columns(2) -with col1: - assignees = sorted(set(fu["assignee"] for fu in follow_ups)) - selected_assignee = st.selectbox("Filter by assignee", ["All"] + assignees) -with col2: - status_filter = st.selectbox("Filter by status", ["All", "open", "done"]) - -filtered = follow_ups -if selected_assignee != "All": - filtered = [fu for fu in filtered if fu["assignee"] == selected_assignee] -if status_filter != "All": - filtered = [fu for fu in filtered if fu["status"] == status_filter] - -open_items = [fu for fu in filtered if fu["status"] == "open"] -done_items = [fu for fu in filtered if fu["status"] == "done"] - -if open_items: - st.subheader(f"Open ({len(open_items)})") - for fu in open_items: - col_check, col_detail = st.columns([1, 11]) - with col_check: - if st.checkbox("", key=f"open_{fu['id']}"): - update_follow_up_status(fu["id"], "done") - st.rerun() - with col_detail: - st.markdown(f"**{fu['task']}**") - st.caption(f"๐Ÿ‘ค {fu['assignee']} ยท ๐Ÿ“… {fu['due']} ยท ๐Ÿ“‹ {fu.get('title', '')}") - -if done_items: - st.divider() - with st.expander(f"Completed ({len(done_items)})"): - for fu in done_items: - col_check, col_detail = st.columns([1, 11]) - with col_check: - if st.checkbox("", value=True, key=f"done_{fu['id']}"): - pass - else: - update_follow_up_status(fu["id"], "open") - st.rerun() - with col_detail: - st.markdown(f"~~{fu['task']}~~") - st.caption(f"๐Ÿ‘ค {fu['assignee']} ยท ๐Ÿ“‹ {fu.get('title', '')}") diff --git a/app/pages/5_admin.py b/app/pages/5_admin.py deleted file mode 100644 index df00d1a..0000000 --- a/app/pages/5_admin.py +++ /dev/null @@ -1,85 +0,0 @@ -import streamlit as st -import json -from utils.db import get_all_people, get_meetings, share_meeting -from utils.brain import get_exec_list, share_in_brain -from utils.auth import render_sidebar -from utils.theme import inject_css - -st.set_page_config(page_title="Medulla | Admin", layout="wide") - -if "current_user" not in st.session_state: - st.warning("Please log in from the home page first.") - st.stop() - -current_user = st.session_state["current_user"] -exec_list = get_exec_list() - -if current_user not in exec_list: - st.error("Access denied. This page is for executives only.") - st.stop() - -inject_css() -render_sidebar() - -st.title("โš™๏ธ Team & Permissions") -st.caption("Executive view โ€” manage who can see what across all meetings.") -st.divider() - -# โ”€โ”€ Team roster โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -st.subheader("Team Roster") -people = get_all_people() - -if people: - col_h1, col_h2, col_h3, col_h4 = st.columns([3, 2, 1, 1]) - col_h1.markdown("**Email**") - col_h2.markdown("**Role**") - col_h3.markdown("**Meetings**") - col_h4.markdown("**Shared with**") - st.markdown("---") - - for email, info in sorted(people.items()): - is_exec_member = email in exec_list - role = "๐Ÿ”ด Executive" if is_exec_member else "๐Ÿ”ต Member" - col1, col2, col3, col4 = st.columns([3, 2, 1, 1]) - col1.markdown(email) - col2.markdown(role) - col3.markdown(str(info["meetings_as_participant"])) - col4.markdown(str(info["meetings_as_viewer"])) -else: - st.info("No team members yet. Transcribe a meeting to get started.") - -st.markdown("") -st.caption("Executive access is configured via EXEC_EMAILS in your .env file.") - -st.divider() - -# โ”€โ”€ Meeting access controls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -st.subheader("Meeting Access") -st.caption("Add or update who can view each meeting in Medulla.") - -meetings = get_meetings(current_user, exec_list) - -if not meetings: - st.info("No meetings yet.") - st.stop() - -for m in sorted(meetings, key=lambda x: x.get("date", ""), reverse=True): - participants = json.loads(m["participants"]) if isinstance(m["participants"], str) else m["participants"] - viewers = json.loads(m["explicit_viewers"]) if isinstance(m["explicit_viewers"], str) else m["explicit_viewers"] - - with st.expander(f"**{m['title']}** โ€” {m['date']}"): - st.caption(f"Participants: {', '.join(participants)}") - - current_viewers = ", ".join(viewers) if viewers else "" - new_viewers_input = st.text_input( - "Shared with (comma-separated emails)", - value=current_viewers, - key=f"viewers_{m['id']}", - placeholder="carol@co.com, dave@co.com", - ) - if st.button("Update access", key=f"save_{m['id']}"): - updated = [e.strip().lower() for e in new_viewers_input.split(",") if e.strip()] - share_meeting(m["id"], updated) - share_in_brain(m["id"], updated) - st.success("Access updated.") - st.rerun() diff --git a/app/routers/brain_route.py b/app/routers/brain_route.py index 9ff40ab..b98695b 100644 --- a/app/routers/brain_route.py +++ b/app/routers/brain_route.py @@ -1,18 +1,64 @@ -from fastapi import APIRouter -from schema import User +from fastapi import APIRouter, WebSocket +from fastapi.websockets import WebSocketDisconnect from services import chat_agent -from pydantic import BaseModel +from .route_schemas.ChatRequest import ChatRequest from fastapi.responses import JSONResponse import logging import traceback +from uuid_utils import uuid4 router = APIRouter() +chatHistory = {} # In-memory chat history, can be replaced with a database in the future -class ChatRequest(BaseModel): - user_query: str - current_user: User +@router.websocket("/ws/chat") +async def websocket_chat(websocket: WebSocket): + await websocket.accept() + auth = await websocket.receive_json() + if auth.get("type") != "auth" or not auth.get("user_id"): + await websocket.close(code=1008) + return + + user_id = auth.get("user_id") + is_admin = auth.get("is_admin", False) + access_level = 1 if is_admin else 0 + + chatId = str(uuid4()) + await websocket.send_json({ + "type": "welcome", + "chatId": chatId, + }) + + chatHistory[chatId] = [] # Initialize chat history for this session + + try: + while True: + data = await websocket.receive_json() + user_query = data.get("user_query") + chat_response = chat_agent(user_query, access_level, chatHistory[chatId]) + chatHistory[chatId].append( + { + "role": "user", + "content": user_query + } + ) + chatHistory[chatId].append( + { + "role": "agent", + "content": chat_response["response"] + } + ) + await websocket.send_json({ + "role": "agent", + **chat_response + }) + except WebSocketDisconnect: + logging.info(f"Client disconnected from /ws/chat (chatId={chatId})") + except Exception as e: + logging.exception("Error in /ws/chat") + tb = traceback.format_exc() + await websocket.send_json({"error": str(e), "trace": tb[:2000]}) @router.post("/chat") def read_brain(chat_request: ChatRequest): diff --git a/app/routers/route_schemas/ChatRequest.py b/app/routers/route_schemas/ChatRequest.py new file mode 100644 index 0000000..1827bcd --- /dev/null +++ b/app/routers/route_schemas/ChatRequest.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + +class ChatRequest(BaseModel): + user_query: str + \ No newline at end of file diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 6249df3..f9ae375 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -3,7 +3,7 @@ from langchain_core.prompts import PromptTemplate from langchain_core.output_parsers import StrOutputParser -def chat(user_input: str, access_level: int) -> dict: +def chat(user_input: str, access_level: int, history: list[dict]) -> dict: # Step 1: Use the expert search to get relevant document chunks based on the user input relevant_chunks = query.expert_search(user_input, access_level) @@ -13,6 +13,8 @@ def chat(user_input: str, access_level: int) -> dict: # Step 3: Create a prompt for the LLM that includes the user input and the retrieved context prompt = PromptTemplate.from_template(""" You are an assistant that helps answer questions based on provided context. Use the following context to answer the question below. If the context does not contain enough information, say you don't know. + history: + {history} Context: {context} @@ -23,11 +25,16 @@ def chat(user_input: str, access_level: int) -> dict: ) # Step 4: Call the LLM with the prompt to get an answer + formatted_history = "\n".join( + f"{msg['role'].capitalize()}: {msg['content']}" for msg in history + ) + chain = prompt | llm | StrOutputParser() - llm_response = chain.invoke({"context": context, "user_input": user_input}) + llm_response = chain.invoke({"context": context, "user_input": user_input, "history": formatted_history}) # Step 5: Return the LLM's response as a dictionary return { "response": llm_response, - "context_used": context + "context_used": context, + "history": history } \ No newline at end of file diff --git a/app/utils/analyze.py b/app/utils/analyze.py deleted file mode 100644 index 8f031cc..0000000 --- a/app/utils/analyze.py +++ /dev/null @@ -1,40 +0,0 @@ -from langchain_openai import ChatOpenAI -from langchain_core.prompts import PromptTemplate -from langchain_core.output_parsers import StrOutputParser -from json_repair import repair_json -import json -import os - -LLM_ENDPOINT = os.getenv("LLM_ENDPOINT", "http://host.containers.internal:12434/v1") -LLM_MODEL = os.getenv("LLM_MODEL", "granite-4.0-micro") - -ANALYSIS_PROMPT = PromptTemplate.from_template("""You are an executive assistant. Analyze this meeting transcript carefully. -Return ONLY valid JSON โ€” no markdown, no explanation, no preamble. - -Transcript: -{transcript} - -Return this exact JSON structure: -{{ - "summary": ["3 to 5 concise bullet points about what was discussed"], - "decisions": ["list of concrete decisions made, empty array if none"], - "follow_ups": [ - {{ - "task": "clear description of the action item", - "assignee": "name or email of person responsible, or Unknown", - "due": "due date if mentioned, or TBD", - "source_quote": "short exact quote from transcript that generated this task" - }} - ] -}}""") - - -def analyze_transcript(transcript: str) -> dict: - llm = ChatOpenAI(model=LLM_MODEL, base_url=LLM_ENDPOINT, api_key="podman-ai-lab") - chain = ANALYSIS_PROMPT | llm | StrOutputParser() - result = chain.invoke({"transcript": transcript}) - - start = result.find("{") - if start == -1: - return {"summary": [result[:300]], "decisions": [], "follow_ups": []} - return json.loads(repair_json(result[start:])) diff --git a/app/utils/auth.py b/app/utils/auth.py deleted file mode 100644 index 303c7f2..0000000 --- a/app/utils/auth.py +++ /dev/null @@ -1,68 +0,0 @@ -import os -import streamlit as st -from utils.brain import get_exec_list - -LOGO_PATH = "/app/static/logo.png" - - -def render_sidebar(): - current_user = st.session_state.get("current_user", "") - exec_list = get_exec_list() - is_exec = current_user in exec_list - initial = current_user[0].upper() if current_user else "?" - role_label = "Executive" if is_exec else "Member" - role_bg = "#FEE2E2" if is_exec else "#EBF0E6" - role_color = "#991B1B" if is_exec else "#3D5229" - - with st.sidebar: - # โ”€โ”€ Logo + Brand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if os.path.exists(LOGO_PATH): - st.image(LOGO_PATH, width=72) - else: - st.markdown("
๐Ÿง 
", unsafe_allow_html=True) - st.markdown( - "
Medulla
", - unsafe_allow_html=True, - ) - - st.markdown("
", unsafe_allow_html=True) - st.divider() - - # โ”€โ”€ Nav โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - st.page_link("app.py", label="๐Ÿ  Dashboard", use_container_width=True) - st.page_link("pages/0_meetings.py", label="๐Ÿ“… Past Meetings", use_container_width=True) - st.page_link("pages/1_transcribe.py", label="๐ŸŽ™๏ธ Transcribe", use_container_width=True) - st.page_link("pages/3_company_brain.py", label="๐Ÿง  Medulla", use_container_width=True) - st.page_link("pages/4_follow_ups.py", label="โœ… Follow-ups", use_container_width=True) - if is_exec: - st.page_link("pages/5_admin.py", label="โš™๏ธ Team & Permissions", use_container_width=True) - - # โ”€โ”€ User profile at bottom โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - st.markdown("
", unsafe_allow_html=True) - st.divider() - st.markdown( - f""" -
-
- {initial} -
-
-
- {current_user} -
- - {role_label} - -
-
- """, - unsafe_allow_html=True, - ) - if st.button("Log out", use_container_width=True): - del st.session_state["current_user"] - st.switch_page("app.py") diff --git a/app/utils/brain.py b/app/utils/brain.py deleted file mode 100644 index c44e864..0000000 --- a/app/utils/brain.py +++ /dev/null @@ -1,121 +0,0 @@ -import chromadb -import os -import json -from langchain_openai import ChatOpenAI -from langchain_core.output_parsers import StrOutputParser - -CHROMA_HOST = os.getenv("CHROMA_HOST", "chromadb") -CHROMA_PORT = int(os.getenv("CHROMA_PORT", 8002)) -LLM_ENDPOINT = os.getenv("LLM_ENDPOINT", "http://host.containers.internal:12434/v1") -LLM_MODEL = os.getenv("LLM_MODEL", "granite-4.0-micro") - - -def get_exec_list(): - return [e.strip().lower() for e in os.getenv("EXEC_EMAILS", "").split(",") if e.strip()] - - -def get_collection(): - client = chromadb.HttpClient(host=CHROMA_HOST, port=CHROMA_PORT) - return client.get_or_create_collection("company_brain") - - -def contribute(meeting_id: str, transcript: str, title: str, date: str, participants: list): - collection = get_collection() - - chunks = [] - step, size = 350, 400 - for i in range(0, len(transcript), step): - chunk = transcript[i:i + size].strip() - if chunk: - chunks.append(chunk) - - if not chunks: - return - - # Avoid re-adding chunks that already exist - try: - existing = collection.get(where={"meeting_id": meeting_id}) - existing_ids = set(existing["ids"]) - except Exception: - existing_ids = set() - - new_ids, new_chunks, new_metas = [], [], [] - for i, chunk in enumerate(chunks): - chunk_id = f"{meeting_id}_chunk_{i}" - if chunk_id not in existing_ids: - new_ids.append(chunk_id) - new_chunks.append(chunk) - new_metas.append({ - "meeting_id": meeting_id, - "title": title, - "date": date, - "participants": json.dumps(participants), - "explicit_viewers": json.dumps([]), - "chunk_index": i - }) - - if new_ids: - collection.add(documents=new_chunks, ids=new_ids, metadatas=new_metas) - - -def share_in_brain(meeting_id: str, viewer_emails: list): - collection = get_collection() - try: - existing = collection.get(where={"meeting_id": meeting_id}) - if existing["ids"]: - updated_metas = [] - for meta in existing["metadatas"]: - updated = dict(meta) - updated["explicit_viewers"] = json.dumps(viewer_emails) - updated_metas.append(updated) - collection.update(ids=existing["ids"], metadatas=updated_metas) - except Exception: - pass - - -def query(question: str, current_user: str) -> dict: - collection = get_collection() - exec_list = get_exec_list() - - try: - if current_user.lower() in exec_list: - results = collection.query(query_texts=[question], n_results=3) - else: - results = collection.query( - query_texts=[question], - n_results=3, - where={"$or": [ - {"participants": {"$contains": current_user}}, - {"explicit_viewers": {"$contains": current_user}} - ]} - ) - # If no results matched the filter, fall back to all contributed meetings - if not results.get("documents", [[]])[0]: - results = collection.query(query_texts=[question], n_results=3) - except Exception: - return {"answer": "No meetings in Medulla yet.", "sources": []} - - docs = results.get("documents", [[]])[0] - metas = results.get("metadatas", [[]])[0] - - if not docs: - return {"answer": "Not found in available meetings.", "sources": []} - - context = "\n\n".join( - f"[{m.get('title', 'Unknown')}]: {doc}" - for doc, m in zip(docs, metas) - ) - - prompt = f"""Meeting notes: -{context} - -Answer in 1-3 sentences, citing the meeting name in parentheses: {question}""" - - llm = ChatOpenAI(model=LLM_MODEL, base_url=LLM_ENDPOINT, api_key="podman-ai-lab") - answer = (llm | StrOutputParser()).invoke(prompt) - - sources = [ - {"title": m.get("title", "Unknown"), "date": m.get("date", "")} - for m in metas - ] - return {"answer": answer, "sources": sources} diff --git a/app/utils/db.py b/app/utils/db.py deleted file mode 100644 index a9fea62..0000000 --- a/app/utils/db.py +++ /dev/null @@ -1,211 +0,0 @@ -import sqlite3 -import os -import json - -DB_PATH = os.getenv("DB_PATH", "/app/db/meetings.db") - - -def get_conn(): - os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def init_db(): - conn = get_conn() - conn.executescript(""" - CREATE TABLE IF NOT EXISTS meetings ( - id TEXT PRIMARY KEY, - title TEXT, - date TEXT, - participants TEXT, - transcript TEXT, - summary TEXT, - decisions TEXT, - in_brain INTEGER DEFAULT 0, - explicit_viewers TEXT DEFAULT '[]' - ); - - CREATE TABLE IF NOT EXISTS follow_ups ( - id TEXT PRIMARY KEY, - meeting_id TEXT, - task TEXT, - assignee TEXT, - due TEXT, - source_quote TEXT, - status TEXT DEFAULT 'open', - FOREIGN KEY (meeting_id) REFERENCES meetings(id) - ); - """) - conn.commit() - conn.close() - - -def save_meeting(meeting_id, title, date, participants, transcript, analysis): - conn = get_conn() - conn.execute( - """INSERT OR REPLACE INTO meetings - (id, title, date, participants, transcript, summary, decisions) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ( - meeting_id, title, date, - json.dumps(participants), - transcript, - json.dumps(analysis.get("summary", [])), - json.dumps(analysis.get("decisions", [])) - ) - ) - for i, fu in enumerate(analysis.get("follow_ups", [])): - conn.execute( - """INSERT OR REPLACE INTO follow_ups - (id, meeting_id, task, assignee, due, source_quote) - VALUES (?, ?, ?, ?, ?, ?)""", - ( - f"{meeting_id}_fu_{i}", meeting_id, - fu.get("task", ""), - fu.get("assignee", "Unknown"), - fu.get("due", "TBD"), - fu.get("source_quote", "") - ) - ) - conn.commit() - conn.close() - - -def get_meetings(user_email, exec_list): - conn = get_conn() - rows = conn.execute("SELECT * FROM meetings").fetchall() - conn.close() - result = [] - for row in rows: - m = dict(row) - participants = json.loads(m["participants"]) - viewers = json.loads(m["explicit_viewers"]) - if user_email in exec_list or user_email in participants or user_email in viewers: - result.append(m) - return result - - -def get_meeting(meeting_id): - conn = get_conn() - row = conn.execute("SELECT * FROM meetings WHERE id = ?", (meeting_id,)).fetchone() - conn.close() - return dict(row) if row else None - - -def get_follow_ups(user_email, exec_list, meeting_id=None): - conn = get_conn() - if meeting_id: - rows = conn.execute( - """SELECT f.*, m.title, m.participants, m.explicit_viewers - FROM follow_ups f JOIN meetings m ON f.meeting_id = m.id - WHERE f.meeting_id = ?""", - (meeting_id,) - ).fetchall() - else: - rows = conn.execute( - """SELECT f.*, m.title, m.participants, m.explicit_viewers - FROM follow_ups f JOIN meetings m ON f.meeting_id = m.id""" - ).fetchall() - conn.close() - result = [] - for row in rows: - r = dict(row) - participants = json.loads(r["participants"]) - viewers = json.loads(r["explicit_viewers"]) - can_see = (user_email in exec_list - or user_email in participants - or user_email in viewers) - is_assignee = r["assignee"].lower() == user_email.lower() - if can_see or is_assignee: - result.append(r) - return result - - -def update_follow_up_status(fu_id, status): - conn = get_conn() - conn.execute("UPDATE follow_ups SET status = ? WHERE id = ?", (status, fu_id)) - conn.commit() - conn.close() - - -def update_follow_up_assignee(fu_id, assignee): - conn = get_conn() - conn.execute("UPDATE follow_ups SET assignee = ? WHERE id = ?", (assignee, fu_id)) - conn.commit() - conn.close() - - -def share_meeting(meeting_id, viewer_emails): - conn = get_conn() - conn.execute( - "UPDATE meetings SET explicit_viewers = ? WHERE id = ?", - (json.dumps(viewer_emails), meeting_id) - ) - conn.commit() - conn.close() - - -def get_stats(user_email, exec_list): - conn = get_conn() - meetings = conn.execute("SELECT * FROM meetings").fetchall() - follow_ups = conn.execute( - "SELECT f.status FROM follow_ups f JOIN meetings m ON f.meeting_id = m.id" - ).fetchall() - conn.close() - - accessible = [] - for row in meetings: - m = dict(row) - participants = json.loads(m["participants"]) - viewers = json.loads(m["explicit_viewers"]) - if user_email in exec_list or user_email in participants or user_email in viewers: - accessible.append(m) - - total = len(accessible) - in_brain = sum(1 for m in accessible if m["in_brain"]) - open_fus = sum(1 for fu in follow_ups if dict(fu)["status"] == "open") - done_fus = sum(1 for fu in follow_ups if dict(fu)["status"] == "done") - return {"total_meetings": total, "in_brain": in_brain, "open_fus": open_fus, "done_fus": done_fus} - - -def get_all_people(): - """Return all unique emails across all meetings with their meeting counts.""" - conn = get_conn() - rows = conn.execute("SELECT id, participants, explicit_viewers FROM meetings").fetchall() - conn.close() - people = {} - for row in rows: - for email in json.loads(row["participants"]): - e = email.lower() - people.setdefault(e, {"meetings_as_participant": 0, "meetings_as_viewer": 0}) - people[e]["meetings_as_participant"] += 1 - for email in json.loads(row["explicit_viewers"]): - e = email.lower() - people.setdefault(e, {"meetings_as_participant": 0, "meetings_as_viewer": 0}) - people[e]["meetings_as_viewer"] += 1 - return people - - -def get_all_decisions(user_email, exec_list): - """Return decisions grouped by meeting for accessible meetings.""" - meetings = get_meetings(user_email, exec_list) - result = [] - for m in sorted(meetings, key=lambda x: x.get("date", ""), reverse=True): - decisions = json.loads(m["decisions"]) if isinstance(m["decisions"], str) else m["decisions"] - if decisions: - result.append({ - "meeting_id": m["id"], - "title": m["title"], - "date": m["date"], - "decisions": decisions, - }) - return result - - -def set_in_brain(meeting_id): - conn = get_conn() - conn.execute("UPDATE meetings SET in_brain = 1 WHERE id = ?", (meeting_id,)) - conn.commit() - conn.close() diff --git a/app/utils/theme.py b/app/utils/theme.py deleted file mode 100644 index 52386b0..0000000 --- a/app/utils/theme.py +++ /dev/null @@ -1,194 +0,0 @@ -import streamlit as st - - -OLIVE = "#5B6B4A" -OLIVE_LIGHT = "#EBF0E6" -CREAM = "#F5F4EE" -WHITE = "#FFFFFF" -BORDER = "#E8E5DF" -TEXT = "#1A1A1A" -TEXT_MUTED = "#6B7280" - - -def inject_css(): - st.markdown( - """ - - """, - unsafe_allow_html=True, - ) - - -def meeting_card_html(title, description, date_str, participant_count, fu_count, in_brain=False): - import html - t = html.escape(str(title)) - d = html.escape(str(description)) - brain_badge = ( - '' - '๐Ÿง  In Medulla' - ) if in_brain else "" - return ( - f'
' - f'

{t}

' - f'

{d}

' - f'

' - f'๐Ÿ“… {date_str}  ' - f'๐Ÿ‘ค {participant_count}  ' - f'โœ“ {fu_count}' - f'{brain_badge}' - f'

' - f'
' - ) - - -def time_group_header(label): - return f'

{label}

' diff --git a/app/utils/transcribe.py b/app/utils/transcribe.py deleted file mode 100644 index 034aa9b..0000000 --- a/app/utils/transcribe.py +++ /dev/null @@ -1,40 +0,0 @@ -import httpx -import subprocess -import os - -WHISPER_ENDPOINT = os.getenv("WHISPER_ENDPOINT", "http://whisper:8001/inference") -UPLOADS_DIR = os.getenv("UPLOADS_DIR", "/app/uploads") - - -def convert_to_wav(input_path: str) -> str: - output_path = input_path.rsplit(".", 1)[0] + "_converted.wav" - subprocess.run( - ["ffmpeg", "-i", input_path, - "-ar", "16000", "-ac", "1", - "-c:a", "pcm_s16le", output_path, - "-y", "-loglevel", "error"], - check=True - ) - return output_path - - -def transcribe_file(audio_bytes: bytes, filename: str) -> str: - os.makedirs(UPLOADS_DIR, exist_ok=True) - raw_path = os.path.join(UPLOADS_DIR, filename) - - with open(raw_path, "wb") as f: - f.write(audio_bytes) - - wav_path = raw_path if filename.endswith(".wav") else convert_to_wav(raw_path) - - with open(wav_path, "rb") as f: - response = httpx.post( - WHISPER_ENDPOINT, - files={"file": (os.path.basename(wav_path), f, "audio/wav")}, - data={"response_format": "verbose_json"}, - timeout=300.0 - ) - response.raise_for_status() - - result = response.json() - return result.get("text", "") if isinstance(result, dict) else str(result) diff --git a/db/meetings.db b/db/meetings.db new file mode 100644 index 0000000..766c515 Binary files /dev/null and b/db/meetings.db differ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes โ€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..122a2d2 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Stage 1: Install dependencies +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# Stage 2: Build +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# Stage 3: Production runner +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..bf5a6f8 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,224 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&family=DM+Mono:wght@300;400;500&display=swap"); + +/* โ”€โ”€ Design Tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +:root { + --font-sans: "DM Sans", sans-serif; + --font-mono: "DM Mono", monospace; + + /* Spacing scale */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + + /* Radii */ + --r-sm: 8px; + --r-md: 14px; + --r-lg: 20px; + + /* Motion */ + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --duration-slide: 480ms; + --duration-fade: 300ms; + + /* Sidebar width */ + --sidebar-w: 220px; + --stats-w: 200px; +} + +/* โ”€โ”€ Light Mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +:root { + --bg: #f5f4f1; + --surface: #ffffff; + --surface-2: #f0eeea; + --surface-3: #e8e6e1; + --border: rgba(0, 0, 0, 0.08); + --border-strong: rgba(0, 0, 0, 0.14); + --text-primary: #1a1917; + --text-secondary: #6b6760; + --text-muted: #a09d98; + --accent: #2563eb; + --accent-subtle: #eff4ff; + --accent-text: #1d4ed8; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.04); + --chat-user-bg: #2563eb; + --chat-user-text: #ffffff; + --chat-ai-bg: #f0eeea; + --chat-ai-text: #1a1917; +} + +/* โ”€โ”€ Dark Mode (system) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #111110; + --surface: #1c1b19; + --surface-2: #242320; + --surface-3: #2e2c29; + --border: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.12); + --text-primary: #f0ede8; + --text-secondary: #9e9b96; + --text-muted: #5c5a56; + --accent: #3b82f6; + --accent-subtle: #1e2d4a; + --accent-text: #60a5fa; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5), 0 4px 8px rgba(0, 0, 0, 0.2); + --chat-user-bg: #3b82f6; + --chat-user-text: #ffffff; + --chat-ai-bg: #242320; + --chat-ai-text: #f0ede8; + } +} + +/* โ”€โ”€ Reset & Base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + font-family: var(--font-sans); + background: var(--bg); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + font-size: 14px; + line-height: 1.5; +} + +/* โ”€โ”€ Dashboard Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.dashboard-root { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr var(--stats-w); + grid-template-rows: 1fr; + height: 100vh; + overflow: hidden; + gap: var(--sp-4); + padding: var(--sp-4); + transition: grid-template-columns var(--duration-slide) var(--ease-out-expo); +} + +/* When chat is expanded, collapse sidebar and stats panel columns to 0 */ +.dashboard-root.chat-expanded { + grid-template-columns: 0px 1fr 0px; +} + +.dashboard-main { + display: flex; + flex-direction: column; + gap: var(--sp-4); + /* Must be a fixed height so flex children can grow into it */ + height: calc(100vh - var(--sp-4) * 2); + overflow: hidden; + min-width: 0; +} + +/* โ”€โ”€ Sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.sidebar-wrapper { + overflow: hidden; + transition: + opacity var(--duration-fade) ease, + transform var(--duration-slide) var(--ease-out-expo); +} + +.sidebar-wrapper.hidden { + opacity: 0; + pointer-events: none; + transform: translateX(-16px); +} + +/* โ”€โ”€ Chat Region โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.chat-region { + /* Default: shrink to content, let lower-region take remaining space */ + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; + transition: + flex var(--duration-slide) var(--ease-out-expo), + max-height var(--duration-slide) var(--ease-out-expo); + /* Cap collapsed height at 60% so meetings always show below */ + max-height: 60vh; +} + +.chat-region.expanded { + /* Take ALL available vertical space in dashboard-main */ + flex: 1 1 auto; + max-height: 100%; + /* Give an explicit height so chatBox height:100% resolves correctly */ + min-height: 0; +} + +/* โ”€โ”€ Lower Region (meetings grid) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.lower-region { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; + transition: + opacity var(--duration-fade) ease, + transform var(--duration-slide) var(--ease-out-expo), + max-height var(--duration-slide) var(--ease-out-expo); +} + +.lower-region.hidden { + flex: 0 0 0px; + max-height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transform: translateY(16px); +} + +/* โ”€โ”€ Stats Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.stats-panel { + overflow: hidden; + transition: + opacity var(--duration-fade) ease, + transform var(--duration-slide) var(--ease-out-expo); +} + +.stats-panel.hidden { + opacity: 0; + pointer-events: none; + transform: translateX(24px); +} + +/* โ”€โ”€ Scrollbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +* { + scrollbar-width: thin; + scrollbar-color: var(--surface-3) transparent; +} +::-webkit-scrollbar { + width: 4px; + height: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--surface-3); + border-radius: 99px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} +::-webkit-scrollbar-button { + display: none; + height: 0; + width: 0; +} +::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..976eb90 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..6b753fd --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import Sidebar from "@/components/SideBar/Sidebar"; +import StatsPanel from "@/components/StatsPanel"; +import MeetingGrid from "@/components/MeetingGrid"; +import ChatBox from "@/components/ChatBox"; + +export default function DashboardPage() { + const [isChatExpanded, setIsChatExpanded] = useState(false); + + return ( +
+ {/* Sidebar โ€” slides out when chat expands */} +
+ +
+ + {/* Main column */} +
+ {/* Chat โ€” grows to fill main when expanded */} +
+ setIsChatExpanded(true)} + onCollapse={() => setIsChatExpanded(false)} + /> +
+ + {/* Meetings โ€” hidden when chat is expanded */} +
+ +
+
+ + {/* Stats โ€” slides out when chat expands */} + +
+ ); +} diff --git a/frontend/components/ChatBox.module.css b/frontend/components/ChatBox.module.css new file mode 100644 index 0000000..f3c3544 --- /dev/null +++ b/frontend/components/ChatBox.module.css @@ -0,0 +1,292 @@ +.chatBox { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + overflow: hidden; + /* Content-driven height in compact mode โ€” avoids the input bar + hanging outside the clipped parent during the collapse animation. */ + transition: box-shadow var(--duration-slide) var(--ease-out-expo); +} + +.chatBox.expanded { + /* Fill the full chat-region height when going full-screen */ + height: 100%; + box-shadow: var(--shadow-md); +} + +/* โ”€โ”€ Message Area โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.messagesArea { + flex: 1; + overflow-y: auto; + padding: var(--sp-6) var(--sp-6) var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-4); + animation: fadeSlideIn var(--duration-fade) var(--ease-out-expo) both; +} + +/* Compact (not full-screen): cap height so meetings grid stays visible below */ +.messagesAreaCollapsed { + flex: none; + max-height: calc(60vh - 110px); +} + +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Empty state fill โ€” shown whenever there are no messages */ +.emptyStateFill { + display: flex; + align-items: center; + justify-content: center; +} + +/* When expanded, stretch to push input bar to the bottom */ +.chatBox.expanded .emptyStateFill { + flex: 1; +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-10) 0; +} + +.emptyTitle { + font-size: 16px; + font-weight: 500; + color: var(--text-primary); +} + +.emptyHint { + font-size: 13px; + color: var(--text-muted); +} + +/* โ”€โ”€ Messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.message { + display: flex; + align-items: flex-start; + gap: var(--sp-3); + animation: msgIn 200ms var(--ease-out-expo) both; +} + +@keyframes msgIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.userMessage { + flex-direction: row-reverse; +} + +.aiAvatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--accent-subtle); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--accent-text); + margin-top: 2px; +} + +.messageBubble { + max-width: 72%; + padding: var(--sp-3) var(--sp-4); + border-radius: var(--r-md); + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; +} + +.userMessage .messageBubble { + background: var(--chat-user-bg); + color: var(--chat-user-text); + border-bottom-right-radius: var(--r-sm); +} + +.aiMessage .messageBubble { + background: var(--chat-ai-bg); + color: var(--chat-ai-text); + border-bottom-left-radius: var(--r-sm); +} + +/* โ”€โ”€ Typing Indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.typingDots { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.typingDots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--text-muted); + animation: dot 1.2s infinite ease-in-out; +} + +.typingDots span:nth-child(2) { + animation-delay: 0.2s; +} +.typingDots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes dot { + 0%, + 60%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 30% { + opacity: 1; + transform: scale(1); + } +} + +/* โ”€โ”€ Input Area โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.inputArea { + padding: var(--sp-3) var(--sp-4) var(--sp-3); + display: flex; + flex-direction: column; + gap: var(--sp-2); + /* No border-top in compact mode โ€” matches HTML .chat:not(.open) .ia */ +} + +.inputAreaExpanded { + border-top: 1px solid var(--border); +} + +/* โ”€โ”€ Top action row (expand / collapse buttons) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.iatop { + display: flex; + align-items: center; +} + +.icbtn { + width: 26px; + height: 26px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-sm); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); + transition: + background 160ms ease, + color 160ms ease; + flex-shrink: 0; +} + +.icbtn:hover { + background: var(--surface-3); + color: var(--text-primary); +} + +.inputWrapper { + display: flex; + align-items: flex-end; + gap: var(--sp-2); + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-4); + transition: + border-color 160ms ease, + box-shadow 160ms ease; +} + +.inputWrapper:focus-within { + border-color: var(--border-strong); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.textarea { + flex: 1; + background: transparent; + border: none; + outline: none; + resize: none; + font-family: var(--font-sans); + font-size: 14px; + color: var(--text-primary); + line-height: 1.5; + padding: var(--sp-1) 0; + min-height: 24px; + max-height: 160px; +} + +.textarea::placeholder { + color: var(--text-muted); +} + +.sendBtn { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: var(--r-sm); + background: var(--surface-3); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-muted); + transition: + background 160ms ease, + color 160ms ease, + border-color 160ms ease; +} + +.sendBtn.sendActive { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.sendBtn:disabled { + cursor: default; +} + +.hint { + font-size: 11px; + color: var(--text-muted); + padding: 0 var(--sp-1); +} + +.kbdKey { + font-family: var(--font-mono); + font-size: 10px; + background: var(--surface-3); + border: 1px solid var(--border-strong); + border-radius: 4px; + padding: 1px 4px; + color: var(--text-secondary); +} diff --git a/frontend/components/ChatBox.tsx b/frontend/components/ChatBox.tsx new file mode 100644 index 0000000..b5bc68f --- /dev/null +++ b/frontend/components/ChatBox.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useState, useRef, useEffect, KeyboardEvent } from "react"; +import styles from "./ChatBox.module.css"; + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; +} + +interface ChatBoxProps { + isExpanded: boolean; + onExpand: () => void; + onCollapse: () => void; +} + +// Mock streaming response โ€” replace with your real backend call +async function fetchLLMResponse( + query: string, + onChunk: (chunk: string) => void, +): Promise { + const responses = [ + "Based on your recent meetings, ", + "I can see that the team discussed ", + "several key action items. ", + "The main follow-ups include: reviewing the Q2 roadmap, ", + "scheduling a design review with the product team, ", + "and sending the proposal to the client by Friday.", + ]; + for (const chunk of responses) { + await new Promise((r) => setTimeout(r, 80 + Math.random() * 120)); + onChunk(chunk); + } +} + +export default function ChatBox({ + isExpanded, + onExpand, + onCollapse, +}: ChatBoxProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const textareaRef = useRef(null); + const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const hasMessages = messages.length > 0; + + // Auto-resize textarea + useEffect(() => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = Math.min(ta.scrollHeight, 160) + "px"; + }, [input]); + + // Scroll to bottom on new message + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Focus textarea when expanded + useEffect(() => { + if (isExpanded) { + setTimeout(() => textareaRef.current?.focus(), 100); + } + }, [isExpanded]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleSend = async () => { + const trimmed = input.trim(); + if (!trimmed || isStreaming) return; + + if (!isExpanded) onExpand(); + + const userMsg: Message = { + id: crypto.randomUUID(), + role: "user", + content: trimmed, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMsg]); + setInput(""); + setIsStreaming(true); + + // Placeholder assistant message for streaming + const assistantId = crypto.randomUUID(); + setMessages((prev) => [ + ...prev, + { + id: assistantId, + role: "assistant", + content: "", + timestamp: new Date(), + }, + ]); + + await fetchLLMResponse(trimmed, (chunk) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, content: m.content + chunk } : m, + ), + ); + }); + + setIsStreaming(false); + }; + + return ( +
+ {/* Message history โ€” shown whenever messages exist */} + {hasMessages && ( +
+ {messages.map((msg) => ( +
+ {msg.role === "assistant" && ( +
+ + + +
+ )} +
+ {msg.content} + {msg.role === "assistant" && + isStreaming && + msg.content === "" && ( + + + + + + )} +
+
+ ))} +
+
+ )} + + {/* Empty state โ€” shown whenever there are no messages */} + {!hasMessages && ( +
+
+

+ Ask anything about your meetings +

+

+ Summaries, follow-ups, decisions, action itemsโ€ฆ +

+
+
+ )} + + {/* Input bar */} +
+ {/* Top action row: expand โ†” collapse โ€” only when there are messages */} + {hasMessages && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} + +
+