diff --git a/frontend/chatbot/forms.py b/frontend/chatbot/forms.py
index 93f03def..77e0e969 100644
--- a/frontend/chatbot/forms.py
+++ b/frontend/chatbot/forms.py
@@ -26,7 +26,7 @@ async def create_input_form(
form_card = ui.card().classes(
"w-full max-w-full min-w-0 text-sm "
"bg-white ring-1 ring-zinc-200 rounded-2xl rounded-tl-none shadow-sm "
- "border-0 rb-form-wrapper"
+ "border-0 rb-form-wrapper !p-0"
)
with form_card:
form_generator = FormGenerator()
diff --git a/frontend/chatbot/tool_config.py b/frontend/chatbot/tool_config.py
index e803b450..a36a8967 100644
--- a/frontend/chatbot/tool_config.py
+++ b/frontend/chatbot/tool_config.py
@@ -285,6 +285,40 @@ def create_advanced_granite_prompt(user_query: str) -> list[dict[str, str]]:
# Generate Dynamic Schema for the prompt
tools_definitions = generate_tool_definitions()
+ from frontend.utils import get_active_case
+ from nicegui import app
+
+ active_case = get_active_case()
+ context_prefix = ""
+ if active_case:
+ context_prefix += f"ACTIVE CASE CONTEXT:\n- Case Number: {active_case.caseNumber}\n- Evidence Path: {active_case.evidencePath}\n"
+ context_prefix += "- Use the evidence path as the default input directory/file path for all tools if not specified otherwise.\n"
+
+ pipeline_job_id = None
+ try:
+ pipeline_job_id = app.storage.user.get("pipeline_job_id")
+ except Exception:
+ pass
+
+ if pipeline_job_id:
+ try:
+ from frontend.database import get_job_db
+
+ job = get_job_db().get_job_by_uid_sync(pipeline_job_id)
+ if job and job.response:
+ from frontend.chatbot.multi_tool_handler import extract_output_path
+ from rb.api.models import ResponseBody
+
+ response_body = job.response
+ if not isinstance(response_body, ResponseBody):
+ response_body = ResponseBody(**response_body)
+ output_path = extract_output_path(response_body)
+ if output_path:
+ context_prefix += f"PIPELINED JOB CONTEXT:\n- Source Job ID: {pipeline_job_id}\n- Source Job Output Path: {output_path}\n"
+ context_prefix += "- Use this output path as the input directory/file path for the next tool call if the user asks to analyze/pipeline/use the results of the previous job.\n"
+ except Exception as e:
+ logger.error("Error extracting pipeline job output path in prompt: %s", e)
+
# ==========================================
# FEW-SHOT PROMPTING (The Secret Sauce)
# ==========================================
@@ -293,6 +327,7 @@ def create_advanced_granite_prompt(user_query: str) -> list[dict[str, str]]:
system_msg = {
"role": "system",
"content": (
+ f"{context_prefix}\n"
"You are a forensic analysis assistant for RescueBox.\n"
"RULES:\n"
"1. CHAINING: If the user requests multiple actions, generate a LIST of tools in execution order.\n"
diff --git a/frontend/components/about.py b/frontend/components/about.py
index e885c012..6a03d28c 100644
--- a/frontend/components/about.py
+++ b/frontend/components/about.py
@@ -2,7 +2,7 @@
import logging
from pathlib import Path
from typing import List, Optional, Tuple
-from urllib.parse import quote, unquote
+from urllib.parse import unquote
from nicegui import ui
from starlette.requests import Request
@@ -16,7 +16,7 @@
logger.setLevel(logging.INFO)
-_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
+_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
LICENSE_ROOT = _REPO_ROOT / "License&Copyright"
# Relative to LICENSE_ROOT — default document when ``?doc=`` is missing/invalid.
DEFAULT_LICENSE_REL = "LICENSE"
@@ -114,7 +114,7 @@ def render_one_file(
)
ui.code(raw).classes(
"w-full max-w-none text-sm whitespace-pre-wrap break-words "
- "block p-4 bg-zinc-50 rounded-lg border border-zinc-300"
+ "block p-4 bg-slate-50 rounded-xl border border-slate-200 shadow-inner"
)
@@ -124,92 +124,132 @@ def render_license_documents_section(
static_url: str = "/license-copyright",
page_path: str = "/about",
) -> None:
- """License & Copyright picker and viewer; uses ``?doc=`` on ``page_path``."""
- doc = request.query_params.get("doc")
+ """License & Copyright picker and viewer; uses dynamic, inline, closable, and scrollable rendering."""
root = LICENSE_ROOT
files = list_text_docs(root)
ui.element("div").props('id="license-copyright"').classes("scroll-mt-24")
with ui.card().classes(
- "w-full max-w-3xl p-6 bg-white border border-zinc-300 rounded-xl shadow-sm"
+ "w-full max-w-3xl p-6 bg-white border border-slate-200 rounded-2xl shadow-md border-t-4 border-t-[#881c1c] flex flex-col gap-4"
):
- ui.label("License & Copyright").classes(
- "text-xl font-semibold text-[#505759] mb-2"
- )
+ ui.label("License & Copyright").classes("text-xl font-semibold text-slate-800")
ui.label(
- "RescueBox LICENSE, COPYRIGHT, and NOTICE, see bundled third-party notices when you choose Third party."
- ).classes("text-sm text-zinc-600 mb-4")
-
- if not root.is_dir():
- ui.label(f"Folder not found: {root}").classes("text-red-600")
- return
- if not files:
- ui.label("No license documents found in that folder.").classes("text-zinc-600")
- return
-
- primary_entries, third_party_files = _primary_and_third_party_paths(files)
-
- rel = unquote(doc) if doc else ""
- if rel not in files:
- if DEFAULT_LICENSE_REL in files:
- rel = DEFAULT_LICENSE_REL
- elif primary_entries:
- rel = primary_entries[0][1]
- else:
- rel = files[0]
-
- base = page_path.rstrip("/") or "/about"
-
- def _navigate_to_doc(new_rel: str) -> None:
- if new_rel in files:
- ui.navigate.to(f"{base}?doc={quote(new_rel, safe='')}")
+ "Select a document below to view RescueBox LICENSE, COPYRIGHT, NOTICE, or bundled third-party notices."
+ ).classes("text-sm text-zinc-600")
- # Main picker: primary docs + optional "Third party".
- # NiceGUI dict options are {value: label} (keys are selected values; values are shown in the UI).
- main_options: dict[str, str] = {path: label for label, path in primary_entries}
- if third_party_files:
- main_options[_THIRD_PARTY_SENTINEL] = "Third party"
-
- if rel in third_party_files:
- main_value = _THIRD_PARTY_SENTINEL
- else:
- main_value = next(
- (path for label, path in primary_entries if path == rel),
- primary_entries[0][1] if primary_entries else rel,
- )
-
- def _on_main_pick(e) -> None:
- v = e.value
- if not isinstance(v, str):
+ if not root.is_dir():
+ ui.label(f"Folder not found: {root}").classes("text-red-600")
return
- if v == _THIRD_PARTY_SENTINEL and third_party_files:
- target = rel if rel in third_party_files else third_party_files[0]
- _navigate_to_doc(target)
- elif v != _THIRD_PARTY_SENTINEL:
- _navigate_to_doc(v)
-
- ui.select(
- options=main_options,
- value=main_value,
- label="Document",
- on_change=_on_main_pick,
- ).classes("w-full max-w-2xl")
-
- if third_party_files:
- with ui.column().classes("w-full max-w-2xl mt-2") as third_wrap:
- third_wrap.visible = rel in third_party_files
-
- def _on_third_pick(e) -> None:
- v = e.value
- if isinstance(v, str) and v in third_party_files:
- _navigate_to_doc(v)
-
- ui.select(
+ if not files:
+ ui.label("No license documents found in that folder.").classes(
+ "text-zinc-600"
+ )
+ return
+
+ primary_entries, third_party_files = _primary_and_third_party_paths(files)
+
+ # Main options for the select dropdown
+ main_options: dict[str, str] = {path: label for label, path in primary_entries}
+ if third_party_files:
+ main_options[_THIRD_PARTY_SENTINEL] = "Third party"
+
+ # Dropdowns row
+ with ui.row().classes("w-full gap-4 items-center flex-wrap sm:flex-nowrap"):
+ # Primary document selector
+ main_select = ui.select(
+ options=main_options,
+ label="Document",
+ value=None,
+ on_change=lambda e: _on_main_change(e),
+ ).classes("flex-1 min-w-[200px]")
+
+ # Third-party document selector (hidden by default)
+ third_select = ui.select(
options=third_party_files,
- value=rel if rel in third_party_files else third_party_files[0],
label="Third-party document",
- on_change=_on_third_pick,
- ).classes("w-full")
+ value=None,
+ on_change=lambda e: _on_third_change(e),
+ ).classes("flex-1 min-w-[200px]")
+ third_select.visible = False
+
+ # Closable & Scrollable Viewer Container (hidden by default)
+ with ui.card().classes(
+ "w-full p-4 bg-slate-50 border border-slate-200 rounded-xl shadow-sm flex flex-col gap-3"
+ ) as viewer_card:
+ viewer_card.visible = False
+
+ # Viewer Header
+ with ui.row().classes(
+ "w-full justify-between items-center border-b pb-2 border-slate-200"
+ ):
+ with ui.row().classes("items-center gap-2"):
+ ui.icon("article", size="sm").classes("text-[#881c1c]")
+ viewer_title = ui.label("").classes(
+ "text-sm font-bold text-slate-700 font-mono"
+ )
+
+ # Close button
+ ui.button(
+ "Close",
+ icon="close",
+ color=None,
+ on_click=lambda: _close_viewer(),
+ ).props("flat dense no-caps").classes(
+ "text-slate-600 hover:text-slate-800 hover:bg-slate-100 px-3 py-1 "
+ "rounded-lg border border-slate-200 transition-colors text-sm font-medium"
+ )
+
+ # Scrollable body
+ viewer_body = ui.column().classes(
+ "w-full max-h-[350px] overflow-y-auto pr-2"
+ )
- body = ui.column().classes("w-full min-w-0 mt-6")
- render_one_file(body, root, rel, static_url=static_url)
+ # Helper to render document content
+ def _show_document(rel_path: str):
+ viewer_body.clear()
+ viewer_title.text = rel_path
+ render_one_file(viewer_body, root, rel_path, static_url=static_url)
+ viewer_card.visible = True
+
+ # Close viewer action
+ def _close_viewer():
+ viewer_card.visible = False
+ main_select.value = None
+ third_select.value = None
+ third_select.visible = False
+
+ # On primary selection change
+ def _on_main_change(e):
+ val = e.value
+ if not val:
+ return
+ if val == _THIRD_PARTY_SENTINEL:
+ third_select.visible = True
+ # Automatically select and show the first third party file
+ first_third = third_party_files[0] if third_party_files else None
+ if first_third:
+ third_select.value = first_third
+ _show_document(first_third)
+ else:
+ third_select.visible = False
+ third_select.value = None
+ _show_document(val)
+
+ # On third-party selection change
+ def _on_third_change(e):
+ val = e.value
+ if val and val in third_party_files:
+ _show_document(val)
+
+ # Initial load from query param if present
+ doc = request.query_params.get("doc")
+ if doc:
+ rel = unquote(doc)
+ if rel in files:
+ if rel in third_party_files:
+ main_select.value = _THIRD_PARTY_SENTINEL
+ third_select.value = rel
+ third_select.visible = True
+ else:
+ main_select.value = rel
+ _show_document(rel)
diff --git a/frontend/components/chat/dialogs.py b/frontend/components/chat/dialogs.py
index dd35253b..118faf35 100644
--- a/frontend/components/chat/dialogs.py
+++ b/frontend/components/chat/dialogs.py
@@ -8,7 +8,9 @@ def show_help_dialog(help_text: str, title: Optional[str] = "RescueBox Help") ->
with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_WIDE):
with ui.row().classes(Design.PANEL_SHELL_HEADER):
ui.label(title or "Help").classes(Design.PANEL_SHELL_HEADER_TITLE)
- ui.button(icon="close", on_click=dialog.close).props("flat round dense")
+ ui.button(icon="close", color=None, on_click=dialog.close).props(
+ "flat round dense"
+ )
with ui.column().classes("w-full flex-1 overflow-y-auto p-6"):
ui.markdown(help_text or "No help available.")
dialog.open()
@@ -26,7 +28,9 @@ async def show_history_dialog(
with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_WIDE):
with ui.row().classes(Design.PANEL_SHELL_HEADER):
ui.label("Chat History").classes(Design.PANEL_SHELL_HEADER_TITLE)
- ui.button(icon="close", on_click=dialog.close).props("flat round dense")
+ ui.button(icon="close", color=None, on_click=dialog.close).props(
+ "flat round dense"
+ )
with ui.column().classes(
f"{Design.PANEL_SHELL_BODY} gap-3 overflow-y-auto max-h-[60vh] w-full"
@@ -99,5 +103,7 @@ def _render_message_card(msg: Any) -> None:
):
for msg in messages:
_render_message_card(msg)
- ui.button("Close", on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY)
+ ui.button("Close", color=None, on_click=dialog.close).classes(
+ Design.BTN_MEDIUM_GRAY
+ )
dialog.open()
diff --git a/frontend/components/chat/rendering.py b/frontend/components/chat/rendering.py
index 2ad33f01..5605bac1 100644
--- a/frontend/components/chat/rendering.py
+++ b/frontend/components/chat/rendering.py
@@ -5,26 +5,26 @@
logger = logging.getLogger(__name__)
-ASSISTANT_MARKDOWN_CLASSES = "prose prose-zinc max-w-none !text-base !leading-relaxed"
-USER_PLAIN_CLASSES = "!text-base !leading-relaxed text-zinc-800"
+ASSISTANT_MARKDOWN_CLASSES = "prose prose-slate max-w-none !text-base !leading-relaxed"
+USER_PLAIN_CLASSES = "!text-base !leading-relaxed text-slate-800"
def render_welcome_message(container: ui.element) -> None:
with container:
with card().classes(
- "w-full max-w-sm bg-white ring-1 ring-zinc-200 shadow-sm rounded-2xl rounded-tl-none"
+ "w-full max-w-sm bg-white ring-1 ring-slate-200 shadow-sm rounded-2xl rounded-tl-none border-l-4 border-l-[#881c1c]"
):
with column().classes("p-3 gap-1"):
label("Assistant").classes(
- "font-medium !text-sm text-zinc-500 uppercase tracking-wide"
+ "font-medium !text-sm text-slate-500 uppercase tracking-wider"
)
label("New conversation. How can I help you?").classes(
- "!text-base !leading-relaxed text-zinc-800"
+ "!text-base !leading-relaxed text-slate-800"
)
with row().classes("mt-2"):
- button("Open Tools Menu", icon="menu").props(
+ button("Open Tools Menu", icon="menu", color=None).props(
"flat dense no-caps"
- ).classes("text-sm text-zinc-600 hover:text-zinc-900").on(
+ ).classes("text-sm text-slate-600 hover:text-[#881c1c]").on(
"click",
lambda: ui.run_javascript(
'document.querySelectorAll("button").forEach(b => { if(b.innerText.includes("Menu")) b.click(); })'
@@ -44,12 +44,12 @@ def render_message_card(
else Design.CHAT_ASSISTANT_BUBBLE
)
with row().classes(f"w-full {alignment}"):
- with card().classes(f"{bubble} max-w-2xl"):
+ with card().classes(f"{bubble} max-w-4xl"):
if role == "user":
label("YOU:").classes(Design.CHAT_USER_LABEL)
else:
label("Assistant").classes(
- "font-medium !text-xs text-zinc-500 uppercase tracking-wide"
+ "font-semibold !text-sm text-slate-500 uppercase tracking-wider"
)
if (
@@ -71,22 +71,36 @@ def render_conversation_card(
container: ui.column, conversation: Any, view_callback, load_callback
) -> None:
with container:
- with card().classes("p-4 cursor-pointer hover:bg-zinc-50"):
+ with card().classes(
+ "p-4 cursor-pointer hover:bg-slate-50 border border-slate-200 rounded-xl shadow-sm transition-all"
+ ):
with row().classes("items-center justify-between mb-2"):
- label(conversation.title).classes("font-semibold flex-1")
+ label(conversation.title).classes("font-semibold flex-1 text-slate-800")
with row().classes("gap-2"):
button(
- "View", on_click=lambda: view_callback(conversation.conversation_id)
- ).classes("text-sm rb-brand-primary text-white")
+ "View",
+ icon="visibility",
+ color=None,
+ on_click=lambda: view_callback(conversation.conversation_id),
+ ).classes(
+ "text-sm bg-slate-100 hover:bg-slate-200 text-slate-800 px-3 py-1 rounded transition-colors"
+ )
button(
- "Load", on_click=lambda: load_callback(conversation.conversation_id)
- ).classes("text-sm rb-brand-primary text-white")
+ "Load",
+ icon="login",
+ color=None,
+ on_click=lambda: load_callback(conversation.conversation_id),
+ ).classes(
+ "text-sm rb-brand-primary text-white px-3 py-1 rounded transition-colors"
+ )
def render_message_in_dialog(message: Any) -> None:
"""Simplified version for dialog viewing."""
role = getattr(message, "role", "assistant")
content = getattr(message, "content", "")
- with column().classes("w-full border-b border-zinc-100 pb-2 mb-2"):
- label(role.upper()).classes("text-xs font-bold text-zinc-400")
- label(content).classes("text-sm text-zinc-800 whitespace-pre-wrap")
+ with column().classes("w-full border-b border-slate-100 pb-2 mb-2"):
+ label(role.upper()).classes(
+ "text-xs font-bold text-slate-400 uppercase tracking-wider"
+ )
+ label(content).classes("text-sm text-slate-800 whitespace-pre-wrap")
diff --git a/frontend/components/chat/ui_elements.py b/frontend/components/chat/ui_elements.py
index f827664e..8408662d 100644
--- a/frontend/components/chat/ui_elements.py
+++ b/frontend/components/chat/ui_elements.py
@@ -10,17 +10,17 @@ def create_chat_header(on_show_history: Optional[Callable] = None):
"rb-chat-toolbar-floating items-center justify-end w-full px-4 py-3 sticky top-0 z-10 gap-3"
):
models_btn = (
- ui.button("Menu")
+ ui.button("Menu", icon="menu", color=None)
.classes(Design.BTN_PRIMARY_COMPACT)
.props("unelevated no-caps")
)
analyze_btn = (
- ui.button("Chat")
+ ui.button("Chat", icon="chat", color=None)
.classes(Design.BTN_PRIMARY_COMPACT)
.props("unelevated no-caps")
)
history_btn = (
- ui.button("History", on_click=on_show_history)
+ ui.button("History", icon="history", color=None, on_click=on_show_history)
.classes(Design.BTN_PRIMARY_COMPACT)
.props("unelevated no-caps")
)
@@ -30,7 +30,7 @@ def create_chat_header(on_show_history: Optional[Callable] = None):
def create_chat_window() -> Any:
# Use flex-1 to ensure it expands to available space in the card
container = ui.column().classes(
- "rb-chat-messages-scroll w-full flex-1 overflow-y-auto p-6 space-y-4 bg-white"
+ "rb-chat-messages-scroll w-full flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50 min-w-0"
)
render_welcome_message(container)
return container
@@ -38,7 +38,7 @@ def create_chat_window() -> Any:
def create_input_area(status_text_ref: Optional[object], on_send: Callable):
input_area = ui.column().classes(
- "rb-chat-input-area w-full flex-none bg-white border-t p-4"
+ "rb-chat-input-area w-full flex-none bg-white border-t border-slate-200 p-4"
)
set_latest_input_area(input_area)
with input_area:
@@ -56,7 +56,7 @@ def create_input_area(status_text_ref: Optional[object], on_send: Callable):
if status_text_ref:
status_label.bind_text_from(status_text_ref, "status_text")
# Add a spinner that only shows while processing
- # Use explicit maroon hex for spinner to avoid indigo defaults
+ # Use explicit UMass Maroon hex for spinner to avoid indigo defaults
spinner = ui.spinner(color="#881c1c", size="sm").classes("ml-1")
status_text_ref.attach_processing_strip(spinner)
diff --git a/frontend/components/demo.py b/frontend/components/demo.py
index 03a68f17..f0e226e6 100644
--- a/frontend/components/demo.py
+++ b/frontend/components/demo.py
@@ -190,17 +190,25 @@ def refresh() -> None:
with nav:
ui.button(
"Demo root",
+ color=None,
on_click=lambda: go_to(str(root)),
).classes(
- "text-xs"
- ).props("dense outline")
+ "text-xs bg-slate-100 hover:bg-slate-200 text-slate-800 px-2 py-1 rounded border border-slate-200 transition-colors"
+ ).props(
+ "dense"
+ )
if cur != root:
parent = cur.parent
if parent == root or _is_under_root(parent, root):
ui.button(
"Up one level",
+ color=None,
on_click=lambda: go_to(str(parent)),
- ).classes("text-xs").props("dense outline")
+ ).classes(
+ "text-xs bg-slate-100 hover:bg-slate-200 text-slate-800 px-2 py-1 rounded border border-slate-200 transition-colors"
+ ).props(
+ "dense"
+ )
for path, is_dir in _list_entries(
cur,
diff --git a/frontend/components/errors.py b/frontend/components/errors.py
index 4aebce6a..caea0226 100644
--- a/frontend/components/errors.py
+++ b/frontend/components/errors.py
@@ -41,7 +41,9 @@ def render_error_boundary(
try:
# action should be a tuple (label, on_click_callable, classes)
label, callback, classes = action
- ui.button(label, on_click=callback).classes(classes)
+ ui.button(label, color=None, on_click=callback).classes(
+ classes
+ )
except Exception as e:
logger.exception(
"Error rendering error message component: %s", e
@@ -97,7 +99,7 @@ def show_validation_dialog(
ui.label("Additional errors:").classes("font-semibold mb-2")
for additional_error in additional_errors:
ui.label(f"• {additional_error}").classes("mb-1")
- ui.button("OK", on_click=error_dialog.close).classes(
+ ui.button("OK", color=None, on_click=error_dialog.close).classes(
f"mt-4 {Design.BTN_MEDIUM_GRAY}"
)
error_dialog.open()
diff --git a/frontend/components/forms/dialogs.py b/frontend/components/forms/dialogs.py
index d5386013..620f0957 100644
--- a/frontend/components/forms/dialogs.py
+++ b/frontend/components/forms/dialogs.py
@@ -21,11 +21,12 @@ async def show_case_notes_dialog() -> Optional[str]:
).classes(f"w-full min-h-24 {Design.INPUT_OUTLINED}")
with ui.row().classes(f"{Design.PANEL_SHELL_FOOTER} justify-end flex-wrap"):
- ui.button("Cancel", on_click=lambda: dialog.submit(None)).classes(
- Design.BTN_MEDIUM_GRAY
- )
+ ui.button(
+ "Cancel", color=None, on_click=lambda: dialog.submit(None)
+ ).classes(Design.BTN_MEDIUM_GRAY)
ui.button(
"Submit Job",
+ color=None,
on_click=lambda: dialog.submit((textarea.value or "").strip()),
).classes("rb-brand-primary text-white rounded-xl px-4 py-2")
diff --git a/frontend/components/forms/field_builders.py b/frontend/components/forms/field_builders.py
index a1bdc202..cb056e3a 100644
--- a/frontend/components/forms/field_builders.py
+++ b/frontend/components/forms/field_builders.py
@@ -175,17 +175,27 @@ async def create_parameter_field(
def create_directory_input(
field_id, initial_value, form_widgets, autofill_output_key=None
):
+ from frontend.utils import get_active_case
+
+ active_case = get_active_case()
+ default_path = active_case.evidencePath if active_case else ""
+
+ val = ""
+ if isinstance(initial_value, dict):
+ val = initial_value.get("path", "")
+ elif isinstance(initial_value, str):
+ val = initial_value
+
+ if not val:
+ val = default_path
+
with ui.column().classes("w-full min-w-0 gap-1"):
ui.label("Directory path").classes("text-sm font-medium text-zinc-700")
with ui.row().classes("w-full min-w-0 items-center gap-2 flex-nowrap"):
dir_input = (
ui.input(
placeholder="/path/to/directory",
- value=(
- initial_value.get("path", "")
- if isinstance(initial_value, dict)
- else ""
- ),
+ value=val,
)
.classes("flex-1 min-w-0")
.props("outlined dense")
@@ -219,24 +229,33 @@ def validate():
ui.button(
"Browse",
on_click=lambda: browse_directory_simple(
- dir_input, on_after_select=validate
+ dir_input,
+ initial_path=default_path or None,
+ on_after_select=validate,
),
).classes(Design.BTN_MEDIUM_GRAY)
form_widgets[field_id] = dir_input
def create_file_input(field_id, initial_value, form_widgets, autofill_mount_key=None):
+ from frontend.utils import get_active_case
+
+ active_case = get_active_case()
+ default_path = active_case.evidencePath if active_case else ""
+
+ val = ""
+ if isinstance(initial_value, dict):
+ val = initial_value.get("path", "")
+ elif isinstance(initial_value, str):
+ val = initial_value
+
with ui.column().classes("w-full min-w-0 gap-1"):
ui.label("File path").classes("text-sm font-medium text-zinc-700")
with ui.row().classes("w-full min-w-0 items-center gap-2 flex-nowrap"):
file_input = (
ui.input(
placeholder="/path/to/file",
- value=(
- initial_value.get("path", "")
- if isinstance(initial_value, dict)
- else ""
- ),
+ value=val,
)
.classes("flex-1 min-w-0")
.props("outlined dense")
@@ -270,7 +289,9 @@ def validate():
ui.button(
"Browse",
on_click=lambda: browse_file_simple(
- file_input, on_after_select=validate
+ file_input,
+ initial_path=default_path or None,
+ on_after_select=validate,
),
).classes(Design.BTN_MEDIUM_GRAY)
form_widgets[field_id] = file_input
diff --git a/frontend/components/forms/form_generator.py b/frontend/components/forms/form_generator.py
index 9db455c3..29793163 100644
--- a/frontend/components/forms/form_generator.py
+++ b/frontend/components/forms/form_generator.py
@@ -42,7 +42,7 @@ def _cancel_wrapper():
return
on_cancel()
- ui.button("Cancel", on_click=_cancel_wrapper).classes(
+ ui.button("Cancel", color=None, on_click=_cancel_wrapper).classes(
Design.BTN_MEDIUM_GRAY
)
@@ -59,9 +59,9 @@ async def _submit_wrapper():
finally:
btn.props["loading"] = False
- submit_btn = ui.button("▶ Submit Job", on_click=_submit_wrapper).classes(
- "rb-brand-primary text-white rounded-xl"
- )
+ submit_btn = ui.button(
+ "▶ Submit Job", color=None, on_click=_submit_wrapper
+ ).classes("rb-brand-primary text-white rounded-xl")
btn_ref[0] = submit_btn
return submit_btn
diff --git a/frontend/components/jobs.py b/frontend/components/jobs.py
index 4ad6c81d..200d782d 100644
--- a/frontend/components/jobs.py
+++ b/frontend/components/jobs.py
@@ -46,10 +46,11 @@ def _download() -> None:
ui.button(
"Export CASE JSON-LD",
icon="download",
+ color=None,
on_click=_download,
- ).classes(
- Design.BTN_MEDIUM_GRAY
- ).props("dense").tooltip("Download a JSON-LD fragment (UCO-oriented) for this job")
+ ).classes(Design.BTN_MEDIUM_GRAY).props("dense").tooltip(
+ "Download a JSON-LD fragment (UCO-oriented) for this job"
+ )
def render_compact_inputs_summary(
@@ -182,7 +183,7 @@ async def render_job_details_panel(
with container:
with ui.card().classes(
- "w-full min-w-0 max-w-full self-stretch bg-white border border-zinc-300 p-6"
+ "w-full min-w-0 max-w-full self-stretch bg-white border border-slate-200 shadow-md rounded-2xl p-6"
):
# Job metadata header
with ui.column().classes("gap-4 w-full min-w-0 max-w-full"):
@@ -324,7 +325,7 @@ async def render_job_outputs_card(container, api_client, job):
return
with ui.card().classes(
- "w-full min-w-0 max-w-full self-stretch bg-white border border-zinc-300 p-6"
+ "w-full min-w-0 max-w-full self-stretch bg-white border border-slate-200 shadow-md rounded-2xl p-6"
):
# Breadcrumbs live on the job page layout only (avoid duplicating under Outputs).
@@ -418,14 +419,17 @@ def render_job_row(
)
status = job.get("status", "Unknown")
- status_colors = {
- "Running": "text-[#881c1c]",
- "Completed": "text-green-600",
- "Failed": "text-red-600",
- "Canceled": "text-zinc-600",
+
+ # Status Pill Badges
+ status_pill_classes = {
+ "Completed": "bg-emerald-50 text-emerald-700 border border-emerald-200",
+ "Running": "bg-rose-50 text-[#881c1c] border border-rose-200",
+ "Failed": "bg-rose-50 text-rose-700 border border-rose-200",
+ "Canceled": "bg-slate-100 text-slate-600 border border-slate-200",
}
- status_color = status_colors.get(status, "text-zinc-600")
- # logger.debug("Job status: %s, color class: %s", status, status_color)
+ pill_cls = status_pill_classes.get(
+ status, "bg-slate-50 text-slate-500 border border-slate-200"
+ )
# Format timestamps
start_time_str = "N/A"
@@ -433,7 +437,6 @@ def render_job_row(
try:
start_time = datetime.fromisoformat(job["startTime"].replace("Z", "+00:00"))
start_time_str = start_time.strftime("%Y-%m-%d %H:%M")
- # logger.debug("Formatted start time: %s", start_time_str)
except Exception as e:
logger.warning(
"Failed to parse start time: %s, error: %s", job["startTime"], e
@@ -445,7 +448,6 @@ def render_job_row(
try:
end_time = datetime.fromisoformat(job["endTime"].replace("Z", "+00:00"))
end_time_str = end_time.strftime("%Y-%m-%d %H:%M")
- # logger.debug("Formatted end time: %s", end_time_str)
except Exception as e:
logger.warning("Failed to parse end time: %s, error: %s", job["endTime"], e)
end_time_str = job["endTime"]
@@ -453,55 +455,77 @@ def render_job_row(
job_uid = job.get("uid", "N/A")
with container:
with ui.row().classes(
- "p-4 border-b hover:bg-zinc-50 items-center w-full flex-nowrap gap-2"
+ "p-4 border-b border-slate-200 hover:bg-slate-50 items-center w-full flex-nowrap gap-2 bg-white"
):
# Job ID - truncated with ellipsis, full ID on hover
with ui.element("div").classes("w-40 min-w-0 shrink-0"):
- id_label = ui.label(job_uid).classes("font-mono text-sm truncate block")
+ id_label = ui.label(job_uid).classes(
+ "font-mono text-sm truncate block text-slate-800"
+ )
id_label.tooltip(job_uid)
# Model name (and notes indicator)
with ui.element("div").classes(
- "flex-1 min-w-0 overflow-hidden flex items-center gap-2"
+ "flex-1 min-w-0 overflow-hidden flex items-center gap-2 text-slate-800"
):
- ui.label(plugin_name or "Unknown").classes("truncate block")
+ ui.label(plugin_name or "Unknown").classes("truncate block font-medium")
if job.get("caseNotes"):
notes_preview = (job["caseNotes"] or "")[:50]
if len(job.get("caseNotes", "") or "") > 50:
notes_preview += "…"
ui.icon("description", size="sm").classes(
- "text-zinc-500 shrink-0"
+ "text-slate-500 shrink-0"
).tooltip(notes_preview)
# Times (start / end)
- with ui.column().classes("w-64 shrink-0"):
- ui.label(start_time_str).classes("text-sm")
- ui.label(end_time_str).classes("text-xs text-zinc-600")
+ with ui.column().classes("w-64 shrink-0 gap-0.5"):
+ ui.label(start_time_str).classes("text-sm text-slate-700")
+ ui.label(
+ f"Ended: {end_time_str}" if end_time_str != "N/A" else "Active"
+ ).classes("text-xs text-slate-500")
- # Status
- with ui.row().classes("w-32 shrink-0 items-center gap-1"):
- ui.label(status).classes(f"{status_color} font-semibold")
- if status == "Running":
- ui.spinner(color="primary", size="xs")
+ # Status Pill Badge
+ with ui.row().classes(
+ f"w-32 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}"
+ ):
+ if status == "Completed":
+ ui.icon("check_circle", size="14px")
+ elif status == "Running":
+ ui.spinner(size="14px").classes("text-[#881c1c]")
+ elif status == "Failed":
+ ui.icon("error", size="14px")
+ else:
+ ui.icon("cancel", size="14px")
+ ui.label(status)
# Actions
- with ui.row().classes("gap-2 w-48 shrink-0"):
+ with ui.row().classes("gap-2 w-48 shrink-0 flex-nowrap"):
if on_view:
ui.button(
"View",
+ icon="visibility",
+ color=None,
on_click=lambda: on_view(job["uid"]) if on_view else None,
).classes(Design.BTN_PRIMARY_TIGHT)
if status == "Running" and on_cancel:
ui.button(
"Cancel",
+ icon="cancel",
+ color=None,
on_click=lambda: on_cancel(job["uid"]) if on_cancel else None,
- ).classes("bg-red-600 text-white text-sm")
+ ).classes(
+ "bg-rose-50 hover:bg-rose-100 text-rose-700 px-3 py-1 rounded text-sm transition-colors border border-rose-200"
+ )
elif status != "Running" and on_delete:
ui.button(
"Delete",
+ icon="delete",
+ color=None,
on_click=lambda: on_delete(job["uid"]) if on_delete else None,
- ).classes(Design.BTN_PRIMARY_TIGHT)
+ ).classes(
+ "bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200"
+ )
# logger.debug("Delete button added")
diff --git a/frontend/components/logs.py b/frontend/components/logs.py
index 467b6576..82afbe0e 100644
--- a/frontend/components/logs.py
+++ b/frontend/components/logs.py
@@ -9,17 +9,32 @@
def render_log_viewer(container: ui.element, log_file: Path, max_lines: int = 1000):
"""
- Render a log viewer inside `container`. Returns the code element for updates.
+ Render a log viewer inside `container` with search/filtering capabilities.
+ Returns the code element for updates.
"""
try:
with container:
- # Controls row
- with ui.row().classes("gap-4 items-center mb-4"):
+ # Controls row with Refresh and Search Input (instant typing filter)
+ with ui.row().classes(
+ "gap-4 items-center mb-4 w-full flex-wrap sm:flex-nowrap"
+ ):
refresh_btn = (
ui.button("Refresh")
.props("icon=refresh")
.classes(Design.BTN_PRIMARY_COMPACT)
)
+
+ # Search input with prepended search icon, clearable prop, and debounce
+ search_input = (
+ ui.input(
+ placeholder="Search/filter logs...",
+ )
+ .props("outlined dense clearable debounce=300")
+ .classes("w-64 bg-white")
+ )
+ with search_input.add_slot("prepend"):
+ ui.icon("search").classes("text-slate-400")
+
ui.label(f"Log file: {str(log_file)}").classes("text-sm text-zinc-600")
# Log content display - full width, fill viewport height below navbar
@@ -27,23 +42,68 @@ def render_log_viewer(container: ui.element, log_file: Path, max_lines: int = 10
with ui.scroll_area().classes(
"min-h-[calc(100vh-12rem)] w-full max-w-full"
):
- log_display = ui.code().classes(
- "w-full max-w-full text-xs font-mono whitespace-pre-wrap"
+ # Use a custom lightweight label subclass instead of ui.code to prevent Prism.js DOM bloat and focus lag
+ class LogDisplayLabel(ui.label):
+ @property
+ def content(self) -> str:
+ return self.text
+
+ @content.setter
+ def content(self, value: str):
+ self.set_text(value)
+
+ log_display = LogDisplayLabel().classes(
+ "w-full max-w-full text-xs font-mono whitespace-pre-wrap block p-4 bg-slate-50 rounded-xl border border-slate-200 shadow-inner"
)
- log_display.props("language=text")
- # Attach simple refresh handler (caller may override or call _load_logs directly)
+ # Initialize attributes on log_display
+ log_display.search_input = search_input
+ log_display.raw_content = ""
+
+ def _apply_filter(query: str = None):
+ if query is None:
+ query = (search_input.value or "").strip()
+ else:
+ query = str(query).strip() if query is not None else ""
+
+ if not log_display.raw_content:
+ log_display.content = ""
+ return
+
+ if not query:
+ log_display.content = log_display.raw_content
+ return
+
+ # Filter lines
+ lines = log_display.raw_content.splitlines()
+ matching_lines = [
+ line for line in lines if query.lower() in line.lower()
+ ]
+
+ if matching_lines:
+ header = f"[Found {len(matching_lines)} matching lines for '{query}']\n\n"
+ log_display.content = header + "\n".join(matching_lines)
+ else:
+ log_display.content = f"[No matching lines found for '{query}']"
+
+ # Expose apply_filter on log_display so external callers can trigger it
+ log_display.apply_filter = _apply_filter
+
+ # Attach simple refresh handler
def _refresh():
try:
- from frontend.pages.logs import read_log_file, format_log_content
+ from frontend.pages.logs import read_log_file
content = read_log_file(log_file, max_lines)
- formatted = format_log_content(content)
- log_display.content = formatted
+ log_display.raw_content = content
+ _apply_filter(search_input.value)
except Exception as e:
logger.exception("Failed refreshing logs: %s", e)
+ # Bind event handlers
refresh_btn.on("click", lambda e=None: _refresh())
+ search_input.on_value_change(lambda e: _apply_filter(e.value))
+
# Return the element for callers to update
return log_display
except Exception as e:
diff --git a/frontend/components/models.py b/frontend/components/models.py
index ae7561da..1c5f4739 100644
--- a/frontend/components/models.py
+++ b/frontend/components/models.py
@@ -3,6 +3,7 @@
from datetime import datetime
from nicegui import ui
from frontend.constants import UI_BUTTONS
+from frontend.design_tokens import Design
# Configure logging for this module
logger = logging.getLogger(__name__)
@@ -95,7 +96,7 @@ def render_model_card(
with container:
logger.debug("Creating model card container")
with ui.card().classes(
- "rb-models-plugin-card w-full p-6 hover:shadow-lg transition-shadow"
+ "rb-models-plugin-card w-full p-6 hover:shadow-md transition-all border-l-4 border-l-[#881c1c] border-y border-r border-slate-200 rounded-xl bg-white"
):
with ui.row().classes("items-center justify-between w-full"):
# Left section - Model info
@@ -103,7 +104,7 @@ def render_model_card(
# Icon and name row
with ui.row().classes("items-center gap-3"):
# Model icon based on category (you can enhance this)
- icon = (
+ icon_name = (
"image"
if "image" in model.get("name", "").lower()
else (
@@ -112,28 +113,42 @@ def render_model_card(
else (
"description"
if "text" in model.get("name", "").lower()
- else "category"
+ else "extension"
)
)
)
- logger.debug("Selected icon: %s for model category", icon)
- # ui.icon(icon, size='lg').classes('text-indigo-600')
- ui.label(model["name"]).classes("text-2xl font-bold")
+ logger.debug("Selected icon: %s for model category", icon_name)
+ ui.icon(icon_name, size="sm").classes("text-[#881c1c]")
+ ui.label(model["name"]).classes(
+ "text-2xl font-bold text-slate-800"
+ )
logger.debug("Model name label added: %s", model["name"])
# Version, author, GPU info
with ui.row().classes(
- "gap-4 mt-2 text-sm text-zinc-600 items-center"
+ "gap-4 mt-2 text-sm text-slate-500 items-center"
):
ui.label(f"v{model['version']}")
ui.label("•")
ui.label(model.get("author", "Unknown"))
if model.get("gpu"):
- ui.badge("GPU Required", color="black").classes("text-xs")
+ ui.badge("GPU Required", color="orange").classes(
+ "text-xs font-semibold px-2 py-0.5 rounded"
+ )
# Right section - Status and actions
- with ui.column().classes("items-end gap-2"):
- # Status badge
+ with ui.column().classes("items-end gap-3"):
+ # Status Badge
+ status_pill_cls = (
+ "bg-emerald-50 text-emerald-700 border border-emerald-200"
+ if is_online
+ else "bg-rose-50 text-rose-700 border border-rose-200"
+ )
+ with ui.row().classes(
+ f"items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold {status_pill_cls}"
+ ):
+ ui.icon("check_circle" if is_online else "error", size="14px")
+ ui.label(status_text)
# Action buttons
with ui.row().classes("gap-2"):
@@ -141,19 +156,25 @@ def render_model_card(
if on_inspect:
ui.button(
UI_BUTTONS["plugin_readme"],
+ icon="menu_book",
+ color=None,
on_click=lambda: (
on_inspect(model["uid"]) if on_inspect else None
),
- ).classes("rb-brand-primary text-white")
+ ).classes(Design.BTN_PRIMARY_COMPACT)
logger.debug("README button added")
if not is_online and on_connect:
ui.button(
- "🔌 Connect",
+ "Connect",
+ icon="power",
+ color=None,
on_click=lambda: (
on_connect(model["uid"]) if on_connect else None
),
- ).classes("bg-zinc-600 text-white")
+ ).classes(
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200"
+ )
logger.debug("Connect button added (model is offline)")
logger.debug("Model card rendered successfully")
diff --git a/frontend/components/pickers.py b/frontend/components/pickers.py
index 620c738d..af19b8b3 100644
--- a/frontend/components/pickers.py
+++ b/frontend/components/pickers.py
@@ -28,6 +28,7 @@ def show_analysis_picker_dialog(
for num, option in options.items():
ui.button(
f'{num}. {option["name"]} - {option["desc"]}',
+ color=None,
on_click=lambda *a, opt=option: on_selected(opt["name"]),
).classes(
"text-left p-2 h-auto whitespace-normal justify-start text-sm "
diff --git a/frontend/components/results.py b/frontend/components/results.py
index acb4a741..7a9fa631 100644
--- a/frontend/components/results.py
+++ b/frontend/components/results.py
@@ -137,9 +137,12 @@ def open_file(path: str):
with ui.row().classes("gap-2 mt-2"):
ui.button(
"Open folder",
+ color=None,
on_click=lambda: open_folder(os.path.dirname(path)),
- ).props("outline")
- ui.button("Close", on_click=d.close).classes(Design.BTN_MEDIUM_GRAY)
+ ).classes(Design.BTN_SECONDARY_NEUTRAL)
+ ui.button("Close", color=None, on_click=d.close).classes(
+ Design.BTN_MEDIUM_GRAY
+ )
d.open()
else:
ui.navigate.to(route)
@@ -302,9 +305,12 @@ def open_image_bbox_preview_dialog(
ui.button(
"Open folder",
icon="folder_open",
+ color=None,
on_click=lambda: open_folder(os.path.dirname(abs_path)),
- ).props("outline")
- ui.button("Close", on_click=dialog.close).classes(Design.BTN_MEDIUM_GRAY)
+ ).classes(Design.BTN_SECONDARY_NEUTRAL)
+ ui.button("Close", color=None, on_click=dialog.close).classes(
+ Design.BTN_MEDIUM_GRAY
+ )
dialog.open()
@@ -570,6 +576,7 @@ def render_directory(container, response):
ui.button(
"Open Folder",
icon="folder_open",
+ color=None,
on_click=lambda: open_folder(path),
).classes(Design.BTN_PRIMARY_COMPACT)
if path:
@@ -636,11 +643,13 @@ def render_file(container, response):
ui.button(
"Open File",
icon="visibility",
+ color=None,
on_click=lambda: open_file(path),
).classes(Design.BTN_PRIMARY_COMPACT)
ui.button(
"Open Folder",
icon="folder",
+ color=None,
on_click=lambda: open_folder(os.path.dirname(path)),
).classes(Design.BTN_SECONDARY_NEUTRAL)
if path:
@@ -887,10 +896,9 @@ def _open_image_summary_markdown_modal(file_info: Dict[str, Any]) -> None:
)
with ui.dialog() as dialog:
dialog.props("position=right full-height").classes("image-summary-side-dialog")
- dialog.style("width: min(520px, 48vw); max-width: 100vw;")
with ui.card().classes(
- "w-full h-full min-h-0 flex flex-col p-6 rounded-none shadow-2xl border-l border-zinc-200 bg-white"
- ):
+ "h-full min-h-0 flex flex-col p-6 rounded-none shadow-2xl border-l border-zinc-200 bg-white"
+ ).style("width: min(520px, 48vw); max-width: 100vw;"):
ui.label(name).classes("text-2xl font-semibold shrink-0 mb-4")
with ui.column().classes(
"overflow-y-auto flex-1 min-h-0 w-full image-summary-md-modal"
@@ -899,9 +907,26 @@ def _open_image_summary_markdown_modal(file_info: Dict[str, Any]) -> None:
with ui.row().classes("gap-2 mt-4 shrink-0 justify-end flex-wrap"):
if path_full:
ui.button(
- "Open raw file", on_click=lambda: open_file(path_full)
- ).props("flat outline")
- ui.button("Close", on_click=dialog.close).classes(
+ "Open raw file",
+ color=None,
+ on_click=lambda: open_file(path_full),
+ ).classes(Design.BTN_SECONDARY_NEUTRAL)
+
+ def _download_raw():
+ try:
+ import os
+
+ with open(path_full, "rb") as f:
+ data = f.read()
+ ui.download(data, os.path.basename(path_full))
+ except Exception as e:
+ ui.notify(f"Error downloading file: {e}", type="negative")
+
+ ui.button(
+ "Download raw file", color=None, on_click=_download_raw
+ ).classes(Design.BTN_SECONDARY_NEUTRAL)
+
+ ui.button("Close", color=None, on_click=dialog.close).classes(
Design.BTN_MEDIUM_GRAY
)
dialog.open()
diff --git a/frontend/components/shared.py b/frontend/components/shared.py
index bfcf6c5e..c34ceef3 100644
--- a/frontend/components/shared.py
+++ b/frontend/components/shared.py
@@ -4,7 +4,6 @@
from nicegui import ui
from frontend.utils.ui import notify_success as _ns, notify_error as _ne
from frontend.utils.ui import notify_info as _ni, notify_warning as _nw
-from frontend.config import APP_TITLE, APP_VERSION
import frontend.constants as constants
from frontend.design_tokens import Design
from frontend.utils import get_user_id_for_jobs
@@ -125,22 +124,20 @@ def create_navbar():
with ui.header(wrap=False).classes(Design.NAV_HEADER):
# logger.debug("Header created with sticky positioning and blue theme")
- _logo_px = "11.25rem"
- _logo_style = (
- f"width:{_logo_px};height:{_logo_px};max-width:{_logo_px};max-height:{_logo_px};"
- "min-width:0;min-height:0;display:block;object-fit:contain;"
- )
-
_link_cls = Design.NAV_LINK
_nav_locked = get_user_id_for_jobs() is None
def _nav_blocked_msg():
ui.notify(
- "Enter a valid User ID on the home page.",
+ "Please select or create an active Case on the home page.",
type="warning",
classes="rb-notify-505759",
)
+ from frontend.utils import get_active_case
+
+ active_case = get_active_case()
+
with ui.row().classes(
"w-full min-w-0 min-h-12 h-auto sm:h-14 px-2 sm:px-3 py-0 items-center gap-2 sm:gap-3 "
"box-border flex-wrap sm:flex-nowrap justify-start"
@@ -148,18 +145,83 @@ def _nav_blocked_msg():
# logger.debug("Creating navbar container with responsive layout")
with ui.row().classes("shrink-0 items-center gap-2 min-w-0"):
- (
- ui.element("img")
- .props(f'src=/icons/rb.webp alt="{APP_TITLE}"')
- .classes("shrink-0 object-contain")
- .style(_logo_style)
- )
- with ui.row().classes("items-baseline gap-2 min-w-0"):
- ui.label(APP_TITLE).classes(
- "!text-base sm:!text-lg lg:!text-xl font-bold !leading-tight text-white "
- "truncate min-w-0 max-w-[12rem] sm:max-w-[16rem] lg:max-w-[18rem]"
+ with ui.row().classes("items-center cursor-pointer").on(
+ "click", lambda _: ui.navigate.to("/")
+ ):
+ ui.html(
+ '
',
+ sanitize=False,
)
- ui.label(APP_VERSION).classes(Design.NAV_VERSION_MUTED)
+ if active_case:
+ from frontend.database import get_case_db
+ from frontend.utils import set_active_case_id, clear_active_case_id
+
+ try:
+ all_cases = get_case_db().get_all_cases_sync()
+ other_cases = [
+ c for c in all_cases if c.caseId != active_case.caseId
+ ]
+ except Exception:
+ all_cases = [active_case]
+ other_cases = []
+
+ if len(all_cases) <= 1:
+ # Just show a clean static badge if there is only one case in the system
+ with ui.row().classes(
+ "items-center gap-1 bg-black/20 px-2.5 py-1 rounded-lg border border-white/20 ml-2 cursor-pointer"
+ ).on("click", lambda _: ui.navigate.to("/case")):
+ ui.icon("folder", size="xs").classes("text-white")
+ ui.label(f"Case: {active_case.caseNumber}").classes(
+ "text-xs font-semibold text-white"
+ )
+ else:
+ # Show the interactive dropdown if there are multiple cases to switch between
+ with ui.dropdown_button(
+ f"Case: {active_case.caseNumber}",
+ icon="folder",
+ color=None,
+ auto_close=True,
+ ).classes(
+ "text-xs font-semibold text-white bg-black/20 px-2.5 py-1 rounded-lg border border-white/20 ml-2 cursor-pointer"
+ ).props(
+ "flat dense no-caps split"
+ ).on(
+ "click", lambda _: ui.navigate.to("/case")
+ ):
+ ui.menu_item(
+ "Case Overview",
+ on_click=lambda: ui.navigate.to("/case"),
+ ).classes("font-semibold text-[#881c1c]")
+ ui.separator()
+ if other_cases:
+ ui.label("Switch Case:").classes(
+ "text-[10px] font-bold text-slate-400 px-3 py-1 uppercase tracking-wider"
+ )
+ for c in other_cases[:5]: # Show up to 5 other cases
+
+ def _switch_case(cid=c.caseId):
+ set_active_case_id(cid)
+ ui.notify(
+ f"Switched to case {c.caseNumber}.",
+ type="positive",
+ )
+ ui.timer(
+ 0.3,
+ lambda: ui.navigate.to("/case"),
+ once=True,
+ )
+
+ ui.menu_item(c.caseNumber, on_click=_switch_case)
+ ui.separator()
+
+ def _close_active_case():
+ clear_active_case_id()
+ ui.notify("Case closed.", type="info")
+ ui.timer(0.2, lambda: ui.navigate.to("/"), once=True)
+
+ ui.menu_item(
+ "Close Case", on_click=_close_active_case
+ ).classes("text-rose-500 font-semibold")
with ui.row().classes("min-w-0 flex-1 justify-end items-center"):
with ui.row().classes(
@@ -171,10 +233,10 @@ def _nav_blocked_msg():
_nav_items = (
("Assistant", "/chatbot"),
("Jobs", "/jobs"),
- ("Demo", "/demo"),
+ ("Logs", "/logs"),
)
for label, path in _nav_items:
- if _nav_locked and label != "Demo":
+ if _nav_locked:
ui.label(label).classes(
_link_cls + " opacity-50 cursor-not-allowed select-none"
).on("click", lambda _: _nav_blocked_msg())
@@ -190,11 +252,8 @@ def _open_readme() -> None:
else:
ui.navigate.to("/models")
- def _open_logs() -> None:
- if _nav_locked:
- _nav_blocked_msg()
- else:
- ui.navigate.to("/logs")
+ def _open_demo() -> None:
+ ui.navigate.to("/demo")
with ui.dropdown_button(
"Resources",
@@ -202,7 +261,6 @@ def _open_logs() -> None:
auto_close=True,
).classes(_link_cls).props("flat dense no-caps"):
ui.menu_item("Readme", on_click=_open_readme)
- ui.menu_item("Logs", on_click=_open_logs)
ui.menu_item("About", on_click=_open_about)
# Session display removed for demo safety (avoids accidental user actions)
diff --git a/frontend/config.py b/frontend/config.py
index 1ae96765..d82a74bf 100644
--- a/frontend/config.py
+++ b/frontend/config.py
@@ -29,8 +29,8 @@
APP_TITLE = os.getenv("RESCUEBOX_APP_TITLE", "RescueBox")
APP_PORT = int(os.getenv("RESCUEBOX_PORT", "8080"))
APP_VERSION = os.getenv("RESCUEBOX_VERSION", "3.0.0")
-# Tab icon: filesystem path so NiceGUI can serve it at /favicon.ico (webp is fine for modern browsers)
-APP_FAVICON = Path(__file__).resolve().parent / "icons" / "rb.webp"
+# Tab icon: filesystem path so NiceGUI can serve it at /favicon.ico
+APP_FAVICON = Path(__file__).resolve().parent / "icons" / "favicon.png"
APP_DARK_MODE = os.getenv("RESCUEBOX_DARK_MODE", "false").lower() == "true"
APP_SHOW_BROWSER = os.getenv("RESCUEBOX_SHOW_BROWSER", "false").lower() == "false"
diff --git a/frontend/constants.py b/frontend/constants.py
index 28f25c05..58b83783 100644
--- a/frontend/constants.py
+++ b/frontend/constants.py
@@ -108,6 +108,7 @@ def is_valid_explicit_user_id(value: Optional[str]) -> bool:
"demo": "/demo",
"about": "/about",
"home": "/",
+ "case": "/case",
}
# Legacy: License & Copyright UI lives on ``/about``; ``/licenses`` redirects there.
diff --git a/frontend/database/__init__.py b/frontend/database/__init__.py
index c3b77021..43d9f06b 100644
--- a/frontend/database/__init__.py
+++ b/frontend/database/__init__.py
@@ -15,6 +15,7 @@
from .job_db import JobRecord, JobStatus, get_job_db, init_database as init_job_database
from .chat_history_db import ConversationRecord, ChatMessageRecord, get_chat_history_db
+from .case_db import CaseRecord, CaseDB, get_case_db, init_case_database
logger = logging.getLogger(__name__)
@@ -144,4 +145,8 @@ async def get_cached_model_by_uid(uid: str) -> Optional[Dict[str, Any]]:
"ConversationRecord",
"ChatMessageRecord",
"get_chat_history_db", # Chat History DB
+ "CaseRecord",
+ "CaseDB",
+ "get_case_db",
+ "init_case_database", # Case DB
]
diff --git a/frontend/database/case_db.py b/frontend/database/case_db.py
new file mode 100644
index 00000000..ebf10d97
--- /dev/null
+++ b/frontend/database/case_db.py
@@ -0,0 +1,179 @@
+"""
+Case Database Module
+
+This module provides SQLite database functionality for storing and managing cases
+in the RescueBox Desktop application.
+"""
+
+import logging
+import sqlite3
+from datetime import datetime
+from pathlib import Path
+from typing import List, Optional
+import uuid
+from pydantic import BaseModel, Field
+
+from frontend.database.base_db import BaseDatabase
+from frontend.database.schemas import JobDatabaseSchema, SchemaManager
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+
+
+class CaseRecord(BaseModel):
+ """Pydantic model for case records in the database."""
+
+ caseId: str = Field(..., description="Unique case identifier")
+ caseNumber: str = Field(..., description="Case number or ID")
+ investigators: Optional[str] = Field(None, description="Names of investigators")
+ evidencePath: str = Field(..., description="Path to evidence folder or UFDR file")
+ createdAt: str = Field(..., description="ISO timestamp of creation")
+ updatedAt: str = Field(..., description="ISO timestamp of last update")
+
+
+class CaseDB(BaseDatabase):
+ """
+ Case database manager for SQLite storage.
+ Manages case records in the SQLite database (jobs.db).
+ """
+
+ def __init__(self, db_path: Optional[Path] = None):
+ super().__init__(db_path, "jobs.db")
+ schema = JobDatabaseSchema()
+ self.schema_manager = SchemaManager(schema)
+
+ def _create_schema(self) -> None:
+ """Create database schema for cases (part of JobDatabaseSchema)."""
+ self.schema_manager.create_schema(self.conn)
+
+ async def initialize_schema(self):
+ """Initialize database schema (create cases table if it doesn't exist)."""
+ logger.info("Initializing database schema for cases")
+ self._create_schema()
+
+ async def create_case(
+ self,
+ case_number: str,
+ investigators: Optional[str],
+ evidence_path: str,
+ ) -> CaseRecord:
+ """
+ Create a new case record.
+ """
+ conn = self.connect()
+ case_id = f"CASE_{uuid.uuid4().hex[:6]}"
+ now = datetime.now().isoformat()
+
+ case_record = CaseRecord(
+ caseId=case_id,
+ caseNumber=case_number.strip(),
+ investigators=investigators.strip() if investigators else None,
+ evidencePath=evidence_path.strip(),
+ createdAt=now,
+ updatedAt=now,
+ )
+
+ insert_sql = """
+ INSERT INTO cases (caseId, caseNumber, investigators, evidencePath, createdAt, updatedAt)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """
+ params = (
+ case_record.caseId,
+ case_record.caseNumber,
+ case_record.investigators,
+ case_record.evidencePath,
+ case_record.createdAt,
+ case_record.updatedAt,
+ )
+
+ try:
+ conn.execute(insert_sql, params)
+ conn.commit()
+ logger.info("Case %s (%s) created successfully", case_id, case_number)
+ return case_record
+ except sqlite3.IntegrityError as e:
+ logger.error("Failed to create case due to integrity error: %s", e)
+ raise ValueError(f"Case number '{case_number}' already exists.")
+ except Exception as e:
+ logger.error("Failed to create case: %s", e)
+ raise
+
+ async def get_case_by_id(self, case_id: str) -> Optional[CaseRecord]:
+ """Get a case by its ID."""
+ conn = self.connect()
+ cursor = conn.execute("SELECT * FROM cases WHERE caseId = ?", (case_id,))
+ row = cursor.fetchone()
+ if row:
+ return CaseRecord(**dict(row))
+ return None
+
+ def get_case_by_id_sync(self, case_id: str) -> Optional[CaseRecord]:
+ """Get a case by its ID synchronously."""
+ conn = self.connect()
+ cursor = conn.execute("SELECT * FROM cases WHERE caseId = ?", (case_id,))
+ row = cursor.fetchone()
+ if row:
+ return CaseRecord(**dict(row))
+ return None
+
+ async def get_case_by_number(self, case_number: str) -> Optional[CaseRecord]:
+ """Get a case by its case number."""
+ conn = self.connect()
+ cursor = conn.execute(
+ "SELECT * FROM cases WHERE caseNumber = ?", (case_number.strip(),)
+ )
+ row = cursor.fetchone()
+ if row:
+ return CaseRecord(**dict(row))
+ return None
+
+ async def get_all_cases(self) -> List[CaseRecord]:
+ """Get all cases, sorted by creation time (newest first)."""
+ conn = self.connect()
+ cursor = conn.execute("SELECT * FROM cases ORDER BY createdAt DESC")
+ return [CaseRecord(**dict(row)) for row in cursor.fetchall()]
+
+ def get_all_cases_sync(self) -> List[CaseRecord]:
+ """Get all cases synchronously, sorted by creation time (newest first)."""
+ conn = self.connect()
+ cursor = conn.execute("SELECT * FROM cases ORDER BY createdAt DESC")
+ return [CaseRecord(**dict(row)) for row in cursor.fetchall()]
+
+ async def update_case_evidence_path(self, case_id: str, new_path: str) -> bool:
+ """Update the evidence path of a case."""
+ conn = self.connect()
+ now = datetime.now().isoformat()
+ cursor = conn.execute(
+ "UPDATE cases SET evidencePath = ?, updatedAt = ? WHERE caseId = ?",
+ (new_path.strip(), now, case_id),
+ )
+ conn.commit()
+ return cursor.rowcount > 0
+
+ async def delete_case(self, case_id: str) -> bool:
+ """Delete a case by its ID."""
+ conn = self.connect()
+ cursor = conn.execute("DELETE FROM cases WHERE caseId = ?", (case_id,))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+_case_db: Optional[CaseDB] = None
+
+
+async def init_case_database(db_path: Optional[Path] = None) -> CaseDB:
+ """Initialize case database and return CaseDB instance."""
+ global _case_db
+ if _case_db is None:
+ _case_db = CaseDB(db_path)
+ await _case_db.initialize_schema()
+ return _case_db
+
+
+def get_case_db() -> CaseDB:
+ """Get global CaseDB instance, initializing it if needed."""
+ global _case_db
+ if _case_db is None:
+ _case_db = CaseDB()
+ _case_db.connect()
+ return _case_db
diff --git a/frontend/database/job_db.py b/frontend/database/job_db.py
index a4b69d38..520f19cb 100644
--- a/frontend/database/job_db.py
+++ b/frontend/database/job_db.py
@@ -684,6 +684,47 @@ async def create_job(
)
raise RuntimeError("Failed to create job due to database errors")
+ def get_job_by_uid_sync(self, uid: str) -> Optional[JobRecord]:
+ """
+ Get job by UID synchronously.
+ """
+ conn = self.connect()
+ try:
+ self._ensure_userid_column(conn)
+ self._ensure_caseNotes_column(conn)
+ self._ensure_endpoint_chain_column(conn)
+ self._ensure_pipeline_root_job_id_column(conn)
+ self._ensure_pipeline_metadata_filter_criteria_column(conn)
+ except Exception:
+ pass
+
+ cursor = conn.execute("SELECT * FROM jobs WHERE uid = ?", (uid,))
+ row = cursor.fetchone()
+
+ if row:
+ job_dict = self._row_to_dict(row)
+ try:
+ from frontend.utils import get_user_id_for_jobs
+
+ current_user_id = get_user_id_for_jobs()
+ except Exception:
+ current_user_id = None
+
+ if (
+ current_user_id
+ and job_dict.get("userId")
+ and job_dict.get("userId") != current_user_id
+ ):
+ logger.warning("Access denied for job %s: session mismatch", uid)
+ return None
+ try:
+ job_record = JobRecord(**job_dict)
+ return job_record
+ except Exception as e:
+ logger.error("Failed to validate job %s as JobRecord: %s", uid, e)
+ return None
+ return None
+
async def get_job_by_uid(self, uid: str) -> Optional[JobRecord]:
"""
Get job by UID.
@@ -949,6 +990,16 @@ async def update_job_status(
logger.warning("Job %s not found for update", uid)
return False
+ async def disassociate_job_from_case(self, uid: str) -> bool:
+ """
+ Disassociate a job from its case by setting its userId to None.
+ """
+ conn = self.connect()
+ logger.info("Disassociating job %s from case", uid)
+ cursor = conn.execute("UPDATE jobs SET userId = NULL WHERE uid = ?", (uid,))
+ conn.commit()
+ return cursor.rowcount > 0
+
async def delete_job(self, uid: str) -> bool:
"""
Delete job by UID.
diff --git a/frontend/database/schemas.py b/frontend/database/schemas.py
index f9407f5d..0de24040 100644
--- a/frontend/database/schemas.py
+++ b/frontend/database/schemas.py
@@ -85,6 +85,16 @@ def get_create_statements(self) -> List[str]:
updated_at TEXT NOT NULL
)
""",
+ """
+ CREATE TABLE IF NOT EXISTS cases (
+ caseId TEXT PRIMARY KEY,
+ caseNumber TEXT NOT NULL UNIQUE,
+ investigators TEXT,
+ evidencePath TEXT NOT NULL,
+ createdAt TEXT NOT NULL,
+ updatedAt TEXT NOT NULL
+ )
+ """,
]
def get_index_statements(self) -> List[str]:
@@ -98,6 +108,7 @@ def get_index_statements(self) -> List[str]:
"CREATE INDEX IF NOT EXISTS filterID ON jobs(filterId)",
"CREATE INDEX IF NOT EXISTS idx_file_filters_input_dir ON file_filters(input_dir)",
"CREATE INDEX IF NOT EXISTS idx_file_filters_owner_id ON file_filters(owner_id)",
+ "CREATE INDEX IF NOT EXISTS idx_cases_case_number ON cases(caseNumber)",
]
diff --git a/frontend/design_tokens.py b/frontend/design_tokens.py
index 935e5081..38ff65b6 100644
--- a/frontend/design_tokens.py
+++ b/frontend/design_tokens.py
@@ -1,9 +1,8 @@
"""
Canonical Tailwind class strings for RescueBox.
-Global primary actions use UMass Maroon (PMS 202, #881c1c, RGB 136 28 28); hover is a darker
+Global primary actions use UMass Maroon (#881c1c); hover is a darker
maroon (#6a1616). Quasar ``--q-primary`` is set in ``frontend/utils/ui_readability_css.py``.
-See https://www.umass.edu/brand/visual-identity/brand-colors and ``frontend/design.json``.
"""
from __future__ import annotations
@@ -12,18 +11,18 @@
class Design:
"""Brand-aligned utility classes (NiceGUI + Quasar + Tailwind)."""
- # --- Navigation (Medium Gray #505759 — background from .rb-brand-nav in ui_readability_css) ---
+ # --- Navigation (UMass Maroon #881c1c with white text) ---
NAV_HEADER = (
"rb-brand-nav text-white shadow-lg shadow-black/30 sticky top-0 z-50 "
"w-full max-w-[100vw] overflow-hidden"
)
NAV_LINK = (
"text-white hover:underline px-1.5 py-0.5 sm:px-2 sm:py-0.5 rounded "
- "hover:bg-white/10 !text-sm sm:!text-base whitespace-nowrap !leading-snug"
+ "hover:bg-white/10 !text-sm sm:!text-base whitespace-nowrap !leading-snug font-semibold"
)
- NAV_VERSION_MUTED = "!text-sm sm:!text-base font-medium text-zinc-400 shrink-0"
+ NAV_VERSION_MUTED = "!text-sm sm:!text-base font-medium text-slate-400 shrink-0"
- # --- Buttons (Maroon #881c1c; hover #6a1616 — see :root / .rb-brand-primary in ui_readability_css) ---
+ # --- Buttons (UMass Maroon #881c1c; hover #6a1616) ---
BTN_PRIMARY = (
"rb-brand-primary text-white px-5 py-2.5 rounded-xl "
"font-semibold shadow-md shadow-black/20 transition-all active:scale-95"
@@ -33,15 +32,17 @@ class Design:
"font-medium shadow-sm transition-colors"
)
BTN_PRIMARY_TIGHT = (
- "rb-brand-primary text-white px-3 py-1 rounded text-sm " "transition-colors"
+ "rb-brand-primary text-white px-3 py-1 rounded text-sm transition-colors"
+ )
+ BTN_GHOST = (
+ "text-slate-600 hover:bg-slate-100 px-4 py-2 rounded-lg transition-colors"
)
- BTN_GHOST = "text-zinc-600 hover:bg-zinc-100 px-4 py-2 rounded-lg transition-colors"
BTN_SECONDARY_NEUTRAL = (
- "bg-zinc-100 hover:bg-zinc-200 text-zinc-800 px-4 py-2 rounded-lg "
- "font-medium transition-colors border border-zinc-200"
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg "
+ "font-medium transition-colors border border-slate-200"
)
- BTN_DISABLED = "bg-zinc-300 text-zinc-500 cursor-not-allowed"
- # Browse / Cancel-style actions (UMass Medium Gray #505759 — see .rb-btn-medium-gray in ui_readability_css)
+ BTN_DISABLED = "bg-slate-200 text-slate-400 cursor-not-allowed"
+ # Browse / Cancel-style actions (UMass Maroon #881c1c)
BTN_MEDIUM_GRAY = (
"rb-btn-medium-gray text-white rounded-lg font-medium transition-colors"
)
@@ -50,81 +51,77 @@ class Design:
LINK = "text-[#881c1c] hover:underline"
# --- Chat bubbles (cards) ---
- # User bubble: no tinted fill — white surface + zinc ring (assistant-style, right tail)
CHAT_USER_BUBBLE = (
- "bg-white text-zinc-900 rounded-2xl rounded-tr-none px-4 py-3 shadow-sm "
- "ring-1 ring-zinc-200 border-0"
+ "bg-slate-100 text-slate-800 rounded-2xl rounded-tr-none px-4 py-3 shadow-sm "
+ "ring-1 ring-slate-200 border-0"
)
CHAT_USER_LABEL = (
- "font-medium !text-xs sm:!text-sm text-zinc-900 uppercase tracking-wide"
+ "font-medium !text-sm sm:!text-base text-slate-600 uppercase tracking-wide"
)
CHAT_ASSISTANT_BUBBLE = (
- "bg-white text-zinc-800 ring-1 ring-zinc-200 rounded-2xl rounded-tl-none "
+ "bg-white text-slate-800 ring-1 ring-slate-200 rounded-2xl rounded-tl-none "
"px-4 py-3 shadow-sm border-0"
)
- # Use with CHAT_ASSISTANT_BUBBLE so assistant text, markdown, and tool-call cards share one column width.
CHAT_ASSISTANT_BUBBLE_WIDTH = "w-full max-w-3xl min-w-0"
CHAT_SYSTEM_TOOL = (
- "bg-zinc-50 border-l-4 border-[#505759] p-4 italic text-zinc-600 text-sm"
+ "bg-slate-50 border-l-4 border-[#881c1c] p-4 italic text-slate-600 text-sm"
)
- # Plugins mode tool list rows (/chatbot Menu) — UMass Medium Gray #505759 (not indigo)
CHATBOT_PLUGIN_MENU_ROW = (
- "border-2 border-[#505759]/35 bg-white shadow-sm hover:bg-[#505759]/10 "
- "hover:border-[#505759] cursor-pointer transition-colors duration-150 items-start"
+ "border border-slate-200 bg-white shadow-sm hover:bg-slate-50 "
+ "hover:border-[#881c1c] cursor-pointer transition-all duration-150 items-start rounded-xl"
)
# --- Form fields (chat / long text) ---
INPUT_MODERN = (
- "w-full min-w-0 !text-base bg-white border-none ring-1 ring-zinc-300 "
- "focus:ring-2 focus:ring-[#881c1c] rounded-2xl p-4 shadow-inner transition-all"
+ "w-full min-w-0 !text-base bg-white text-slate-800 border-none ring-1 ring-slate-200 "
+ "focus:ring-2 focus:ring-[#881c1c] rounded-2xl p-4 shadow-sm transition-all"
)
- # Legacy-compatible: bordered field (jobs, forms)
INPUT_OUTLINED = (
- "rounded-xl border-2 border-zinc-200 focus:border-[#881c1c] "
+ "rounded-xl border border-slate-200 bg-white text-slate-800 focus:border-[#881c1c] "
"focus:ring-2 focus:ring-[#881c1c]/10 transition-all duration-200 resize-none shadow-sm"
)
# --- Tool invocation / result (chat tool cards) ---
- CARD_TOOL_CALL = "p-4 my-2 bg-zinc-50 border border-zinc-200 rounded-lg"
- CARD_TOOL_RESULT = "p-4 my-2 bg-zinc-50 border border-zinc-200 rounded-lg"
- LABEL_TOOL_CALL_TITLE = "font-semibold text-black mt-3"
- LABEL_TOOL_CALL_ARGS = "font-medium text-black mt-3"
- LABEL_TOOL_RESULT_TITLE = "font-medium text-black mt-3"
- LABEL_TOOL_RESULT_CONTENT = "text-sm text-black mt-1 whitespace-pre-wrap"
+ CARD_TOOL_CALL = (
+ "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800"
+ )
+ CARD_TOOL_RESULT = (
+ "p-4 my-2 bg-slate-50 border border-slate-200 rounded-xl text-slate-800"
+ )
+ LABEL_TOOL_CALL_TITLE = "font-semibold text-slate-800 mt-3"
+ LABEL_TOOL_CALL_ARGS = "font-medium text-slate-700 mt-3"
+ LABEL_TOOL_RESULT_TITLE = "font-medium text-slate-800 mt-3"
+ LABEL_TOOL_RESULT_CONTENT = "text-sm text-slate-600 mt-1 whitespace-pre-wrap"
# --- Status text ---
STATUS_PROCESSING = "text-[#881c1c]"
SPINNER_PROCESSING = "text-[#881c1c]"
# --- Focused panel shell (dialogs, chat, plugin pickers) ---
- # Outer card: rounded container, soft zinc shadow, no padding (header/body/footer own regions).
PANEL_SHELL_CARD = (
- "w-full max-w-4xl mx-auto flex flex-col flex-1 min-h-0 "
- "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden"
+ "w-full max-w-6xl mx-auto flex flex-col flex-1 min-h-0 "
+ "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white"
)
- # Chat page only: no flex-1 on the card so short threads do not leave a tall empty band
- # between messages and the input; scrolling is handled on the message column (max-h).
PANEL_SHELL_CHAT_CARD = (
- "w-full max-w-4xl mx-auto flex flex-col min-h-0 "
- "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden"
+ "w-full max-w-6xl mx-auto flex flex-col min-h-0 "
+ "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white"
)
PANEL_SHELL_CARD_NARROW = (
"w-full max-w-2xl mx-auto flex flex-col min-h-0 max-h-[85vh] "
- "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden"
+ "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white"
)
PANEL_SHELL_CARD_MD = (
"w-full max-w-md min-w-0 mx-auto flex flex-col "
- "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden"
+ "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white"
)
PANEL_SHELL_CARD_WIDE = (
"w-[95vw] max-w-[1400px] max-h-[95vh] mx-auto flex flex-col min-h-0 "
- "rounded-3xl shadow-xl shadow-zinc-200/50 border border-zinc-100 p-0 overflow-hidden"
+ "rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-200 p-0 overflow-hidden bg-white"
)
- PANEL_SHELL_HEADER = "w-full bg-zinc-50 p-4 border-b border-zinc-100 items-center justify-between flex-none"
- PANEL_SHELL_HEADER_TITLE = "text-lg font-bold text-zinc-900 tracking-tight"
- # Icon-only close on dialogs (Medium Gray #505759; hover matches .rb-btn-medium-gray hover)
- PANEL_SHELL_HEADER_ICON = "!text-[#505759] hover:!text-[#3d4442] transition-colors"
- PANEL_SHELL_BODY = "flex-1 min-h-0 overflow-y-auto bg-white p-6"
+ PANEL_SHELL_HEADER = "w-full bg-slate-50 p-4 border-b border-slate-200 items-center justify-between flex-none"
+ PANEL_SHELL_HEADER_TITLE = "text-lg font-bold text-slate-800 tracking-tight"
+ PANEL_SHELL_HEADER_ICON = "!text-[#881c1c] hover:!text-[#6a1616] transition-colors"
+ PANEL_SHELL_BODY = "flex-1 min-h-0 overflow-y-auto bg-slate-50 p-6"
PANEL_SHELL_FOOTER = (
- "w-full flex-none p-4 bg-white border-t border-zinc-100 items-center gap-2"
+ "w-full flex-none p-4 bg-white border-t border-slate-200 items-center gap-2"
)
diff --git a/frontend/icons/favicon.png b/frontend/icons/favicon.png
new file mode 100644
index 00000000..278ac350
Binary files /dev/null and b/frontend/icons/favicon.png differ
diff --git a/frontend/icons/logo.png b/frontend/icons/logo.png
new file mode 100644
index 00000000..507cee9d
Binary files /dev/null and b/frontend/icons/logo.png differ
diff --git a/frontend/main.py b/frontend/main.py
index 4dc98973..2cb6c5c0 100644
--- a/frontend/main.py
+++ b/frontend/main.py
@@ -21,19 +21,18 @@
API_TIMEOUT,
APP_PORT,
APP_TITLE,
- APP_VERSION,
BACKEND_URL,
LOG_FILE,
LOG_LEVEL,
RECONNECT_TIMEOUT,
)
-from frontend.constants import (
- HOME_USER_ID,
- NAV_LINKS,
- UI_BUTTONS,
- UI_TITLES,
- is_valid_explicit_user_id,
+from frontend.utils import (
+ apply_saved_theme,
+ browse_directory_simple,
+ get_active_case_id,
+ set_active_case_id,
)
+from frontend.database import get_case_db
from frontend.database import init_db
from frontend.components.shared import create_navbar
from frontend.utils import configure_logging_with_context
@@ -41,11 +40,7 @@
from frontend import utils as _backend_integration
from frontend.design_tokens import Design
from frontend.utils import (
- clear_explicit_user_id,
ensure_explicit_user_id_for_tests,
- get_explicit_user_id,
- set_explicit_user_id,
- try_claim_explicit_user_id,
)
logging.basicConfig(level=parse_log_level(LOG_LEVEL))
@@ -99,7 +94,12 @@
base_path = os.path.abspath(".")
# Construct the absolute path to the icon inside the bundle
-APP_FAVICON = os.path.join(base_path, "icons", "rb.webp")
+if hasattr(sys, "_MEIPASS"):
+ APP_FAVICON = os.path.join(sys._MEIPASS, "icons", "favicon.png")
+else:
+ APP_FAVICON = os.path.join(
+ os.path.abspath(os.path.dirname(__file__)), "icons", "favicon.png"
+ )
try:
@@ -116,22 +116,20 @@
@ui.page("/")
async def index():
- """Main dashboard / home page."""
+ """Main dashboard / home page (Case Management Dashboard)."""
logger.debug("Rendering main dashboard page (index route)")
- from frontend.utils import apply_saved_theme
-
apply_saved_theme()
logger.debug("Theme preference applied")
ui.add_head_html(
"""
"""
)
@@ -140,97 +138,486 @@ async def index():
logger.debug("Navigation bar added to page")
ensure_explicit_user_id_for_tests()
- explicit_user_id = get_explicit_user_id()
-
- with ui.column().classes("container mx-auto p-8"):
- logger.debug("Creating main content container")
- if explicit_user_id:
- ui.label(UI_TITLES["home"]).classes("text-4xl font-bold mb-4")
- ui.label(UI_TITLES["home_subtitle"]).classes("text-xl text-zinc-600")
- with ui.card().classes("w-full max-w-xl mt-4 p-4 bg-zinc-50"):
- ui.label(
- f"{HOME_USER_ID['current_prefix']} {explicit_user_id}"
- ).classes("text-sm font-medium")
- ui.label(HOME_USER_ID["change_user_hint"]).classes(
- "text-xs text-zinc-500 mt-1"
- )
- with ui.row().classes("mt-3"):
-
- def _change_user_id():
- clear_explicit_user_id()
- ui.timer(0.2, lambda: ui.navigate.reload(), once=True)
- # ui.button(
- # HOME_USER_ID["change_user_button"],
- # on_click=_change_user_id,
- # ).classes("bg-zinc-200 text-zinc-800")
-
- with ui.row().classes("gap-4 mt-8"):
- logger.debug("Creating action buttons")
-
- ui.button(
- UI_BUTTONS["browse_models"],
- on_click=lambda: ui.navigate.to(NAV_LINKS["models"]),
- ).classes(Design.BTN_PRIMARY)
- logger.debug("Browse Models button created")
-
- ui.button(
- UI_BUTTONS["open_assistant"],
- on_click=lambda: ui.navigate.to(NAV_LINKS["chatbot"]),
- ).classes(Design.BTN_PRIMARY)
- logger.debug("Open Assistant button created")
- else:
- with ui.card().classes("w-full max-w-xl p-6 shadow-md border"):
- ui.label(HOME_USER_ID["title"]).classes("text-xl font-semibold mb-2")
- ui.label(HOME_USER_ID["blurb"]).classes("text-zinc-600 mb-4")
- uid_input = ui.input(
- HOME_USER_ID["input_label"],
- placeholder=HOME_USER_ID["placeholder"],
- ).classes("w-full")
-
- def _save_home_user_id():
- val = (uid_input.value or "").strip()
- if not val:
- ui.notify(
- "Please enter a User ID.",
- type="warning",
- classes="rb-notify-505759",
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8"
+ ):
+ # Main Header
+ with ui.row().classes("w-full items-center gap-3 mb-2"):
+ ui.icon("folder_shared", size="lg").classes("text-[#881c1c]")
+ ui.label("RescueBox Case Management").classes(
+ "text-4xl font-bold text-slate-800"
+ )
+ ui.label(
+ "Create a new investigative case or load an existing one to begin."
+ ).classes("text-lg text-slate-500 mb-8 pl-1")
+
+ # Unconditional Dual-pane Case Management setup
+ with ui.row().classes("w-full gap-8 items-stretch flex-wrap md:flex-nowrap"):
+ # Left Pane: Create New Case
+ with ui.card().classes(
+ "flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white"
+ ):
+ with ui.row().classes("items-center gap-2 mb-4"):
+ ui.icon("create_new_folder", size="sm").classes("text-[#881c1c]")
+ ui.label("Create New Case").classes(
+ "text-2xl font-bold text-slate-800"
+ )
+
+ case_num_input = (
+ ui.input(
+ "Case Number / ID (Required, Unique)",
+ placeholder="e.g., CASE-2026-0042",
+ )
+ .classes("w-full mb-4")
+ .props("outlined dense")
+ )
+ with case_num_input.add_slot("prepend"):
+ ui.icon("assignment").classes("text-slate-400")
+
+ investigators_input = (
+ ui.input(
+ "Investigators",
+ placeholder="e.g., Det. Smith, Agent Jones",
+ )
+ .classes("w-full mb-4")
+ .props("outlined dense")
+ )
+ with investigators_input.add_slot("prepend"):
+ ui.icon("people").classes("text-slate-400")
+
+ with ui.column().classes("w-full mb-6 gap-1"):
+ ui.label("Evidence Directory / UFDR Path").classes(
+ "text-sm font-medium text-slate-700"
+ )
+ with ui.row().classes("w-full items-center gap-2 flex-nowrap"):
+ path_input = (
+ ui.input(
+ placeholder="/path/to/evidence",
+ )
+ .classes("flex-1")
+ .props("outlined dense")
)
+ with path_input.add_slot("prepend"):
+ ui.icon("folder").classes("text-slate-400")
+
+ ui.button(
+ "Browse",
+ icon="folder_open",
+ color=None,
+ on_click=lambda: browse_directory_simple(path_input),
+ ).classes(Design.BTN_MEDIUM_GRAY)
+
+ async def _create_case():
+ num = (case_num_input.value or "").strip()
+ inv = (investigators_input.value or "").strip()
+ path = (path_input.value or "").strip()
+
+ if not num:
+ ui.notify("Case Number is required.", type="warning")
return
- if not is_valid_explicit_user_id(val):
- ui.notify(
- HOME_USER_ID["invalid_format"],
- type="warning",
- classes="rb-notify-505759",
- )
+ if not path:
+ ui.notify("Evidence Path is required.", type="warning")
return
- claim = try_claim_explicit_user_id(val)
- if claim == "taken":
+
+ try:
+ case_db = get_case_db()
+ new_case = await case_db.create_case(
+ case_number=num,
+ investigators=inv,
+ evidence_path=path,
+ )
+ set_active_case_id(new_case.caseId)
ui.notify(
- HOME_USER_ID["id_taken"],
- type="warning",
- classes="rb-notify-a2aaad",
+ f"Case {num} created and loaded successfully.",
+ type="positive",
)
- return
- if claim != "ok":
- return
- set_explicit_user_id(val)
- # After set_explicit_user_id (deferred browser write); reload must run later.
- ui.timer(0.08, lambda: ui.navigate.reload(), once=True)
-
- def _on_uid_keydown(e):
- if getattr(e, "args", None) and e.args.get("key") == "Enter":
- _save_home_user_id()
+ ui.timer(0.5, lambda: ui.navigate.to("/case"), once=True)
+ except ValueError as e:
+ ui.notify(str(e), type="negative")
+ except Exception as e:
+ ui.notify(f"Failed to create case: {e}", type="negative")
- uid_input.on("keydown", _on_uid_keydown)
ui.button(
- HOME_USER_ID["save_button"],
- on_click=_save_home_user_id,
- ).classes(f"mt-4 {Design.BTN_PRIMARY}")
+ "Create & Load Case",
+ icon="add_circle",
+ color=None,
+ on_click=_create_case,
+ ).classes(Design.BTN_PRIMARY + " w-full py-3 text-base")
+
+ # Right Pane: Load Existing Case
+ with ui.card().classes(
+ "flex-1 p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white flex flex-col"
+ ):
+ with ui.row().classes("items-center gap-2 mb-4"):
+ ui.icon("folder_open", size="sm").classes("text-[#881c1c]")
+ ui.label("Load Existing Case").classes(
+ "text-2xl font-bold text-slate-800"
+ )
+
+ cases_container = ui.column().classes(
+ "w-full flex-1 overflow-y-auto space-y-3 max-h-[400px]"
+ )
+
+ async def _load_cases():
+ cases_container.clear()
+ try:
+ case_db = get_case_db()
+ all_cases = await case_db.get_all_cases()
+ if not all_cases:
+ with cases_container:
+ ui.label("No existing cases found.").classes(
+ "text-slate-400 italic p-4 text-center w-full"
+ )
+ return
+
+ with cases_container:
+ for c in all_cases:
+ with ui.card().classes(
+ "w-full p-4 border-l-4 border-l-[#881c1c] border-y border-r border-slate-200 hover:border-slate-300 hover:shadow-md transition-all bg-slate-50 rounded-xl"
+ ):
+ with ui.row().classes(
+ "w-full justify-between items-center"
+ ):
+ with ui.column().classes(
+ "gap-1 flex-1 min-w-0"
+ ):
+ with ui.row().classes(
+ "items-center gap-1.5"
+ ):
+ ui.icon("folder", size="xs").classes(
+ "text-[#881c1c]"
+ )
+ ui.label(c.caseNumber).classes(
+ "font-bold text-lg text-slate-800 truncate"
+ )
+ if c.investigators:
+ with ui.row().classes(
+ "items-center gap-1.5"
+ ):
+ ui.icon(
+ "people", size="xs"
+ ).classes("text-slate-400")
+ ui.label(
+ f"Investigators: {c.investigators}"
+ ).classes(
+ "text-sm text-slate-600 truncate"
+ )
+ with ui.row().classes(
+ "items-center gap-1.5"
+ ):
+ ui.icon("link", size="xs").classes(
+ "text-slate-400"
+ )
+ ui.label(
+ f"Path: {c.evidencePath}"
+ ).classes(
+ "text-xs font-mono text-slate-500 truncate"
+ )
+
+ def _load(cid=c.caseId, cnum=c.caseNumber):
+ set_active_case_id(cid)
+ ui.notify(
+ f"Loaded case {cnum}.", type="positive"
+ )
+ ui.timer(
+ 0.3,
+ lambda: ui.navigate.to("/case"),
+ once=True,
+ )
+
+ ui.button(
+ "Load",
+ icon="login",
+ color=None,
+ on_click=lambda cid=c.caseId, cnum=c.caseNumber: _load(
+ cid, cnum
+ ),
+ ).classes(Design.BTN_PRIMARY_COMPACT)
+ except Exception as e:
+ logger.error("Error loading cases: %s", e)
+ with cases_container:
+ ui.label(f"Error loading cases: {e}").classes(
+ "text-red-500"
+ )
+
+ await _load_cases()
logger.debug("Main dashboard page rendered successfully")
+@ui.page("/case")
+async def case_overview():
+ """Active Case Overview / Dashboard."""
+ logger.debug("Rendering case overview page")
+
+ from frontend.utils import (
+ apply_saved_theme,
+ clear_active_case_id,
+ )
+ from frontend.database import get_case_db, get_job_db
+
+ apply_saved_theme()
+ logger.debug("Theme preference applied")
+
+ ui.add_head_html(
+ """
+
+ """
+ )
+
+ create_navbar()
+ logger.debug("Navigation bar added to page")
+
+ ensure_explicit_user_id_for_tests()
+ active_case_id = get_active_case_id()
+
+ if not active_case_id:
+ # If no active case, redirect to home page to create or load one
+ ui.notify(
+ "No active case loaded. Please create or load a case.", type="warning"
+ )
+ ui.timer(0.1, lambda: ui.navigate.to("/"), once=True)
+ return
+
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-8"
+ ):
+ # Active Case Dashboard
+ case_db = get_case_db()
+ case = await case_db.get_case_by_id(active_case_id)
+ if not case:
+ # Fallback if case not found
+ clear_active_case_id()
+ ui.timer(0.1, lambda: ui.navigate.to("/"), once=True)
+ return
+
+ with ui.row().classes("items-center gap-3 mb-2"):
+ ui.icon("folder_special", size="lg").classes("text-[#881c1c]")
+ ui.label(f"Case: {case.caseNumber}").classes(
+ "text-4xl font-bold text-slate-800"
+ )
+ if case.investigators:
+ with ui.row().classes("items-center gap-2 mb-6 pl-1"):
+ ui.icon("people", size="xs").classes("text-slate-500")
+ ui.label(f"Investigators: {case.investigators}").classes(
+ "text-lg text-slate-600"
+ )
+
+ # Case Details Card
+ with ui.card().classes(
+ "w-full p-6 border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 shadow-md rounded-2xl bg-white mb-8"
+ ):
+ with ui.row().classes(
+ "items-center gap-2 mb-4 border-b pb-2 border-slate-100"
+ ):
+ ui.icon("info", size="sm").classes("text-[#881c1c]")
+ ui.label("Case Information").classes("text-xl font-bold text-slate-800")
+ with ui.column().classes("w-full gap-3"):
+ with ui.row().classes("items-center gap-2.5"):
+ ui.icon("fingerprint", size="xs").classes("text-slate-400")
+ ui.label("Case ID:").classes(
+ "font-semibold text-slate-700 w-24 shrink-0"
+ )
+ ui.label(case.caseId).classes(
+ "font-mono text-slate-600 truncate bg-slate-50 px-2 py-0.5 rounded border border-slate-100"
+ )
+ with ui.row().classes("items-center gap-2.5"):
+ ui.icon("today", size="xs").classes("text-slate-400")
+ ui.label("Created:").classes(
+ "font-semibold text-slate-700 w-24 shrink-0"
+ )
+ ui.label(case.createdAt[:10] + " " + case.createdAt[11:16]).classes(
+ "text-slate-600"
+ )
+ with ui.row().classes(
+ "items-center gap-2.5 w-full flex-wrap sm:flex-nowrap"
+ ):
+ ui.icon("folder", size="xs").classes("text-slate-400")
+ ui.label("Evidence Path:").classes(
+ "font-semibold text-slate-700 w-24 shrink-0"
+ )
+ path_display = (
+ ui.input(value=case.evidencePath)
+ .classes("flex-1 min-w-0")
+ .props("outlined dense readonly")
+ )
+ with path_display.add_slot("prepend"):
+ ui.icon("folder", size="xs").classes("text-slate-400")
+
+ async def _change_path():
+ with ui.dialog() as d, ui.card().classes(
+ "p-6 w-full max-w-lg bg-white border-t-4 border-t-[#881c1c] border-x border-b border-slate-200 rounded-2xl shadow-xl"
+ ):
+ with ui.row().classes("items-center gap-2 mb-4"):
+ ui.icon("edit", size="sm").classes("text-[#881c1c]")
+ ui.label("Update Evidence Path").classes(
+ "text-xl font-bold text-slate-800"
+ )
+ new_path_input = (
+ ui.input(
+ "New Evidence Directory / UFDR Path",
+ value=case.evidencePath,
+ )
+ .classes("w-full mb-6")
+ .props("outlined dense")
+ )
+ with new_path_input.add_slot("prepend"):
+ ui.icon("folder").classes("text-slate-400")
+ with ui.row().classes("w-full justify-end gap-2"):
+ ui.button(
+ "Cancel", icon="close", color=None, on_click=d.close
+ ).classes(Design.BTN_MEDIUM_GRAY)
+
+ async def _save_path():
+ p = (new_path_input.value or "").strip()
+ if not p:
+ ui.notify(
+ "Path cannot be empty.", type="warning"
+ )
+ return
+ await case_db.update_case_evidence_path(
+ case.caseId, p
+ )
+ ui.notify(
+ "Evidence path updated successfully.",
+ type="positive",
+ )
+ d.close()
+ ui.timer(
+ 0.3, lambda: ui.navigate.reload(), once=True
+ )
+
+ ui.button(
+ "Save", icon="save", color=None, on_click=_save_path
+ ).classes(Design.BTN_PRIMARY_COMPACT)
+ d.open()
+
+ ui.button(
+ "Change Path", icon="edit", color=None, on_click=_change_path
+ ).classes(Design.BTN_MEDIUM_GRAY)
+
+ # Case Results (Jobs) Table
+ with ui.row().classes("items-center gap-2 mb-4"):
+ ui.icon("view_list", size="sm").classes("text-[#881c1c]")
+ ui.label("Case Results & Jobs").classes("text-2xl font-bold text-slate-800")
+
+ jobs_container = ui.column().classes("w-full space-y-2")
+
+ async def _load_case_jobs():
+ jobs_container.clear()
+ try:
+ job_db = get_job_db()
+ jobs_data = await job_db.get_all_jobs()
+ if not jobs_data:
+ with jobs_container:
+ ui.label(
+ "No jobs or results associated with this case yet."
+ ).classes(
+ "text-slate-400 italic p-6 text-center w-full bg-slate-50 rounded-xl border border-dashed border-slate-200"
+ )
+ return
+
+ with jobs_container:
+ # Header Row
+ with ui.row().classes(
+ "bg-[#1c1c1c] text-white p-4 font-semibold w-full rounded-t-xl items-center"
+ ):
+ ui.label("Job ID").classes("w-32 shrink-0")
+ ui.label("Plugin / Task").classes("flex-1 min-w-0")
+ ui.label("Start Time").classes("w-48 shrink-0")
+ ui.label("Status").classes("w-36 shrink-0")
+ ui.label("Actions").classes("w-48 shrink-0")
+
+ for job in jobs_data:
+ uid = job.get("uid")
+ endpoint = job.get("endpoint")
+ pname = job.get("plugin_name") or endpoint or "Unknown"
+ start_time = job.get("startTime") or "N/A"
+ if "T" in start_time:
+ start_time = start_time.replace("T", " ")[:16]
+ status = job.get("status", "Unknown")
+
+ # Status Pill Badges
+ status_pill_classes = {
+ "Completed": "bg-emerald-50 text-emerald-700 border border-emerald-200",
+ "Running": "bg-rose-50 text-[#881c1c] border border-rose-200",
+ "Failed": "bg-rose-50 text-rose-700 border border-rose-200",
+ "Canceled": "bg-slate-100 text-slate-600 border border-slate-200",
+ }
+ pill_cls = status_pill_classes.get(
+ status, "bg-slate-50 text-slate-500 border border-slate-200"
+ )
+
+ with ui.row().classes(
+ "p-4 border-b border-slate-200 hover:bg-slate-50 items-center w-full flex-nowrap gap-2 bg-white"
+ ):
+ ui.label(uid).classes(
+ "font-mono text-sm w-32 shrink-0 truncate text-slate-800"
+ ).tooltip(uid)
+ ui.label(pname).classes(
+ "flex-1 min-w-0 truncate text-slate-800"
+ )
+ ui.label(start_time).classes(
+ "w-48 shrink-0 text-sm text-slate-600"
+ )
+
+ # Render status pill badge
+ with ui.row().classes(
+ f"w-36 shrink-0 items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold {pill_cls}"
+ ):
+ if status == "Completed":
+ ui.icon("check_circle", size="14px")
+ elif status == "Running":
+ ui.spinner(size="14px").classes("text-[#881c1c]")
+ elif status == "Failed":
+ ui.icon("error", size="14px")
+ else:
+ ui.icon("cancel", size="14px")
+ ui.label(status)
+
+ with ui.row().classes("w-48 shrink-0 gap-2 flex-nowrap"):
+ ui.button(
+ "Open",
+ icon="visibility",
+ color=None,
+ on_click=lambda jid=uid: ui.navigate.to(
+ f"/jobs/{jid}"
+ ),
+ ).classes(Design.BTN_PRIMARY_TIGHT)
+
+ async def _remove_job(jid=uid):
+ await get_job_db().disassociate_job_from_case(jid)
+ ui.notify(
+ f"Job {jid} removed from case.", type="info"
+ )
+ await _load_case_jobs()
+
+ ui.button(
+ "Remove",
+ icon="delete",
+ color=None,
+ on_click=lambda jid=uid: _remove_job(jid),
+ ).classes(
+ "bg-rose-50 hover:bg-rose-100 text-[#881c1c] px-3 py-1 rounded text-sm transition-colors border border-rose-200"
+ )
+
+ except Exception as e:
+ logger.error("Error loading case jobs: %s", e)
+ with jobs_container:
+ ui.label(f"Error loading jobs: {e}").classes("text-red-500")
+
+ await _load_case_jobs()
+
+ logger.debug("Case overview page rendered successfully")
+
+
_LICENSES_COPYRIGHT_DIR = _project_root / "License&Copyright"
@@ -326,7 +713,7 @@ async def _on_client_delete(client: Client):
release_demo_folder_for_client(client)
ui.run(
- title=f"{APP_TITLE} · {APP_VERSION}",
+ title=APP_TITLE,
port=APP_PORT,
favicon=APP_FAVICON,
show=False,
diff --git a/frontend/pages/about.py b/frontend/pages/about.py
index ad3edcf6..3e12437d 100644
--- a/frontend/pages/about.py
+++ b/frontend/pages/about.py
@@ -28,37 +28,155 @@ async def about_page(request: Request):
apply_saved_theme()
create_navbar()
- with ui.column().classes("w-full max-w-full min-w-0 container mx-auto p-4 pb-16"):
- ui.label("About").classes("text-3xl font-bold text-zinc-900 mb-6")
-
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-6"
+ ):
+ # Hero Header Card
with ui.card().classes(
- "w-full max-w-3xl mb-10 p-6 bg-white border border-zinc-300 rounded-xl shadow-sm"
+ "w-full p-6 sm:p-8 bg-gradient-to-br from-slate-900 via-[#1c1c1c] to-slate-900 "
+ "text-white rounded-2xl shadow-lg border border-slate-800 relative overflow-hidden"
):
- ui.label("Application").classes("text-lg font-semibold text-[#505759] mb-4")
- _rows = (
- ("name", APP_TITLE, False),
- ("version", APP_VERSION, False),
- ("authors", ABOUT_AUTHORS, False),
- ("rescue lab website", RESCUE_LAB_URL, True),
- ("repository", ABOUT_REPO_URL, True),
+ # Decorative background pattern/overlay
+ ui.element("div").classes(
+ "absolute -right-10 -bottom-10 w-40 h-40 bg-[#881c1c]/20 rounded-full blur-3xl"
)
- for key, val, is_url in _rows:
- with ui.row().classes(
- "w-full gap-4 py-2 border-b border-zinc-200 last:border-0 items-start"
+ with ui.row().classes("items-center gap-4 w-full relative z-10"):
+ ui.icon("info", size="2.5rem").classes("text-[#881c1c]")
+ with ui.column().classes("gap-1 flex-1"):
+ ui.label("About RescueBox").classes(
+ "text-2xl sm:text-3xl font-extrabold tracking-tight"
+ )
+ ui.label(
+ "An advanced, AI-powered forensic and investigative platform designed for deep data analysis, "
+ "media processing, and intelligence gathering."
+ ).classes(
+ "text-slate-300 text-sm sm:text-base max-w-3xl leading-relaxed"
+ )
+
+ # Two-Column Layout
+ with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-3 gap-6 items-start"):
+ # Left Column (System Info & Licenses) - Takes 2 cols on large screens
+ with ui.column().classes("lg:col-span-2 w-full gap-6"):
+ # System Info Card
+ with ui.card().classes(
+ "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm border-t-4 border-t-[#881c1c]"
):
- ui.label(f"{key}").classes(
- "text-sm font-mono text-zinc-600 shrink-0 w-44 sm:w-52"
+ ui.label("System Information").classes(
+ "text-xl font-bold text-slate-800 mb-4"
)
- if is_url and val.startswith("http"):
- ui.link(val, val, new_tab=True).classes(
- "text-sm text-[#505759] hover:text-[#3d4442] hover:underline "
- "break-all min-w-0 flex-1"
+
+ _system_rows = (
+ ("Application Name", APP_TITLE, "label", False),
+ ("Software Version", f"v{APP_VERSION}", "tag", False),
+ ("Core Developers", ABOUT_AUTHORS, "people", False),
+ ("Official Repository", ABOUT_REPO_URL, "code", True),
+ )
+
+ with ui.column().classes("w-full gap-3"):
+ for label_text, val, icon_name, is_url in _system_rows:
+ with ui.row().classes(
+ "w-full gap-4 py-3 border-b border-slate-100 last:border-0 items-center hover:bg-slate-50/50 px-2 rounded-lg transition-colors"
+ ):
+ ui.icon(icon_name, size="sm").classes(
+ "text-[#881c1c] shrink-0"
+ )
+ with ui.column().classes("gap-0.5 flex-1 min-w-0"):
+ ui.label(label_text).classes(
+ "text-xs font-semibold text-slate-400 uppercase tracking-wider"
+ )
+ if is_url and val.startswith("http"):
+ ui.link(val, val, new_tab=True).classes(
+ "text-sm font-medium text-[#881c1c] hover:underline break-all min-w-0"
+ )
+ else:
+ ui.label(val).classes(
+ "text-sm font-semibold text-slate-800 break-words"
+ )
+
+ # Licenses Section
+ with ui.column().classes("w-full"):
+ render_license_documents_section(request, page_path="/about")
+
+ # Right Column (Sponsor & Quick Actions) - Takes 1 col
+ with ui.column().classes("w-full gap-6"):
+ # RescueLab Sponsor Card
+ with ui.card().classes(
+ "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm "
+ "flex flex-col items-center text-center gap-4 border-t-4 border-t-[#881c1c]"
+ ):
+ ui.label("Research & Sponsorship").classes(
+ "text-sm font-bold text-slate-400 uppercase tracking-wider self-start"
+ )
+ ui.element("img").props(
+ 'src=/icons/rb.webp alt="RescueLab Logo"'
+ ).classes("h-16 object-contain my-2")
+ with ui.column().classes("gap-1 items-center"):
+ ui.label("RescueLab").classes(
+ "text-lg font-bold text-slate-800"
)
- else:
- ui.label(val).classes(
- "text-sm text-zinc-900 break-words flex-1 min-w-0"
+ ui.label("University of Massachusetts Amherst").classes(
+ "text-xs font-semibold text-slate-500"
)
+ ui.label(
+ "RescueLab conducts cutting-edge research in systems, security, and digital forensics. "
+ "RescueBox is developed and maintained as part of our commitment to open-source investigative tools."
+ ).classes("text-slate-600 text-xs leading-relaxed")
+ ui.separator().classes("w-full my-1")
+ ui.link(
+ "Visit RescueLab Website", RESCUE_LAB_URL, new_tab=True
+ ).classes(
+ "text-sm font-bold text-[#881c1c] hover:underline flex items-center gap-1"
+ )
+
+ # Quick Actions / Resources Card
+ with ui.card().classes(
+ "w-full p-6 bg-white border border-slate-200 rounded-2xl shadow-sm border-t-4 border-t-[#881c1c]"
+ ):
+ ui.label("Quick Resources").classes(
+ "text-sm font-bold text-slate-400 uppercase tracking-wider mb-2"
+ )
+
+ _resources = (
+ (
+ "Case Dashboard",
+ "folder_shared",
+ "/",
+ "Manage active cases and evidence",
+ ),
+ (
+ "AI Assistant",
+ "forum",
+ "/chatbot",
+ "Interact with Granite AI models",
+ ),
+ (
+ "Jobs & Pipelines",
+ "view_list",
+ "/jobs",
+ "Monitor background tasks",
+ ),
+ (
+ "System Logs",
+ "terminal",
+ "/logs",
+ "View real-time application logs",
+ ),
+ )
- render_license_documents_section(request, page_path="/about")
+ with ui.column().classes("w-full gap-2"):
+ for name, icon_name, path, desc in _resources:
+ with ui.row().classes(
+ "w-full p-2.5 rounded-xl border border-slate-100 hover:border-slate-200 hover:bg-slate-50 cursor-pointer items-center gap-3 transition-all"
+ ).on("click", lambda _, p=path: ui.navigate.to(p)):
+ ui.icon(icon_name, size="sm").classes(
+ "text-[#881c1c] shrink-0"
+ )
+ with ui.column().classes("gap-0.5 flex-1 min-w-0"):
+ ui.label(name).classes(
+ "text-sm font-bold text-slate-800"
+ )
+ ui.label(desc).classes(
+ "text-[11px] text-slate-500 truncate"
+ )
logger.debug("About page rendered")
diff --git a/frontend/pages/chatbot/coordinator.py b/frontend/pages/chatbot/coordinator.py
index 3d77d16d..3493cae5 100644
--- a/frontend/pages/chatbot/coordinator.py
+++ b/frontend/pages/chatbot/coordinator.py
@@ -2,6 +2,7 @@
import logging
import asyncio
from typing import Dict, Any, Callable, Optional, List
+from frontend.design_tokens import Design
from nicegui import ui
from frontend.chatbot.config import ToolRegistry
@@ -604,8 +605,12 @@ def _apply_filter():
dialog.close()
with ui.row().classes("mt-4 gap-2"):
- ui.button("Use all", on_click=_use_all)
- ui.button("Apply filter", on_click=_apply_filter)
+ ui.button("Use all", on_click=_use_all, color=None).classes(
+ Design.BTN_MEDIUM_GRAY
+ )
+ ui.button(
+ "Apply filter", on_click=_apply_filter, color=None
+ ).classes(Design.BTN_PRIMARY_COMPACT)
dialog.open()
try:
return await asyncio.wait_for(future, timeout=120.0)
diff --git a/frontend/pages/chatbot/handlers.py b/frontend/pages/chatbot/handlers.py
index 1a07148a..f27fc205 100644
--- a/frontend/pages/chatbot/handlers.py
+++ b/frontend/pages/chatbot/handlers.py
@@ -3,7 +3,7 @@
import asyncio
from typing import Any, Optional
from nicegui import ui
-
+from frontend.utils.ui import _safe_ui_call
from frontend.utils import get_user_id_for_jobs
from .database_service import DatabaseService
@@ -78,23 +78,30 @@ async def _execute_job(
f"Processing {ToolRegistry.display_name_for_endpoint(endpoint)}..."
)
+ pipeline_total = (1 + len(remaining_calls)) if remaining_calls else None
+ db_kwargs = {k: v for k, v in kwargs.items() if k not in ("form_element",)}
+
+ # Create and track job in the main thread (so we get the job_id and can redirect immediately)
+ job_id = None
+ try:
+ job_record = await DatabaseService.create_and_track_job(
+ request_body,
+ endpoint,
+ task_schema,
+ user_id=get_user_id_for_jobs(),
+ pipeline_total_steps=pipeline_total,
+ **db_kwargs,
+ )
+ job_id = job_record.get("job_id") if job_record else None
+ except Exception as e:
+ self.logger.error(f"Failed to create and track job in DB: {e}")
+
+ if job_id:
+ # Redirect immediately to the general jobs view so the user can see the list of jobs
+ _safe_ui_call(ui.timer, 0.1, lambda: ui.navigate.to("/jobs"), once=True)
+
async def do_submit():
try:
- pipeline_total = (1 + len(remaining_calls)) if remaining_calls else None
- db_kwargs = {
- k: v for k, v in kwargs.items() if k not in ("form_element",)
- }
-
- job_record = await DatabaseService.create_and_track_job(
- request_body,
- endpoint,
- task_schema,
- user_id=get_user_id_for_jobs(),
- pipeline_total_steps=pipeline_total,
- **db_kwargs,
- )
- job_id = job_record.get("job_id") if job_record else None
-
if conversation_id and job_id:
await DatabaseService.save_job_started_to_history(
conversation_id,
@@ -114,36 +121,66 @@ async def do_submit():
except Exception:
pass
- await self._handle_success(
- request_body,
- endpoint,
- task_schema,
- target_container,
- core,
- remaining_calls,
- conversation_id,
- response_body,
- {"job_id": job_id},
- )
+ try:
+ await self._handle_success(
+ request_body,
+ endpoint,
+ task_schema,
+ target_container,
+ core,
+ remaining_calls,
+ conversation_id,
+ response_body,
+ {"job_id": job_id},
+ )
+ except Exception as ui_err:
+ self.logger.debug(
+ f"UI update skipped (likely navigated away): {ui_err}"
+ )
except Exception as e:
self.logger.error(f"Job submission failed: {e}")
+ message = str(e)
+ if job_id:
+ try:
+ await DatabaseService.update_job_status(
+ job_uid=job_id, status="Failed", status_text=message
+ )
+ except Exception as db_err:
+ self.logger.error(
+ f"Failed to update job status to Failed in DB: {db_err}"
+ )
+ if conversation_id:
+ try:
+ await DatabaseService.save_error_to_history(
+ conversation_id, endpoint, message
+ )
+ except Exception as hist_err:
+ self.logger.error(
+ f"Failed to save error to chat history: {hist_err}"
+ )
if loading_row and hasattr(loading_row, "delete"):
try:
loading_row.delete()
except Exception:
pass
- message = str(e)
- if "demo_???" in message:
- from frontend.pages.chatbot.ui import UIOperations
- UIOperations.safe_notify(message, type="warning")
- else:
- self.error_handler.display_error_boundary(
- target_container, "Submission Failed", message
- )
+ try:
+ if "demo_???" in message:
+ from frontend.pages.chatbot.ui import UIOperations
+
+ UIOperations.safe_notify(message, type="warning")
+ else:
+ self.error_handler.display_error_boundary(
+ target_container, "Submission Failed", message
+ )
+ except Exception as ui_err:
+ self.logger.debug(f"Could not display error to UI: {ui_err}")
finally:
- self.state_manager.set_processing(False)
- self.state_manager.set_input_enabled(True)
+ try:
+ self.state_manager.set_processing(False)
+ self.state_manager.set_input_enabled(True)
+ except Exception:
+ pass
background_tasks.create(do_submit())
return True
@@ -235,25 +272,50 @@ async def show(self):
f"ToolPicker.show menu source: {'Instance' if hasattr(self.tool_registry, 'TOOL_MENU') else 'Class'}. Items: {len(menu)}"
)
+ def get_tool_icon(name: str) -> str:
+ name_lower = name.lower()
+ if "transcribe" in name_lower or "audio" in name_lower:
+ return "mic"
+ if "describe" in name_lower or "summarize images" in name_lower:
+ return "visibility"
+ if "search images" in name_lower:
+ return "image_search"
+ if "age" in name_lower or "gender" in name_lower:
+ return "face_retouching_natural"
+ if "deepfake" in name_lower:
+ return "security"
+ if "upload face" in name_lower:
+ return "cloud_upload"
+ if "find face" in name_lower or "face match" in name_lower:
+ return "person_search"
+ if "summarize text" in name_lower or "text_summarization" in name_lower:
+ return "summarize"
+ if "search text" in name_lower:
+ return "find_in_page"
+ if "mount" in name_lower or "ufdr" in name_lower:
+ return "folder_open"
+ if "similar" in name_lower:
+ return "photo_library"
+ return "extension"
+
with self.container:
- # Replicating original TOOL_PICKER_CLASSES
- picker_classes = (
- "w-full max-w-3xl min-w-0 mx-auto bg-gradient-to-br from-zinc-50 via-white to-zinc-100 "
- "border-2 border-[#505759]/40 shadow-lg rounded-xl text-base"
- )
- with ui.card().classes(picker_classes):
+ with ui.card().classes(
+ "w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0"
+ ):
with ui.row().classes(Design.PANEL_SHELL_HEADER):
- ui.label("RescueBox Plugin Selector").classes(
- Design.PANEL_SHELL_HEADER_TITLE
- )
+ with ui.row().classes("items-center gap-2"):
+ ui.icon("extension", size="sm").classes("text-[#881c1c]")
+ ui.label("RescueBox Plugin Selector").classes(
+ Design.PANEL_SHELL_HEADER_TITLE
+ )
- with ui.column().classes("p-4 gap-3 w-full"):
+ with ui.column().classes("p-6 gap-3 w-full bg-slate-50"):
ui.label("Choose a plugin to run:").classes(
- "text-sm font-semibold text-zinc-700"
+ "text-sm font-semibold text-slate-500 uppercase tracking-wider"
)
if not menu:
ui.label("No plugins available in TOOL_MENU.").classes(
- "text-sm text-red-500"
+ "text-sm text-rose-500 font-medium"
)
else:
for num, tool in menu.items():
@@ -261,7 +323,9 @@ async def show(self):
f"Adding tool to UI: {num} - {tool.get('name')}"
)
row = ui.row().classes(
- f"w-full min-w-0 py-2 px-3 rounded-lg {Design.CHATBOT_PLUGIN_MENU_ROW} cursor-pointer"
+ "w-full min-w-0 py-4 px-5 rounded-xl border border-slate-200 bg-white shadow-sm "
+ "hover:bg-slate-50 hover:border-[#881c1c] cursor-pointer transition-all duration-150 "
+ "items-center justify-between gap-4 border-l-4 border-l-[#881c1c]"
)
row.on(
"click",
@@ -270,12 +334,35 @@ async def show(self):
),
)
with row:
- ui.label(
- f'{num}. {tool["name"]} — {tool.get("desc", "No description")}'
- ).classes(
- "w-full text-left text-sm leading-snug font-medium text-zinc-900 "
- "whitespace-normal break-words"
- )
+ # Left side: Icon and Text
+ with ui.row().classes(
+ "items-center gap-4 flex-1 min-w-0"
+ ):
+ # Beautiful icon container
+ with ui.element("div").classes(
+ "w-12 h-12 rounded-xl bg-[#881c1c]/5 flex items-center justify-center shrink-0 border border-[#881c1c]/10"
+ ):
+ ui.icon(
+ get_tool_icon(tool["name"]), size="24px"
+ ).classes("text-[#881c1c]")
+
+ # Text column
+ with ui.column().classes("flex-1 min-w-0 gap-0.5"):
+ ui.label(f'{num}. {tool["name"]}').classes(
+ "text-lg font-bold text-slate-800 leading-snug"
+ )
+ ui.label(
+ tool.get("desc", "No description")
+ ).classes(
+ "text-sm sm:text-base text-slate-500 whitespace-normal break-words leading-relaxed"
+ )
+
+ # Right side: Launch action indicator
+ with ui.row().classes(
+ "items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all"
+ ):
+ ui.label("Launch")
+ ui.icon("arrow_forward", size="16px")
self.logger.info("ToolPicker.show finished building UI.")
@@ -291,29 +378,75 @@ async def show(self):
self.logger.info("AnalysisPicker.show started")
with self.container:
- # Replicating original ANALYSIS_PICKER_CLASSES
- picker_classes = "w-full max-w-2xl mx-auto bg-zinc-50 border-2 border-[#505759]/40 text-sm shadow-lg rounded-xl"
- with ui.card().classes(picker_classes):
+ with ui.card().classes(
+ "w-full max-w-full bg-white border border-slate-200 shadow-md rounded-2xl overflow-hidden p-0"
+ ):
with ui.row().classes(Design.PANEL_SHELL_HEADER):
- ui.label("Analysis Mode").classes(Design.PANEL_SHELL_HEADER_TITLE)
+ with ui.row().classes("items-center gap-2"):
+ ui.icon("analytics", size="sm").classes("text-[#881c1c]")
+ ui.label("Analysis Mode").classes(
+ Design.PANEL_SHELL_HEADER_TITLE
+ )
- with ui.column().classes("p-4 gap-3 w-full"):
+ with ui.column().classes("p-6 gap-3 w-full bg-slate-50"):
ui.label("Select an analysis type:").classes(
- "text-sm text-zinc-600"
+ "text-sm font-semibold text-slate-500 uppercase tracking-wider"
)
options = ["Surface Scan", "Deep Forensic", "AI Content Analysis"]
+ analysis_details = {
+ "Surface Scan": {
+ "desc": "Quickly analyze metadata, file headers, and basic structures",
+ "icon": "radar",
+ },
+ "Deep Forensic": {
+ "desc": "Comprehensive, byte-level analysis of all partitions and hidden data",
+ "icon": "biotech",
+ },
+ "AI Content Analysis": {
+ "desc": "Leverage machine learning models to detect objects, faces, and transcribe media",
+ "icon": "psychology",
+ },
+ }
for a_type in options:
+ details = analysis_details.get(
+ a_type,
+ {"desc": "Run automated analysis", "icon": "analytics"},
+ )
self.logger.info(f"Adding analysis option: {a_type}")
row = ui.row().classes(
- f"w-full min-w-0 py-3 px-3 rounded-lg {Design.CHATBOT_PLUGIN_MENU_ROW} cursor-pointer"
+ "w-full min-w-0 py-4 px-5 rounded-xl border border-slate-200 bg-white shadow-sm "
+ "hover:bg-slate-50 hover:border-[#881c1c] cursor-pointer transition-all duration-150 "
+ "items-center justify-between gap-4 border-l-4 border-l-[#881c1c]"
)
row.on(
"click", lambda *a, t=a_type: self.on_analysis_selected(t)
)
with row:
- ui.label(a_type).classes(
- "w-full text-left text-sm leading-snug font-medium text-zinc-900"
- )
+ # Left side: Icon and Text
+ with ui.row().classes("items-center gap-4 flex-1 min-w-0"):
+ # Beautiful icon container
+ with ui.element("div").classes(
+ "w-12 h-12 rounded-xl bg-[#881c1c]/5 flex items-center justify-center shrink-0 border border-[#881c1c]/10"
+ ):
+ ui.icon(details["icon"], size="24px").classes(
+ "text-[#881c1c]"
+ )
+
+ # Text column
+ with ui.column().classes("flex-1 min-w-0 gap-0.5"):
+ ui.label(a_type).classes(
+ "text-lg font-bold text-slate-800 leading-snug"
+ )
+ ui.label(details["desc"]).classes(
+ "text-sm sm:text-base text-slate-500 whitespace-normal break-words leading-relaxed"
+ )
+
+ # Right side: Launch action indicator
+ with ui.row().classes(
+ "items-center gap-1 shrink-0 text-[#881c1c] font-semibold text-sm bg-[#881c1c]/5 hover:bg-[#881c1c]/10 px-3 py-1.5 rounded-lg transition-all"
+ ):
+ ui.label("Analyze")
+ ui.icon("arrow_forward", size="16px")
self.logger.info("AnalysisPicker.show finished building UI.")
@@ -336,14 +469,20 @@ async def show_case_notes_dialog() -> Optional[str]:
future = loop.create_future()
with ui.dialog() as dialog, ui.card().classes(Design.PANEL_SHELL_CARD_NARROW):
with ui.row().classes(Design.PANEL_SHELL_HEADER):
- ui.label("Job Submission Details").classes(Design.PANEL_SHELL_HEADER_TITLE)
+ with ui.row().classes("items-center gap-2"):
+ ui.icon("rate_review", size="sm").classes("text-[#881c1c]")
+ ui.label("Job Submission Details").classes(
+ Design.PANEL_SHELL_HEADER_TITLE
+ )
ui.button(
- icon="close", on_click=lambda: (future.set_result(None), dialog.close())
+ icon="close",
+ color=None,
+ on_click=lambda: (future.set_result(None), dialog.close()),
).props("flat round dense").classes(Design.PANEL_SHELL_HEADER_ICON)
with ui.column().classes(Design.PANEL_SHELL_BODY + " gap-4"):
ui.label("Add optional notes for the case file:").classes(
- "text-sm text-zinc-500"
+ "text-sm text-slate-500 font-medium"
)
# Use rb-case-notes-field to ensure maroon/gray brand colors and no blue/indigo
notes = (
@@ -355,10 +494,12 @@ async def show_case_notes_dialog() -> Optional[str]:
with ui.row().classes(Design.PANEL_SHELL_FOOTER + " justify-end"):
ui.button(
"Skip & Submit",
+ color=None,
on_click=lambda: (future.set_result(""), dialog.close()),
).classes(Design.BTN_MEDIUM_GRAY).props("outline")
ui.button(
"Submit with Notes",
+ color=None,
on_click=lambda: (future.set_result(notes.value), dialog.close()),
).classes(Design.BTN_PRIMARY)
dialog.open()
diff --git a/frontend/pages/chatbot/ui.py b/frontend/pages/chatbot/ui.py
index ef4f0394..2da46a0f 100644
--- a/frontend/pages/chatbot/ui.py
+++ b/frontend/pages/chatbot/ui.py
@@ -12,6 +12,7 @@
from frontend.design_tokens import Design
from frontend.pages.chatbot.state import ChatbotStateManager, ChatMessage
from frontend.utils import (
+ app,
get_conversation_to_load,
handle_api_error as _handle_api_error,
show_error_to_user as _show_error_to_user,
@@ -131,9 +132,20 @@ def render_message(container: element, message: ChatMessage):
"""Render a message in the chat container."""
with container:
if message.role == "user":
- chat_message(message.content, name="You", sent=True)
+ chat_message(
+ message.content,
+ name="You",
+ sent=True,
+ bg_color="blue-grey-1",
+ text_color="dark",
+ )
else:
- chat_message(message.content, name="Assistant")
+ chat_message(
+ message.content,
+ name="RescueBox Assistant",
+ bg_color="primary",
+ text_color="white",
+ )
def _history_record_to_chat_message(msg: Any) -> ChatMessage:
@@ -190,15 +202,17 @@ def render_merged_job_tool_results(
"""
with container:
with card().classes(
- "w-full max-w-3xl border border-zinc-200 rounded-xl p-4 bg-white "
- "shadow-sm space-y-2"
+ "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 bg-slate-50 "
+ "shadow-sm space-y-2 border-l-4 border-l-[#881c1c]"
):
- label("Assistant").classes("text-xs font-semibold text-zinc-500 uppercase")
+ label("Assistant").classes(
+ "text-sm font-semibold text-slate-500 uppercase tracking-wider"
+ )
label((getattr(started_msg, "content", "") or "").strip()).classes(
- "text-sm text-zinc-900 whitespace-pre-wrap break-words"
+ "text-base text-slate-800 whitespace-pre-wrap break-words font-medium"
)
label((getattr(completed_msg, "content", "") or "").strip()).classes(
- "text-sm text-green-800 font-medium whitespace-pre-wrap break-words"
+ "text-base text-emerald-700 font-semibold whitespace-pre-wrap break-words"
)
ep = getattr(started_msg, "tool_call_endpoint", None)
if ep:
@@ -206,7 +220,7 @@ def render_merged_job_tool_results(
dn = ToolRegistry.display_name_for_endpoint(ep)
except Exception:
dn = ep
- label(f"Plugin: {dn}").classes("text-xs text-zinc-500")
+ label(f"Plugin: {dn}").classes("text-sm text-slate-500")
args = getattr(started_msg, "tool_call_arguments", None)
if isinstance(args, dict) and (
args.get("inputs") is not None or args.get("parameters") is not None
@@ -225,7 +239,10 @@ def _open_job() -> None:
navigate.to(f"/jobs/{jid}")
button(
- "Open job details", icon="open_in_new", on_click=_open_job
+ "Open job details",
+ icon="open_in_new",
+ color=None,
+ on_click=_open_job,
).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}")
@@ -243,14 +260,14 @@ def render_persisted_history_message(container: element, msg: Any) -> None:
if mt == "tool_result":
with container:
with card().classes(
- "w-full max-w-3xl border border-zinc-200 rounded-xl p-4 bg-white "
- "shadow-sm space-y-2"
+ "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 bg-slate-50 "
+ "shadow-sm space-y-2 border-l-4 border-l-[#881c1c]"
):
label("Assistant").classes(
- "text-xs font-semibold text-zinc-500 uppercase"
+ "text-sm font-semibold text-slate-500 uppercase tracking-wider"
)
label(content).classes(
- "text-sm text-zinc-900 whitespace-pre-wrap break-words"
+ "text-base text-slate-800 whitespace-pre-wrap break-words font-medium"
)
ep = getattr(msg, "tool_call_endpoint", None)
if ep:
@@ -258,7 +275,7 @@ def render_persisted_history_message(container: element, msg: Any) -> None:
dn = ToolRegistry.display_name_for_endpoint(ep)
except Exception:
dn = ep
- label(f"Plugin: {dn}").classes("text-xs text-zinc-500")
+ label(f"Plugin: {dn}").classes("text-sm text-slate-500")
args = getattr(msg, "tool_call_arguments", None)
if isinstance(args, dict) and (
args.get("inputs") is not None or args.get("parameters") is not None
@@ -277,24 +294,29 @@ def _open_job() -> None:
navigate.to(f"/jobs/{jid}")
button(
- "Open job details", icon="open_in_new", on_click=_open_job
+ "Open job details",
+ icon="open_in_new",
+ color=None,
+ on_click=_open_job,
).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}")
return
if mt == "tool_call":
with container:
with card().classes(
- "w-full max-w-3xl border border-zinc-200 rounded-xl p-4 "
- "bg-amber-50/80 space-y-2"
+ "w-full max-w-3xl border border-slate-200 rounded-2xl p-5 "
+ "bg-amber-50/80 space-y-2 border-l-4 border-l-[#881c1c]"
):
- label("Tool call").classes("text-xs font-semibold text-[#881c1c]")
+ label("Tool call").classes(
+ "text-sm font-semibold text-[#881c1c] uppercase tracking-wider"
+ )
tcalls = getattr(msg, "tool_calls", None) or []
if tcalls:
code(json.dumps(tcalls, indent=2, default=str)).classes(
"text-xs w-full whitespace-pre-wrap"
)
elif content:
- label(content).classes("text-sm text-zinc-800")
+ label(content).classes("text-base text-slate-800")
message_id = getattr(msg, "message_id", None)
if message_id:
from frontend.components.chat import rerun_tool_call
@@ -302,9 +324,9 @@ def _open_job() -> None:
async def _do_rerun(mid: str = message_id) -> None:
await rerun_tool_call(mid)
- button("Re-run Job", icon="replay", on_click=_do_rerun).classes(
- f"mt-1 {Design.BTN_MEDIUM_GRAY}"
- )
+ button(
+ "Re-run Job", icon="replay", color=None, on_click=_do_rerun
+ ).classes(f"mt-1 {Design.BTN_MEDIUM_GRAY}")
return
if mt == "error":
@@ -312,15 +334,26 @@ async def _do_rerun(mid: str = message_id) -> None:
with card().classes(
"w-full max-w-3xl border border-red-200 bg-red-50 p-4 space-y-1"
):
- label("Error").classes("text-xs font-semibold text-red-800")
- label(content).classes("text-sm text-red-900 whitespace-pre-wrap")
+ label("Error").classes("text-sm font-semibold text-red-800")
+ label(content).classes("text-base text-red-900 whitespace-pre-wrap")
return
with container:
if role == "user":
- chat_message(content, name="You", sent=True)
+ chat_message(
+ content,
+ name="You",
+ sent=True,
+ bg_color="blue-grey-1",
+ text_color="dark",
+ )
else:
- chat_message(content, name="Assistant")
+ chat_message(
+ content,
+ name="RescueBox Assistant",
+ bg_color="primary",
+ text_color="white",
+ )
def show_error_message(container: element, message: str):
@@ -349,7 +382,7 @@ async def show_tool_selection(container: element, endpoint: str):
render_tool_selection_message(container, endpoint)
except Exception:
with container:
- label(f"Running {endpoint}...").classes("text-sm text-zinc-500 italic")
+ label(f"Running {endpoint}...").classes("text-sm text-slate-500 italic")
async def load_and_show_form(
@@ -364,6 +397,52 @@ async def load_and_show_form(
)
return
+ # Inject pipelined job output if present
+ pipeline_job_id = None
+ try:
+ pipeline_job_id = app.storage.user.get("pipeline_job_id")
+ except Exception:
+ pass
+
+ if pipeline_job_id:
+ try:
+ from frontend.database import get_job_db
+
+ job = get_job_db().get_job_by_uid_sync(pipeline_job_id)
+ if job and job.response:
+ from frontend.chatbot.multi_tool_handler import extract_output_path
+ from rb.api.models import ResponseBody
+
+ response_body = job.response
+ if not isinstance(response_body, ResponseBody):
+ response_body = ResponseBody(**response_body)
+ output_path = extract_output_path(response_body)
+ if output_path:
+ from rb.api.models import InputType
+
+ input_dir_key = None
+ for input_schema in task_schema.inputs:
+ if input_schema.input_type == InputType.DIRECTORY:
+ key_lower = input_schema.key.lower()
+ if "input" in key_lower and "dir" in key_lower:
+ input_dir_key = input_schema.key
+ break
+ if not input_dir_key:
+ for input_schema in task_schema.inputs:
+ if input_schema.input_type == InputType.DIRECTORY:
+ input_dir_key = input_schema.key
+ break
+ if input_dir_key:
+ arguments = arguments.copy() if arguments else {}
+ arguments[input_dir_key] = output_path
+ logger.info(
+ "Pipelining: injected output path '%s' into '%s'",
+ output_path,
+ input_dir_key,
+ )
+ except Exception as e:
+ logger.error("Error auto-injecting pipeline path: %s", e)
+
initial_values = core.convert_arguments_to_initial_values(
arguments, task_schema, endpoint
)
@@ -375,7 +454,7 @@ async def _wrapped_submit(form_data, endpoint=None, task_schema=None, **kwargs):
form_data, endpoint=endpoint, task_schema=task_schema, **kwargs
)
- await core.create_input_form(
+ return await core.create_input_form(
task_schema,
endpoint,
initial_values=initial_values,
@@ -456,36 +535,126 @@ def __init__(
self.models_btn = None
self.analyze_btn = None
self.history_btn = None
+ self.active_form = None
def build_ui(self):
from frontend.components.chat import (
- create_chat_header,
create_chat_window,
create_input_area,
)
+ pipeline_job_id = None
+ try:
+ pipeline_job_id = app.storage.user.get("pipeline_job_id")
+ except Exception:
+ pass
+
with column().classes(
- "rb-chat-layout-core min-h-screen w-full flex flex-col -mt-16 bg-zinc-50 relative"
+ "rb-chat-layout-core min-h-screen w-full flex flex-col bg-slate-50 relative"
):
- self.models_btn, self.analyze_btn, self.history_btn = create_chat_header(
- on_show_history=self._show_history_dialog
- )
-
+ # We integrate the buttons directly into the card header below, so we don't need the floating header row here anymore.
with column().classes(
- "container mx-auto w-full px-4 flex-1 flex flex-col min-h-0 pb-4"
+ "container mx-auto w-full max-w-6xl px-4 sm:px-8 py-8 flex-1 flex flex-col min-h-0 pb-16"
):
+ # Page Header (Matches Jobs, Logs, Models pages)
+ from frontend.constants import UI_TITLES
+
+ with row().classes("items-center gap-2 mb-6"):
+ icon("forum", size="lg").classes("text-[#881c1c]")
+ label(UI_TITLES.get("chatbot", "RescueBox Assistant")).classes(
+ "text-4xl font-bold text-slate-800"
+ )
+
+ if pipeline_job_id:
+ from frontend.database import get_job_db
+
+ job = get_job_db().get_job_by_uid_sync(pipeline_job_id)
+ if job:
+ endpoint = job.endpoint or "Unknown"
+ pname = job.plugin_name or endpoint
+ from frontend.chatbot.multi_tool_handler import (
+ extract_output_path,
+ )
+ from rb.api.models import ResponseBody
+
+ response_body = job.response
+ if response_body:
+ if not isinstance(response_body, ResponseBody):
+ response_body = ResponseBody(**response_body)
+ output_path = extract_output_path(response_body)
+ else:
+ output_path = "N/A"
+
+ with row().classes(
+ "w-full bg-rose-50 border border-rose-200 p-3 rounded-xl items-center justify-between mb-4 shadow-sm"
+ ):
+ with row().classes("items-center gap-2"):
+ icon("link").classes("text-[#881c1c]")
+ with column().classes("gap-0.5"):
+ label(
+ f"Pipelining from Job {pipeline_job_id} ({pname})"
+ ).classes("font-bold text-rose-900 text-sm")
+ label(f"Output Path: {output_path}").classes(
+ "font-mono text-xs text-rose-700"
+ )
+
+ def _clear_pipeline():
+ app.storage.user.pop("pipeline_job_id", None)
+ ui.notify("Pipeline cleared.", type="info")
+ ui.timer(0.1, lambda: ui.navigate.reload(), once=True)
+
+ button("Clear Pipeline", on_click=_clear_pipeline).classes(
+ "bg-red-50 hover:bg-red-100 text-[#881c1c] px-3 py-1 rounded text-xs transition-colors"
+ )
+
with card().classes(Design.PANEL_SHELL_CHAT_CARD):
with row().classes(Design.PANEL_SHELL_HEADER):
- label("RescueBox Assistant").classes(
- Design.PANEL_SHELL_HEADER_TITLE
- )
- self.mode_indicator = badge("Chat mode", color=None).classes(
- "text-xs font-medium rb-chat-mode-badge"
- )
+ with row().classes("items-center gap-3"):
+ icon("settings_suggest", size="sm").classes(
+ "text-[#881c1c]"
+ )
+ label("Active Mode:").classes(
+ "text-base font-bold text-slate-700"
+ )
+ self.mode_indicator = badge(
+ "Chat mode", color=None
+ ).classes(
+ "text-sm font-semibold rb-chat-mode-badge px-3 py-1 rounded-full"
+ )
+
+ with row().classes("items-center gap-2"):
+ self.analyze_btn = (
+ button("Chat", icon="chat", color=None)
+ .classes(
+ "rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base"
+ )
+ .props("unelevated no-caps")
+ )
+ self.models_btn = (
+ button("Menu", icon="menu", color=None)
+ .classes(
+ "rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base"
+ )
+ .props("unelevated no-caps")
+ )
+ self.history_btn = (
+ button(
+ "History",
+ icon="history",
+ color=None,
+ on_click=self._show_history_dialog,
+ )
+ .classes(
+ "rb-chatbot-tab-btn px-4 py-2 rounded-lg font-semibold transition-all text-base"
+ )
+ .props("unelevated no-caps")
+ )
chat_container = create_chat_window()
- input_area = create_input_area(self.status_text_ref, self.on_send)
- self.input_field = input_area.input_field
+ self.input_area = create_input_area(
+ self.status_text_ref, self.on_send
+ )
+ self.input_field = self.input_area.input_field
below_input_area = column().classes(
"rb-chat-below-input-area w-full max-w-none space-y-4 mt-2 mb-4"
@@ -498,14 +667,24 @@ def build_ui(self):
chat_container,
self.input_field,
self.status_text_ref,
- input_area,
+ self.input_area,
below_input_area,
)
def _setup_mode_handlers(self, chat_container):
+ # Initial active state: Chat mode
+ self.analyze_btn.classes("rb-tab-active")
+
async def handle_models_click():
self.mode_indicator.set_text("Menu mode")
+ self.models_btn.classes("rb-tab-active")
+ self.analyze_btn.classes(remove="rb-tab-active")
chat_container.clear()
+
+ # Hide the chat input area completely in Menu Mode
+ if hasattr(self, "input_area") and self.input_area:
+ self.input_area.classes("hidden")
+
await asyncio.sleep(0.01) # Give NiceGUI a moment
from .handlers import ToolPicker
@@ -516,7 +695,16 @@ async def handle_models_click():
async def handle_analyze_click():
self.mode_indicator.set_text("Chat mode")
+ self.analyze_btn.classes("rb-tab-active")
+ self.models_btn.classes(remove="rb-tab-active")
chat_container.clear()
+
+ # Show and enable the chat input area in Chat Mode
+ if hasattr(self, "input_area") and self.input_area:
+ self.input_area.classes(remove="hidden")
+ if self.state_manager:
+ self.state_manager.set_input_enabled(True)
+
from frontend.components.chat import render_welcome_message
render_welcome_message(chat_container)
@@ -525,9 +713,20 @@ async def handle_analyze_click():
self.analyze_btn.on_click(handle_analyze_click)
async def _on_tool_selected(self, endpoint, arguments):
+ # Delete previous unsubmitted form if it exists
+ if hasattr(self, "active_form") and self.active_form:
+ try:
+ self.active_form.delete()
+ except Exception:
+ pass
+ self.active_form = None
+
async def handle_form_submit(
request_body, endpoint=None, task_schema=None, **kwargs
):
+ # Form is being submitted, so it's no longer an active unsubmitted form
+ if hasattr(self, "active_form"):
+ self.active_form = None
return await self.form_submit_handler.submit_form(
request_body,
endpoint,
@@ -540,12 +739,17 @@ async def handle_form_submit(
def _on_cancel():
if self.state_manager:
self.state_manager.set_input_enabled(True)
+ if hasattr(self, "active_form") and self.active_form:
+ form_to_delete = self.active_form
+ self.active_form = None
+ # Delete the form card in the next event loop tick to let container.clear() finish safely
+ ui.timer(0.01, lambda: form_to_delete.delete(), once=True)
# Stage 1: Grey out input area while form is being filled
if self.state_manager:
self.state_manager.set_input_enabled(False, hide_completely=False)
- await load_and_show_form(
+ self.active_form = await load_and_show_form(
self.chat_container,
self.core,
endpoint,
@@ -687,7 +891,7 @@ async def _handle_conversation_select(self, conversation_id: str):
with self.chat_container:
separator()
label("Conversation history").classes(
- "text-xs font-medium text-zinc-500 uppercase tracking-wide"
+ "text-xs font-semibold text-slate-500 uppercase tracking-wider"
)
i = 0
@@ -850,20 +1054,20 @@ async def chatbot_page(
ui.add_head_html(
"""
"""
diff --git a/frontend/pages/demo.py b/frontend/pages/demo.py
index c431bf61..9a7d64c3 100644
--- a/frontend/pages/demo.py
+++ b/frontend/pages/demo.py
@@ -52,10 +52,16 @@ async def demo_page(walkthrough: Optional[str] = None):
preset = normalize_demo_walkthrough_query(walkthrough)
samples_only = preset != "all"
- with ui.column().classes("container mx-auto p-8 max-w-5xl w-full min-w-0"):
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"
+ ):
if samples_only:
with ui.column().props("id=sample-inputs").classes("scroll-mt-24 w-full"):
- ui.label("Sample inputs & outputs").classes("text-2xl font-bold mb-1")
+ with ui.row().classes("items-center gap-2 mb-1"):
+ ui.icon("folder_zip", size="sm").classes("text-[#881c1c]")
+ ui.label("Sample inputs & outputs").classes(
+ "text-2xl font-bold text-slate-800"
+ )
if preset in _SAMPLE_FILTER_BLURB:
ui.label(_SAMPLE_FILTER_BLURB[preset]).classes(
"text-zinc-600 text-sm mb-3"
@@ -67,19 +73,22 @@ async def demo_page(walkthrough: Optional[str] = None):
)
if guide and label:
ui.link(label, guide).classes(
- "text-[#a2aaad] hover:text-[#8a9194] hover:underline text-sm mb-4 inline-block"
+ "text-[#881c1c] hover:underline text-sm mb-4 inline-block"
)
else:
- ui.label("RescueBox Demo").classes("text-3xl font-bold mb-4")
+ with ui.row().classes("items-center gap-2 mb-2"):
+ ui.icon("school", size="lg").classes("text-[#881c1c]")
+ ui.label("RescueBox Demo").classes("text-4xl font-bold text-slate-800")
ui.label("Follow the step-by-step guide to learn RescueBox.").classes(
- "text-black-600 mb-6"
+ "text-slate-500 mb-6 pl-1 text-lg"
)
- with ui.column().classes("gap-3 items-start"):
+ with ui.column().classes("gap-3 items-stretch w-full max-w-2xl"):
# Neutral outline: no Quasar primary / no brand fill (color=None + flat outline).
_demo_btn = (
- "text-zinc-800 px-6 py-3 rounded-xl font-semibold "
- "bg-white border border-zinc-300 hover:bg-zinc-50 transition-colors"
+ "text-slate-800 px-6 py-3 rounded-xl font-semibold "
+ "bg-white border border-slate-200 hover:bg-slate-50 hover:shadow-md transition-all "
+ "w-full text-left flex items-center gap-3 border-l-4 border-l-[#881c1c]"
)
_demo_btn_props = "flat unelevated no-caps"
ui.button(
diff --git a/frontend/pages/demo_image_summary_walkthrough.py b/frontend/pages/demo_image_summary_walkthrough.py
index e8b7eb29..68d0e8ca 100644
--- a/frontend/pages/demo_image_summary_walkthrough.py
+++ b/frontend/pages/demo_image_summary_walkthrough.py
@@ -42,10 +42,14 @@ async def demo_image_search_walkthrough_page():
text = load_markdown_file(_MD_FILE, _fallback_markdown)
- with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"):
- ui.label("Search Image — Assistant prompt walkthrough").classes(
- "text-3xl font-bold mb-2"
- )
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"
+ ):
+ with ui.row().classes("items-center gap-2 mb-2"):
+ ui.icon("image", size="lg").classes("text-[#881c1c]")
+ ui.label("Search Image — Assistant prompt walkthrough").classes(
+ "text-4xl font-bold text-slate-800"
+ )
render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text)
@@ -54,8 +58,11 @@ async def demo_image_search_walkthrough_page():
with ui.row().classes("gap-4 flex-wrap items-center mt-8"):
ui.button(
"Back to Demo",
+ icon="arrow_back",
on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]),
- ).classes("rb-brand-primary text-white")
+ ).classes(
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200"
+ )
# ui.link("Open Assistant", NAV_LINKS["chatbot"]).classes("text-[#881c1c] hover:underline")
# ui.link(UI_TITLES["jobs"], NAV_LINKS["jobs"]).classes("text-[#881c1c] hover:underline")
diff --git a/frontend/pages/demo_other_walkthrough.py b/frontend/pages/demo_other_walkthrough.py
index 3642f34c..3d5d8a24 100644
--- a/frontend/pages/demo_other_walkthrough.py
+++ b/frontend/pages/demo_other_walkthrough.py
@@ -42,10 +42,14 @@ async def demo_other_walkthrough_page():
text = load_markdown_file(_MD_FILE, _fallback_markdown)
- with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"):
- ui.label("Interesting plugins & pipeline walkthrough").classes(
- "text-3xl font-bold mb-2"
- )
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"
+ ):
+ with ui.row().classes("items-center gap-2 mb-2"):
+ ui.icon("extension", size="lg").classes("text-[#881c1c]")
+ ui.label("Interesting plugins & pipeline walkthrough").classes(
+ "text-4xl font-bold text-slate-800"
+ )
render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text)
@@ -54,14 +58,17 @@ async def demo_other_walkthrough_page():
with ui.row().classes("gap-4 flex-wrap items-center mt-8"):
ui.button(
"Back to Demo",
+ icon="arrow_back",
on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]),
- ).classes("rb-brand-primary text-white")
+ ).classes(
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200"
+ )
ui.link("Open Assistant", NAV_LINKS["chatbot"]).classes(
- "text-[#881c1c] hover:underline"
+ "text-[#881c1c] hover:underline font-medium"
)
ui.link(UI_TITLES["jobs"], NAV_LINKS["jobs"]).classes(
- "text-[#881c1c] hover:underline"
+ "text-[#881c1c] hover:underline font-medium"
)
schedule_hash_fragment_scroll()
diff --git a/frontend/pages/demo_quick_start.py b/frontend/pages/demo_quick_start.py
index 48d81301..65b97c4b 100644
--- a/frontend/pages/demo_quick_start.py
+++ b/frontend/pages/demo_quick_start.py
@@ -43,8 +43,14 @@ async def demo_quick_start_page():
text = load_markdown_file(_QUICK_START_MD, _fallback_markdown)
- with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"):
- ui.label("RescueBox quick start").classes("text-3xl font-bold mb-2")
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"
+ ):
+ with ui.row().classes("items-center gap-2 mb-2"):
+ ui.icon("rocket_launch", size="lg").classes("text-[#881c1c]")
+ ui.label("RescueBox quick start").classes(
+ "text-4xl font-bold text-slate-800"
+ )
render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text)
@@ -53,11 +59,14 @@ async def demo_quick_start_page():
with ui.row().classes("gap-4 flex-wrap items-center mt-8"):
ui.button(
"Back to Demo",
+ icon="arrow_back",
on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]),
- ).classes("rb-brand-primary text-white")
+ ).classes(
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200"
+ )
ui.link("Demo samples", demo_samples_url("quick_start")).classes(
- "text-[#881c1c] hover:underline text-sm"
+ "text-[#881c1c] hover:underline text-sm font-medium"
)
schedule_hash_fragment_scroll()
diff --git a/frontend/pages/demo_transcribe_walkthrough.py b/frontend/pages/demo_transcribe_walkthrough.py
index beed7fb7..1ee32ff9 100644
--- a/frontend/pages/demo_transcribe_walkthrough.py
+++ b/frontend/pages/demo_transcribe_walkthrough.py
@@ -42,8 +42,14 @@ async def demo_transcribe_walkthrough_page():
text = load_markdown_file(_MD_FILE, _fallback_markdown)
- with ui.column().classes("container mx-auto p-8 max-w-4xl w-full min-w-0 pb-16"):
- ui.label("Transcribe — menu walkthrough").classes("text-3xl font-bold mb-2")
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-4xl pb-16"
+ ):
+ with ui.row().classes("items-center gap-2 mb-2"):
+ ui.icon("audiotrack", size="lg").classes("text-[#881c1c]")
+ ui.label("Transcribe — menu walkthrough").classes(
+ "text-4xl font-bold text-slate-800"
+ )
render_guided_markdown_body(ui.column().classes("w-full min-w-0"), text)
@@ -52,8 +58,11 @@ async def demo_transcribe_walkthrough_page():
with ui.row().classes("gap-4 flex-wrap items-center mt-8"):
ui.button(
"Back to Demo",
+ icon="arrow_back",
on_click=lambda: ui.navigate.to(NAV_LINKS["demo"]),
- ).classes("rb-brand-primary text-white")
+ ).classes(
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200"
+ )
schedule_hash_fragment_scroll()
logger.debug("Transcribe walkthrough page rendered")
diff --git a/frontend/pages/jobs/components.py b/frontend/pages/jobs/components.py
index 6323611c..b6381d82 100644
--- a/frontend/pages/jobs/components.py
+++ b/frontend/pages/jobs/components.py
@@ -32,8 +32,13 @@ async def export_audit():
logger.error("Error exporting audit trail: %s", e)
notify_error(f"Error exporting audit trail: {str(e)}")
- return ui.button("📋 Export Audit Trail", on_click=export_audit).classes(
- "rb-brand-primary text-white rounded-xl"
+ return ui.button(
+ "Export Audit Trail",
+ icon="assignment_turned_in",
+ color=None,
+ on_click=export_audit,
+ ).classes(
+ "bg-slate-100 hover:bg-slate-200 text-slate-800 px-4 py-2 rounded-lg font-medium transition-colors border border-slate-200"
)
@@ -43,10 +48,13 @@ def render_job_action_buttons(job_fields: Dict[str, Any]):
if model_uid:
ui.button(
"Model Doc",
+ color=None,
on_click=lambda: ui.navigate.to(f"/models/{model_uid}/details"),
).classes("rb-brand-primary text-white")
ui.button(
- "Run Model", on_click=lambda: ui.navigate.to(f"/models/{model_uid}/run")
+ "Run Model",
+ color=None,
+ on_click=lambda: ui.navigate.to(f"/models/{model_uid}/run"),
).classes("rb-brand-primary text-white rounded-xl")
@@ -73,11 +81,17 @@ def render_readonly_form(task_schema, request_body):
def render_error_status(status: str, status_text: Optional[str] = None):
- with ui.card().classes("bg-red-50 border border-red-300 p-6"):
- ui.label("Job Failed").classes("text-2xl font-bold text-red-800 mb-2")
- ui.label(f"Status: {status}").classes("text-lg text-red-600")
+ with ui.card().classes(
+ "bg-rose-50 border border-rose-200 p-6 rounded-2xl shadow-sm border-t-4 border-t-rose-500"
+ ):
+ with ui.row().classes("items-center gap-2 mb-2"):
+ ui.icon("error", size="md").classes("text-rose-600")
+ ui.label("Job Failed").classes("text-2xl font-bold text-rose-800")
+ ui.label(f"Status: {status}").classes("text-lg text-rose-700 font-medium")
if status_text:
- ui.label(status_text).classes("text-sm text-red-500 mt-2")
+ ui.label(status_text).classes(
+ "text-sm text-rose-600 mt-2 bg-white/50 p-3 rounded-lg border border-rose-100 whitespace-pre-wrap"
+ )
async def render_model_info(api_client, job_fields: Dict[str, Any]):
diff --git a/frontend/pages/jobs/details.py b/frontend/pages/jobs/details.py
index 433686ba..f9158d72 100644
--- a/frontend/pages/jobs/details.py
+++ b/frontend/pages/jobs/details.py
@@ -52,7 +52,15 @@ async def job_details_page_route(job_id: str):
return
jf = extract_job_fields(job)
- with ui.column().classes("w-full items-stretch px-4 sm:px-8 py-8"):
+
+ # Auto-refresh if the job is running or pending
+ status = str(jf.get("status", "")).lower()
+ if status in ("running", "pending"):
+ ui.timer(3.0, lambda: ui.navigate.reload(), once=True)
+
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 items-stretch"
+ ):
create_breadcrumbs(
[{"label": "Jobs", "link": "/jobs"}, {"label": f"Job {job_id}"}]
)
diff --git a/frontend/pages/jobs/list.py b/frontend/pages/jobs/list.py
index 520653db..3b77d89f 100644
--- a/frontend/pages/jobs/list.py
+++ b/frontend/pages/jobs/list.py
@@ -29,9 +29,12 @@ def __init__(self):
self.jobs = []
async def render(self):
- with ui.column().classes("container mx-auto p-8"):
- with ui.row().classes("items-center justify-between mb-6"):
- ui.label(UI_TITLES["jobs"]).classes("text-4xl font-bold")
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"
+ ):
+ with ui.row().classes("items-center gap-2 mb-6"):
+ ui.icon("view_list", size="lg").classes("text-[#881c1c]")
+ ui.label(UI_TITLES["jobs"]).classes("text-4xl font-bold text-slate-800")
self.jobs_container = ui.column().classes("space-y-2 w-full")
await self.load_jobs()
@@ -50,7 +53,7 @@ async def render_jobs(self):
self.jobs_container.clear()
with self.jobs_container:
with ui.row().classes(
- "bg-[#505759] border-b border-[#3d4442] p-4 font-semibold text-white w-full"
+ "bg-[#1c1c1c] text-white p-4 font-semibold w-full rounded-t-xl items-center"
):
ui.label("Job ID").classes("w-40 shrink-0")
ui.label("Plugin").classes("flex-1 min-w-0")
@@ -63,14 +66,15 @@ async def render_jobs(self):
if len(group) > 1:
root_id = pipeline_group_root_id(group)
with ui.row().classes(
- "w-full items-center gap-2 py-2 px-3 mb-1 rounded-md bg-[#505759] text-white"
+ "w-full items-center gap-2 py-2 px-3 mb-1 rounded-md bg-[#881c1c] text-white"
):
+ ui.icon("layers").classes("text-white")
ui.label("Pipeline").classes("font-semibold")
ui.link(root_id, f"/jobs/{root_id}").classes(
- "text-white/90 hover:underline"
+ "text-white/90 hover:underline font-mono"
)
group_wrap = ui.column().classes(
- "w-full border-l-2 border-[#505759]/50 pl-3 mb-3"
+ "w-full border-l-2 border-[#881c1c]/50 pl-3 mb-3"
)
else:
group_wrap = self.jobs_container
@@ -116,4 +120,14 @@ async def jobs_page_route():
return
apply_saved_theme()
create_navbar()
- await JobsPage().render()
+
+ page = JobsPage()
+ await page.render()
+
+ # Auto-refresh if there are any running or pending jobs in the list
+ has_active_jobs = any(
+ str(job.get("status", "")).lower() in ("running", "pending")
+ for job in page.jobs
+ )
+ if has_active_jobs:
+ ui.timer(3.0, lambda: ui.navigate.reload(), once=True)
diff --git a/frontend/pages/logs.py b/frontend/pages/logs.py
index 9c0240d6..b4a9f965 100644
--- a/frontend/pages/logs.py
+++ b/frontend/pages/logs.py
@@ -34,12 +34,14 @@ async def render(self):
logger.info("Rendering logs page")
with ui.column().classes(
- "w-full max-w-full min-w-0 p-4 gap-4 flex flex-col flex-1"
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16 gap-4 flex flex-col flex-1"
):
# Page header
- ui.label(UI_TITLES.get("logs", "Application Logs")).classes(
- "text-2xl font-bold mb-4"
- )
+ with ui.row().classes("items-center gap-2 mb-4"):
+ ui.icon("terminal", size="lg").classes("text-[#881c1c]")
+ ui.label(UI_TITLES.get("logs", "Application Logs")).classes(
+ "text-4xl font-bold text-slate-800"
+ )
# Use extracted log viewer component (full width, fill available space)
try:
@@ -61,9 +63,19 @@ async def render(self):
async def _load_logs(self):
"""Load and display log file contents. Reads the log file, limits to max_lines, and displays in the UI."""
self.log_content = read_log_file(LOG_FILE, self.max_lines)
- formatted_content = format_log_content(self.log_content)
- self.log_display.content = formatted_content
+ # Cache raw content in log_display and apply search filter if available
+ if hasattr(self, "log_display") and self.log_display is not None:
+ self.log_display.raw_content = self.log_content
+ if hasattr(self.log_display, "apply_filter"):
+ self.log_display.apply_filter()
+ else:
+ formatted_content = format_log_content(self.log_content)
+ self.log_display.content = formatted_content
+ else:
+ formatted_content = format_log_content(self.log_content)
+ if hasattr(self, "log_display") and self.log_display is not None:
+ self.log_display.content = formatted_content
# Auto-scroll to bottom
await ui.run_javascript(
diff --git a/frontend/pages/models.py b/frontend/pages/models.py
index 06b92874..3c3fee40 100644
--- a/frontend/pages/models.py
+++ b/frontend/pages/models.py
@@ -56,7 +56,9 @@ async def render(self):
"""Render the models page UI. Creates the page layout with header, refresh button, loading indicator,"""
logger.debug("Rendering models page")
try:
- with ui.column().classes("container mx-auto p-8"):
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"
+ ):
# Header
logger.debug("Creating page header")
try:
@@ -309,7 +311,9 @@ async def model_details_page(model_uid: str):
)
return
- with ui.column().classes("container mx-auto p-8"):
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 w-full max-w-6xl pb-16"
+ ):
# Two-column layout
with ui.row().classes("gap-6 w-full"):
# Left column - Documentation
diff --git a/frontend/tests/unit/test_job_background_submission.py b/frontend/tests/unit/test_job_background_submission.py
index 930a9677..80410b85 100644
--- a/frontend/tests/unit/test_job_background_submission.py
+++ b/frontend/tests/unit/test_job_background_submission.py
@@ -139,20 +139,20 @@ def fake_create_store_coro(coroutine, name=None, handle_exceptions=False):
core = MagicMock()
core.config = MagicMock()
core.config.RESCUEBOX_HOST = "http://localhost"
+ core.submit_job = AsyncMock(side_effect=Exception("Simulated API failure"))
with patch(
- "frontend.pages.chatbot.api_helpers.post_job", new_callable=AsyncMock
- ) as mock_post, patch(
"frontend.pages.chatbot.DatabaseService.create_and_track_job",
new_callable=AsyncMock,
return_value={"job_id": "job1"},
), patch(
"frontend.pages.chatbot.DatabaseService.save_user_prompt_if_missing_from_form_submission",
new_callable=AsyncMock,
+ ), patch(
+ "frontend.pages.chatbot.DatabaseService.update_job_status",
+ new_callable=AsyncMock,
):
- mock_post.side_effect = Exception("Simulated API failure")
-
request_body = MagicMock()
request_body.inputs = {}
request_body.parameters = {}
diff --git a/frontend/utils/__init__.py b/frontend/utils/__init__.py
index 46f68d3b..c0797099 100644
--- a/frontend/utils/__init__.py
+++ b/frontend/utils/__init__.py
@@ -52,6 +52,10 @@
set_draft_message,
set_conversation_to_load,
get_conversation_to_load,
+ get_active_case_id,
+ set_active_case_id,
+ clear_active_case_id,
+ get_active_case,
)
from .ui import (
notify_success,
@@ -132,6 +136,10 @@
"set_draft_message",
"set_conversation_to_load",
"get_conversation_to_load",
+ "get_active_case_id",
+ "set_active_case_id",
+ "clear_active_case_id",
+ "get_active_case",
"notify_success",
"notify_error",
"notify_info",
diff --git a/frontend/utils/browser.py b/frontend/utils/browser.py
index 32047c04..1baf75e1 100644
--- a/frontend/utils/browser.py
+++ b/frontend/utils/browser.py
@@ -196,11 +196,12 @@ def show(self):
# Footer
with ui.row().classes(Design.PANEL_SHELL_FOOTER + " justify-end"):
- ui.button("Cancel", on_click=self.dialog.close).classes(
+ ui.button("Cancel", color=None, on_click=self.dialog.close).classes(
Design.BTN_MEDIUM_GRAY
).props("outline")
ui.button(
"Select This Folder",
+ color=None,
on_click=lambda: (
self.on_select(self.state["current_path"]),
self.dialog.close(),
@@ -343,11 +344,12 @@ def show(self):
"text-sm font-medium text-[#881c1c] truncate"
)
- ui.button("Cancel", on_click=self.dialog.close).classes(
+ ui.button("Cancel", color=None, on_click=self.dialog.close).classes(
Design.BTN_MEDIUM_GRAY
).props("outline")
self.confirm_btn = ui.button(
"Confirm Selection",
+ color=None,
on_click=lambda: (
self.on_select(self.state["selected_file"]),
self.dialog.close(),
diff --git a/frontend/utils/storage.py b/frontend/utils/storage.py
index e8e86661..3d1d786d 100644
--- a/frontend/utils/storage.py
+++ b/frontend/utils/storage.py
@@ -42,34 +42,15 @@ def get_user_id() -> Optional[str]:
def get_explicit_user_id() -> Optional[str]:
- try:
- return app.storage.user.get("explicit_job_user_id")
- except Exception:
- if _runs_under_pytest():
- return _test_fallback_storage.get("explicit_job_user_id")
- return None
+ return get_active_case_id()
def set_explicit_user_id(value: str):
- v = value.strip()
- try:
- app.storage.user["explicit_job_user_id"] = v
- except Exception:
- pass
- if _runs_under_pytest():
- _test_fallback_storage["explicit_job_user_id"] = v
+ set_active_case_id(value)
def clear_explicit_user_id():
- uid = get_explicit_user_id()
- if uid:
- release_explicit_user_id_claim(uid)
- try:
- app.storage.user.pop("explicit_job_user_id", None)
- except Exception:
- pass
- if _runs_under_pytest():
- _test_fallback_storage.pop("explicit_job_user_id", None)
+ clear_active_case_id()
def release_explicit_user_id_claim(uid: str):
@@ -121,9 +102,61 @@ def try_claim_explicit_user_id(uid: str) -> str:
return "invalid"
+def get_active_case_id() -> Optional[str]:
+ try:
+ return app.storage.user.get("active_case_id")
+ except Exception:
+ if _runs_under_pytest():
+ return _test_fallback_storage.get("active_case_id")
+ return None
+
+
+def set_active_case_id(value: str):
+ v = value.strip()
+ try:
+ app.storage.user["active_case_id"] = v
+ except Exception:
+ pass
+ if _runs_under_pytest():
+ _test_fallback_storage["active_case_id"] = v
+
+
+def clear_active_case_id():
+ try:
+ app.storage.user.pop("active_case_id", None)
+ except Exception:
+ pass
+ if _runs_under_pytest():
+ _test_fallback_storage.pop("active_case_id", None)
+
+
+def get_active_case() -> Optional[Any]:
+ case_id = get_active_case_id()
+ if not case_id:
+ return None
+ try:
+ from frontend.database.case_db import get_case_db
+
+ case = get_case_db().get_case_by_id_sync(case_id)
+ if not case and _runs_under_pytest():
+ from frontend.database.case_db import CaseRecord
+
+ return CaseRecord(
+ caseId=case_id,
+ caseNumber="TEST-CASE",
+ investigators="Test Investigator",
+ evidencePath="/tmp",
+ createdAt="2026-06-04T13:15:00",
+ updatedAt="2026-06-04T13:15:00",
+ )
+ return case
+ except Exception:
+ return None
+
+
def get_user_id_for_jobs() -> Optional[str]:
- """Alias for get_explicit_user_id for backward compatibility."""
- return get_explicit_user_id()
+ """Returns the active case ID so that all jobs and chat history are scoped to the active case."""
+ return get_active_case_id()
def set_user_preference(key: str, value: Any):
diff --git a/frontend/utils/ui.py b/frontend/utils/ui.py
index 9703d0fa..f5101213 100644
--- a/frontend/utils/ui.py
+++ b/frontend/utils/ui.py
@@ -139,7 +139,9 @@ def require_demo_user_session():
if get_user_id_for_jobs():
return True
- with ui.column().classes("container mx-auto p-8 max-w-2xl w-full"):
+ with ui.column().classes(
+ "container mx-auto px-4 sm:px-8 py-8 max-w-2xl w-full pb-16"
+ ):
ui.label(HOME_USER_ID["title"]).classes("text-2xl font-semibold mb-2")
ui.label(HOME_USER_ID["blurb"]).classes("text-zinc-600 mb-4")
ui.link("Go to Home", NAV_LINKS["home"]).classes(
diff --git a/frontend/utils/ui_readability_css.py b/frontend/utils/ui_readability_css.py
index 1155a4ee..33bfba99 100644
--- a/frontend/utils/ui_readability_css.py
+++ b/frontend/utils/ui_readability_css.py
@@ -25,6 +25,7 @@ def inject_global_readability_css() -> None:
_READABILITY_CSS_DONE = True
ui.add_head_html(
"""
+