Spaces:
Sleeping
Sleeping
Polish: 4-step pipeline progress, large empty state w/ icon, subtle entrance animations
Browse files
app.py
CHANGED
|
@@ -291,6 +291,94 @@ button.secondary, button[variant="secondary"] {
|
|
| 291 |
0%, 100% { opacity: 0.3; transform: scale(1); }
|
| 292 |
50% { opacity: 1; transform: scale(1.3); }
|
| 293 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
.turn-error {
|
| 295 |
background: var(--accent-soft);
|
| 296 |
border-left: 3px solid var(--accent);
|
|
@@ -700,12 +788,36 @@ def _data_table_html(rows: list[dict], max_rows: int = 10) -> str:
|
|
| 700 |
return f'<table class="data-table"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>{note}'
|
| 701 |
|
| 702 |
|
| 703 |
-
|
| 704 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
return (
|
| 706 |
'<div class="turn">'
|
| 707 |
f'<div class="turn-question">{question}</div>'
|
| 708 |
-
f'<div class="
|
| 709 |
'</div>'
|
| 710 |
)
|
| 711 |
|
|
@@ -749,7 +861,8 @@ def _turn_html_complete(result: dict) -> str:
|
|
| 749 |
|
| 750 |
|
| 751 |
def _conversation_html(history: list[dict], in_progress: tuple[str, str] | None = None) -> str:
|
| 752 |
-
"""Conversation HTML reacts to: data loaded? history? in-progress?
|
|
|
|
| 753 |
has_data = bool(get_agent().list_tables())
|
| 754 |
has_turns = bool(history) or in_progress is not None
|
| 755 |
|
|
@@ -765,11 +878,25 @@ def _conversation_html(history: list[dict], in_progress: tuple[str, str] | None
|
|
| 765 |
|
| 766 |
|
| 767 |
def _empty_state_html() -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
return (
|
| 769 |
-
'<div class="empty">'
|
| 770 |
-
'<div class="empty-
|
| 771 |
-
'<div class="empty-
|
| 772 |
-
'
|
|
|
|
| 773 |
'</div>'
|
| 774 |
)
|
| 775 |
|
|
@@ -861,8 +988,10 @@ def on_ask(question: str, history: list) -> Generator[Tuple[str, str, list], Non
|
|
| 861 |
yield _conversation_html([result]), "", [result]
|
| 862 |
return
|
| 863 |
|
| 864 |
-
#
|
| 865 |
-
|
|
|
|
|
|
|
| 866 |
|
| 867 |
try:
|
| 868 |
result = _gpu_process(question)
|
|
@@ -870,7 +999,6 @@ def on_ask(question: str, history: list) -> Generator[Tuple[str, str, list], Non
|
|
| 870 |
logger.exception("ask failed")
|
| 871 |
result = {"question": question, "error": str(e)}
|
| 872 |
|
| 873 |
-
# Replace history with just this single result
|
| 874 |
yield _conversation_html([result]), "", [result]
|
| 875 |
|
| 876 |
|
|
|
|
| 291 |
0%, 100% { opacity: 0.3; transform: scale(1); }
|
| 292 |
50% { opacity: 1; transform: scale(1.3); }
|
| 293 |
}
|
| 294 |
+
|
| 295 |
+
/* Pipeline stages — 4 dots with state */
|
| 296 |
+
.pipeline {
|
| 297 |
+
display: flex;
|
| 298 |
+
flex-direction: column;
|
| 299 |
+
gap: 6px;
|
| 300 |
+
padding: 16px 18px;
|
| 301 |
+
background: var(--surface-raised);
|
| 302 |
+
border: 1px solid var(--ink-faint);
|
| 303 |
+
border-radius: var(--radius-sm);
|
| 304 |
+
margin: 6px 0;
|
| 305 |
+
}
|
| 306 |
+
.pipeline-step {
|
| 307 |
+
display: flex;
|
| 308 |
+
align-items: center;
|
| 309 |
+
gap: 10px;
|
| 310 |
+
font-size: 13px;
|
| 311 |
+
color: var(--ink-muted) !important;
|
| 312 |
+
transition: color 200ms ease;
|
| 313 |
+
}
|
| 314 |
+
.pipeline-step.done .pipeline-dot {
|
| 315 |
+
background: var(--accent);
|
| 316 |
+
border-color: var(--accent);
|
| 317 |
+
}
|
| 318 |
+
.pipeline-step.done .pipeline-label {
|
| 319 |
+
color: var(--ink) !important;
|
| 320 |
+
}
|
| 321 |
+
.pipeline-step.active .pipeline-dot {
|
| 322 |
+
background: var(--accent);
|
| 323 |
+
border-color: var(--accent);
|
| 324 |
+
animation: pulse 1.2s ease-in-out infinite;
|
| 325 |
+
}
|
| 326 |
+
.pipeline-step.active .pipeline-label {
|
| 327 |
+
color: var(--ink) !important;
|
| 328 |
+
font-weight: 500;
|
| 329 |
+
}
|
| 330 |
+
.pipeline-step.pending .pipeline-dot {
|
| 331 |
+
background: transparent;
|
| 332 |
+
border-color: var(--ink-faint);
|
| 333 |
+
}
|
| 334 |
+
.pipeline-dot {
|
| 335 |
+
width: 8px; height: 8px;
|
| 336 |
+
border-radius: 50%;
|
| 337 |
+
border: 1.5px solid var(--ink-faint);
|
| 338 |
+
flex-shrink: 0;
|
| 339 |
+
}
|
| 340 |
+
.pipeline-label {
|
| 341 |
+
font-size: 13px;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* Polished empty state — large icon for big empty panel */
|
| 345 |
+
.empty-large {
|
| 346 |
+
padding: 80px 20px;
|
| 347 |
+
display: flex;
|
| 348 |
+
flex-direction: column;
|
| 349 |
+
align-items: center;
|
| 350 |
+
justify-content: center;
|
| 351 |
+
min-height: 480px;
|
| 352 |
+
}
|
| 353 |
+
.empty-icon {
|
| 354 |
+
color: var(--ink-muted);
|
| 355 |
+
margin-bottom: 22px;
|
| 356 |
+
animation: fadeIn 400ms ease-out;
|
| 357 |
+
}
|
| 358 |
+
.empty-large .empty-title { font-size: 16px; margin-bottom: 8px; }
|
| 359 |
+
.empty-large .empty-sub { max-width: 360px; }
|
| 360 |
+
|
| 361 |
+
/* Subtle entrance animations */
|
| 362 |
+
@keyframes fadeIn {
|
| 363 |
+
from { opacity: 0; }
|
| 364 |
+
to { opacity: 1; }
|
| 365 |
+
}
|
| 366 |
+
@keyframes fadeInUp {
|
| 367 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 368 |
+
to { opacity: 1; transform: translateY(0); }
|
| 369 |
+
}
|
| 370 |
+
.turn { animation: fadeInUp 250ms ease-out; }
|
| 371 |
+
.chart-wrap { animation: fadeInUp 350ms ease-out 50ms backwards; }
|
| 372 |
+
.narration { animation: fadeInUp 350ms ease-out 150ms backwards; }
|
| 373 |
+
.downloads { animation: fadeInUp 350ms ease-out 200ms backwards; }
|
| 374 |
+
.suggestion-chip {
|
| 375 |
+
animation: fadeInUp 250ms ease-out backwards;
|
| 376 |
+
}
|
| 377 |
+
.suggestions .suggestion-chip:nth-child(1) { animation-delay: 50ms; }
|
| 378 |
+
.suggestions .suggestion-chip:nth-child(2) { animation-delay: 100ms; }
|
| 379 |
+
.suggestions .suggestion-chip:nth-child(3) { animation-delay: 150ms; }
|
| 380 |
+
.suggestions .suggestion-chip:nth-child(4) { animation-delay: 200ms; }
|
| 381 |
+
.schema-col { animation: fadeIn 200ms ease-out backwards; }
|
| 382 |
.turn-error {
|
| 383 |
background: var(--accent-soft);
|
| 384 |
border-left: 3px solid var(--accent);
|
|
|
|
| 788 |
return f'<table class="data-table"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>{note}'
|
| 789 |
|
| 790 |
|
| 791 |
+
PIPELINE_STAGES = [
|
| 792 |
+
("sql", "Generating SQL"),
|
| 793 |
+
("execute", "Running query"),
|
| 794 |
+
("chart", "Designing chart"),
|
| 795 |
+
("render", "Rendering visualization"),
|
| 796 |
+
]
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
def _turn_html_progress(question: str, current_stage: str = "sql") -> str:
|
| 800 |
+
"""Render a turn that's still in progress with multi-step pipeline view."""
|
| 801 |
+
items = []
|
| 802 |
+
seen_active = False
|
| 803 |
+
for key, label in PIPELINE_STAGES:
|
| 804 |
+
if key == current_stage:
|
| 805 |
+
cls = "active"
|
| 806 |
+
seen_active = True
|
| 807 |
+
elif seen_active:
|
| 808 |
+
cls = "pending"
|
| 809 |
+
else:
|
| 810 |
+
cls = "done"
|
| 811 |
+
items.append(
|
| 812 |
+
f'<div class="pipeline-step {cls}">'
|
| 813 |
+
f'<span class="pipeline-dot"></span>'
|
| 814 |
+
f'<span class="pipeline-label">{label}</span>'
|
| 815 |
+
f'</div>'
|
| 816 |
+
)
|
| 817 |
return (
|
| 818 |
'<div class="turn">'
|
| 819 |
f'<div class="turn-question">{question}</div>'
|
| 820 |
+
f'<div class="pipeline">{"".join(items)}</div>'
|
| 821 |
'</div>'
|
| 822 |
)
|
| 823 |
|
|
|
|
| 861 |
|
| 862 |
|
| 863 |
def _conversation_html(history: list[dict], in_progress: tuple[str, str] | None = None) -> str:
|
| 864 |
+
"""Conversation HTML reacts to: data loaded? history? in-progress?
|
| 865 |
+
in_progress is a tuple of (question, current_stage_key)."""
|
| 866 |
has_data = bool(get_agent().list_tables())
|
| 867 |
has_turns = bool(history) or in_progress is not None
|
| 868 |
|
|
|
|
| 878 |
|
| 879 |
|
| 880 |
def _empty_state_html() -> str:
|
| 881 |
+
"""No data loaded — polished placeholder for the right panel."""
|
| 882 |
+
icon = (
|
| 883 |
+
'<svg viewBox="0 0 64 64" width="64" height="64" fill="none" '
|
| 884 |
+
'stroke="currentColor" stroke-width="1.25" stroke-linecap="round" '
|
| 885 |
+
'stroke-linejoin="round" style="opacity:0.35">'
|
| 886 |
+
'<rect x="8" y="14" width="48" height="38" rx="4"/>'
|
| 887 |
+
'<line x1="8" y1="24" x2="56" y2="24"/>'
|
| 888 |
+
'<line x1="22" y1="14" x2="22" y2="52"/>'
|
| 889 |
+
'<line x1="36" y1="14" x2="36" y2="52"/>'
|
| 890 |
+
'<circle cx="46" cy="36" r="6"/>'
|
| 891 |
+
'<line x1="50" y1="40" x2="56" y2="46"/>'
|
| 892 |
+
'</svg>'
|
| 893 |
+
)
|
| 894 |
return (
|
| 895 |
+
'<div class="empty empty-large">'
|
| 896 |
+
f'<div class="empty-icon">{icon}</div>'
|
| 897 |
+
'<div class="empty-title">No data loaded</div>'
|
| 898 |
+
'<div class="empty-sub">Upload a CSV, JSON, Parquet or Excel file '
|
| 899 |
+
'on the left, or click <strong>Demo</strong> to try sample data.</div>'
|
| 900 |
'</div>'
|
| 901 |
)
|
| 902 |
|
|
|
|
| 988 |
yield _conversation_html([result]), "", [result]
|
| 989 |
return
|
| 990 |
|
| 991 |
+
# Stream pipeline stages so user sees progress (4 visual steps).
|
| 992 |
+
# The actual GPU call is one shot (yielding mid-call would break the
|
| 993 |
+
# @spaces.GPU window) but we still surface the stages around it.
|
| 994 |
+
yield _conversation_html([], in_progress=(question, "sql")), "", []
|
| 995 |
|
| 996 |
try:
|
| 997 |
result = _gpu_process(question)
|
|
|
|
| 999 |
logger.exception("ask failed")
|
| 1000 |
result = {"question": question, "error": str(e)}
|
| 1001 |
|
|
|
|
| 1002 |
yield _conversation_html([result]), "", [result]
|
| 1003 |
|
| 1004 |
|