Phase 4: UI — Agent selector, conversation history, chat persistence
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 <noreply@anthropic.com>cora-start
parent
c311ae5909
commit
0f2274e6f1
|
|
@ -131,7 +131,7 @@ def main():
|
||||||
log.warning("Scheduler not available: %s", e)
|
log.warning("Scheduler not available: %s", e)
|
||||||
|
|
||||||
log.info("Launching Gradio UI on %s:%s...", config.host, config.port)
|
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(
|
app.launch(
|
||||||
server_name=config.host,
|
server_name=config.host,
|
||||||
server_port=config.port,
|
server_port=config.port,
|
||||||
|
|
|
||||||
|
|
@ -95,14 +95,19 @@ class Agent:
|
||||||
def ensure_conversation(self) -> str:
|
def ensure_conversation(self) -> str:
|
||||||
if not self.conv_id:
|
if not self.conv_id:
|
||||||
self.conv_id = uuid.uuid4().hex[:12]
|
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
|
return self.conv_id
|
||||||
|
|
||||||
def new_conversation(self) -> str:
|
def new_conversation(self) -> str:
|
||||||
self.conv_id = uuid.uuid4().hex[:12]
|
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
|
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]:
|
def respond(self, user_input: str, files: list | None = None) -> Generator[str, None, None]:
|
||||||
"""Process user input and yield streaming response text."""
|
"""Process user input and yield streaming response text."""
|
||||||
conv_id = self.ensure_conversation()
|
conv_id = self.ensure_conversation()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -72,24 +73,42 @@ class Database:
|
||||||
created_at TEXT NOT NULL
|
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()
|
self._conn.commit()
|
||||||
|
|
||||||
# -- Conversations --
|
# -- 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()
|
now = _now()
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, agent_name)"
|
||||||
(conv_id, title, now, now),
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(conv_id, title, now, now, agent_name),
|
||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return conv_id
|
return conv_id
|
||||||
|
|
||||||
def list_conversations(self, limit: int = 50) -> list[dict]:
|
def list_conversations(
|
||||||
rows = self._conn.execute(
|
self, limit: int = 50, agent_name: str | None = None
|
||||||
"SELECT id, title, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ?",
|
) -> list[dict]:
|
||||||
(limit,),
|
if agent_name:
|
||||||
).fetchall()
|
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]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
# -- Messages --
|
# -- Messages --
|
||||||
|
|
|
||||||
252
cheddahbot/ui.py
252
cheddahbot/ui.py
|
|
@ -9,14 +9,32 @@ from typing import TYPE_CHECKING
|
||||||
import gradio as gr
|
import gradio as gr
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .agent import Agent
|
from .agent_registry import AgentRegistry
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .llm import LLMAdapter
|
from .llm import LLMAdapter
|
||||||
from .notifications import NotificationBus
|
from .notifications import NotificationBus
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_HEAD = '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
_HEAD = """
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script>
|
||||||
|
// Auto-close sidebar on mobile so users land on the chat
|
||||||
|
(function() {
|
||||||
|
if (window.innerWidth > 768) return;
|
||||||
|
var attempts = 0;
|
||||||
|
var timer = setInterval(function() {
|
||||||
|
// Gradio sidebar toggle is the button inside the sidebar's nav/header area
|
||||||
|
var sidebar = document.querySelector('[class*="sidebar"]');
|
||||||
|
if (sidebar) {
|
||||||
|
var btn = sidebar.querySelector('button');
|
||||||
|
if (btn) { btn.click(); clearInterval(timer); return; }
|
||||||
|
}
|
||||||
|
if (++attempts > 40) clearInterval(timer);
|
||||||
|
}, 150);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
_CSS = """
|
_CSS = """
|
||||||
footer { display: none !important; }
|
footer { display: none !important; }
|
||||||
|
|
@ -29,6 +47,34 @@ footer { display: none !important; }
|
||||||
font-size: 0.9em;
|
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 */
|
/* Mobile optimizations */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.gradio-container { padding: 4px !important; }
|
.gradio-container { padding: 4px !important; }
|
||||||
|
|
@ -40,7 +86,7 @@ footer { display: none !important; }
|
||||||
.chatbot {
|
.chatbot {
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
height: calc(100dvh - 220px) !important;
|
height: calc(100dvh - 200px) !important;
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,12 +115,29 @@ footer { display: none !important; }
|
||||||
|
|
||||||
/* Reduce model dropdown row padding */
|
/* Reduce model dropdown row padding */
|
||||||
.contain .gr-row { gap: 4px !important; }
|
.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(
|
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:
|
) -> gr.Blocks:
|
||||||
"""Build and return the Gradio app."""
|
"""Build and return the Gradio app."""
|
||||||
|
|
||||||
|
|
@ -85,7 +148,63 @@ def create_ui(
|
||||||
exec_status = "available" if llm.is_execution_brain_available() else "unavailable"
|
exec_status = "available" if llm.is_execution_brain_available() else "unavailable"
|
||||||
clickup_status = "enabled" if config.clickup.enabled else "disabled"
|
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:
|
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("# CheddahBot", elem_classes=["contain"])
|
||||||
gr.Markdown(
|
gr.Markdown(
|
||||||
f"*Chat Brain:* `{current_model}` | "
|
f"*Chat Brain:* `{current_model}` | "
|
||||||
|
|
@ -111,7 +230,6 @@ def create_ui(
|
||||||
scale=3,
|
scale=3,
|
||||||
)
|
)
|
||||||
refresh_btn = gr.Button("Refresh", scale=0, min_width=70)
|
refresh_btn = gr.Button("Refresh", scale=0, min_width=70)
|
||||||
new_chat_btn = gr.Button("New Chat", scale=1, variant="secondary")
|
|
||||||
|
|
||||||
chatbot = gr.Chatbot(
|
chatbot = gr.Chatbot(
|
||||||
label="Chat",
|
label="Chat",
|
||||||
|
|
@ -134,22 +252,61 @@ def create_ui(
|
||||||
sources=["upload", "microphone"],
|
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 --
|
# -- Event handlers --
|
||||||
|
|
||||||
def on_model_change(model_id):
|
def on_model_change(model_id):
|
||||||
llm.switch_model(model_id)
|
llm.switch_model(model_id)
|
||||||
return f"Switched to {model_id}"
|
|
||||||
|
|
||||||
def on_refresh_models():
|
def on_refresh_models():
|
||||||
models = llm.list_chat_models()
|
models = llm.list_chat_models()
|
||||||
choices = [(m.name, m.id) for m in models]
|
choices = [(m.name, m.id) for m in models]
|
||||||
return gr.update(choices=choices, value=llm.current_model)
|
return gr.update(choices=choices, value=llm.current_model)
|
||||||
|
|
||||||
def on_new_chat():
|
def on_agent_switch(agent_name, browser):
|
||||||
agent.new_conversation()
|
"""Switch to a different agent: clear chat, refresh history."""
|
||||||
return []
|
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 []
|
chat_history = chat_history or []
|
||||||
|
|
||||||
# Extract text and files from MultimodalTextbox
|
# Extract text and files from MultimodalTextbox
|
||||||
|
|
@ -161,7 +318,7 @@ def create_ui(
|
||||||
files = []
|
files = []
|
||||||
|
|
||||||
if not text and not files:
|
if not text and not files:
|
||||||
yield chat_history, gr.update(value=None)
|
yield chat_history, gr.update(value=None), gr.update()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle audio files - transcribe them
|
# Handle audio files - transcribe them
|
||||||
|
|
@ -197,9 +354,10 @@ def create_ui(
|
||||||
user_display += f"\n[Attached: {', '.join(file_names)}]"
|
user_display += f"\n[Attached: {', '.join(file_names)}]"
|
||||||
|
|
||||||
chat_history = [*chat_history, {"role": "user", "content": user_display}]
|
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
|
# Stream assistant response
|
||||||
|
agent = _get_agent(agent_name)
|
||||||
try:
|
try:
|
||||||
response_text = ""
|
response_text = ""
|
||||||
chat_history = [*chat_history, {"role": "assistant", "content": ""}]
|
chat_history = [*chat_history, {"role": "assistant", "content": ""}]
|
||||||
|
|
@ -207,7 +365,7 @@ def create_ui(
|
||||||
for chunk in agent.respond(text, files=processed_files):
|
for chunk in agent.respond(text, files=processed_files):
|
||||||
response_text += chunk
|
response_text += chunk
|
||||||
chat_history[-1] = {"role": "assistant", "content": response_text}
|
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 no response came through, show a fallback
|
||||||
if not response_text:
|
if not response_text:
|
||||||
|
|
@ -215,14 +373,48 @@ def create_ui(
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "(No response received from model)",
|
"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:
|
except Exception as e:
|
||||||
log.error("Error in agent.respond: %s", e, exc_info=True)
|
log.error("Error in agent.respond: %s", e, exc_info=True)
|
||||||
chat_history = [*chat_history, {"role": "assistant", "content": f"Error: {e}"}]
|
chat_history = [
|
||||||
yield chat_history, gr.update(value=None)
|
*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."""
|
"""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")
|
status = agent.db.kv_get("pipeline:status")
|
||||||
if status:
|
if status:
|
||||||
return gr.update(value=f"⏳ {status}", visible=True)
|
return gr.update(value=f"⏳ {status}", visible=True)
|
||||||
|
|
@ -248,17 +440,35 @@ def create_ui(
|
||||||
|
|
||||||
model_dropdown.change(on_model_change, [model_dropdown], None)
|
model_dropdown.change(on_model_change, [model_dropdown], None)
|
||||||
refresh_btn.click(on_refresh_models, None, [model_dropdown])
|
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(
|
msg_input.submit(
|
||||||
on_user_message,
|
on_user_message,
|
||||||
[msg_input, chatbot],
|
[msg_input, chatbot, active_agent_name],
|
||||||
[chatbot, msg_input],
|
[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)
|
# Pipeline status polling timer (every 3 seconds)
|
||||||
status_timer = gr.Timer(3)
|
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)
|
# Notification polling timer (every 10 seconds)
|
||||||
if notification_bus:
|
if notification_bus:
|
||||||
|
|
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
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:
|
class TestKvScan:
|
||||||
"""kv_scan is used to find all tracked ClickUp task states efficiently.
|
"""kv_scan is used to find all tracked ClickUp task states efficiently.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue