New front-end Launch Chat API Manage Sources Enable re-embedding of all contents Sources can be added without a notebook now Improved settings Enable model selector on all chats Background processing for better experience Dark mode Improved Notes Improved Docs: - Remove all Streamlit references from documentation - Update deployment guides with React frontend setup - Fix Docker environment variables format (SURREAL_URL, SURREAL_PASSWORD) - Update docker image tag from :latest to :v1-latest - Change navigation references (Settings → Models to just Models) - Update development setup to include frontend npm commands - Add MIGRATION.md guide for users upgrading from Streamlit - Update quick-start guide with correct environment variables - Add port 5055 documentation for API access - Update project structure to reflect frontend/ directory - Remove outdated source-chat documentation files
257 lines
11 KiB
Python
257 lines
11 KiB
Python
import asyncio
|
||
|
||
import humanize
|
||
import streamlit as st
|
||
from loguru import logger
|
||
|
||
from api.chat_service import chat_service
|
||
from api.episode_profiles_service import episode_profiles_service
|
||
from api.podcast_service import PodcastService
|
||
|
||
# from open_notebook.plugins.podcasts import PodcastConfig
|
||
from open_notebook.utils import parse_thinking_content, token_count
|
||
from pages.stream_app.utils import (
|
||
convert_source_references,
|
||
create_session_for_notebook,
|
||
)
|
||
|
||
from .note import make_note_from_chat
|
||
|
||
|
||
# todo: build a smarter, more robust context manager function
|
||
async def build_context(notebook_id):
|
||
# Convert context_config format for API
|
||
context_config: dict[str, dict[str, str]] = {"sources": {}, "notes": {}}
|
||
|
||
for id, status in st.session_state[notebook_id]["context_config"].items():
|
||
if not id:
|
||
continue
|
||
|
||
item_type, item_id = id.split(":")
|
||
if item_type not in ["note", "source"]:
|
||
continue
|
||
|
||
if item_type == "source":
|
||
context_config["sources"][item_id] = status
|
||
elif item_type == "note":
|
||
context_config["notes"][item_id] = status
|
||
|
||
# Get context via API
|
||
result = await chat_service.build_context(
|
||
notebook_id=notebook_id, context_config=context_config
|
||
)
|
||
|
||
# Store in session state for compatibility
|
||
st.session_state[notebook_id]["context"] = result["context"]
|
||
|
||
return st.session_state[notebook_id]["context"]
|
||
|
||
|
||
async def execute_chat(txt_input, context, current_session):
|
||
# Execute chat via API
|
||
result = await chat_service.execute_chat(
|
||
session_id=current_session["id"],
|
||
message=txt_input,
|
||
context=context
|
||
)
|
||
|
||
# Update session state with API response
|
||
st.session_state[current_session["id"]]["messages"] = result["messages"]
|
||
|
||
return result
|
||
|
||
|
||
def chat_sidebar(current_notebook, current_session):
|
||
context = asyncio.run(build_context(notebook_id=current_notebook.id))
|
||
tokens = token_count(
|
||
str(context) + str(st.session_state[current_session["id"]]["messages"])
|
||
)
|
||
chat_tab, podcast_tab = st.tabs(["Chat", "Podcast"])
|
||
with st.expander(f"Context ({tokens} tokens), {len(str(context))} chars"):
|
||
st.json(context)
|
||
with podcast_tab:
|
||
with st.container(border=True):
|
||
# Fetch available episode profiles
|
||
try:
|
||
episode_profiles = episode_profiles_service.get_all_episode_profiles()
|
||
episode_profile_names = [ep.name for ep in episode_profiles]
|
||
except Exception as e:
|
||
st.error(f"Failed to load episode profiles: {str(e)}")
|
||
episode_profiles = []
|
||
episode_profile_names = []
|
||
|
||
if len(episode_profiles) == 0:
|
||
st.warning(
|
||
"No episode profiles found. Please create profiles in the Podcast Profiles tab first."
|
||
)
|
||
st.page_link("pages/5_🎙️_Podcasts.py", label="🎙️ Go to Podcast Profiles")
|
||
else:
|
||
# Episode Profile selection
|
||
selected_episode_profile = st.selectbox(
|
||
"Episode Profile", episode_profile_names
|
||
)
|
||
|
||
# Get the selected episode profile object to access speaker_config
|
||
selected_profile_obj = next(
|
||
(
|
||
ep
|
||
for ep in episode_profiles
|
||
if ep.name == selected_episode_profile
|
||
),
|
||
None,
|
||
)
|
||
|
||
# Episode details
|
||
episode_name = st.text_input(
|
||
"Episode Name", placeholder="e.g., AI and the Future of Work"
|
||
)
|
||
instructions = st.text_area(
|
||
"Additional Instructions (Optional)",
|
||
placeholder="Any specific instructions beyond the episode profile's default briefing...",
|
||
help="These instructions will be added to the episode profile's default briefing.",
|
||
)
|
||
|
||
# Check for context availability
|
||
if len(context.get("note", [])) + len(context.get("source", [])) == 0:
|
||
st.warning(
|
||
"No notes or sources found in context. You don't want a boring podcast, right? So, add some context first."
|
||
)
|
||
else:
|
||
# Generate button
|
||
if st.button("🎙️ Generate Podcast", type="primary"):
|
||
if not episode_name.strip():
|
||
st.error("Please enter an episode name")
|
||
else:
|
||
try:
|
||
with st.spinner("Starting podcast generation..."):
|
||
# Use podcast service to generate podcast
|
||
async def generate_podcast():
|
||
return await PodcastService.submit_generation_job(
|
||
episode_profile_name=selected_episode_profile,
|
||
speaker_profile_name=selected_profile_obj.speaker_config
|
||
if selected_profile_obj
|
||
else "",
|
||
episode_name=episode_name.strip(),
|
||
content=str(context),
|
||
briefing_suffix=instructions.strip()
|
||
if instructions.strip()
|
||
else None,
|
||
notebook_id=str(current_notebook.id),
|
||
)
|
||
|
||
job_id = asyncio.run(generate_podcast())
|
||
|
||
if job_id:
|
||
st.info(
|
||
"🎉 Podcast generation started successfully! Check the **Podcasts** page to monitor progress and download results."
|
||
)
|
||
else:
|
||
st.error(
|
||
"Failed to start podcast generation: No job ID returned"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error generating podcast: {str(e)}")
|
||
st.error(f"Error generating podcast: {str(e)}")
|
||
|
||
# Navigation link
|
||
st.divider()
|
||
st.page_link("pages/5_🎙️_Podcasts.py", label="🎙️ Go to Podcasts")
|
||
with chat_tab:
|
||
with st.expander(
|
||
f"**Session:** {current_session['title']} - {humanize.naturaltime(current_session['updated'])}"
|
||
):
|
||
new_session_name = st.text_input(
|
||
"Current Session",
|
||
key="new_session_name",
|
||
value=current_session["title"],
|
||
)
|
||
c1, c2 = st.columns(2)
|
||
if c1.button("Rename", key="rename_session"):
|
||
asyncio.run(chat_service.update_session(current_session["id"], new_session_name))
|
||
st.rerun()
|
||
if c2.button("Delete", key="delete_session_1"):
|
||
asyncio.run(chat_service.delete_session(current_session["id"]))
|
||
st.session_state[current_notebook.id]["active_session"] = None
|
||
st.rerun()
|
||
st.divider()
|
||
new_session_name = st.text_input(
|
||
"New Session Name",
|
||
key="new_session_name_f",
|
||
placeholder="Enter a name for the new session...",
|
||
)
|
||
st.caption("If no name provided, we'll use the current date.")
|
||
if st.button("Create New Session", key="create_new_session"):
|
||
new_session = create_session_for_notebook(
|
||
notebook_id=current_notebook.id, session_name=new_session_name
|
||
)
|
||
st.session_state[current_notebook.id]["active_session"] = new_session["id"]
|
||
st.rerun()
|
||
st.divider()
|
||
sessions = asyncio.run(chat_service.get_sessions(current_notebook.id))
|
||
if len(sessions) > 1:
|
||
st.markdown("**Other Sessions:**")
|
||
for session in sessions:
|
||
if session["id"] == current_session["id"]:
|
||
continue
|
||
|
||
st.markdown(
|
||
f"{session['title']} - {humanize.naturaltime(session['updated'])}"
|
||
)
|
||
if st.button(label="Load", key=f"load_session_{session['id']}"):
|
||
st.session_state[current_notebook.id]["active_session"] = (
|
||
session["id"]
|
||
)
|
||
st.rerun()
|
||
with st.container(border=True):
|
||
request = st.chat_input("Enter your question")
|
||
# removing for now since it's not multi-model capable right now
|
||
if request:
|
||
response = asyncio.run(execute_chat(
|
||
txt_input=request,
|
||
context=context,
|
||
current_session=current_session,
|
||
))
|
||
st.session_state[current_session["id"]]["messages"] = response["messages"]
|
||
|
||
for msg in st.session_state[current_session["id"]]["messages"][::-1]:
|
||
# Handle both domain objects and dict responses from API
|
||
msg_type = msg.get("type") if isinstance(msg, dict) else msg.type
|
||
msg_content = msg.get("content") if isinstance(msg, dict) else msg.content
|
||
msg_id = msg.get("id") if isinstance(msg, dict) else getattr(msg, 'id', 'unknown')
|
||
|
||
if msg_type not in ["human", "ai"]:
|
||
continue
|
||
if not msg_content:
|
||
continue
|
||
|
||
with st.chat_message(name=msg_type):
|
||
if msg_type == "ai":
|
||
# Parse thinking content for AI messages
|
||
thinking_content, cleaned_content = parse_thinking_content(
|
||
msg_content
|
||
)
|
||
|
||
# Show thinking content in expander if present
|
||
if thinking_content:
|
||
with st.expander("🤔 AI Reasoning", expanded=False):
|
||
st.markdown(thinking_content)
|
||
|
||
# Show the cleaned regular content
|
||
if cleaned_content:
|
||
st.markdown(convert_source_references(cleaned_content))
|
||
elif (
|
||
msg_content
|
||
): # Fallback to original if cleaning resulted in empty content
|
||
st.markdown(convert_source_references(msg_content))
|
||
|
||
# New Note button for AI messages
|
||
if st.button("💾 New Note", key=f"render_save_{msg_id}"):
|
||
make_note_from_chat(
|
||
content=msg_content,
|
||
notebook_id=current_notebook.id,
|
||
)
|
||
st.rerun()
|
||
else:
|
||
# Human messages - display normally
|
||
st.markdown(convert_source_references(msg_content))
|