From 0f2274e6f1760f2634ed210e6da29b07a51a02b5 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Tue, 17 Feb 2026 11:23:50 -0600 Subject: [PATCH] =?UTF-8?q?Phase=204:=20UI=20=E2=80=94=20Agent=20selector,?= =?UTF-8?q?=20conversation=20history,=20chat=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sidebar layout with agent selector (Radio), conversation history (gr.render), and BrowserState for localStorage session persistence. Conversations tagged by agent_name for per-agent history filtering. Sidebar auto-closes on mobile viewports via JS. 11 new tests (135 total). Co-Authored-By: Claude Opus 4.6 --- cheddahbot/__main__.py | 2 +- cheddahbot/agent.py | 9 +- cheddahbot/db.py | 35 ++++-- cheddahbot/ui.py | 252 +++++++++++++++++++++++++++++++++++---- tests/test_db.py | 56 ++++++++- tests/test_ui_helpers.py | 53 ++++++++ 6 files changed, 374 insertions(+), 33 deletions(-) create mode 100644 tests/test_ui_helpers.py diff --git a/cheddahbot/__main__.py b/cheddahbot/__main__.py index dcf7b4d..1144a23 100644 --- a/cheddahbot/__main__.py +++ b/cheddahbot/__main__.py @@ -131,7 +131,7 @@ def main(): log.warning("Scheduler not available: %s", e) log.info("Launching Gradio UI on %s:%s...", config.host, config.port) - app = create_ui(default_agent, config, default_llm, notification_bus=notification_bus) + app = create_ui(registry, config, default_llm, notification_bus=notification_bus) app.launch( server_name=config.host, server_port=config.port, diff --git a/cheddahbot/agent.py b/cheddahbot/agent.py index 73aae28..58b1217 100644 --- a/cheddahbot/agent.py +++ b/cheddahbot/agent.py @@ -95,14 +95,19 @@ class Agent: def ensure_conversation(self) -> str: if not self.conv_id: self.conv_id = uuid.uuid4().hex[:12] - self.db.create_conversation(self.conv_id) + self.db.create_conversation(self.conv_id, agent_name=self.name) return self.conv_id def new_conversation(self) -> str: self.conv_id = uuid.uuid4().hex[:12] - self.db.create_conversation(self.conv_id) + self.db.create_conversation(self.conv_id, agent_name=self.name) return self.conv_id + def load_conversation(self, conv_id: str) -> list[dict]: + """Load an existing conversation by ID. Returns message list.""" + self.conv_id = conv_id + return self.db.get_messages(conv_id) + def respond(self, user_input: str, files: list | None = None) -> Generator[str, None, None]: """Process user input and yield streaming response text.""" conv_id = self.ensure_conversation() diff --git a/cheddahbot/db.py b/cheddahbot/db.py index 6703f0b..8d63eca 100644 --- a/cheddahbot/db.py +++ b/cheddahbot/db.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import json import sqlite3 import threading @@ -72,24 +73,42 @@ class Database: created_at TEXT NOT NULL ); """) + # Migration: add agent_name column to conversations (idempotent) + with contextlib.suppress(sqlite3.OperationalError): + self._conn.execute( + "ALTER TABLE conversations ADD COLUMN agent_name TEXT DEFAULT 'default'" + ) self._conn.commit() # -- Conversations -- - def create_conversation(self, conv_id: str, title: str = "New Chat") -> str: + def create_conversation( + self, conv_id: str, title: str = "New Chat", agent_name: str = "default" + ) -> str: now = _now() self._conn.execute( - "INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", - (conv_id, title, now, now), + "INSERT INTO conversations (id, title, created_at, updated_at, agent_name)" + " VALUES (?, ?, ?, ?, ?)", + (conv_id, title, now, now, agent_name), ) self._conn.commit() return conv_id - def list_conversations(self, limit: int = 50) -> list[dict]: - rows = self._conn.execute( - "SELECT id, title, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ?", - (limit,), - ).fetchall() + def list_conversations( + self, limit: int = 50, agent_name: str | None = None + ) -> list[dict]: + if agent_name: + rows = self._conn.execute( + "SELECT id, title, updated_at, agent_name FROM conversations" + " WHERE agent_name = ? ORDER BY updated_at DESC LIMIT ?", + (agent_name, limit), + ).fetchall() + else: + rows = self._conn.execute( + "SELECT id, title, updated_at, agent_name FROM conversations" + " ORDER BY updated_at DESC LIMIT ?", + (limit,), + ).fetchall() return [dict(r) for r in rows] # -- Messages -- diff --git a/cheddahbot/ui.py b/cheddahbot/ui.py index 49df93e..69c8482 100644 --- a/cheddahbot/ui.py +++ b/cheddahbot/ui.py @@ -9,14 +9,32 @@ from typing import TYPE_CHECKING import gradio as gr if TYPE_CHECKING: - from .agent import Agent + from .agent_registry import AgentRegistry from .config import Config from .llm import LLMAdapter from .notifications import NotificationBus log = logging.getLogger(__name__) -_HEAD = '' +_HEAD = """ + + +""" _CSS = """ footer { display: none !important; } @@ -29,6 +47,34 @@ footer { display: none !important; } font-size: 0.9em; } +/* Sidebar conversation buttons */ +.conv-btn { + text-align: left !important; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 8px 12px !important; + margin: 2px 0 !important; + font-size: 0.85em !important; + min-height: 44px !important; + width: 100% !important; +} +.conv-btn.active { + border-color: var(--color-accent) !important; + background: var(--color-accent-soft) !important; +} + +/* Agent radio: larger touch targets */ +.agent-radio .wrap { + gap: 4px !important; +} +.agent-radio .wrap label { + min-height: 44px !important; + padding: 8px 12px !important; + display: flex; + align-items: center; +} + /* Mobile optimizations */ @media (max-width: 768px) { .gradio-container { padding: 4px !important; } @@ -40,7 +86,7 @@ footer { display: none !important; } .chatbot { overflow-y: auto !important; -webkit-overflow-scrolling: touch; - height: calc(100dvh - 220px) !important; + height: calc(100dvh - 200px) !important; max-height: none !important; } @@ -69,12 +115,29 @@ footer { display: none !important; } /* Reduce model dropdown row padding */ .contain .gr-row { gap: 4px !important; } + + /* Prevent horizontal overflow on narrow viewports */ + .gradio-container { overflow-x: hidden !important; max-width: 100vw !important; } } """ +def _messages_to_chatbot(messages: list[dict]) -> list[dict]: + """Convert DB messages to Gradio chatbot format, skipping tool messages.""" + chatbot_msgs = [] + for msg in messages: + role = msg.get("role", "") + content = msg.get("content", "") + if role in ("user", "assistant") and content: + chatbot_msgs.append({"role": role, "content": content}) + return chatbot_msgs + + def create_ui( - agent: Agent, config: Config, llm: LLMAdapter, notification_bus: NotificationBus | None = None + registry: AgentRegistry, + config: Config, + llm: LLMAdapter, + notification_bus: NotificationBus | None = None, ) -> gr.Blocks: """Build and return the Gradio app.""" @@ -85,7 +148,63 @@ def create_ui( exec_status = "available" if llm.is_execution_brain_available() else "unavailable" clickup_status = "enabled" if config.clickup.enabled else "disabled" + # Build agent name → display_name mapping + agent_names = registry.list_agents() + agent_display_map = {} # display_name → internal name + agent_choices = [] # (display_name, internal_name) for Radio + for name in agent_names: + agent = registry.get(name) + display = agent.agent_config.display_name if agent else name + agent_display_map[display] = name + agent_choices.append((display, name)) + + default_agent_name = registry.default_name + with gr.Blocks(title="CheddahBot", fill_width=True, css=_CSS, head=_HEAD) as app: + # -- State -- + active_agent_name = gr.State(default_agent_name) + conv_list_state = gr.State([]) # list of {id, title, updated_at} + browser_state = gr.BrowserState( + {"agent_name": default_agent_name, "conv_id": None}, + storage_key="cheddahbot_session", + ) + + # -- Sidebar -- + with gr.Sidebar(label="CheddahBot", open=True, width=280): + gr.Markdown("### Agents") + agent_selector = gr.Radio( + choices=agent_choices, + value=default_agent_name, + label="Active Agent", + interactive=True, + elem_classes=["agent-radio"], + ) + + gr.Markdown("---") + new_chat_btn = gr.Button("+ New Chat", variant="secondary", size="sm") + + gr.Markdown("### History") + + @gr.render(inputs=[conv_list_state]) + def render_conv_list(convs): + if not convs: + gr.Markdown("*No conversations yet*") + return + for conv in convs: + btn = gr.Button( + conv["title"] or "New Chat", + elem_classes=["conv-btn"], + variant="secondary", + size="sm", + key=f"conv-{conv['id']}", + ) + btn.click( + on_load_conversation, + inputs=[gr.State(conv["id"]), active_agent_name], + outputs=[chatbot, conv_list_state, browser_state], + ) + + # -- Main area -- gr.Markdown("# CheddahBot", elem_classes=["contain"]) gr.Markdown( f"*Chat Brain:* `{current_model}`  |  " @@ -111,7 +230,6 @@ def create_ui( scale=3, ) refresh_btn = gr.Button("Refresh", scale=0, min_width=70) - new_chat_btn = gr.Button("New Chat", scale=1, variant="secondary") chatbot = gr.Chatbot( label="Chat", @@ -134,22 +252,61 @@ def create_ui( sources=["upload", "microphone"], ) + # -- Helper functions -- + + def _get_agent(agent_name: str): + """Get agent by name, falling back to default.""" + return registry.get(agent_name) or registry.default + + def _refresh_conv_list(agent_name: str) -> list[dict]: + """Load conversation list for the given agent.""" + agent = _get_agent(agent_name) + if not agent: + return [] + return agent.db.list_conversations(limit=50, agent_name=agent_name) + # -- Event handlers -- def on_model_change(model_id): llm.switch_model(model_id) - return f"Switched to {model_id}" def on_refresh_models(): models = llm.list_chat_models() choices = [(m.name, m.id) for m in models] return gr.update(choices=choices, value=llm.current_model) - def on_new_chat(): - agent.new_conversation() - return [] + def on_agent_switch(agent_name, browser): + """Switch to a different agent: clear chat, refresh history.""" + agent = _get_agent(agent_name) + if agent: + agent.new_conversation() + convs = _refresh_conv_list(agent_name) + browser = dict(browser) if browser else {} + browser["agent_name"] = agent_name + browser["conv_id"] = agent.conv_id if agent else None + return agent_name, [], convs, browser - def on_user_message(message, chat_history): + def on_new_chat(agent_name, browser): + """Create a new conversation on the active agent.""" + agent = _get_agent(agent_name) + if agent: + agent.new_conversation() + convs = _refresh_conv_list(agent_name) + browser = dict(browser) if browser else {} + browser["conv_id"] = agent.conv_id if agent else None + return [], convs, browser + + def on_load_conversation(conv_id, agent_name): + """Load an existing conversation into the chatbot.""" + agent = _get_agent(agent_name) + if not agent: + return [], [], {"agent_name": agent_name, "conv_id": None} + messages = agent.load_conversation(conv_id) + chatbot_msgs = _messages_to_chatbot(messages) + convs = _refresh_conv_list(agent_name) + return chatbot_msgs, convs, {"agent_name": agent_name, "conv_id": conv_id} + + def on_user_message(message, chat_history, agent_name): chat_history = chat_history or [] # Extract text and files from MultimodalTextbox @@ -161,7 +318,7 @@ def create_ui( files = [] if not text and not files: - yield chat_history, gr.update(value=None) + yield chat_history, gr.update(value=None), gr.update() return # Handle audio files - transcribe them @@ -197,9 +354,10 @@ def create_ui( user_display += f"\n[Attached: {', '.join(file_names)}]" chat_history = [*chat_history, {"role": "user", "content": user_display}] - yield chat_history, gr.update(value=None) + yield chat_history, gr.update(value=None), gr.update() # Stream assistant response + agent = _get_agent(agent_name) try: response_text = "" chat_history = [*chat_history, {"role": "assistant", "content": ""}] @@ -207,7 +365,7 @@ def create_ui( for chunk in agent.respond(text, files=processed_files): response_text += chunk chat_history[-1] = {"role": "assistant", "content": response_text} - yield chat_history, gr.update(value=None) + yield chat_history, gr.update(value=None), gr.update() # If no response came through, show a fallback if not response_text: @@ -215,14 +373,48 @@ def create_ui( "role": "assistant", "content": "(No response received from model)", } - yield chat_history, gr.update(value=None) + yield chat_history, gr.update(value=None), gr.update() except Exception as e: log.error("Error in agent.respond: %s", e, exc_info=True) - chat_history = [*chat_history, {"role": "assistant", "content": f"Error: {e}"}] - yield chat_history, gr.update(value=None) + chat_history = [ + *chat_history, + {"role": "assistant", "content": f"Error: {e}"}, + ] + yield chat_history, gr.update(value=None), gr.update() - def poll_pipeline_status(): + # Refresh conversation list after message (title/updated_at may have changed) + convs = _refresh_conv_list(agent_name) + yield chat_history, gr.update(value=None), convs + + def on_app_load(browser): + """Restore session from browser localStorage on page load.""" + browser = browser or {} + agent_name = browser.get("agent_name", default_agent_name) + conv_id = browser.get("conv_id") + + # Validate agent exists + agent = registry.get(agent_name) + if not agent: + agent_name = default_agent_name + agent = registry.default + + chatbot_msgs = [] + if conv_id and agent: + messages = agent.load_conversation(conv_id) + chatbot_msgs = _messages_to_chatbot(messages) + elif agent: + agent.ensure_conversation() + conv_id = agent.conv_id + + convs = _refresh_conv_list(agent_name) + new_browser = {"agent_name": agent_name, "conv_id": conv_id} + return agent_name, agent_name, chatbot_msgs, convs, new_browser + + def poll_pipeline_status(agent_name): """Poll the DB for pipeline progress updates.""" + agent = _get_agent(agent_name) + if not agent: + return gr.update(value="", visible=False) status = agent.db.kv_get("pipeline:status") if status: return gr.update(value=f"⏳ {status}", visible=True) @@ -248,17 +440,35 @@ def create_ui( model_dropdown.change(on_model_change, [model_dropdown], None) refresh_btn.click(on_refresh_models, None, [model_dropdown]) - new_chat_btn.click(on_new_chat, None, [chatbot]) + + agent_selector.change( + on_agent_switch, + [agent_selector, browser_state], + [active_agent_name, chatbot, conv_list_state, browser_state], + ) + + new_chat_btn.click( + on_new_chat, + [active_agent_name, browser_state], + [chatbot, conv_list_state, browser_state], + ) msg_input.submit( on_user_message, - [msg_input, chatbot], - [chatbot, msg_input], + [msg_input, chatbot, active_agent_name], + [chatbot, msg_input, conv_list_state], + ) + + # Restore session on page load + app.load( + on_app_load, + inputs=[browser_state], + outputs=[active_agent_name, agent_selector, chatbot, conv_list_state, browser_state], ) # Pipeline status polling timer (every 3 seconds) status_timer = gr.Timer(3) - status_timer.tick(poll_pipeline_status, None, [pipeline_status]) + status_timer.tick(poll_pipeline_status, [active_agent_name], [pipeline_status]) # Notification polling timer (every 10 seconds) if notification_bus: diff --git a/tests/test_db.py b/tests/test_db.py index d70116a..8fab2d0 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,9 +1,63 @@ -"""Tests for the Database kv_scan and notifications methods.""" +"""Tests for the Database kv_scan, notifications, and conversations methods.""" from __future__ import annotations import json +from cheddahbot.db import Database + + +class TestConversationsAgentName: + """Conversations are tagged by agent_name for per-agent history filtering.""" + + def test_create_with_default_agent_name(self, tmp_db): + tmp_db.create_conversation("conv1") + convs = tmp_db.list_conversations() + assert len(convs) == 1 + assert convs[0]["agent_name"] == "default" + + def test_create_with_custom_agent_name(self, tmp_db): + tmp_db.create_conversation("conv1", agent_name="writer") + convs = tmp_db.list_conversations() + assert convs[0]["agent_name"] == "writer" + + def test_list_filters_by_agent_name(self, tmp_db): + tmp_db.create_conversation("c1", agent_name="default") + tmp_db.create_conversation("c2", agent_name="writer") + tmp_db.create_conversation("c3", agent_name="default") + + default_convs = tmp_db.list_conversations(agent_name="default") + writer_convs = tmp_db.list_conversations(agent_name="writer") + all_convs = tmp_db.list_conversations() + + assert len(default_convs) == 2 + assert len(writer_convs) == 1 + assert len(all_convs) == 3 + + def test_list_without_filter_returns_all(self, tmp_db): + tmp_db.create_conversation("c1", agent_name="a") + tmp_db.create_conversation("c2", agent_name="b") + + convs = tmp_db.list_conversations() + assert len(convs) == 2 + + def test_list_returns_agent_name_in_results(self, tmp_db): + tmp_db.create_conversation("c1", agent_name="researcher") + convs = tmp_db.list_conversations() + assert "agent_name" in convs[0] + assert convs[0]["agent_name"] == "researcher" + + def test_migration_idempotency(self, tmp_path): + """Running _init_schema twice doesn't error (ALTER TABLE is skipped).""" + db_path = tmp_path / "test_migrate.db" + db1 = Database(db_path) + db1.create_conversation("c1", agent_name="ops") + # Re-init on same DB file triggers migration again + db2 = Database(db_path) + convs = db2.list_conversations() + assert len(convs) == 1 + assert convs[0]["agent_name"] == "ops" + class TestKvScan: """kv_scan is used to find all tracked ClickUp task states efficiently. diff --git a/tests/test_ui_helpers.py b/tests/test_ui_helpers.py new file mode 100644 index 0000000..26d32d0 --- /dev/null +++ b/tests/test_ui_helpers.py @@ -0,0 +1,53 @@ +"""Tests for UI helper functions.""" + +from __future__ import annotations + +from cheddahbot.ui import _messages_to_chatbot + + +class TestMessagesToChatbot: + """_messages_to_chatbot converts DB messages to Gradio chatbot format.""" + + def test_converts_user_and_assistant(self): + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ] + result = _messages_to_chatbot(messages) + assert result == [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + ] + + def test_skips_tool_messages(self): + messages = [ + {"role": "user", "content": "Search for X"}, + {"role": "assistant", "content": "Let me search..."}, + {"role": "tool", "content": '{"results": []}'}, + {"role": "assistant", "content": "Here are the results."}, + ] + result = _messages_to_chatbot(messages) + assert len(result) == 3 + assert all(m["role"] in ("user", "assistant") for m in result) + + def test_skips_empty_content(self): + messages = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": ""}, + {"role": "assistant", "content": "Real response"}, + ] + result = _messages_to_chatbot(messages) + assert len(result) == 2 + assert result[1]["content"] == "Real response" + + def test_empty_input(self): + assert _messages_to_chatbot([]) == [] + + def test_skips_system_messages(self): + messages = [ + {"role": "system", "content": "You are a bot"}, + {"role": "user", "content": "Hi"}, + ] + result = _messages_to_chatbot(messages) + assert len(result) == 1 + assert result[0]["role"] == "user"