Spaces:
Running
Running
Deploy: components-only (no HTML), 33 chart kinds incl 3D/rose/tree, HTML export, robustness + no-fabrication/no-JSON-leak (rebuilt dist)
Browse files- vivek/.gitignore +3 -0
- vivek/backend/combined_prompt.py +44 -301
- vivek/backend/config.py +1 -1
- vivek/backend/server.py +16 -108
- vivek/frontend-vue/dist/assets/ActionRow-B9EJZ-NA.js +0 -1
- vivek/frontend-vue/dist/assets/ActionRow-D2v3mo3f.js +1 -0
- vivek/frontend-vue/dist/assets/ChartBlock-CdTvHCQ6.js +0 -0
- vivek/frontend-vue/dist/assets/ChartBlock-zIyaBwdN.js +0 -1
- vivek/frontend-vue/dist/assets/index-39HK7jxF.css +0 -0
- vivek/frontend-vue/dist/assets/{index-DxH9q0Bt.js → index-DE9MARTs.js} +0 -0
- vivek/frontend-vue/dist/assets/index-yy7aehFb.css +0 -0
- vivek/frontend-vue/dist/index.html +2 -2
- vivek/frontend-vue/package-lock.json +19 -0
- vivek/frontend-vue/package.json +1 -0
- vivek/frontend-vue/src/components/AnalyticsPanel.vue +1 -1
- vivek/frontend-vue/src/components/LiveWidgetFrame.vue +0 -111
- vivek/frontend-vue/src/components/LiveWidgetSchema.vue +0 -213
- vivek/frontend-vue/src/components/WidgetRegistryRenderer.vue +114 -27
- vivek/frontend-vue/src/components/WidgetSchemaChart.vue +5 -507
- vivek/frontend-vue/src/components/widgets/ActionRow.vue +9 -2
- vivek/frontend-vue/src/lib/analyticsStore.ts +6 -6
- vivek/frontend-vue/src/lib/echartsOption.ts +727 -0
- vivek/frontend-vue/src/lib/exportWidgetHtml.ts +264 -0
- vivek/frontend-vue/src/lib/progressiveWidget.ts +0 -25
- vivek/frontend-vue/src/pages/Analytics.vue +4 -4
- vivek/frontend-vue/src/pages/Chat.vue +92 -168
- vivek/frontend-vue/src/widget-registry.json +33 -5
- vivek/strategies.json +1 -1
vivek/.gitignore
CHANGED
|
@@ -30,3 +30,6 @@ Thumbs.db
|
|
| 30 |
|
| 31 |
# adaptiveui.sqlite3 is tracked for GitHub (demo_vivek). Do not push that branch to
|
| 32 |
# Hugging Face origin — their hook rejects binary sqlite in git.
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
# adaptiveui.sqlite3 is tracked for GitHub (demo_vivek). Do not push that branch to
|
| 32 |
# Hugging Face origin — their hook rejects binary sqlite in git.
|
| 33 |
+
|
| 34 |
+
# Binary vector store (regenerable; never commit)
|
| 35 |
+
rag_finance/chroma_db/
|
vivek/backend/combined_prompt.py
CHANGED
|
@@ -23,7 +23,6 @@ from pathlib import Path
|
|
| 23 |
from typing import Any, Tuple
|
| 24 |
|
| 25 |
from . import config
|
| 26 |
-
from .widget_prompt import inject_design_system
|
| 27 |
|
| 28 |
|
| 29 |
def strip_widget_markdown_fences(raw: str) -> str:
|
|
@@ -191,12 +190,23 @@ def _chart_has_data(chart: dict[str, Any]) -> bool:
|
|
| 191 |
return _nonempty_list(chart.get("boxes"))
|
| 192 |
if kind in {"sankey", "graph"}:
|
| 193 |
return _nonempty_list(chart.get("links"))
|
| 194 |
-
if kind in {"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
if _nonempty_list(chart.get("items")):
|
| 196 |
return True
|
| 197 |
s = chart.get("series")
|
| 198 |
return isinstance(s, list) and any(isinstance(x, dict) and x.get("values") for x in s)
|
| 199 |
# line | bar | hbar | area | scatter | bubble | stacked | combo | histogram | radar
|
|
|
|
| 200 |
s = chart.get("series")
|
| 201 |
return isinstance(s, list) and any(
|
| 202 |
isinstance(x, dict) and isinstance(x.get("values"), list) and len(x.get("values")) for x in s
|
|
@@ -308,41 +318,6 @@ def widget_schema_json_is_valid(schema_str: str) -> bool:
|
|
| 308 |
return isinstance(o, dict) and isinstance(o.get("layout"), list)
|
| 309 |
|
| 310 |
|
| 311 |
-
def extract_embeddable_html_document(raw: str) -> str | None:
|
| 312 |
-
"""
|
| 313 |
-
If the model put HTML/JS widgets inside <WIDGET> while WIDGET_MODE=json, return a full HTML
|
| 314 |
-
document suitable for inject_design_system + iframe. Otherwise None.
|
| 315 |
-
"""
|
| 316 |
-
s = strip_widget_markdown_fences((raw or "").strip())
|
| 317 |
-
if not s or "<" not in s or ">" not in s:
|
| 318 |
-
return None
|
| 319 |
-
st = s.lstrip()
|
| 320 |
-
if st.startswith("{") and "<div" not in s.lower() and "<html" not in s.lower():
|
| 321 |
-
return None
|
| 322 |
-
low = s.lower()
|
| 323 |
-
if "<html" in low or "<!doctype" in low:
|
| 324 |
-
return re.sub(r"<!DOCTYPE[^>]*>", "", s, flags=re.IGNORECASE).strip()
|
| 325 |
-
if any(
|
| 326 |
-
tag in low
|
| 327 |
-
for tag in (
|
| 328 |
-
"<script",
|
| 329 |
-
"<body",
|
| 330 |
-
"<div",
|
| 331 |
-
"<canvas",
|
| 332 |
-
"<iframe",
|
| 333 |
-
"<form",
|
| 334 |
-
"<input",
|
| 335 |
-
"<button",
|
| 336 |
-
"<style",
|
| 337 |
-
)
|
| 338 |
-
):
|
| 339 |
-
inner = re.sub(r"<!DOCTYPE[^>]*>", "", s, flags=re.IGNORECASE).strip()
|
| 340 |
-
if "<html" not in inner.lower():
|
| 341 |
-
return f"<html><head></head><body>{inner}</body></html>"
|
| 342 |
-
return inner
|
| 343 |
-
return None
|
| 344 |
-
|
| 345 |
-
|
| 346 |
def finalize_widget_schema_json(raw: str) -> str:
|
| 347 |
"""
|
| 348 |
Normalize model output for the Vue renderer: strip fences, extract JSON, coerce layout.
|
|
@@ -397,7 +372,8 @@ Honoring an explicitly requested chart type:
|
|
| 397 |
- Never pretend an unsupported type is supported, and never relabel a different chart as the requested type.
|
| 398 |
|
| 399 |
Interactivity:
|
| 400 |
-
-
|
|
|
|
| 401 |
- OUTPUT JSON ONLY. Never output HTML, <script>, <style>, <canvas>, raw markup, or code inside <WIDGET> — only the JSON schema above. There is no HTML mode.
|
| 402 |
- If something cannot be expressed with the supported block types (e.g. a playable game, live sliders), DO NOT invent HTML — return <WIDGET></WIDGET> (empty) and explain in <RESPONSE> instead. Never dump raw index arrays like `[0,1,2]`.
|
| 403 |
- For photographs, diagrams, or icons, use the `image` block with a valid https:// URL or a data: URI. Combine `image` with `text`, `chart`, and `table` blocks as needed.
|
|
@@ -416,12 +392,24 @@ Dynamic layout (JSON) — avoid static, single-block dashboards:
|
|
| 416 |
_REGISTRY_JSON_PATH = Path(__file__).resolve().parent.parent / "frontend-vue" / "src" / "widget-registry.json"
|
| 417 |
|
| 418 |
_JSON_RULE_PREAMBLE = """WIDGET JSON SCHEMA MODE (WIDGET_MODE=json):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
- The content inside <WIDGET> MUST be valid JSON (no markdown fences, no comments).
|
| 420 |
- Root object: { "version": "1.0", "layout": [ ... ] }
|
| 421 |
- layout is an ordered array of blocks (top-to-bottom).
|
| 422 |
- Supported block types ONLY (do not invent new ones):"""
|
| 423 |
|
| 424 |
_JSON_RULE_TRAILER = """
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
Data grounding (STRICT — the widget must mirror your <RESPONSE>):
|
| 426 |
- Use ONLY the exact numbers and labels stated in your <RESPONSE>. Do NOT invent values, round differently, or add labels/values not written in <RESPONSE>.
|
| 427 |
- For category charts, "x_categories" MUST be the exact entity/segment names you named in <RESPONSE> (e.g. "Data Center", "Gaming") — NEVER 0, 1, 2. Each series value MUST equal the number in <RESPONSE>, aligned to those categories.
|
|
@@ -434,7 +422,8 @@ Honoring an explicitly requested chart type:
|
|
| 434 |
- Never pretend an unsupported type is supported, and never relabel a different chart as the requested type.
|
| 435 |
|
| 436 |
Interactivity:
|
| 437 |
-
-
|
|
|
|
| 438 |
- OUTPUT JSON ONLY. Never output HTML, <script>, <style>, <canvas>, raw markup, or code inside <WIDGET> — only the JSON schema above. There is no HTML mode.
|
| 439 |
- If something cannot be expressed with the supported block types (e.g. a playable game, live sliders), DO NOT invent HTML — return <WIDGET></WIDGET> (empty) and explain in <RESPONSE> instead. Never dump raw index arrays like `[0,1,2]`.
|
| 440 |
- For photographs, diagrams, or icons, use the `image` block with a valid https:// URL or a data: URI. Combine `image` with `text`, `chart`, and `table` blocks as needed.
|
|
@@ -471,61 +460,6 @@ def build_json_widget_rule() -> str:
|
|
| 471 |
return _JSON_WIDGET_RULE.strip()
|
| 472 |
|
| 473 |
|
| 474 |
-
# ── Design system injected into combined output ────────────────────────────
|
| 475 |
-
|
| 476 |
-
_DESIGN_SYSTEM_REMINDER = """
|
| 477 |
-
A CSS design system is pre-injected into every widget iframe. Use ONLY these variables:
|
| 478 |
-
--bg, --bg2, --bg3 (backgrounds) --text, --text2, --text3 (text)
|
| 479 |
-
--border, --border2 (borders) --accent, --accent-bg, --accent-b (blue)
|
| 480 |
-
--success, --success-bg (green) --warn, --warn-bg (amber)
|
| 481 |
-
--danger, --danger-bg (red) --radius, --radius-sm, --radius-pill
|
| 482 |
-
|
| 483 |
-
Pre-built CSS classes (use them directly, no need to redefine):
|
| 484 |
-
.card .raised .card-title .tabs .tab .panel .search .pills .pill
|
| 485 |
-
.ctrl-row .ctrl-lbl .ctrl-val .btn-group .btn .ask-btn
|
| 486 |
-
.badge .b-blue .b-green .b-amber .b-red .b-gray
|
| 487 |
-
.metric-grid .metric .metric-lbl .metric-val
|
| 488 |
-
.progress-wrap .progress-bar .result-box .result-lbl .result-val .result-sub
|
| 489 |
-
.step-row .step-num .step-title .step-desc .count-lbl .empty
|
| 490 |
-
"""
|
| 491 |
-
|
| 492 |
-
_SENDPROMPT_RULE = """
|
| 493 |
-
Always define and use this exact bridge function inside <WIDGET>:
|
| 494 |
-
function sendPrompt(t){window.parent.postMessage({type:"streamlit:setComponentValue",value:t},"*");}
|
| 495 |
-
Every clickable card, row, chip, and button must call sendPrompt with a specific, contextual message.
|
| 496 |
-
"""
|
| 497 |
-
|
| 498 |
-
_REACTIVE_RUNTIME_RULE = """
|
| 499 |
-
Universal reactive mini-app contract (follow for every widget):
|
| 500 |
-
- Your widget MUST follow this exact execution model:
|
| 501 |
-
1) Define:
|
| 502 |
-
- const data = ... // embedded data derived ONLY from user/context and your <RESPONSE>
|
| 503 |
-
- const state = {...} // ALL user inputs (sliders/filters/selections). Initial values must match exact numbers you used in <RESPONSE>.
|
| 504 |
-
2) Implement:
|
| 505 |
-
- function compute(state, data) { return {...} } // pure transforms: filter/aggregate/calc/sort. No network.
|
| 506 |
-
- function render() { const c = compute(state, data); ... update DOM + chart + table from c ... }
|
| 507 |
-
3) On load: always call render() once so the widget is never empty.
|
| 508 |
-
4) On interaction: update state -> call render() immediately (instant UX; never call the LLM on slider drag).
|
| 509 |
-
5) sendPrompt: ONLY when new knowledge/data is required. Include current state in the prompt.
|
| 510 |
-
|
| 511 |
-
Charts:
|
| 512 |
-
- Use any public chart/library CDN from cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, or cdn.plot.ly.
|
| 513 |
-
- Chart backgrounds must be transparent for iframe embedding.
|
| 514 |
-
- ECharts (preferred): https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js
|
| 515 |
-
- Plotly, D3, Chart.js, ApexCharts, and other public viz libraries are allowed.
|
| 516 |
-
|
| 517 |
-
Forbidden (never include):
|
| 518 |
-
- fetch / XMLHttpRequest / WebSocket
|
| 519 |
-
- eval / new Function
|
| 520 |
-
"""
|
| 521 |
-
|
| 522 |
-
_DYNAMIC_WIDGET_UX_RULE = """
|
| 523 |
-
Dynamic, responsive widgets (not static posters):
|
| 524 |
-
- Layout: use flex/grid with wrap, minmax(), and clamp() so content reflows when the iframe is narrow or wide. Prefer fluid widths (%, fr, max-width) over fixed pixel widths for main columns.
|
| 525 |
-
- Motion & feedback: add CSS transitions on hover/focus for cards, buttons, and controls; subtle transform (translateY) on hover where it aids affordance. Enable chart library animation (e.g. ECharts animation / animationDuration) so series draw in smoothly.
|
| 526 |
-
- Interactivity: expose meaningful controls — tabs, toggles, filters, sliders, dataZoom/brush on charts when data density warrants it. When state changes, re-render charts/tables immediately (same reactive pattern as sliders).
|
| 527 |
-
- Depth: combine visuals (chart + KPI strip + short table + optional image/diagram) when the answer benefits; vary structure by use case instead of repeating one template every turn.
|
| 528 |
-
"""
|
| 529 |
|
| 530 |
_OUTPUT_CONTRACT_STRICT = """
|
| 531 |
OUTPUT CONTRACT (STRICT — MUST FOLLOW)
|
|
@@ -545,85 +479,6 @@ Rules:
|
|
| 545 |
If you fail to follow this contract, the system will break.
|
| 546 |
"""
|
| 547 |
|
| 548 |
-
_LIBRARIES_RULE = """
|
| 549 |
-
Allowed libraries for <WIDGET> — use any public CDN from cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, or cdn.plot.ly.
|
| 550 |
-
Use whichever library produces the best visual for the use case. You may combine libraries (e.g. ECharts + Tabulator).
|
| 551 |
-
|
| 552 |
-
Recommended (pick the best fit):
|
| 553 |
-
- ECharts: https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js
|
| 554 |
-
- Plotly.js: https://cdn.plot.ly/plotly-2.30.0.min.js
|
| 555 |
-
- D3.js: https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js
|
| 556 |
-
- Chart.js: https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js
|
| 557 |
-
- ApexCharts: https://cdn.jsdelivr.net/npm/apexcharts
|
| 558 |
-
- Tabulator (tables): https://cdn.jsdelivr.net/npm/tabulator-tables/dist/js/tabulator.min.js + CSS
|
| 559 |
-
- Mermaid (flowcharts, sequence diagrams): https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js
|
| 560 |
-
- Three.js (simple 3D): https://cdn.jsdelivr.net/npm/three/build/three.min.js
|
| 561 |
-
|
| 562 |
-
You may use any other public library from these CDNs. Choose the library that creates the best, most accurate visualization.
|
| 563 |
-
|
| 564 |
-
Color + theming baseline (applies to every engine):
|
| 565 |
-
- Always define a JS palette (array of hex colors) and apply it explicitly to series/marks.
|
| 566 |
-
- Detect dark mode with: const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
| 567 |
-
- Explicitly set: axis label color, grid line color, legend text color, and tooltip styling.
|
| 568 |
-
|
| 569 |
-
Library choice guidance (use the best fit; do not force the same layout every time):
|
| 570 |
-
- Time-series trends (date/time x-axis, >=5 points): ECharts line/area + tooltip + subtle dataZoom.
|
| 571 |
-
- Categorical rankings (categories with numeric values): ECharts horizontal bar + click-to-filter + cross-filter table.
|
| 572 |
-
- Composition/share: stacked bars (or 100% stacked) with tooltip value + %; pie/donut only when 3–5 short categories.
|
| 573 |
-
- Distributions:
|
| 574 |
-
- if you have raw samples: histogram-like bins
|
| 575 |
-
- if you only have summary stats: do not invent bins; use KPI tiles + short explanation.
|
| 576 |
-
- Correlation/relationship (x-y pairs): ECharts scatter; highlight outliers.
|
| 577 |
-
- Hierarchies: treemap only when parent/child is explicit; otherwise use grouped table.
|
| 578 |
-
- Many series: avoid clutter; use small multiples or series toggles (do not plot >6 lines by default).
|
| 579 |
-
- Tables: Tabulator always for scan/sort/filter when it helps (rows > 8 or user asked for a breakdown).
|
| 580 |
-
- Prose/conceptual answers with no extractable dataset: still generate a widget using illustrative/mock data and label it clearly (e.g. "Example", "Illustrative", "Mock data").
|
| 581 |
-
|
| 582 |
-
Engine-specific rendering requirements:
|
| 583 |
-
- ECharts: option.backgroundColor must be 'transparent'; set textStyle/axis/grid colors from theme.
|
| 584 |
-
- Plotly: set paper_bgcolor/plot_bgcolor to 'rgba(0,0,0,0)'; set layout.font.color and layout.colorway=palette.
|
| 585 |
-
- D3: create SVG with responsive sizing; set tooltip styles; apply palette for strokes/fills.
|
| 586 |
-
"""
|
| 587 |
-
|
| 588 |
-
_ANALYTICS_DEFAULTS_RULE = """
|
| 589 |
-
Dashboard decision policy (data-driven; do this internally—do not output the reasoning):
|
| 590 |
-
1) DATASET EXTRACTION:
|
| 591 |
-
- Prefer data from: (a) user request/context, (b) numeric values in <RESPONSE>.
|
| 592 |
-
- If sufficient data exists, use it. If not, use illustrative/mock data — but you MUST clearly label it (e.g. "Example data", "Mock data", "Illustrative") in the widget title or a visible subtitle.
|
| 593 |
-
2) DATA-SHAPE DETECTION:
|
| 594 |
-
- Determine shape: time-series, categorical ranking, composition, distribution, correlation, hierarchy, steps/process, or other.
|
| 595 |
-
3) WIDGET WARRANT:
|
| 596 |
-
- Generate a widget wherever there is possibility. If extractable data exists, use it.
|
| 597 |
-
- If no extractable dataset (or too few points): use illustrative/mock data and clearly label it (e.g. "Example data", "Mock data", "Illustrative"). Do NOT return empty <WIDGET></WIDGET> when a chart/calculator/table would help.
|
| 598 |
-
4) BI LAYOUT (only when warranted):
|
| 599 |
-
- KPI row (3–6 tiles) → optional Controls row → Primary visualization → optional detail table → Insights (2–4).
|
| 600 |
-
5) CROSS-VIEW INTERACTION:
|
| 601 |
-
- Any filter/control must update KPIs + chart + table from the SAME filtered dataset.
|
| 602 |
-
6) DRILLDOWN LOOP:
|
| 603 |
-
- Click chart mark / legend / table row → sendPrompt('...') with clicked entity + metric + relevant time window (if present) + current filter summary.
|
| 604 |
-
7) INSIGHT RULE:
|
| 605 |
-
- Insights must be computed from the dataset in JS (or computed from extracted values). Do not write obvious generic commentary.
|
| 606 |
-
"""
|
| 607 |
-
|
| 608 |
-
_COLOR_THEMING_RULE = """
|
| 609 |
-
Color & theming (best-in-class readability + polish):
|
| 610 |
-
- You may choose ANY colors, but they MUST remain readable and “enterprise clean”.
|
| 611 |
-
- Detect dark mode with: const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
| 612 |
-
- Create theme tokens in JS:
|
| 613 |
-
- text = dark ? '#e8eaf4' : '#111318'
|
| 614 |
-
- text2 = dark ? '#8d93aa' : '#5a5f72'
|
| 615 |
-
- grid = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'
|
| 616 |
-
- border = dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)'
|
| 617 |
-
- Define palette in JS (hex array) and use it explicitly.
|
| 618 |
-
- Deterministic category coloring:
|
| 619 |
-
- Build `colorMap` from category keys to palette entries (stable ordering).
|
| 620 |
-
- Reuse the same `colorMap` for KPIs and chart series/marks.
|
| 621 |
-
- Selection/interaction states:
|
| 622 |
-
- Hover: subtle opacity/brightness change
|
| 623 |
-
- Selected: stronger accent (thicker stroke/line), not neon
|
| 624 |
-
- Grid/labels must always be visible: explicitly set label/text/grid colors for the chart engine.
|
| 625 |
-
"""
|
| 626 |
-
|
| 627 |
# Strict bandit primitive: extra lines for <RESPONSE> when STRICT_PRIMITIVES is on (prompt-only).
|
| 628 |
_STRICT_PRIMITIVE_EXTRAS: dict[str, str] = {
|
| 629 |
"structured_bullets": (
|
|
@@ -647,8 +502,8 @@ _STRICT_PRIMITIVE_EXTRAS: dict[str, str] = {
|
|
| 647 |
"No section titles, bullets, or paragraphs outside the table. Do not put JSON in <RESPONSE>."
|
| 648 |
),
|
| 649 |
"visualization": (
|
| 650 |
-
"
|
| 651 |
-
"
|
| 652 |
),
|
| 653 |
}
|
| 654 |
|
|
@@ -704,7 +559,6 @@ def build_combined_system_prompt(
|
|
| 704 |
format_rule: str,
|
| 705 |
primitive_extra_context: str,
|
| 706 |
user_message: str,
|
| 707 |
-
widget_required: bool = True,
|
| 708 |
forbidden_components: list[str] | None = None,
|
| 709 |
required_components: list[str] | None = None,
|
| 710 |
) -> str:
|
|
@@ -765,58 +619,12 @@ The user's message is only a greeting, thanks, acknowledgement, or goodbye.
|
|
| 765 |
═══════════════════════════════════════════════════════
|
| 766 |
"""
|
| 767 |
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
)
|
| 774 |
-
|
| 775 |
-
widget_rules_header = (
|
| 776 |
-
"WIDGET RULES — for the HTML inside <WIDGET>"
|
| 777 |
-
if widget_mode != "json"
|
| 778 |
-
else "WIDGET RULES — for the JSON schema inside <WIDGET>"
|
| 779 |
-
)
|
| 780 |
-
|
| 781 |
-
widget_requirement_block = (
|
| 782 |
-
"WIDGET REQUIRED for this user turn: return a NON-EMPTY widget."
|
| 783 |
-
if widget_required
|
| 784 |
-
else "WIDGET OPTIONAL for this user turn: return <WIDGET></WIDGET> if the turn is better as text-only."
|
| 785 |
-
)
|
| 786 |
-
|
| 787 |
-
widget_rules_body = (
|
| 788 |
-
f"""- Hard output contract (never violate):
|
| 789 |
-
- You MUST output BOTH tags exactly once: <RESPONSE>...</RESPONSE> and <WIDGET>...</WIDGET>.
|
| 790 |
-
- Never omit <WIDGET> tags. For text-only turns, output `<WIDGET></WIDGET>`.
|
| 791 |
-
- {widget_requirement_block}
|
| 792 |
-
- Choose the UI based on the content in <RESPONSE> (data-driven). Do not follow any fixed template.
|
| 793 |
-
- IMPORTANT: In HTML mode, the content inside <WIDGET> MUST be HTML (not JSON). It must contain opening <html> and closing </html>.
|
| 794 |
-
- Return a COMPLETE, self-contained HTML document (opening <html> to closing </html>).
|
| 795 |
-
- Inline ALL CSS in <style> and ALL JS in <script>. External files: use any public CDN (cdnjs, jsdelivr, unpkg, cdn.plot.ly) for charts, tables, and other libraries.
|
| 796 |
-
- Visuals — use freely when they clarify the answer: <img> (https:// or data:image/...), inline <svg>, <figure>/<figcaption>, <picture>, <canvas> for drawings, and background-image in CSS (url() to https or data URIs). For diagrams/flowcharts you may embed SVG markup or use canvas/D3/Mermaid via CDN. Attribute image sources when the license requires it.
|
| 797 |
-
- {_LIBRARIES_RULE.strip()}
|
| 798 |
-
- {_ANALYTICS_DEFAULTS_RULE.strip()}
|
| 799 |
-
- {_COLOR_THEMING_RULE.strip()}
|
| 800 |
-
- No frameworks (React/Vue/jQuery). Plain HTML + CSS + JS only.
|
| 801 |
-
- body background must be transparent (background:transparent!important).
|
| 802 |
-
- No position:fixed anywhere.
|
| 803 |
-
- Wrap content in <div class="widget-root">.
|
| 804 |
-
- No markdown fences/backticks inside <WIDGET>. Use ONLY raw HTML/CSS/JS.
|
| 805 |
-
- Always call your main render/calc function once on page load so output is never empty (e.g., call `init()` or `render()` at the end of <script>).
|
| 806 |
-
- Charts/tables must be drawn from the embedded dataset immediately after the first render call.
|
| 807 |
-
- {_DYNAMIC_WIDGET_UX_RULE.strip()}
|
| 808 |
-
- Slider/input changes → local calc() only (never sendPrompt on drag).
|
| 809 |
-
- Slider initial values MUST match the exact numbers in your <RESPONSE>. Never invent defaults.
|
| 810 |
-
- Use 0.5px solid borders — never 1px solid.
|
| 811 |
-
- UI (HTML/CSS): use CSS variables only (no hardcoded hex/rgb). Charts (ECharts/Plotly/Chart.js): you MAY use hex colors in JS configs for palettes/series.
|
| 812 |
-
- NO EMOJIS — never use emojis in widgets or labels. Use text only.
|
| 813 |
-
- Neat and clean for any data: light backgrounds (#F5F5F5, #F2F2F2), clear typography, generous spacing. Minimal, professional layout. No decorative icons or clutter.
|
| 814 |
-
{_REACTIVE_RUNTIME_RULE}
|
| 815 |
-
{_DESIGN_SYSTEM_REMINDER}
|
| 816 |
-
{_SENDPROMPT_RULE}"""
|
| 817 |
-
if widget_mode != "json"
|
| 818 |
-
else build_json_widget_rule()
|
| 819 |
-
)
|
| 820 |
|
| 821 |
combined_max_tokens = getattr(config, "COMBINED_MAX_TOKENS", 7500)
|
| 822 |
token_limit_block = f"""
|
|
@@ -826,49 +634,14 @@ TOKEN LIMIT — you have ~{combined_max_tokens} tokens total for <RESPONSE> + <W
|
|
| 826 |
- For comparison tables in <RESPONSE>: keep focused so the widget has room. Both must fit.
|
| 827 |
"""
|
| 828 |
|
| 829 |
-
return f"""You are an expert AI assistant
|
| 830 |
{social_turn_banner}
|
| 831 |
Output style: No emojis. Neat, clean, professional — in both <RESPONSE> text and <WIDGET>.
|
| 832 |
{token_limit_block}
|
| 833 |
{_OUTPUT_CONTRACT_STRICT}
|
| 834 |
|
| 835 |
-
For every
|
| 836 |
-
The widget block may be empty for text-only turns where interactivity is not helpful.
|
| 837 |
-
|
| 838 |
-
CRITICAL — Never describe a widget you do not generate. If your <RESPONSE> mentions "the dashboard below", "interactive chart", "explore visually", or anything that implies a visualization exists, you MUST output a complete, non-empty <WIDGET>. Do NOT say "the dashboard below" if you return empty <WIDGET></WIDGET>. Either generate the full widget HTML or do not mention it in the text at all.
|
| 839 |
-
|
| 840 |
-
Only return an EMPTY widget block (<WIDGET></WIDGET>) when the turn is not “widget-worthy”:
|
| 841 |
-
- greetings (hi, hello, hey), acknowledgements (thanks, ok, got it), goodbyes (bye, goodbye), pure chit-chat — on these turns do NOT apply the bandit Strategy/Rule to <RESPONSE>; use a short natural reply
|
| 842 |
-
- conceptual Q&A with no dataset/comparison/actionable metrics
|
| 843 |
-
- planning/roadmap/implementation-step requests where prose is the primary output
|
| 844 |
-
|
| 845 |
-
Infer from the user's question and your <RESPONSE> content whether a widget would help. If your <RESPONSE> has structure, numbers, comparisons, or decision support, generate the best-fit widget.
|
| 846 |
-
|
| 847 |
-
Widget warrant decision checklist:
|
| 848 |
-
- Generate NON-EMPTY widget if the user asks for charts, dashboard, analytics, comparison, trends, KPIs, forecasting, ranking, tabular breakdown, numeric exploration, diagrams, illustrations, or images that support the answer.
|
| 849 |
-
- Generate NON-EMPTY widget if your response includes measurable values that benefit from visual or interactive interpretation.
|
| 850 |
-
- Return EMPTY widget for pure explanation/definition/planning where a chart would be decorative noise.
|
| 851 |
|
| 852 |
-
Understand the user's intent. When the question implies visualization, calculation, comparison, or learning, generate a NON-EMPTY <WIDGET>.
|
| 853 |
-
|
| 854 |
-
Chart/visual selection — pick the right type for the data and use case:
|
| 855 |
-
| Data / use case | Best widget type | Library |
|
| 856 |
-
|-----------------|------------------|---------|
|
| 857 |
-
| Time-series, trends | Line or area chart | ECharts, Plotly, Chart.js |
|
| 858 |
-
| Categorical comparison | Bar chart (horizontal) | ECharts, Chart.js, ApexCharts |
|
| 859 |
-
| Part of whole | Pie, donut, stacked bar | ECharts, Chart.js |
|
| 860 |
-
| Correlation, x-y | Scatter plot | ECharts, Plotly, D3 |
|
| 861 |
-
| Adjustable numbers, formulas | Interactive calculator with sliders | Plain JS + Chart.js/ECharts |
|
| 862 |
-
| Rows > 8, breakdown | Tabulator table | Tabulator |
|
| 863 |
-
| KPI metrics | KPI tiles + optional chart | CSS + ECharts |
|
| 864 |
-
| Process, flow | Diagram, sankey, funnel | D3, ECharts |
|
| 865 |
-
| Photo, illustration, icon | <img> / inline SVG / image block (JSON) | https or data URI |
|
| 866 |
-
|
| 867 |
-
Libraries: ECharts (cdn.jsdelivr.net/npm/echarts), Plotly (cdn.plot.ly), Chart.js (cdnjs), Tabulator (tabulator.info). Pick what fits.
|
| 868 |
-
|
| 869 |
-
One chart vs multiple: Use one chart/widget when it suffices. Add more only when each adds distinct value. Never duplicate the same data in multiple chart types.
|
| 870 |
-
|
| 871 |
-
CRITICAL — Complete the widget: Never truncate or stop mid-generation. The <WIDGET> must be a complete, functional HTML document. If space is tight, shorten <RESPONSE> — the widget must always finish.
|
| 872 |
═══════════════════════════════════════════════════════
|
| 873 |
OUTPUT FORMAT — always use exactly this structure
|
| 874 |
═══════════════════════════════════════════════════════
|
|
@@ -885,35 +658,18 @@ RESPONSE FORMAT RULE — {response_rule_line}
|
|
| 885 |
Strategy: {strategy_id}
|
| 886 |
Rule: {format_rule}
|
| 887 |
Do not mention this rule. Do not add <WIDGET> inside <RESPONSE>.
|
|
|
|
|
|
|
| 888 |
CRITICAL — Primitives vs Widget (never confuse these):
|
| 889 |
- The Strategy/Rule above applies ONLY to <RESPONSE> (text format: bullets, table, prose, etc.). It does NOT constrain <WIDGET>.
|
| 890 |
-
- <WIDGET> is SEPARATE and INDEPENDENT.
|
| 891 |
|
| 892 |
═══════════════════════════════════════════════════════
|
| 893 |
{widget_rules_header}
|
| 894 |
═══════════════════════════════════════════════════════
|
| 895 |
{widget_rules_body}
|
| 896 |
{widget_block}
|
| 897 |
-
{constraint_block}
|
| 898 |
-
═══════════════════════════════════════════════════════
|
| 899 |
-
DATA GROUNDING — most critical quality rule
|
| 900 |
-
═══════════════════════════════════════════════════════
|
| 901 |
-
Every entity, name, number, ticker, percentage shown in the widget MUST come from either:
|
| 902 |
-
- the numeric values you extracted from the user request/context, OR
|
| 903 |
-
- the numeric values present in your <RESPONSE>, OR
|
| 904 |
-
- (for educational/concept demos only) reasonable illustrative values you choose (e.g. P=1000, r=5%, t=10 for compound interest).
|
| 905 |
-
- Use meaningful labels (Principal, Rate, Years). When using mock data, clearly label it (e.g. "Example", "Mock data").
|
| 906 |
-
- When real data is missing: you MAY use illustrative/mock data; label it clearly in the widget.
|
| 907 |
-
- Prefer data from <RESPONSE> or user context; when unavailable, use mock/illustrative data and label it.
|
| 908 |
-
- When using mock/illustrative data (dates, tickers, values), label it clearly “e.g. Example data, Mock data”.
|
| 909 |
-
|
| 910 |
-
═══════════════════════════════���═══════════════════════
|
| 911 |
-
sendPrompt specificity — always specific, never generic
|
| 912 |
-
═══════════════════════════════════════════════════════
|
| 913 |
-
GOOD: sendPrompt('What are the risks of VTI at 0.03% expense ratio?')
|
| 914 |
-
BAD: sendPrompt('Tell me more')
|
| 915 |
-
BAD: sendPrompt('Click for details')
|
| 916 |
-
"""
|
| 917 |
|
| 918 |
|
| 919 |
def build_combined_user_prompt(
|
|
@@ -963,23 +719,10 @@ def parse_combined_output(raw: str) -> Tuple[str, str]:
|
|
| 963 |
else:
|
| 964 |
response_text = raw.strip()
|
| 965 |
|
| 966 |
-
# Extract <WIDGET>...</WIDGET>
|
| 967 |
widget_match = re.search(r"<WIDGET>(.*?)</WIDGET>", raw, re.DOTALL | re.IGNORECASE)
|
| 968 |
if widget_match:
|
| 969 |
raw_widget = widget_match.group(1).strip()
|
| 970 |
-
|
| 971 |
-
widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
|
| 972 |
-
if widget_mode == "json":
|
| 973 |
-
widget_payload = finalize_widget_schema_json(raw_widget)
|
| 974 |
-
else:
|
| 975 |
-
# HTML mode: strip markdown fences if model wrapped widget in ```...```
|
| 976 |
-
if "```" in raw_widget:
|
| 977 |
-
fence = re.search(r"```(?:json|html)?\s*(.*?)```", raw_widget, re.DOTALL | re.IGNORECASE)
|
| 978 |
-
raw_widget = fence.group(1).strip() if fence else re.sub(r"```\w*", "", raw_widget).strip()
|
| 979 |
-
if "<" in raw_widget and ">" in raw_widget:
|
| 980 |
-
if "<html" not in raw_widget.lower():
|
| 981 |
-
raw_widget = f"<html><head></head><body>{raw_widget}</body></html>"
|
| 982 |
-
raw_widget = re.sub(r"<!DOCTYPE[^>]*>", "", raw_widget, flags=re.IGNORECASE).strip()
|
| 983 |
-
widget_payload = inject_design_system(raw_widget)
|
| 984 |
|
| 985 |
return response_text, widget_payload
|
|
|
|
| 23 |
from typing import Any, Tuple
|
| 24 |
|
| 25 |
from . import config
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def strip_widget_markdown_fences(raw: str) -> str:
|
|
|
|
| 190 |
return _nonempty_list(chart.get("boxes"))
|
| 191 |
if kind in {"sankey", "graph"}:
|
| 192 |
return _nonempty_list(chart.get("links"))
|
| 193 |
+
if kind in {"tree", "mindmap", "org", "orgchart"}:
|
| 194 |
+
# Accept the same variants the renderer reads: root | tree | data | top-level node | items.
|
| 195 |
+
root = chart.get("root") or chart.get("tree") or chart.get("data")
|
| 196 |
+
if isinstance(root, list):
|
| 197 |
+
root = root[0] if root else None
|
| 198 |
+
if isinstance(root, dict) and (root.get("name") or root.get("label") or root.get("children")):
|
| 199 |
+
return True
|
| 200 |
+
if chart.get("name") or _nonempty_list(chart.get("children")) or _nonempty_list(chart.get("items")):
|
| 201 |
+
return True
|
| 202 |
+
return False
|
| 203 |
+
if kind in {"pie", "donut", "funnel", "treemap", "sunburst", "waterfall", "gauge", "rose"}:
|
| 204 |
if _nonempty_list(chart.get("items")):
|
| 205 |
return True
|
| 206 |
s = chart.get("series")
|
| 207 |
return isinstance(s, list) and any(isinstance(x, dict) and x.get("values") for x in s)
|
| 208 |
# line | bar | hbar | area | scatter | bubble | stacked | combo | histogram | radar
|
| 209 |
+
# | timeseries | polar | parallel | themeriver | scatter3d | bar3d | line3d
|
| 210 |
s = chart.get("series")
|
| 211 |
return isinstance(s, list) and any(
|
| 212 |
isinstance(x, dict) and isinstance(x.get("values"), list) and len(x.get("values")) for x in s
|
|
|
|
| 318 |
return isinstance(o, dict) and isinstance(o.get("layout"), list)
|
| 319 |
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
def finalize_widget_schema_json(raw: str) -> str:
|
| 322 |
"""
|
| 323 |
Normalize model output for the Vue renderer: strip fences, extract JSON, coerce layout.
|
|
|
|
| 372 |
- Never pretend an unsupported type is supported, and never relabel a different chart as the requested type.
|
| 373 |
|
| 374 |
Interactivity:
|
| 375 |
+
- action_row buttons are VISUALS-ONLY: each button must only offer to redraw the data ALREADY shown as a different supported chart kind (e.g. "Show as bar chart", "View as treemap", "Show as pie", "View as horizontal bars"). The button label is sent back as the next prompt, so it must be something you can definitely do with the data on screen.
|
| 376 |
+
- NEVER add action buttons that need data you may not have: no new tickers/companies, no new time periods, no "compare X vs Y", no growth/peers/forecasts, no "explain ...". If no alternative chart kind fits the data, omit the action_row.
|
| 377 |
- OUTPUT JSON ONLY. Never output HTML, <script>, <style>, <canvas>, raw markup, or code inside <WIDGET> — only the JSON schema above. There is no HTML mode.
|
| 378 |
- If something cannot be expressed with the supported block types (e.g. a playable game, live sliders), DO NOT invent HTML — return <WIDGET></WIDGET> (empty) and explain in <RESPONSE> instead. Never dump raw index arrays like `[0,1,2]`.
|
| 379 |
- For photographs, diagrams, or icons, use the `image` block with a valid https:// URL or a data: URI. Combine `image` with `text`, `chart`, and `table` blocks as needed.
|
|
|
|
| 392 |
_REGISTRY_JSON_PATH = Path(__file__).resolve().parent.parent / "frontend-vue" / "src" / "widget-registry.json"
|
| 393 |
|
| 394 |
_JSON_RULE_PREAMBLE = """WIDGET JSON SCHEMA MODE (WIDGET_MODE=json):
|
| 395 |
+
- WIDGET WARRANT (decide for yourself — there is no keyword trigger):
|
| 396 |
+
Include a NON-EMPTY widget ONLY IF both are true: (1) a visual materially helps (a comparison of
|
| 397 |
+
several numbers, a trend, a breakdown/share, a flow, or a matrix), AND (2) you can populate it with
|
| 398 |
+
real values from your <RESPONSE> using the supported block types below. If you cannot build it from
|
| 399 |
+
the allowed blocks (or there is no concrete data), return <WIDGET></WIDGET> (empty) and say so in
|
| 400 |
+
<RESPONSE>. Do NOT chart a single number, a definition, a yes/no, or an opinion.
|
| 401 |
- The content inside <WIDGET> MUST be valid JSON (no markdown fences, no comments).
|
| 402 |
- Root object: { "version": "1.0", "layout": [ ... ] }
|
| 403 |
- layout is an ordered array of blocks (top-to-bottom).
|
| 404 |
- Supported block types ONLY (do not invent new ones):"""
|
| 405 |
|
| 406 |
_JSON_RULE_TRAILER = """
|
| 407 |
+
NO FABRICATED DATA (most important — applies to <RESPONSE> and <WIDGET>):
|
| 408 |
+
- If you do NOT actually have the real figures to answer (e.g. live or historical stock prices, a fund's daily returns, exact financials you are not sure of), DO NOT invent, estimate, or use "illustrative"/"mock"/"example"/"est." data, and DO NOT draw a widget.
|
| 409 |
+
- In that case: say plainly in <RESPONSE> that you do not have that specific data, suggest what the user could provide or ask instead, and return <WIDGET></WIDGET> (empty).
|
| 410 |
+
- NEVER label a chart or value "mock", "illustrative", "estimated", or "example" — if it is not real, do not render it. A widget must only ever show real, known values.
|
| 411 |
+
- Educational math demos (e.g. compound-interest with user-given P/r/t) are fine because the user supplied the inputs; market data you don't have is NOT.
|
| 412 |
+
|
| 413 |
Data grounding (STRICT — the widget must mirror your <RESPONSE>):
|
| 414 |
- Use ONLY the exact numbers and labels stated in your <RESPONSE>. Do NOT invent values, round differently, or add labels/values not written in <RESPONSE>.
|
| 415 |
- For category charts, "x_categories" MUST be the exact entity/segment names you named in <RESPONSE> (e.g. "Data Center", "Gaming") — NEVER 0, 1, 2. Each series value MUST equal the number in <RESPONSE>, aligned to those categories.
|
|
|
|
| 422 |
- Never pretend an unsupported type is supported, and never relabel a different chart as the requested type.
|
| 423 |
|
| 424 |
Interactivity:
|
| 425 |
+
- action_row buttons are VISUALS-ONLY: each button must only offer to redraw the data ALREADY shown as a different supported chart kind (e.g. "Show as bar chart", "View as treemap", "Show as pie", "View as horizontal bars"). The button label is sent back as the next prompt, so it must be something you can definitely do with the data on screen.
|
| 426 |
+
- NEVER add action buttons that need data you may not have: no new tickers/companies, no new time periods, no "compare X vs Y", no growth/peers/forecasts, no "explain ...". If no alternative chart kind fits the data, omit the action_row.
|
| 427 |
- OUTPUT JSON ONLY. Never output HTML, <script>, <style>, <canvas>, raw markup, or code inside <WIDGET> — only the JSON schema above. There is no HTML mode.
|
| 428 |
- If something cannot be expressed with the supported block types (e.g. a playable game, live sliders), DO NOT invent HTML — return <WIDGET></WIDGET> (empty) and explain in <RESPONSE> instead. Never dump raw index arrays like `[0,1,2]`.
|
| 429 |
- For photographs, diagrams, or icons, use the `image` block with a valid https:// URL or a data: URI. Combine `image` with `text`, `chart`, and `table` blocks as needed.
|
|
|
|
| 460 |
return _JSON_WIDGET_RULE.strip()
|
| 461 |
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
_OUTPUT_CONTRACT_STRICT = """
|
| 465 |
OUTPUT CONTRACT (STRICT — MUST FOLLOW)
|
|
|
|
| 479 |
If you fail to follow this contract, the system will break.
|
| 480 |
"""
|
| 481 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
# Strict bandit primitive: extra lines for <RESPONSE> when STRICT_PRIMITIVES is on (prompt-only).
|
| 483 |
_STRICT_PRIMITIVE_EXTRAS: dict[str, str] = {
|
| 484 |
"structured_bullets": (
|
|
|
|
| 502 |
"No section titles, bullets, or paragraphs outside the table. Do not put JSON in <RESPONSE>."
|
| 503 |
),
|
| 504 |
"visualization": (
|
| 505 |
+
"Write a brief 1-2 sentence lead-in only. The <WIDGET> carries the actual visual. "
|
| 506 |
+
"NEVER draw ASCII charts, tree diagrams (├──), or text-art, and never put code blocks or JSON in <RESPONSE>."
|
| 507 |
),
|
| 508 |
}
|
| 509 |
|
|
|
|
| 559 |
format_rule: str,
|
| 560 |
primitive_extra_context: str,
|
| 561 |
user_message: str,
|
|
|
|
| 562 |
forbidden_components: list[str] | None = None,
|
| 563 |
required_components: list[str] | None = None,
|
| 564 |
) -> str:
|
|
|
|
| 619 |
═══════════════════════════════════════════════════════
|
| 620 |
"""
|
| 621 |
|
| 622 |
+
# Components-only / JSON schema mode is the only mode. The widget vocabulary,
|
| 623 |
+
# warrant rubric, strict grounding, and decline rules all come from the registry
|
| 624 |
+
# (build_json_widget_rule → widget-registry.json). No HTML, no external libraries.
|
| 625 |
+
widget_format_line = "JSON UI schema ONLY (no HTML) for the widget"
|
| 626 |
+
widget_rules_header = "WIDGET RULES — for the JSON schema inside <WIDGET>"
|
| 627 |
+
widget_rules_body = build_json_widget_rule()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
|
| 629 |
combined_max_tokens = getattr(config, "COMBINED_MAX_TOKENS", 7500)
|
| 630 |
token_limit_block = f"""
|
|
|
|
| 634 |
- For comparison tables in <RESPONSE>: keep focused so the widget has room. Both must fit.
|
| 635 |
"""
|
| 636 |
|
| 637 |
+
return f"""You are an expert AI assistant. Each turn you produce a written answer AND an OPTIONAL interactive widget built ONLY from a fixed set of UI components defined below — there is NO HTML and NO external charting libraries.
|
| 638 |
{social_turn_banner}
|
| 639 |
Output style: No emojis. Neat, clean, professional — in both <RESPONSE> text and <WIDGET>.
|
| 640 |
{token_limit_block}
|
| 641 |
{_OUTPUT_CONTRACT_STRICT}
|
| 642 |
|
| 643 |
+
For every turn you produce TWO sections in one generation: the <RESPONSE> text first, then the <WIDGET>. The widget may be empty when a visual is not warranted (see WIDGET WARRANT below). Never describe a widget you did not produce (e.g. don't write "the dashboard below" and then return an empty widget).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
═══════════════════════════════════════════════════════
|
| 646 |
OUTPUT FORMAT — always use exactly this structure
|
| 647 |
═══════════════════════════════════════════════════════
|
|
|
|
| 658 |
Strategy: {strategy_id}
|
| 659 |
Rule: {format_rule}
|
| 660 |
Do not mention this rule. Do not add <WIDGET> inside <RESPONSE>.
|
| 661 |
+
<RESPONSE> is prose for the user — NEVER put JSON, a widget schema, block objects, or code fences in it. All structured data goes ONLY inside <WIDGET>.
|
| 662 |
+
NEVER draw visuals as text in <RESPONSE>: no ASCII charts/bars, no tree drawings (├──, └──), no aligned-column tables-as-art. The <WIDGET> is the ONLY place a visualization lives. For a hierarchy/mind map, put it in a chart block with "kind":"tree"|"mindmap"|"org" and a "root", not as text.
|
| 663 |
CRITICAL — Primitives vs Widget (never confuse these):
|
| 664 |
- The Strategy/Rule above applies ONLY to <RESPONSE> (text format: bullets, table, prose, etc.). It does NOT constrain <WIDGET>.
|
| 665 |
+
- <WIDGET> is SEPARATE and INDEPENDENT. Widget choice depends on the content of your <RESPONSE>, not on the text format.
|
| 666 |
|
| 667 |
═══════════════════════════════════════════════════════
|
| 668 |
{widget_rules_header}
|
| 669 |
═══════════════════════════════════════════════════════
|
| 670 |
{widget_rules_body}
|
| 671 |
{widget_block}
|
| 672 |
+
{constraint_block}"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
|
| 674 |
|
| 675 |
def build_combined_user_prompt(
|
|
|
|
| 719 |
else:
|
| 720 |
response_text = raw.strip()
|
| 721 |
|
| 722 |
+
# Extract <WIDGET>...</WIDGET> — JSON schema only (no HTML mode).
|
| 723 |
widget_match = re.search(r"<WIDGET>(.*?)</WIDGET>", raw, re.DOTALL | re.IGNORECASE)
|
| 724 |
if widget_match:
|
| 725 |
raw_widget = widget_match.group(1).strip()
|
| 726 |
+
widget_payload = finalize_widget_schema_json(raw_widget)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
|
| 728 |
return response_text, widget_payload
|
vivek/backend/config.py
CHANGED
|
@@ -89,7 +89,7 @@ DEFAULT_STRATEGIES = {
|
|
| 89 |
"step_by_step": "Numbered list of 3-6 steps only.",
|
| 90 |
# NEW
|
| 91 |
"comparison_table": "Return a single MARKDOWN TABLE only. Use columns that help compare options (e.g., Option | Pros | Cons | Best for). No bullets outside the table.",
|
| 92 |
-
"visualization": "
|
| 93 |
}
|
| 94 |
|
| 95 |
# Module-level strategy state (may be overwritten by `reload_strategies()`).
|
|
|
|
| 89 |
"step_by_step": "Numbered list of 3-6 steps only.",
|
| 90 |
# NEW
|
| 91 |
"comparison_table": "Return a single MARKDOWN TABLE only. Use columns that help compare options (e.g., Option | Pros | Cons | Best for). No bullets outside the table.",
|
| 92 |
+
"visualization": "Write a brief 1-2 sentence lead-in that frames what the visual shows. The chart/widget carries the visualization. NEVER draw ASCII charts, tree diagrams, or text-art in the response.",
|
| 93 |
}
|
| 94 |
|
| 95 |
# Module-level strategy state (may be overwritten by `reload_strategies()`).
|
vivek/backend/server.py
CHANGED
|
@@ -44,7 +44,6 @@ from . import config, llm
|
|
| 44 |
from .combined_prompt import (
|
| 45 |
build_combined_system_prompt,
|
| 46 |
build_combined_user_prompt,
|
| 47 |
-
extract_embeddable_html_document,
|
| 48 |
finalize_widget_schema_json,
|
| 49 |
is_social_or_greeting_turn,
|
| 50 |
parse_combined_output,
|
|
@@ -68,7 +67,6 @@ from .utils import (
|
|
| 68 |
fast_valence,
|
| 69 |
negative_strength,
|
| 70 |
)
|
| 71 |
-
from .widget_prompt import estimate_widget_height, inject_design_system
|
| 72 |
from .widget_stream import parse_combined_stream
|
| 73 |
|
| 74 |
|
|
@@ -144,24 +142,6 @@ def sse_pack(evt: dict) -> str:
|
|
| 144 |
return f"data: {json.dumps(evt, ensure_ascii=False)}\n\n"
|
| 145 |
|
| 146 |
|
| 147 |
-
def _looks_truncated_widget_html(html: str) -> bool:
|
| 148 |
-
if not html:
|
| 149 |
-
return True
|
| 150 |
-
lower = html.lower()
|
| 151 |
-
if lower.count("<style") > lower.count("</style>"):
|
| 152 |
-
return True
|
| 153 |
-
if lower.count("<script") > lower.count("</script>"):
|
| 154 |
-
return True
|
| 155 |
-
if lower.count("<body") > lower.count("</body>"):
|
| 156 |
-
return True
|
| 157 |
-
if lower.count("<html") > lower.count("</html>"):
|
| 158 |
-
return True
|
| 159 |
-
import re as _re
|
| 160 |
-
if _re.search(r"[<{(]$", html.strip()):
|
| 161 |
-
return True
|
| 162 |
-
return False
|
| 163 |
-
|
| 164 |
-
|
| 165 |
def _json_layout_is_only_numeric_index_arrays(schema_str: str) -> bool:
|
| 166 |
"""
|
| 167 |
Detect bogus JSON widgets where every block is text like '[0,1,2]' (e.g. tic-tac-toe
|
|
@@ -207,42 +187,11 @@ def _dispatch_json_mode_widget(widget_payload_raw: str) -> tuple[str, str, int,
|
|
| 207 |
return finalized, "", 0, "json_schema_invalid"
|
| 208 |
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
if not text:
|
| 216 |
-
return False
|
| 217 |
-
low_signal = {"hi", "hello", "hey", "thanks", "thank you", "ok", "okay", "got it", "cool"}
|
| 218 |
-
if text in low_signal:
|
| 219 |
-
return False
|
| 220 |
-
|
| 221 |
-
widget_triggers = (
|
| 222 |
-
"chart", "graph", "plot", "dashboard", "table", "compare", "comparison",
|
| 223 |
-
"trend", "timeseries", "time series", "distribution", "heatmap", "scatter",
|
| 224 |
-
"pie", "bar", "line", "kpi", "analytics", "analyze", "analysis", "forecast",
|
| 225 |
-
"breakdown", "report", "visualize", "visualise", "show me", "insight", "metrics",
|
| 226 |
-
"tic tac", "tic-tac", "tictac", "game", "playable", "simulator", "write code",
|
| 227 |
-
"html page", "mini app", "calculator app",
|
| 228 |
-
)
|
| 229 |
-
if any(t in text for t in widget_triggers):
|
| 230 |
-
return True
|
| 231 |
-
|
| 232 |
-
text_only_intents = (
|
| 233 |
-
"explain", "what is", "why", "how does", "difference between", "define",
|
| 234 |
-
"summarize", "summarise", "plan", "roadmap", "steps", "implementation plan",
|
| 235 |
-
)
|
| 236 |
-
if any(t in text for t in text_only_intents):
|
| 237 |
-
return False
|
| 238 |
-
|
| 239 |
-
has_numeric_cue = bool(_re.search(r"\b\d+(\.\d+)?%?\b", text))
|
| 240 |
-
has_time_cue = any(t in text for t in ("daily", "weekly", "monthly", "quarterly", "yearly", "over time", "timeline"))
|
| 241 |
-
has_compare_cue = any(t in text for t in ("vs", "versus", "compare", "top", "rank", "distribution"))
|
| 242 |
-
if has_numeric_cue and (has_time_cue or has_compare_cue):
|
| 243 |
-
return True
|
| 244 |
-
|
| 245 |
-
return False
|
| 246 |
|
| 247 |
|
| 248 |
# ---------------------------------------------------------------------------
|
|
@@ -888,7 +837,6 @@ def _build_adaptive_prompt(uid: str, msg: str):
|
|
| 888 |
)
|
| 889 |
|
| 890 |
format_rule = config.STRATEGIES.get(strat, "Be helpful and clear.")
|
| 891 |
-
widget_required = _should_generate_widget(msg)
|
| 892 |
|
| 893 |
prim_block = ""
|
| 894 |
if _is_admin_user(uid):
|
|
@@ -908,7 +856,6 @@ def _build_adaptive_prompt(uid: str, msg: str):
|
|
| 908 |
format_rule=format_rule,
|
| 909 |
primitive_extra_context=(getattr(config, "SKILLS_CONTENT", "") or "") + prim_block,
|
| 910 |
user_message=msg,
|
| 911 |
-
widget_required=widget_required,
|
| 912 |
forbidden_components=None,
|
| 913 |
required_components=None,
|
| 914 |
)
|
|
@@ -918,7 +865,7 @@ def _build_adaptive_prompt(uid: str, msg: str):
|
|
| 918 |
"user": user, "ev": ev, "auto_detected": auto_detected, "auto_r": auto_r,
|
| 919 |
"explicit": explicit, "force_explore": force_explore,
|
| 920 |
"strat": strat, "scores": scores, "x": x, "prev": prev,
|
| 921 |
-
"format_rule": format_rule,
|
| 922 |
"combined_system": combined_system, "combined_prompt": combined_prompt,
|
| 923 |
}
|
| 924 |
|
|
@@ -1006,39 +953,20 @@ def chat(req: ChatReq, bg: BackgroundTasks, user_id: str = Depends(require_user_
|
|
| 1006 |
response = raw_combined.strip()
|
| 1007 |
response = _maybe_enforce_primitive(msg, ctx["strat"], response)
|
| 1008 |
|
| 1009 |
-
widget_html = ""
|
| 1010 |
widget_schema = ""
|
| 1011 |
widget_height = 0
|
| 1012 |
widget_debug = ""
|
| 1013 |
-
widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
|
| 1014 |
raw_preview = ""
|
| 1015 |
|
| 1016 |
if widget_payload_raw:
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
widget_debug = tag or ""
|
| 1020 |
-
else:
|
| 1021 |
-
if _looks_truncated_widget_html(widget_payload_raw):
|
| 1022 |
-
widget_debug = "combined_widget_truncated"
|
| 1023 |
-
else:
|
| 1024 |
-
widget_html = widget_payload_raw
|
| 1025 |
-
widget_height = estimate_widget_height(widget_payload_raw)
|
| 1026 |
-
widget_debug = "combined_widget_ok"
|
| 1027 |
else:
|
| 1028 |
-
|
|
|
|
|
|
|
| 1029 |
raw_preview = (raw_combined or "")[:800]
|
| 1030 |
-
if widget_mode != "json" and ctx["widget_required"]:
|
| 1031 |
-
placeholder = (
|
| 1032 |
-
"<html><head></head><body>"
|
| 1033 |
-
"<div class='widget-root card'><div class='card-title'>Interactive widget</div>"
|
| 1034 |
-
"<div class='empty'>No widget returned.</div></div>"
|
| 1035 |
-
"</body></html>"
|
| 1036 |
-
)
|
| 1037 |
-
widget_html = inject_design_system(placeholder)
|
| 1038 |
-
widget_height = estimate_widget_height(widget_html)
|
| 1039 |
-
widget_debug = "fallback_widget_generated"
|
| 1040 |
-
elif not ctx["widget_required"]:
|
| 1041 |
-
widget_debug = "widget_skipped_by_intent"
|
| 1042 |
|
| 1043 |
user = ctx["user"]
|
| 1044 |
user["history"].append({"user": msg, "assistant": response})
|
|
@@ -1115,11 +1043,10 @@ def chat_stream(req: ChatReq, bg: BackgroundTasks, user_id: str = Depends(requir
|
|
| 1115 |
"auto_reason": ev["reason"],
|
| 1116 |
})
|
| 1117 |
|
| 1118 |
-
widget_html = ""
|
| 1119 |
widget_schema = ""
|
| 1120 |
widget_height = 0
|
| 1121 |
widget_debug = ""
|
| 1122 |
-
widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
|
| 1123 |
raw_preview = ""
|
| 1124 |
response = ""
|
| 1125 |
mode = adapt_mode
|
|
@@ -1191,29 +1118,10 @@ def chat_stream(req: ChatReq, bg: BackgroundTasks, user_id: str = Depends(requir
|
|
| 1191 |
else:
|
| 1192 |
raise RuntimeError("Unsupported ADAPTIVE_LLM_MODE (expected openai_compat or anthropic)")
|
| 1193 |
|
| 1194 |
-
# Finalize widget payload for the canonical 'done' event.
|
| 1195 |
if widget_payload_raw:
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
widget_debug = tag or ""
|
| 1199 |
-
else:
|
| 1200 |
-
import re as _re
|
| 1201 |
-
raw_widget = widget_payload_raw
|
| 1202 |
-
if "```" in raw_widget:
|
| 1203 |
-
fence = _re.search(r"```(?:json|html)?\s*(.*?)```", raw_widget, _re.DOTALL | _re.IGNORECASE)
|
| 1204 |
-
raw_widget = fence.group(1).strip() if fence else _re.sub(r"```\w*", "", raw_widget).strip()
|
| 1205 |
-
if "<" in raw_widget and ">" in raw_widget:
|
| 1206 |
-
if "<html" not in raw_widget.lower():
|
| 1207 |
-
raw_widget = f"<html><head></head><body>{raw_widget}</body></html>"
|
| 1208 |
-
raw_widget = _re.sub(r"<!DOCTYPE[^>]*>", "", raw_widget, flags=_re.IGNORECASE).strip()
|
| 1209 |
-
widget_payload_raw = inject_design_system(raw_widget)
|
| 1210 |
-
if _looks_truncated_widget_html(widget_payload_raw):
|
| 1211 |
-
widget_debug = "stream_widget_truncated"
|
| 1212 |
-
else:
|
| 1213 |
-
widget_html = widget_payload_raw
|
| 1214 |
-
widget_height = estimate_widget_height(widget_html)
|
| 1215 |
-
if widget_mode != "json" and not widget_html:
|
| 1216 |
-
widget_debug = widget_debug or "no_widget"
|
| 1217 |
if not response and not widget_payload_raw:
|
| 1218 |
response = "(No content)"
|
| 1219 |
|
|
|
|
| 44 |
from .combined_prompt import (
|
| 45 |
build_combined_system_prompt,
|
| 46 |
build_combined_user_prompt,
|
|
|
|
| 47 |
finalize_widget_schema_json,
|
| 48 |
is_social_or_greeting_turn,
|
| 49 |
parse_combined_output,
|
|
|
|
| 67 |
fast_valence,
|
| 68 |
negative_strength,
|
| 69 |
)
|
|
|
|
| 70 |
from .widget_stream import parse_combined_stream
|
| 71 |
|
| 72 |
|
|
|
|
| 142 |
return f"data: {json.dumps(evt, ensure_ascii=False)}\n\n"
|
| 143 |
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
def _json_layout_is_only_numeric_index_arrays(schema_str: str) -> bool:
|
| 146 |
"""
|
| 147 |
Detect bogus JSON widgets where every block is text like '[0,1,2]' (e.g. tic-tac-toe
|
|
|
|
| 187 |
return finalized, "", 0, "json_schema_invalid"
|
| 188 |
|
| 189 |
|
| 190 |
+
# NOTE: widget generation is NOT gated by keyword matching. The synthesizer LLM
|
| 191 |
+
# decides per turn whether a widget helps AND whether it can populate it with real
|
| 192 |
+
# values using the allowed block types (see the warrant rubric in combined_prompt).
|
| 193 |
+
# A keyword list both over-fires ("bar exam") and misses ("how has revenue moved?"),
|
| 194 |
+
# and it can't know whether the data exists to fill a chart — the model can.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
# ---------------------------------------------------------------------------
|
|
|
|
| 837 |
)
|
| 838 |
|
| 839 |
format_rule = config.STRATEGIES.get(strat, "Be helpful and clear.")
|
|
|
|
| 840 |
|
| 841 |
prim_block = ""
|
| 842 |
if _is_admin_user(uid):
|
|
|
|
| 856 |
format_rule=format_rule,
|
| 857 |
primitive_extra_context=(getattr(config, "SKILLS_CONTENT", "") or "") + prim_block,
|
| 858 |
user_message=msg,
|
|
|
|
| 859 |
forbidden_components=None,
|
| 860 |
required_components=None,
|
| 861 |
)
|
|
|
|
| 865 |
"user": user, "ev": ev, "auto_detected": auto_detected, "auto_r": auto_r,
|
| 866 |
"explicit": explicit, "force_explore": force_explore,
|
| 867 |
"strat": strat, "scores": scores, "x": x, "prev": prev,
|
| 868 |
+
"format_rule": format_rule,
|
| 869 |
"combined_system": combined_system, "combined_prompt": combined_prompt,
|
| 870 |
}
|
| 871 |
|
|
|
|
| 953 |
response = raw_combined.strip()
|
| 954 |
response = _maybe_enforce_primitive(msg, ctx["strat"], response)
|
| 955 |
|
| 956 |
+
widget_html = "" # components-only: always empty; kept for payload/DB compatibility
|
| 957 |
widget_schema = ""
|
| 958 |
widget_height = 0
|
| 959 |
widget_debug = ""
|
|
|
|
| 960 |
raw_preview = ""
|
| 961 |
|
| 962 |
if widget_payload_raw:
|
| 963 |
+
widget_schema, widget_html, widget_height, tag = _dispatch_json_mode_widget(widget_payload_raw)
|
| 964 |
+
widget_debug = tag or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 965 |
else:
|
| 966 |
+
# No <WIDGET> payload — the model judged this turn better as text-only (or declined).
|
| 967 |
+
# That is a valid outcome; render prose with no widget card.
|
| 968 |
+
widget_debug = "combined_no_schema"
|
| 969 |
raw_preview = (raw_combined or "")[:800]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
|
| 971 |
user = ctx["user"]
|
| 972 |
user["history"].append({"user": msg, "assistant": response})
|
|
|
|
| 1043 |
"auto_reason": ev["reason"],
|
| 1044 |
})
|
| 1045 |
|
| 1046 |
+
widget_html = "" # components-only: always empty; kept for payload/DB compatibility
|
| 1047 |
widget_schema = ""
|
| 1048 |
widget_height = 0
|
| 1049 |
widget_debug = ""
|
|
|
|
| 1050 |
raw_preview = ""
|
| 1051 |
response = ""
|
| 1052 |
mode = adapt_mode
|
|
|
|
| 1118 |
else:
|
| 1119 |
raise RuntimeError("Unsupported ADAPTIVE_LLM_MODE (expected openai_compat or anthropic)")
|
| 1120 |
|
| 1121 |
+
# Finalize widget payload for the canonical 'done' event (JSON schema only).
|
| 1122 |
if widget_payload_raw:
|
| 1123 |
+
widget_schema, widget_html, widget_height, tag = _dispatch_json_mode_widget(widget_payload_raw)
|
| 1124 |
+
widget_debug = tag or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
if not response and not widget_payload_raw:
|
| 1126 |
response = "(No content)"
|
| 1127 |
|
vivek/frontend-vue/dist/assets/ActionRow-B9EJZ-NA.js
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
import{G as e,M as t,P as n,f as r,h as i,i as a,jt as o,u as s,v as c}from"./runtime-core.esm-bundler-olIhRSij.js";import{n as l}from"./index-DxH9q0Bt.js";var u={class:`flex flex-wrap gap-2`},d=c({__name:`ActionRow`,props:{block:{}},setup(c){return(d,f)=>(t(),r(`div`,u,[(t(!0),r(a,null,n(c.block.buttons||[],(n,r)=>(t(),s(l,{key:r,type:`button`,variant:`outline`,size:`sm`,disabled:``,title:n.intent?`Intent: ${n.intent}`:void 0},{default:e(()=>[i(o(n.label||`Action`),1)]),_:2},1032,[`title`]))),128))]))}});export{d as default};
|
|
|
|
|
|
vivek/frontend-vue/dist/assets/ActionRow-D2v3mo3f.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
import{G as e,M as t,P as n,f as r,h as i,i as a,jt as o,u as s,v as c}from"./runtime-core.esm-bundler-olIhRSij.js";import{tr as l}from"./index-DE9MARTs.js";var u={class:`flex flex-wrap gap-2`},d=c({__name:`ActionRow`,props:{block:{}},emits:[`action`],setup(c,{emit:d}){let f=d;function p(e){let t=String(e.label||e.intent||``).trim();t&&f(`action`,t)}return(d,f)=>(t(),r(`div`,u,[(t(!0),r(a,null,n(c.block.buttons||[],(n,r)=>(t(),s(l,{key:r,type:`button`,variant:`outline`,size:`sm`,title:n.intent?`Ask: ${n.label||n.intent}`:void 0,onClick:e=>p(n)},{default:e(()=>[i(o(n.label||`Action`),1)]),_:2},1032,[`title`,`onClick`]))),128))]))}});export{d as default};
|
vivek/frontend-vue/dist/assets/ChartBlock-CdTvHCQ6.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/assets/ChartBlock-zIyaBwdN.js
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
import{M as e,u as t,v as n}from"./runtime-core.esm-bundler-olIhRSij.js";import{t as r}from"./index-DxH9q0Bt.js";var i=n({__name:`ChartBlock`,props:{block:{}},setup(n){let i=n;return(n,a)=>(e(),t(r,{title:i.block.title,chart:i.block.chart||{}},null,8,[`title`,`chart`]))}});export{i as default};
|
|
|
|
|
|
vivek/frontend-vue/dist/assets/index-39HK7jxF.css
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/assets/{index-DxH9q0Bt.js → index-DE9MARTs.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/assets/index-yy7aehFb.css
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
vivek/frontend-vue/dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>frontend-vue</title>
|
| 8 |
-
<script type="module" crossorigin src="/assets/index-
|
| 9 |
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-olIhRSij.js">
|
| 10 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<div id="app"></div>
|
|
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>frontend-vue</title>
|
| 8 |
+
<script type="module" crossorigin src="/assets/index-DE9MARTs.js"></script>
|
| 9 |
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-olIhRSij.js">
|
| 10 |
+
<link rel="stylesheet" crossorigin href="/assets/index-yy7aehFb.css">
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<div id="app"></div>
|
vivek/frontend-vue/package-lock.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
| 15 |
"clsx": "^2.1.1",
|
| 16 |
"dompurify": "^3.3.3",
|
| 17 |
"echarts": "^6.0.0",
|
|
|
|
| 18 |
"gsap": "^3.14.2",
|
| 19 |
"lucide-vue-next": "^1.0.0",
|
| 20 |
"markdown-it": "^14.1.1",
|
|
@@ -1390,6 +1391,11 @@
|
|
| 1390 |
"url": "https://polar.sh/cva"
|
| 1391 |
}
|
| 1392 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1393 |
"node_modules/clsx": {
|
| 1394 |
"version": "2.1.1",
|
| 1395 |
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
@@ -1445,6 +1451,19 @@
|
|
| 1445 |
"zrender": "6.0.0"
|
| 1446 |
}
|
| 1447 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1448 |
"node_modules/echarts/node_modules/tslib": {
|
| 1449 |
"version": "2.3.0",
|
| 1450 |
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
|
|
|
| 15 |
"clsx": "^2.1.1",
|
| 16 |
"dompurify": "^3.3.3",
|
| 17 |
"echarts": "^6.0.0",
|
| 18 |
+
"echarts-gl": "^2.1.0",
|
| 19 |
"gsap": "^3.14.2",
|
| 20 |
"lucide-vue-next": "^1.0.0",
|
| 21 |
"markdown-it": "^14.1.1",
|
|
|
|
| 1391 |
"url": "https://polar.sh/cva"
|
| 1392 |
}
|
| 1393 |
},
|
| 1394 |
+
"node_modules/claygl": {
|
| 1395 |
+
"version": "1.3.0",
|
| 1396 |
+
"resolved": "https://registry.npmjs.org/claygl/-/claygl-1.3.0.tgz",
|
| 1397 |
+
"integrity": "sha512-+gGtJjT6SSHD2l2yC3MCubW/sCV40tZuSs5opdtn79vFSGUgp/lH139RNEQ6Jy078/L0aV8odCw8RSrUcMfLaQ=="
|
| 1398 |
+
},
|
| 1399 |
"node_modules/clsx": {
|
| 1400 |
"version": "2.1.1",
|
| 1401 |
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
|
|
|
| 1451 |
"zrender": "6.0.0"
|
| 1452 |
}
|
| 1453 |
},
|
| 1454 |
+
"node_modules/echarts-gl": {
|
| 1455 |
+
"version": "2.1.0",
|
| 1456 |
+
"resolved": "https://registry.npmjs.org/echarts-gl/-/echarts-gl-2.1.0.tgz",
|
| 1457 |
+
"integrity": "sha512-GxzAPTYJyOANbu7InkdGV7QLLpVyuQWNvN1yyEEIiqgM11ilAO2OkvPNgRdf854R9ZPt/C1HAgjO8Udrj61lOQ==",
|
| 1458 |
+
"license": "MIT",
|
| 1459 |
+
"dependencies": {
|
| 1460 |
+
"claygl": "^1.2.1",
|
| 1461 |
+
"zrender": "^5.1.1 || ^6.0.0"
|
| 1462 |
+
},
|
| 1463 |
+
"peerDependencies": {
|
| 1464 |
+
"echarts": "^5.1.2 || ^6.0.0"
|
| 1465 |
+
}
|
| 1466 |
+
},
|
| 1467 |
"node_modules/echarts/node_modules/tslib": {
|
| 1468 |
"version": "2.3.0",
|
| 1469 |
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
vivek/frontend-vue/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
| 16 |
"clsx": "^2.1.1",
|
| 17 |
"dompurify": "^3.3.3",
|
| 18 |
"echarts": "^6.0.0",
|
|
|
|
| 19 |
"gsap": "^3.14.2",
|
| 20 |
"lucide-vue-next": "^1.0.0",
|
| 21 |
"markdown-it": "^14.1.1",
|
|
|
|
| 16 |
"clsx": "^2.1.1",
|
| 17 |
"dompurify": "^3.3.3",
|
| 18 |
"echarts": "^6.0.0",
|
| 19 |
+
"echarts-gl": "^2.1.0",
|
| 20 |
"gsap": "^3.14.2",
|
| 21 |
"lucide-vue-next": "^1.0.0",
|
| 22 |
"markdown-it": "^14.1.1",
|
vivek/frontend-vue/src/components/AnalyticsPanel.vue
CHANGED
|
@@ -26,7 +26,7 @@ const rewardWindowSize = 50
|
|
| 26 |
|
| 27 |
const metrics = computed(() => {
|
| 28 |
const done = analyticsState.doneEvents
|
| 29 |
-
const widgetRenderedCount = done.filter((d) => (d.
|
| 30 |
const widgetTotal = done.length
|
| 31 |
|
| 32 |
return {
|
|
|
|
| 26 |
|
| 27 |
const metrics = computed(() => {
|
| 28 |
const done = analyticsState.doneEvents
|
| 29 |
+
const widgetRenderedCount = done.filter((d) => (d.widgetSchema ?? '').trim().length > 0).length
|
| 30 |
const widgetTotal = done.length
|
| 31 |
|
| 32 |
return {
|
vivek/frontend-vue/src/components/LiveWidgetFrame.vue
DELETED
|
@@ -1,111 +0,0 @@
|
|
| 1 |
-
<script setup lang="ts">
|
| 2 |
-
import { computed, ref, watch } from 'vue'
|
| 3 |
-
import { buildProgressiveHtmlDoc } from '@/lib/progressiveWidget'
|
| 4 |
-
|
| 5 |
-
/**
|
| 6 |
-
* Progressive iframe renderer for HTML-mode widgets. While content streams,
|
| 7 |
-
* srcdoc is updated at a throttled rate (~120ms) so the browser re-renders
|
| 8 |
-
* without flickering. When `finalized` goes true, we swap to the polished
|
| 9 |
-
* final HTML so design-system injection + truncation fixes take effect.
|
| 10 |
-
*/
|
| 11 |
-
const props = defineProps<{
|
| 12 |
-
rawStream: string
|
| 13 |
-
finalHtml?: string
|
| 14 |
-
finalized: boolean
|
| 15 |
-
height?: number
|
| 16 |
-
}>()
|
| 17 |
-
|
| 18 |
-
const throttledDoc = ref('')
|
| 19 |
-
let lastWrite = 0
|
| 20 |
-
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
| 21 |
-
|
| 22 |
-
function scheduleWrite(next: string) {
|
| 23 |
-
const now = Date.now()
|
| 24 |
-
const THROTTLE_MS = 120
|
| 25 |
-
if (now - lastWrite >= THROTTLE_MS) {
|
| 26 |
-
throttledDoc.value = next
|
| 27 |
-
lastWrite = now
|
| 28 |
-
return
|
| 29 |
-
}
|
| 30 |
-
if (pendingTimer) clearTimeout(pendingTimer)
|
| 31 |
-
pendingTimer = setTimeout(() => {
|
| 32 |
-
throttledDoc.value = next
|
| 33 |
-
lastWrite = Date.now()
|
| 34 |
-
pendingTimer = null
|
| 35 |
-
}, THROTTLE_MS - (now - lastWrite))
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
watch(
|
| 39 |
-
() => [props.rawStream, props.finalized, props.finalHtml],
|
| 40 |
-
() => {
|
| 41 |
-
if (props.finalized && props.finalHtml) {
|
| 42 |
-
throttledDoc.value = props.finalHtml
|
| 43 |
-
return
|
| 44 |
-
}
|
| 45 |
-
const doc = buildProgressiveHtmlDoc(props.rawStream)
|
| 46 |
-
if (doc) scheduleWrite(doc)
|
| 47 |
-
},
|
| 48 |
-
{ immediate: true },
|
| 49 |
-
)
|
| 50 |
-
|
| 51 |
-
const frameHeight = computed(() => {
|
| 52 |
-
const raw = Number(props.height || 420)
|
| 53 |
-
if (!Number.isFinite(raw)) return 420
|
| 54 |
-
return Math.min(Math.max(raw, 300), 520)
|
| 55 |
-
})
|
| 56 |
-
</script>
|
| 57 |
-
|
| 58 |
-
<template>
|
| 59 |
-
<div class="relative w-full">
|
| 60 |
-
<iframe
|
| 61 |
-
v-if="throttledDoc"
|
| 62 |
-
:srcdoc="throttledDoc"
|
| 63 |
-
sandbox="allow-scripts allow-same-origin"
|
| 64 |
-
class="w-full widget-frame border-0"
|
| 65 |
-
:style="{ height: `${frameHeight}px`, maxHeight: '56vh' }"
|
| 66 |
-
/>
|
| 67 |
-
<div
|
| 68 |
-
v-else
|
| 69 |
-
class="w-full rounded-xl border bg-muted/20 skeleton"
|
| 70 |
-
:style="{ height: `${frameHeight}px`, maxHeight: '56vh' }"
|
| 71 |
-
/>
|
| 72 |
-
<div
|
| 73 |
-
v-if="!finalized"
|
| 74 |
-
class="absolute top-2 right-2 text-[10px] px-2 py-1 rounded-full bg-cyan-500/90 text-white shadow-md flex items-center gap-1.5"
|
| 75 |
-
>
|
| 76 |
-
<span class="relative flex h-1.5 w-1.5">
|
| 77 |
-
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
|
| 78 |
-
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-white" />
|
| 79 |
-
</span>
|
| 80 |
-
streaming
|
| 81 |
-
</div>
|
| 82 |
-
</div>
|
| 83 |
-
</template>
|
| 84 |
-
|
| 85 |
-
<style scoped>
|
| 86 |
-
.skeleton {
|
| 87 |
-
position: relative;
|
| 88 |
-
overflow: hidden;
|
| 89 |
-
background: hsl(var(--muted) / 0.3);
|
| 90 |
-
}
|
| 91 |
-
.skeleton::after {
|
| 92 |
-
content: '';
|
| 93 |
-
position: absolute;
|
| 94 |
-
inset: 0;
|
| 95 |
-
background: linear-gradient(
|
| 96 |
-
90deg,
|
| 97 |
-
transparent 0%,
|
| 98 |
-
hsl(var(--muted) / 0.6) 50%,
|
| 99 |
-
transparent 100%
|
| 100 |
-
);
|
| 101 |
-
animation: shimmerSweep 1.4s linear infinite;
|
| 102 |
-
}
|
| 103 |
-
@keyframes shimmerSweep {
|
| 104 |
-
0% {
|
| 105 |
-
transform: translateX(-100%);
|
| 106 |
-
}
|
| 107 |
-
100% {
|
| 108 |
-
transform: translateX(100%);
|
| 109 |
-
}
|
| 110 |
-
}
|
| 111 |
-
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vivek/frontend-vue/src/components/LiveWidgetSchema.vue
DELETED
|
@@ -1,213 +0,0 @@
|
|
| 1 |
-
<script setup lang="ts">
|
| 2 |
-
import { computed } from 'vue'
|
| 3 |
-
import { Motion } from '@motionone/vue'
|
| 4 |
-
import Button from '@/components/ui/Button.vue'
|
| 5 |
-
import WidgetSchemaChart from '@/components/WidgetSchemaChart.vue'
|
| 6 |
-
import { parseProgressiveWidgetSchema, type WidgetBlock } from '@/lib/progressiveWidget'
|
| 7 |
-
|
| 8 |
-
/**
|
| 9 |
-
* Renders widget blocks progressively as JSON streams in. Each block fades up
|
| 10 |
-
* independently (Claude-artifact style) and we show a soft "pending" block for
|
| 11 |
-
* whichever block the model is currently generating.
|
| 12 |
-
*/
|
| 13 |
-
const props = defineProps<{
|
| 14 |
-
rawStream: string
|
| 15 |
-
finalized?: boolean
|
| 16 |
-
}>()
|
| 17 |
-
|
| 18 |
-
const state = computed(() => parseProgressiveWidgetSchema(props.rawStream))
|
| 19 |
-
const blocks = computed<WidgetBlock[]>(() => state.value.blocks)
|
| 20 |
-
const pendingType = computed<string | null>(() => (props.finalized ? null : state.value.pendingBlockHint))
|
| 21 |
-
|
| 22 |
-
function pendingLabel(t: string | null): string {
|
| 23 |
-
if (!t) return 'Building block'
|
| 24 |
-
const labels: Record<string, string> = {
|
| 25 |
-
text: 'Writing narrative',
|
| 26 |
-
kpi_row: 'Preparing KPIs',
|
| 27 |
-
chart: 'Rendering chart',
|
| 28 |
-
image: 'Loading image',
|
| 29 |
-
table: 'Composing table',
|
| 30 |
-
action_row: 'Wiring actions',
|
| 31 |
-
}
|
| 32 |
-
return labels[t] || `Building ${t}`
|
| 33 |
-
}
|
| 34 |
-
</script>
|
| 35 |
-
|
| 36 |
-
<template>
|
| 37 |
-
<div class="live-widget space-y-3 text-sm">
|
| 38 |
-
<template v-for="(block, i) in blocks" :key="`lw-${i}`">
|
| 39 |
-
<Motion
|
| 40 |
-
tag="div"
|
| 41 |
-
:initial="{ opacity: 0, y: 8, scale: 0.985 }"
|
| 42 |
-
:animate="{ opacity: 1, y: 0, scale: 1 }"
|
| 43 |
-
:transition="{ duration: 0.32, easing: [0.16, 1, 0.3, 1] }"
|
| 44 |
-
>
|
| 45 |
-
<div v-if="block.type === 'text'" class="whitespace-pre-wrap leading-relaxed">
|
| 46 |
-
{{ (block as { content?: string }).content || '' }}
|
| 47 |
-
</div>
|
| 48 |
-
|
| 49 |
-
<div v-else-if="block.type === 'kpi_row'" class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
| 50 |
-
<div
|
| 51 |
-
v-for="(it, j) in (block as { items?: { label?: string; value?: string; tone?: string }[] }).items || []"
|
| 52 |
-
:key="j"
|
| 53 |
-
class="rounded-xl border bg-card px-3 py-2 shadow-sm"
|
| 54 |
-
>
|
| 55 |
-
<div class="text-[10px] text-muted-foreground uppercase tracking-wide">{{ it.label }}</div>
|
| 56 |
-
<div
|
| 57 |
-
class="text-lg font-semibold mt-0.5"
|
| 58 |
-
:class="{
|
| 59 |
-
'text-emerald-600 dark:text-emerald-400': it.tone === 'positive',
|
| 60 |
-
'text-red-600 dark:text-red-400': it.tone === 'negative',
|
| 61 |
-
}"
|
| 62 |
-
>
|
| 63 |
-
{{ it.value }}
|
| 64 |
-
</div>
|
| 65 |
-
</div>
|
| 66 |
-
</div>
|
| 67 |
-
|
| 68 |
-
<WidgetSchemaChart
|
| 69 |
-
v-else-if="block.type === 'chart'"
|
| 70 |
-
:title="(block as { title?: string }).title"
|
| 71 |
-
:chart="(block as { chart?: Record<string, unknown> }).chart as any || {}"
|
| 72 |
-
/>
|
| 73 |
-
|
| 74 |
-
<div v-else-if="block.type === 'image'" class="rounded-xl border bg-card overflow-hidden shadow-sm">
|
| 75 |
-
<div
|
| 76 |
-
v-if="(block as { title?: string }).title"
|
| 77 |
-
class="px-3 py-2 border-b text-xs font-medium"
|
| 78 |
-
>
|
| 79 |
-
{{ (block as { title?: string }).title }}
|
| 80 |
-
</div>
|
| 81 |
-
<div class="p-2 flex justify-center bg-muted/15">
|
| 82 |
-
<img
|
| 83 |
-
:src="(block as { src?: string }).src || ''"
|
| 84 |
-
:alt="(block as { alt?: string }).alt || 'Widget image'"
|
| 85 |
-
class="max-w-full max-h-[min(420px,55vh)] rounded-lg object-contain"
|
| 86 |
-
loading="lazy"
|
| 87 |
-
referrerpolicy="no-referrer"
|
| 88 |
-
/>
|
| 89 |
-
</div>
|
| 90 |
-
</div>
|
| 91 |
-
|
| 92 |
-
<div v-else-if="block.type === 'table'" class="rounded-xl border bg-card overflow-hidden">
|
| 93 |
-
<div v-if="(block as { title?: string }).title" class="px-3 py-2 border-b text-xs font-medium">
|
| 94 |
-
{{ (block as { title?: string }).title }}
|
| 95 |
-
</div>
|
| 96 |
-
<div class="overflow-x-auto">
|
| 97 |
-
<table class="w-full text-xs">
|
| 98 |
-
<thead v-if="(block as { columns?: string[] }).columns?.length">
|
| 99 |
-
<tr>
|
| 100 |
-
<th
|
| 101 |
-
v-for="(c, ci) in (block as { columns?: string[] }).columns"
|
| 102 |
-
:key="ci"
|
| 103 |
-
class="text-left px-3 py-2 border-b bg-muted/40 font-medium"
|
| 104 |
-
>
|
| 105 |
-
{{ c }}
|
| 106 |
-
</th>
|
| 107 |
-
</tr>
|
| 108 |
-
</thead>
|
| 109 |
-
<tbody>
|
| 110 |
-
<tr v-for="(row, ri) in (block as { rows?: unknown[][] }).rows || []" :key="ri">
|
| 111 |
-
<td v-for="(cell, ci) in row" :key="ci" class="px-3 py-1.5 border-b border-border/60">
|
| 112 |
-
{{ cell }}
|
| 113 |
-
</td>
|
| 114 |
-
</tr>
|
| 115 |
-
</tbody>
|
| 116 |
-
</table>
|
| 117 |
-
</div>
|
| 118 |
-
</div>
|
| 119 |
-
|
| 120 |
-
<div v-else-if="block.type === 'action_row'" class="flex flex-wrap gap-2">
|
| 121 |
-
<Button
|
| 122 |
-
v-for="(b, bi) in (block as { buttons?: { label?: string; intent?: string }[] }).buttons || []"
|
| 123 |
-
:key="bi"
|
| 124 |
-
type="button"
|
| 125 |
-
variant="outline"
|
| 126 |
-
size="sm"
|
| 127 |
-
disabled
|
| 128 |
-
:title="(b as { intent?: string }).intent ? `Intent: ${(b as { intent?: string }).intent}` : undefined"
|
| 129 |
-
>
|
| 130 |
-
{{ b.label || 'Action' }}
|
| 131 |
-
</Button>
|
| 132 |
-
</div>
|
| 133 |
-
|
| 134 |
-
<div v-else class="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 px-3 py-2 text-xs whitespace-pre-wrap">
|
| 135 |
-
{{ JSON.stringify(block, null, 2) }}
|
| 136 |
-
</div>
|
| 137 |
-
</Motion>
|
| 138 |
-
</template>
|
| 139 |
-
|
| 140 |
-
<Transition
|
| 141 |
-
enter-active-class="transition-all duration-300 ease-out"
|
| 142 |
-
enter-from-class="opacity-0 translate-y-1"
|
| 143 |
-
enter-to-class="opacity-100 translate-y-0"
|
| 144 |
-
leave-active-class="transition-all duration-200 ease-in"
|
| 145 |
-
leave-from-class="opacity-100 translate-y-0"
|
| 146 |
-
leave-to-class="opacity-0 -translate-y-1"
|
| 147 |
-
>
|
| 148 |
-
<div
|
| 149 |
-
v-if="pendingType"
|
| 150 |
-
class="pending-block rounded-xl border border-cyan-500/30 bg-cyan-500/5 dark:bg-cyan-950/25 px-3 py-2 flex items-center gap-3"
|
| 151 |
-
>
|
| 152 |
-
<span class="relative flex h-2.5 w-2.5 shrink-0">
|
| 153 |
-
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-60" />
|
| 154 |
-
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500" />
|
| 155 |
-
</span>
|
| 156 |
-
<span class="text-[12px] font-medium text-cyan-900 dark:text-cyan-100">
|
| 157 |
-
{{ pendingLabel(pendingType) }}…
|
| 158 |
-
</span>
|
| 159 |
-
<div class="shimmer flex-1 h-1.5 rounded-full bg-muted/40" />
|
| 160 |
-
</div>
|
| 161 |
-
</Transition>
|
| 162 |
-
|
| 163 |
-
<div v-if="!blocks.length && !pendingType" class="skeleton-stack space-y-2">
|
| 164 |
-
<div class="skeleton h-6 w-2/3 rounded-lg" />
|
| 165 |
-
<div class="skeleton h-20 w-full rounded-xl" />
|
| 166 |
-
<div class="skeleton h-4 w-1/2 rounded" />
|
| 167 |
-
</div>
|
| 168 |
-
</div>
|
| 169 |
-
</template>
|
| 170 |
-
|
| 171 |
-
<style scoped>
|
| 172 |
-
.shimmer {
|
| 173 |
-
position: relative;
|
| 174 |
-
overflow: hidden;
|
| 175 |
-
}
|
| 176 |
-
.shimmer::after {
|
| 177 |
-
content: '';
|
| 178 |
-
position: absolute;
|
| 179 |
-
inset: 0;
|
| 180 |
-
background: linear-gradient(
|
| 181 |
-
90deg,
|
| 182 |
-
transparent 0%,
|
| 183 |
-
rgba(6, 182, 212, 0.35) 50%,
|
| 184 |
-
transparent 100%
|
| 185 |
-
);
|
| 186 |
-
animation: shimmerSweep 1.3s linear infinite;
|
| 187 |
-
}
|
| 188 |
-
.skeleton {
|
| 189 |
-
position: relative;
|
| 190 |
-
overflow: hidden;
|
| 191 |
-
background: hsl(var(--muted) / 0.45);
|
| 192 |
-
}
|
| 193 |
-
.skeleton::after {
|
| 194 |
-
content: '';
|
| 195 |
-
position: absolute;
|
| 196 |
-
inset: 0;
|
| 197 |
-
background: linear-gradient(
|
| 198 |
-
90deg,
|
| 199 |
-
transparent 0%,
|
| 200 |
-
hsl(var(--muted) / 0.75) 50%,
|
| 201 |
-
transparent 100%
|
| 202 |
-
);
|
| 203 |
-
animation: shimmerSweep 1.4s linear infinite;
|
| 204 |
-
}
|
| 205 |
-
@keyframes shimmerSweep {
|
| 206 |
-
0% {
|
| 207 |
-
transform: translateX(-100%);
|
| 208 |
-
}
|
| 209 |
-
100% {
|
| 210 |
-
transform: translateX(100%);
|
| 211 |
-
}
|
| 212 |
-
}
|
| 213 |
-
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vivek/frontend-vue/src/components/WidgetRegistryRenderer.vue
CHANGED
|
@@ -13,13 +13,17 @@ import Button from '@/components/ui/Button.vue'
|
|
| 13 |
import { ArrowDownTrayIcon } from '@/components/icons'
|
| 14 |
import { resolveWidget } from '@/lib/widgetRegistry'
|
| 15 |
import { normalizeWidgetBlock } from '@/lib/progressiveWidget'
|
|
|
|
| 16 |
import { downloadTextAsFile, prettifyJsonIfPossible } from '@/lib/downloadFile'
|
|
|
|
| 17 |
import { showToast } from '@/lib/toast'
|
| 18 |
|
| 19 |
const props = withDefaults(
|
| 20 |
-
defineProps<{ jsonStr: string; downloadBase?: string; showDownload?: boolean }>(),
|
| 21 |
-
{ showDownload: true },
|
| 22 |
)
|
|
|
|
|
|
|
| 23 |
|
| 24 |
type Block = Record<string, unknown> & { type?: string }
|
| 25 |
|
|
@@ -29,22 +33,87 @@ function stripFences(s: string): string {
|
|
| 29 |
return s.replace(/```\w*/g, '').trim()
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
function parseLayout(raw: string): Block[] | null {
|
| 33 |
let s = String(raw || '').trim()
|
| 34 |
if (!s) return null
|
| 35 |
if (s.includes('```')) s = stripFences(s)
|
| 36 |
|
| 37 |
-
let parsed: unknown
|
| 38 |
try {
|
| 39 |
parsed = JSON.parse(s)
|
| 40 |
} catch {
|
| 41 |
const i = s.indexOf('{')
|
| 42 |
const j = s.lastIndexOf('}')
|
| 43 |
-
if (i
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
}
|
| 49 |
}
|
| 50 |
|
|
@@ -56,17 +125,14 @@ function parseLayout(raw: string): Block[] | null {
|
|
| 56 |
layout = o.layout ?? o.blocks ?? o.components
|
| 57 |
if (!Array.isArray(layout) && typeof o.type === 'string') layout = [o]
|
| 58 |
}
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
NUMERIC_ARRAY_RE.test(String((b as { content?: string }).content ?? ''))
|
| 68 |
-
),
|
| 69 |
-
)
|
| 70 |
}
|
| 71 |
|
| 72 |
const parsed = computed(() => parseLayout(props.jsonStr))
|
|
@@ -95,20 +161,41 @@ function downloadJson() {
|
|
| 95 |
}
|
| 96 |
downloadTextAsFile(prettifyJsonIfPossible(raw), `${downloadStem()}.json`, 'application/json;charset=utf-8')
|
| 97 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</script>
|
| 99 |
|
| 100 |
<template>
|
| 101 |
<div class="space-y-2">
|
| 102 |
-
<div v-if="showDownload && hasRaw" class="flex justify-end">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
<Button
|
| 104 |
type="button"
|
| 105 |
variant="outline"
|
| 106 |
size="sm"
|
| 107 |
-
class="h-7 px-2"
|
| 108 |
title="Download widget schema as JSON"
|
| 109 |
@click="downloadJson"
|
| 110 |
>
|
| 111 |
-
<ArrowDownTrayIcon class="h-3.5 w-3.5" />
|
| 112 |
</Button>
|
| 113 |
</div>
|
| 114 |
|
|
@@ -125,6 +212,7 @@ function downloadJson() {
|
|
| 125 |
:is="resolve(block.type)!.component"
|
| 126 |
v-if="resolve(block.type)"
|
| 127 |
:block="block"
|
|
|
|
| 128 |
/>
|
| 129 |
<div
|
| 130 |
v-else
|
|
@@ -142,14 +230,13 @@ function downloadJson() {
|
|
| 142 |
</template>
|
| 143 |
</div>
|
| 144 |
|
|
|
|
| 145 |
<div
|
| 146 |
-
v-else-if="parseFailed"
|
| 147 |
class="rounded-lg border border-dashed border-amber-500/40 bg-amber-500/5 px-3 py-3 text-xs text-muted-foreground"
|
| 148 |
>
|
| 149 |
-
<
|
| 150 |
-
|
| 151 |
-
props.jsonStr.slice(0, 2000)
|
| 152 |
-
}}</pre>
|
| 153 |
</div>
|
| 154 |
</div>
|
| 155 |
</template>
|
|
|
|
| 13 |
import { ArrowDownTrayIcon } from '@/components/icons'
|
| 14 |
import { resolveWidget } from '@/lib/widgetRegistry'
|
| 15 |
import { normalizeWidgetBlock } from '@/lib/progressiveWidget'
|
| 16 |
+
import { chartHasRenderableData } from '@/lib/echartsOption'
|
| 17 |
import { downloadTextAsFile, prettifyJsonIfPossible } from '@/lib/downloadFile'
|
| 18 |
+
import { widgetToHtml } from '@/lib/exportWidgetHtml'
|
| 19 |
import { showToast } from '@/lib/toast'
|
| 20 |
|
| 21 |
const props = withDefaults(
|
| 22 |
+
defineProps<{ jsonStr: string; downloadBase?: string; showDownload?: boolean; streaming?: boolean }>(),
|
| 23 |
+
{ showDownload: true, streaming: false },
|
| 24 |
)
|
| 25 |
+
// Bubbles up action_row button clicks (the follow-up prompt text) to the chat.
|
| 26 |
+
const emit = defineEmits<{ (e: 'action', text: string): void }>()
|
| 27 |
|
| 28 |
type Block = Record<string, unknown> & { type?: string }
|
| 29 |
|
|
|
|
| 33 |
return s.replace(/```\w*/g, '').trim()
|
| 34 |
}
|
| 35 |
|
| 36 |
+
/**
|
| 37 |
+
* Salvage complete block objects from a truncated/invalid layout string.
|
| 38 |
+
* Walks the `layout` array brace-by-brace (string-aware) and keeps every fully
|
| 39 |
+
* closed `{...}` object that has a `type`. A cut-off final object is dropped.
|
| 40 |
+
* This guarantees we NEVER fall back to dumping raw JSON to the user.
|
| 41 |
+
*/
|
| 42 |
+
function salvageBlocks(s: string): Block[] {
|
| 43 |
+
const li = s.search(/"(?:layout|blocks|components)"\s*:\s*\[/)
|
| 44 |
+
let start = li >= 0 ? s.indexOf('[', li) + 1 : s.indexOf('[')
|
| 45 |
+
if (start <= 0) return []
|
| 46 |
+
const out: Block[] = []
|
| 47 |
+
const n = s.length
|
| 48 |
+
let i = start
|
| 49 |
+
while (i < n) {
|
| 50 |
+
while (i < n && s[i] !== '{') {
|
| 51 |
+
if (s[i] === ']') return out
|
| 52 |
+
i++
|
| 53 |
+
}
|
| 54 |
+
if (i >= n) break
|
| 55 |
+
let depth = 0
|
| 56 |
+
let inStr = false
|
| 57 |
+
let esc = false
|
| 58 |
+
let j = i
|
| 59 |
+
for (; j < n; j++) {
|
| 60 |
+
const ch = s[j]
|
| 61 |
+
if (inStr) {
|
| 62 |
+
if (esc) esc = false
|
| 63 |
+
else if (ch === '\\') esc = true
|
| 64 |
+
else if (ch === '"') inStr = false
|
| 65 |
+
} else if (ch === '"') inStr = true
|
| 66 |
+
else if (ch === '{') depth++
|
| 67 |
+
else if (ch === '}') {
|
| 68 |
+
depth--
|
| 69 |
+
if (depth === 0) {
|
| 70 |
+
j++
|
| 71 |
+
break
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
if (depth !== 0) break // incomplete (truncated) object → stop salvaging
|
| 76 |
+
try {
|
| 77 |
+
const o = JSON.parse(s.slice(i, j))
|
| 78 |
+
if (o && typeof o === 'object' && typeof o.type === 'string') out.push(o as Block)
|
| 79 |
+
} catch {
|
| 80 |
+
/* skip unparseable element */
|
| 81 |
+
}
|
| 82 |
+
i = j
|
| 83 |
+
}
|
| 84 |
+
return out
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function finalizeBlocks(layout: unknown[]): Block[] {
|
| 88 |
+
const normalized = layout.map((b) => normalizeWidgetBlock(b)) as Block[]
|
| 89 |
+
// Drop bare numeric-array text blocks (e.g. tic-tac-toe win lines "[0,1,2]") — not real content.
|
| 90 |
+
const NUMERIC_ARRAY_RE = /^\s*\[\s*-?\d+(\.\d+)?(\s*,\s*-?\d+(\.\d+)?)*\s*\]\s*$/
|
| 91 |
+
return normalized.filter((b) => {
|
| 92 |
+
const type = String(b.type || '').toLowerCase()
|
| 93 |
+
// Never show an empty chart card — drop charts with no renderable data outright.
|
| 94 |
+
if (type === 'chart' && !chartHasRenderableData((b as { chart?: any }).chart || {})) return false
|
| 95 |
+
if (type === 'text' && NUMERIC_ARRAY_RE.test(String((b as { content?: string }).content ?? ''))) return false
|
| 96 |
+
return true
|
| 97 |
+
})
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
function parseLayout(raw: string): Block[] | null {
|
| 101 |
let s = String(raw || '').trim()
|
| 102 |
if (!s) return null
|
| 103 |
if (s.includes('```')) s = stripFences(s)
|
| 104 |
|
| 105 |
+
let parsed: unknown = null
|
| 106 |
try {
|
| 107 |
parsed = JSON.parse(s)
|
| 108 |
} catch {
|
| 109 |
const i = s.indexOf('{')
|
| 110 |
const j = s.lastIndexOf('}')
|
| 111 |
+
if (i >= 0 && j > i) {
|
| 112 |
+
try {
|
| 113 |
+
parsed = JSON.parse(s.slice(i, j + 1))
|
| 114 |
+
} catch {
|
| 115 |
+
parsed = null
|
| 116 |
+
}
|
| 117 |
}
|
| 118 |
}
|
| 119 |
|
|
|
|
| 125 |
layout = o.layout ?? o.blocks ?? o.components
|
| 126 |
if (!Array.isArray(layout) && typeof o.type === 'string') layout = [o]
|
| 127 |
}
|
| 128 |
+
|
| 129 |
+
if (Array.isArray(layout)) return finalizeBlocks(layout as unknown[])
|
| 130 |
+
|
| 131 |
+
// Clean parse failed (likely truncated stream) — salvage whatever complete blocks exist
|
| 132 |
+
// instead of dumping raw JSON to the user.
|
| 133 |
+
const salvaged = salvageBlocks(s)
|
| 134 |
+
if (salvaged.length) return finalizeBlocks(salvaged)
|
| 135 |
+
return null
|
|
|
|
|
|
|
|
|
|
| 136 |
}
|
| 137 |
|
| 138 |
const parsed = computed(() => parseLayout(props.jsonStr))
|
|
|
|
| 161 |
}
|
| 162 |
downloadTextAsFile(prettifyJsonIfPossible(raw), `${downloadStem()}.json`, 'application/json;charset=utf-8')
|
| 163 |
}
|
| 164 |
+
|
| 165 |
+
function downloadHtml() {
|
| 166 |
+
const raw = String(props.jsonStr || '').trim()
|
| 167 |
+
if (!raw) {
|
| 168 |
+
showToast({ title: 'Nothing to download', message: 'Widget is empty.' })
|
| 169 |
+
return
|
| 170 |
+
}
|
| 171 |
+
// Deterministic, no LLM: same JSON the renderer uses → self-contained interactive HTML.
|
| 172 |
+
const html = widgetToHtml(raw, downloadStem())
|
| 173 |
+
downloadTextAsFile(html, `${downloadStem()}.html`, 'text/html;charset=utf-8')
|
| 174 |
+
}
|
| 175 |
</script>
|
| 176 |
|
| 177 |
<template>
|
| 178 |
<div class="space-y-2">
|
| 179 |
+
<div v-if="showDownload && hasRaw" class="flex justify-end gap-1.5">
|
| 180 |
+
<Button
|
| 181 |
+
type="button"
|
| 182 |
+
variant="outline"
|
| 183 |
+
size="sm"
|
| 184 |
+
class="h-7 px-2 text-[11px]"
|
| 185 |
+
title="Download as a self-contained interactive HTML file"
|
| 186 |
+
@click="downloadHtml"
|
| 187 |
+
>
|
| 188 |
+
<ArrowDownTrayIcon class="h-3.5 w-3.5" /> HTML
|
| 189 |
+
</Button>
|
| 190 |
<Button
|
| 191 |
type="button"
|
| 192 |
variant="outline"
|
| 193 |
size="sm"
|
| 194 |
+
class="h-7 px-2 text-[11px]"
|
| 195 |
title="Download widget schema as JSON"
|
| 196 |
@click="downloadJson"
|
| 197 |
>
|
| 198 |
+
<ArrowDownTrayIcon class="h-3.5 w-3.5" /> JSON
|
| 199 |
</Button>
|
| 200 |
</div>
|
| 201 |
|
|
|
|
| 212 |
:is="resolve(block.type)!.component"
|
| 213 |
v-if="resolve(block.type)"
|
| 214 |
:block="block"
|
| 215 |
+
@action="(t: string) => emit('action', t)"
|
| 216 |
/>
|
| 217 |
<div
|
| 218 |
v-else
|
|
|
|
| 230 |
</template>
|
| 231 |
</div>
|
| 232 |
|
| 233 |
+
<!-- While streaming, stay quiet until the first block completes (panel header shows "building live"). -->
|
| 234 |
<div
|
| 235 |
+
v-else-if="parseFailed && !streaming"
|
| 236 |
class="rounded-lg border border-dashed border-amber-500/40 bg-amber-500/5 px-3 py-3 text-xs text-muted-foreground"
|
| 237 |
>
|
| 238 |
+
<!-- Never dump raw JSON to the user — show a friendly note only. -->
|
| 239 |
+
The widget couldn’t be built for this answer. Try rephrasing or ask again.
|
|
|
|
|
|
|
| 240 |
</div>
|
| 241 |
</div>
|
| 242 |
</template>
|
vivek/frontend-vue/src/components/WidgetSchemaChart.vue
CHANGED
|
@@ -1,527 +1,25 @@
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import { onMounted, onUnmounted, ref, watch, computed, nextTick } from 'vue'
|
| 3 |
import * as echarts from 'echarts'
|
|
|
|
| 4 |
import { ArrowDownTrayIcon } from '@/components/icons'
|
| 5 |
-
|
| 6 |
-
type Series = { name?: string; color?: string; kind?: string; values?: (number | number[])[] }
|
| 7 |
|
| 8 |
const props = defineProps<{
|
| 9 |
title?: string
|
| 10 |
-
chart:
|
| 11 |
-
kind?: string
|
| 12 |
-
x_label?: string
|
| 13 |
-
y_label?: string
|
| 14 |
-
max?: number
|
| 15 |
-
// heatmap / matrix
|
| 16 |
-
x_labels?: string[]
|
| 17 |
-
y_labels?: string[]
|
| 18 |
-
categories?: string[]
|
| 19 |
-
labels?: string[]
|
| 20 |
-
matrix?: number[][]
|
| 21 |
-
// cartesian (line/bar/area/scatter/hbar/stacked/combo/bubble) + radar
|
| 22 |
-
series?: Series[]
|
| 23 |
-
x_categories?: string[]
|
| 24 |
-
// pie / donut / funnel / gauge / treemap / sunburst
|
| 25 |
-
items?: { label?: string; name?: string; value?: number }[]
|
| 26 |
-
// candlestick: [open, close, low, high] per category
|
| 27 |
-
candles?: (number | string)[][]
|
| 28 |
-
// boxplot: [min, q1, median, q3, max] per category
|
| 29 |
-
boxes?: (number | string)[][]
|
| 30 |
-
// sankey / graph
|
| 31 |
-
nodes?: ({ name?: string } | string)[]
|
| 32 |
-
links?: { source?: string | number; target?: string | number; value?: number }[]
|
| 33 |
-
}
|
| 34 |
}>()
|
| 35 |
|
| 36 |
const rootEl = ref<HTMLDivElement | null>(null)
|
| 37 |
let chart: echarts.ECharts | null = null
|
| 38 |
|
| 39 |
-
// Named colors the LLM may use; anything else (e.g. a hex string) passes through.
|
| 40 |
-
const COLOR_MAP: Record<string, string> = {
|
| 41 |
-
blue: '#3b82f6',
|
| 42 |
-
orange: '#f97316',
|
| 43 |
-
green: '#22c55e',
|
| 44 |
-
red: '#ef4444',
|
| 45 |
-
purple: '#a855f7',
|
| 46 |
-
teal: '#14b8a6',
|
| 47 |
-
yellow: '#eab308',
|
| 48 |
-
pink: '#ec4899',
|
| 49 |
-
indigo: '#4f46e5',
|
| 50 |
-
cyan: '#0891b2',
|
| 51 |
-
gray: '#64748b',
|
| 52 |
-
}
|
| 53 |
-
// Fallback palette when the LLM does not specify a color for a series.
|
| 54 |
-
const PALETTE = ['#3b82f6', '#f97316', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#eab308', '#ec4899']
|
| 55 |
-
// Semantic tokens for meaning-bearing series (gains vs. losses, etc.)
|
| 56 |
-
const POSITIVE = '#16a34a'
|
| 57 |
-
const NEGATIVE = '#dc2626'
|
| 58 |
-
|
| 59 |
-
// Honor the LLM-chosen color (named or hex); fall back to the palette by index.
|
| 60 |
-
function resolveColor(c?: string, i = 0): string {
|
| 61 |
-
if (!c) return PALETTE[i % PALETTE.length]
|
| 62 |
-
return COLOR_MAP[c.toLowerCase()] || c
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
const kind = computed(() => String(props.chart?.kind || 'line').toLowerCase())
|
| 66 |
|
| 67 |
-
|
| 68 |
-
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
| 69 |
-
if (typeof v === 'string') {
|
| 70 |
-
const m = v.replace(/,/g, '').match(/-?\d+(\.\d+)?/) // "$47.5B" → 47.5, "—" → null
|
| 71 |
-
return m ? parseFloat(m[0]) : null
|
| 72 |
-
}
|
| 73 |
-
return null
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
function cartesianSeries() {
|
| 77 |
-
const raw = props.chart?.series || []
|
| 78 |
-
return raw
|
| 79 |
-
.map((s, i) => {
|
| 80 |
-
const vals = Array.isArray(s.values) ? s.values : []
|
| 81 |
-
let pts: [number, number | null][] = []
|
| 82 |
-
if (vals.length && !Array.isArray(vals[0])) {
|
| 83 |
-
// plain values (numbers OR strings like "$47.5B"/"—") aligned to x_categories;
|
| 84 |
-
// keep position (null = gap) so values line up with the categories.
|
| 85 |
-
pts = (vals as unknown[]).map((v, j) => [j, _toNum(v)])
|
| 86 |
-
} else {
|
| 87 |
-
pts = (vals as unknown[])
|
| 88 |
-
.filter((p) => Array.isArray(p) && (p as unknown[]).length >= 2)
|
| 89 |
-
.map((p) => [(_toNum((p as unknown[])[0]) ?? 0), _toNum((p as unknown[])[1])] as [number, number | null])
|
| 90 |
-
}
|
| 91 |
-
return { name: s.name || `Series ${i + 1}`, color: resolveColor(s.color, i), kind: s.kind, points: pts }
|
| 92 |
-
})
|
| 93 |
-
.filter((s) => s.points.some((p) => p[1] != null)) // keep series with ≥1 real value
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
function heatmapData() {
|
| 97 |
-
const xs = props.chart?.x_labels || props.chart?.categories || props.chart?.labels || []
|
| 98 |
-
const ys = props.chart?.y_labels || props.chart?.categories || props.chart?.labels || []
|
| 99 |
-
const m = props.chart?.matrix
|
| 100 |
-
const data: [number, number, number][] = []
|
| 101 |
-
let vmin = Infinity
|
| 102 |
-
let vmax = -Infinity
|
| 103 |
-
if (Array.isArray(m)) {
|
| 104 |
-
for (let r = 0; r < m.length; r++) {
|
| 105 |
-
const row = Array.isArray(m[r]) ? m[r] : []
|
| 106 |
-
for (let c = 0; c < row.length; c++) {
|
| 107 |
-
const v = Number(row[c])
|
| 108 |
-
if (!Number.isFinite(v)) continue
|
| 109 |
-
data.push([c, r, v])
|
| 110 |
-
if (v < vmin) vmin = v
|
| 111 |
-
if (v > vmax) vmax = v
|
| 112 |
-
}
|
| 113 |
-
}
|
| 114 |
-
}
|
| 115 |
-
if (!Number.isFinite(vmin)) {
|
| 116 |
-
vmin = 0
|
| 117 |
-
vmax = 1
|
| 118 |
-
}
|
| 119 |
-
return { xs, ys, data, vmin, vmax }
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
function pieData() {
|
| 123 |
-
const items = props.chart?.items || []
|
| 124 |
-
if (items.length) {
|
| 125 |
-
return items.map((it, i) => ({ name: it.label || it.name || `Item ${i + 1}`, value: _toNum(it.value) ?? 0 }))
|
| 126 |
-
}
|
| 127 |
-
const s = (props.chart?.series || [])[0]
|
| 128 |
-
if (s?.values?.length) {
|
| 129 |
-
const cats = props.chart?.x_categories
|
| 130 |
-
return s.values.map((p, i) => ({
|
| 131 |
-
name: cats?.[i] ?? String(i + 1),
|
| 132 |
-
value: _toNum(Array.isArray(p) ? p[1] : p) ?? 0,
|
| 133 |
-
}))
|
| 134 |
-
}
|
| 135 |
-
return []
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
function radarData() {
|
| 139 |
-
const cats = props.chart?.x_categories || props.chart?.categories || props.chart?.labels || []
|
| 140 |
-
const series = cartesianSeries()
|
| 141 |
-
const allVals = series.flatMap((s) => s.points.map((p) => p[1])).filter((v): v is number => v != null)
|
| 142 |
-
const max = allVals.length ? Math.max(...allVals) * 1.1 : 1
|
| 143 |
-
const names = cats.length ? cats : (series[0]?.points.map((_, i) => `#${i + 1}`) || [])
|
| 144 |
-
const indicator = names.map((name) => ({ name, max }))
|
| 145 |
-
const data = series.map((s) => ({ name: s.name, value: s.points.map((p) => p[1] ?? 0) }))
|
| 146 |
-
return { indicator, data }
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
function gaugeData() {
|
| 150 |
-
const d = pieData()
|
| 151 |
-
let value = 0
|
| 152 |
-
let name = props.title || 'Value'
|
| 153 |
-
if (d.length) {
|
| 154 |
-
value = d[0].value
|
| 155 |
-
name = d[0].name
|
| 156 |
-
} else {
|
| 157 |
-
const s = cartesianSeries()[0]
|
| 158 |
-
const last = s?.points[s.points.length - 1]
|
| 159 |
-
if (last && last[1] != null) value = last[1]
|
| 160 |
-
}
|
| 161 |
-
const max = Number(props.chart?.max) || (value >= 0 && value <= 100 ? 100 : value * 1.3 || 1)
|
| 162 |
-
return { value, name, max }
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
function hasRenderableData(): boolean {
|
| 166 |
-
const k = kind.value
|
| 167 |
-
const c = props.chart || {}
|
| 168 |
-
if (k === 'heatmap') return heatmapData().data.length > 0
|
| 169 |
-
if (k === 'candlestick') return Array.isArray(c.candles) && c.candles.length > 0
|
| 170 |
-
if (k === 'boxplot') return Array.isArray(c.boxes) && c.boxes.length > 0
|
| 171 |
-
if (k === 'sankey' || k === 'graph') return Array.isArray(c.links) && c.links.length > 0
|
| 172 |
-
if (k === 'treemap' || k === 'sunburst') return (c.items?.length || 0) > 0 || pieData().length > 0
|
| 173 |
-
if (k === 'pie' || k === 'donut' || k === 'funnel' || k === 'waterfall') return pieData().length > 0
|
| 174 |
-
if (k === 'gauge') return pieData().length > 0 || cartesianSeries().length > 0
|
| 175 |
-
return cartesianSeries().length > 0
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
const renderable = computed(() => hasRenderableData())
|
| 179 |
|
| 180 |
function buildOption(): echarts.EChartsOption {
|
| 181 |
const dark = typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
| 182 |
-
|
| 183 |
-
const k = kind.value
|
| 184 |
-
|
| 185 |
-
if (k === 'heatmap') {
|
| 186 |
-
const { xs, ys, data, vmin, vmax } = heatmapData()
|
| 187 |
-
const absMax = Math.max(Math.abs(vmin), Math.abs(vmax)) || 1
|
| 188 |
-
const symmetric = vmin < 0 // correlation-style data spans negatives → diverging scale
|
| 189 |
-
return {
|
| 190 |
-
animation: true,
|
| 191 |
-
backgroundColor: 'transparent',
|
| 192 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 193 |
-
tooltip: {
|
| 194 |
-
position: 'top',
|
| 195 |
-
formatter: (p: any) =>
|
| 196 |
-
`${ys[p.value?.[1]] ?? ''} × ${xs[p.value?.[0]] ?? ''}: ${Number(p.value?.[2]).toFixed(2)}`,
|
| 197 |
-
},
|
| 198 |
-
grid: { left: 60, right: 20, top: 16, bottom: 64, containLabel: true },
|
| 199 |
-
xAxis: {
|
| 200 |
-
type: 'category',
|
| 201 |
-
data: xs,
|
| 202 |
-
splitArea: { show: true },
|
| 203 |
-
axisLabel: { fontSize: 10, rotate: xs.length > 5 ? 30 : 0 },
|
| 204 |
-
},
|
| 205 |
-
yAxis: { type: 'category', data: ys, splitArea: { show: true }, axisLabel: { fontSize: 10 } },
|
| 206 |
-
visualMap: {
|
| 207 |
-
min: symmetric ? -absMax : vmin,
|
| 208 |
-
max: symmetric ? absMax : vmax,
|
| 209 |
-
calculable: true,
|
| 210 |
-
orient: 'horizontal',
|
| 211 |
-
left: 'center',
|
| 212 |
-
bottom: 0,
|
| 213 |
-
itemHeight: 80,
|
| 214 |
-
textStyle: { color: tc, fontSize: 10 },
|
| 215 |
-
inRange: { color: symmetric ? ['#ef4444', '#f8fafc', '#3b82f6'] : ['#dbeafe', '#3b82f6', '#1e3a8a'] },
|
| 216 |
-
},
|
| 217 |
-
series: [
|
| 218 |
-
{
|
| 219 |
-
type: 'heatmap',
|
| 220 |
-
data,
|
| 221 |
-
label: {
|
| 222 |
-
show: xs.length <= 10 && ys.length <= 10,
|
| 223 |
-
fontSize: 10,
|
| 224 |
-
formatter: (p: any) => (typeof p.value?.[2] === 'number' ? p.value[2].toFixed(2) : ''),
|
| 225 |
-
},
|
| 226 |
-
emphasis: { itemStyle: { shadowBlur: 8, shadowColor: 'rgba(0,0,0,0.3)' } },
|
| 227 |
-
},
|
| 228 |
-
],
|
| 229 |
-
}
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
if (k === 'pie' || k === 'donut') {
|
| 233 |
-
const d = pieData()
|
| 234 |
-
return {
|
| 235 |
-
animation: true,
|
| 236 |
-
backgroundColor: 'transparent',
|
| 237 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 238 |
-
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
| 239 |
-
legend: { bottom: 0, textStyle: { color: tc, fontSize: 10 } },
|
| 240 |
-
series: [
|
| 241 |
-
{
|
| 242 |
-
type: 'pie',
|
| 243 |
-
radius: k === 'donut' ? ['42%', '70%'] : '65%',
|
| 244 |
-
center: ['50%', '46%'],
|
| 245 |
-
data: d.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } })),
|
| 246 |
-
label: { fontSize: 10, color: tc },
|
| 247 |
-
},
|
| 248 |
-
],
|
| 249 |
-
}
|
| 250 |
-
}
|
| 251 |
-
|
| 252 |
-
if (k === 'funnel') {
|
| 253 |
-
const d = pieData()
|
| 254 |
-
return {
|
| 255 |
-
animation: true,
|
| 256 |
-
backgroundColor: 'transparent',
|
| 257 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 258 |
-
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
| 259 |
-
legend: { bottom: 0, textStyle: { color: tc, fontSize: 10 } },
|
| 260 |
-
series: [
|
| 261 |
-
{
|
| 262 |
-
type: 'funnel',
|
| 263 |
-
left: '10%',
|
| 264 |
-
right: '10%',
|
| 265 |
-
top: 20,
|
| 266 |
-
bottom: 40,
|
| 267 |
-
sort: 'descending',
|
| 268 |
-
gap: 2,
|
| 269 |
-
label: { show: true, position: 'inside', fontSize: 10 },
|
| 270 |
-
data: d.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } })),
|
| 271 |
-
},
|
| 272 |
-
],
|
| 273 |
-
}
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
if (k === 'gauge') {
|
| 277 |
-
const g = gaugeData()
|
| 278 |
-
return {
|
| 279 |
-
animation: true,
|
| 280 |
-
backgroundColor: 'transparent',
|
| 281 |
-
series: [
|
| 282 |
-
{
|
| 283 |
-
type: 'gauge',
|
| 284 |
-
min: 0,
|
| 285 |
-
max: g.max,
|
| 286 |
-
progress: { show: true, width: 14 },
|
| 287 |
-
axisLine: { lineStyle: { width: 14 } },
|
| 288 |
-
axisLabel: { fontSize: 9, color: tc },
|
| 289 |
-
detail: { valueAnimation: true, fontSize: 22, color: tc, formatter: '{value}' },
|
| 290 |
-
data: [{ value: Number(g.value.toFixed(2)), name: g.name }],
|
| 291 |
-
title: { fontSize: 11, color: tc },
|
| 292 |
-
},
|
| 293 |
-
],
|
| 294 |
-
}
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
if (k === 'radar') {
|
| 298 |
-
const { indicator, data } = radarData()
|
| 299 |
-
return {
|
| 300 |
-
animation: true,
|
| 301 |
-
backgroundColor: 'transparent',
|
| 302 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 303 |
-
tooltip: { trigger: 'item' },
|
| 304 |
-
legend: data.length > 1 ? { bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 305 |
-
radar: { indicator, axisName: { fontSize: 10, color: tc }, splitLine: { lineStyle: { opacity: 0.3 } } },
|
| 306 |
-
series: [
|
| 307 |
-
{
|
| 308 |
-
type: 'radar',
|
| 309 |
-
data: data.map((s, i) => ({
|
| 310 |
-
...s,
|
| 311 |
-
areaStyle: { opacity: 0.12, color: PALETTE[i % PALETTE.length] },
|
| 312 |
-
lineStyle: { color: PALETTE[i % PALETTE.length] },
|
| 313 |
-
itemStyle: { color: PALETTE[i % PALETTE.length] },
|
| 314 |
-
})),
|
| 315 |
-
},
|
| 316 |
-
],
|
| 317 |
-
}
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
if (k === 'candlestick') {
|
| 321 |
-
const cats = props.chart?.x_categories || []
|
| 322 |
-
const data = (props.chart?.candles || []).map((c) => (Array.isArray(c) ? c.slice(0, 4).map((v) => _toNum(v) ?? 0) : []))
|
| 323 |
-
return {
|
| 324 |
-
backgroundColor: 'transparent',
|
| 325 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 326 |
-
tooltip: { trigger: 'axis' },
|
| 327 |
-
grid: { left: 48, right: 16, top: 20, bottom: 56, containLabel: true },
|
| 328 |
-
xAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10 } },
|
| 329 |
-
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 330 |
-
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 16, bottom: 8 }],
|
| 331 |
-
series: [
|
| 332 |
-
{
|
| 333 |
-
type: 'candlestick',
|
| 334 |
-
data,
|
| 335 |
-
itemStyle: { color: POSITIVE, color0: NEGATIVE, borderColor: POSITIVE, borderColor0: NEGATIVE },
|
| 336 |
-
},
|
| 337 |
-
],
|
| 338 |
-
}
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
if (k === 'boxplot') {
|
| 342 |
-
const cats = props.chart?.x_categories || []
|
| 343 |
-
const data = (props.chart?.boxes || []).map((b) => (Array.isArray(b) ? b.slice(0, 5).map((v) => _toNum(v) ?? 0) : []))
|
| 344 |
-
return {
|
| 345 |
-
backgroundColor: 'transparent',
|
| 346 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 347 |
-
tooltip: { trigger: 'item' },
|
| 348 |
-
grid: { left: 48, right: 16, top: 20, bottom: 40, containLabel: true },
|
| 349 |
-
xAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10 } },
|
| 350 |
-
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 351 |
-
series: [{ type: 'boxplot', data, itemStyle: { color: 'rgba(59,130,246,0.25)', borderColor: '#3b82f6' } }],
|
| 352 |
-
}
|
| 353 |
-
}
|
| 354 |
-
|
| 355 |
-
if (k === 'treemap' || k === 'sunburst') {
|
| 356 |
-
const src = props.chart?.items?.length
|
| 357 |
-
? props.chart.items.map((it, i) => ({ name: it.label || it.name || `Item ${i + 1}`, value: _toNum(it.value) ?? 0 }))
|
| 358 |
-
: pieData()
|
| 359 |
-
const d = src.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } }))
|
| 360 |
-
return {
|
| 361 |
-
backgroundColor: 'transparent',
|
| 362 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 363 |
-
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
| 364 |
-
series: [
|
| 365 |
-
k === 'sunburst'
|
| 366 |
-
? { type: 'sunburst', data: d, radius: [0, '92%'], label: { fontSize: 10 } }
|
| 367 |
-
: { type: 'treemap', data: d, breadcrumb: { show: false }, roam: false, label: { fontSize: 11 } },
|
| 368 |
-
],
|
| 369 |
-
}
|
| 370 |
-
}
|
| 371 |
-
|
| 372 |
-
if (k === 'sankey' || k === 'graph') {
|
| 373 |
-
const nodes = (props.chart?.nodes || []).map((n) => ({ name: typeof n === 'string' ? n : n.name || '' }))
|
| 374 |
-
const links = (props.chart?.links || []).map((l) => ({ source: l.source, target: l.target, value: _toNum(l.value) ?? 1 }))
|
| 375 |
-
const named = new Set(nodes.map((n) => n.name))
|
| 376 |
-
for (const l of links) {
|
| 377 |
-
for (const e of [l.source, l.target]) {
|
| 378 |
-
const s = String(e)
|
| 379 |
-
if (e != null && !named.has(s)) {
|
| 380 |
-
nodes.push({ name: s })
|
| 381 |
-
named.add(s)
|
| 382 |
-
}
|
| 383 |
-
}
|
| 384 |
-
}
|
| 385 |
-
return {
|
| 386 |
-
backgroundColor: 'transparent',
|
| 387 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 388 |
-
tooltip: { trigger: 'item' },
|
| 389 |
-
series: [
|
| 390 |
-
k === 'graph'
|
| 391 |
-
? { type: 'graph', layout: 'force', roam: true, data: nodes, links, label: { show: true, fontSize: 10 }, force: { repulsion: 140 } }
|
| 392 |
-
: { type: 'sankey', data: nodes, links, label: { fontSize: 10, color: tc }, emphasis: { focus: 'adjacency' }, lineStyle: { color: 'gradient', opacity: 0.5 } },
|
| 393 |
-
],
|
| 394 |
-
}
|
| 395 |
-
}
|
| 396 |
-
|
| 397 |
-
if (k === 'waterfall') {
|
| 398 |
-
const pts = pieData()
|
| 399 |
-
const cats = pts.map((p) => p.name)
|
| 400 |
-
const base: (number | string)[] = []
|
| 401 |
-
const inc: (number | string)[] = []
|
| 402 |
-
const dec: (number | string)[] = []
|
| 403 |
-
let run = 0
|
| 404 |
-
for (const p of pts) {
|
| 405 |
-
const v = Number(p.value) || 0
|
| 406 |
-
if (v >= 0) {
|
| 407 |
-
base.push(run); inc.push(v); dec.push('-')
|
| 408 |
-
} else {
|
| 409 |
-
base.push(run + v); inc.push('-'); dec.push(-v)
|
| 410 |
-
}
|
| 411 |
-
run += v
|
| 412 |
-
}
|
| 413 |
-
return {
|
| 414 |
-
backgroundColor: 'transparent',
|
| 415 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 416 |
-
tooltip: { trigger: 'axis' },
|
| 417 |
-
grid: { left: 48, right: 16, top: 20, bottom: 40, containLabel: true },
|
| 418 |
-
xAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10, rotate: cats.length > 6 ? 30 : 0 } },
|
| 419 |
-
yAxis: { type: 'value', splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 420 |
-
series: [
|
| 421 |
-
{ type: 'bar', stack: 'wf', itemStyle: { color: 'transparent' }, emphasis: { itemStyle: { color: 'transparent' } }, data: base },
|
| 422 |
-
{ type: 'bar', stack: 'wf', name: 'Increase', itemStyle: { color: POSITIVE }, data: inc },
|
| 423 |
-
{ type: 'bar', stack: 'wf', name: 'Decrease', itemStyle: { color: NEGATIVE }, data: dec },
|
| 424 |
-
],
|
| 425 |
-
}
|
| 426 |
-
}
|
| 427 |
-
|
| 428 |
-
if (k === 'bubble') {
|
| 429 |
-
const eb = (props.chart?.series || []).map((s, i) => ({
|
| 430 |
-
name: s.name || `Series ${i + 1}`,
|
| 431 |
-
type: 'scatter' as const,
|
| 432 |
-
data: (Array.isArray(s.values) ? s.values : [])
|
| 433 |
-
.filter((v) => Array.isArray(v) && (v as number[]).length >= 2)
|
| 434 |
-
.map((v) => (v as number[]).map((x) => _toNum(x) ?? 0)),
|
| 435 |
-
symbolSize: (val: number[]) => Math.max(8, Math.sqrt(Math.abs(Number(val?.[2]) || 1)) * 5),
|
| 436 |
-
itemStyle: { color: PALETTE[i % PALETTE.length], opacity: 0.7 },
|
| 437 |
-
}))
|
| 438 |
-
return {
|
| 439 |
-
backgroundColor: 'transparent',
|
| 440 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 441 |
-
tooltip: { trigger: 'item' },
|
| 442 |
-
legend: eb.length > 1 ? { bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 443 |
-
grid: { left: 48, right: 24, top: 20, bottom: 40, containLabel: true },
|
| 444 |
-
xAxis: { type: 'value', name: props.chart?.x_label || '', nameLocation: 'middle', nameGap: 26, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 445 |
-
yAxis: { type: 'value', name: props.chart?.y_label || '', nameLocation: 'middle', nameGap: 36, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 446 |
-
series: eb,
|
| 447 |
-
}
|
| 448 |
-
}
|
| 449 |
-
|
| 450 |
-
// cartesian family: line / bar / hbar / area / scatter / stacked / combo / histogram
|
| 451 |
-
const series = cartesianSeries()
|
| 452 |
-
const isHBar = k === 'hbar' || k === 'horizontal-bar' || k === 'horizontal_bar'
|
| 453 |
-
const isStacked = k === 'stacked' || k === 'stacked-bar' || k === 'stacked_bar'
|
| 454 |
-
const isCombo = k === 'combo'
|
| 455 |
-
const isBar = k === 'bar' || k === 'histogram' || isStacked || isHBar
|
| 456 |
-
const isScatter = k === 'scatter' || k === 'bubble'
|
| 457 |
-
const isArea = k === 'area'
|
| 458 |
-
|
| 459 |
-
// Category axis (e.g. "Q1 2023") when x_categories provided; else numeric.
|
| 460 |
-
const cats = props.chart?.x_categories
|
| 461 |
-
const useCat = Array.isArray(cats) && cats.length > 0
|
| 462 |
-
|
| 463 |
-
const eSeries = series.map((s, i) => {
|
| 464 |
-
const seriesType = (
|
| 465 |
-
isCombo ? (s.kind === 'line' ? 'line' : s.kind === 'bar' ? 'bar' : i === 0 ? 'bar' : 'line')
|
| 466 |
-
: isBar ? 'bar'
|
| 467 |
-
: isScatter ? 'scatter'
|
| 468 |
-
: 'line'
|
| 469 |
-
) as 'bar' | 'scatter' | 'line'
|
| 470 |
-
const asLine = seriesType === 'line'
|
| 471 |
-
return {
|
| 472 |
-
name: s.name,
|
| 473 |
-
type: seriesType,
|
| 474 |
-
data: useCat ? s.points.map((p) => p[1]) : s.points,
|
| 475 |
-
stack: isStacked ? 'total' : undefined,
|
| 476 |
-
smooth: asLine,
|
| 477 |
-
showSymbol: seriesType === 'scatter' || asLine,
|
| 478 |
-
symbolSize: seriesType === 'scatter' ? 10 : 6,
|
| 479 |
-
itemStyle: { color: s.color },
|
| 480 |
-
areaStyle: isArea && asLine ? { opacity: 0.18, color: s.color } : undefined,
|
| 481 |
-
lineStyle: asLine ? { width: 2 } : undefined,
|
| 482 |
-
emphasis: { focus: 'series' as const },
|
| 483 |
-
animationDuration: 900,
|
| 484 |
-
animationEasing: 'cubicOut' as const,
|
| 485 |
-
}
|
| 486 |
-
})
|
| 487 |
-
|
| 488 |
-
const legendNames = series.map((s) => s.name)
|
| 489 |
-
const valueAxis = {
|
| 490 |
-
type: 'value' as const,
|
| 491 |
-
name: (isHBar ? props.chart?.x_label : props.chart?.y_label) || '',
|
| 492 |
-
nameLocation: 'middle' as const,
|
| 493 |
-
nameGap: 36,
|
| 494 |
-
splitLine: { show: true, lineStyle: { opacity: 0.2 } },
|
| 495 |
-
}
|
| 496 |
-
const catAxis = useCat
|
| 497 |
-
? {
|
| 498 |
-
type: 'category' as const,
|
| 499 |
-
data: cats,
|
| 500 |
-
name: (isHBar ? props.chart?.y_label : props.chart?.x_label) || '',
|
| 501 |
-
nameLocation: 'middle' as const,
|
| 502 |
-
nameGap: 30,
|
| 503 |
-
axisLabel: { fontSize: 10, rotate: !isHBar && (cats as string[]).length > 6 ? 30 : 0 },
|
| 504 |
-
}
|
| 505 |
-
: {
|
| 506 |
-
type: 'value' as const,
|
| 507 |
-
name: (isHBar ? props.chart?.y_label : props.chart?.x_label) || '',
|
| 508 |
-
nameLocation: 'middle' as const,
|
| 509 |
-
nameGap: 28,
|
| 510 |
-
splitLine: { show: true, lineStyle: { opacity: 0.2 } },
|
| 511 |
-
}
|
| 512 |
-
|
| 513 |
-
return {
|
| 514 |
-
animation: true,
|
| 515 |
-
animationDuration: 1100,
|
| 516 |
-
backgroundColor: 'transparent',
|
| 517 |
-
textStyle: { color: tc, fontSize: 11 },
|
| 518 |
-
grid: { left: 48, right: 24, top: 36, bottom: 40, containLabel: true },
|
| 519 |
-
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
| 520 |
-
legend: legendNames.length > 1 ? { data: legendNames, bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 521 |
-
xAxis: isHBar ? valueAxis : catAxis,
|
| 522 |
-
yAxis: isHBar ? catAxis : valueAxis,
|
| 523 |
-
series: eSeries,
|
| 524 |
-
}
|
| 525 |
}
|
| 526 |
|
| 527 |
function resize() {
|
|
|
|
| 1 |
<script setup lang="ts">
|
| 2 |
import { onMounted, onUnmounted, ref, watch, computed, nextTick } from 'vue'
|
| 3 |
import * as echarts from 'echarts'
|
| 4 |
+
import 'echarts-gl' // registers 3D series: scatter3D / bar3D / line3D / surface
|
| 5 |
import { ArrowDownTrayIcon } from '@/components/icons'
|
| 6 |
+
import { buildEChartsOption, chartHasRenderableData, type ChartSpec } from '@/lib/echartsOption'
|
|
|
|
| 7 |
|
| 8 |
const props = defineProps<{
|
| 9 |
title?: string
|
| 10 |
+
chart: ChartSpec
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}>()
|
| 12 |
|
| 13 |
const rootEl = ref<HTMLDivElement | null>(null)
|
| 14 |
let chart: echarts.ECharts | null = null
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const kind = computed(() => String(props.chart?.kind || 'line').toLowerCase())
|
| 17 |
|
| 18 |
+
const renderable = computed(() => chartHasRenderableData(props.chart))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
function buildOption(): echarts.EChartsOption {
|
| 21 |
const dark = typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
| 22 |
+
return buildEChartsOption(props.chart, props.title || '', { dark })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
function resize() {
|
vivek/frontend-vue/src/components/widgets/ActionRow.vue
CHANGED
|
@@ -2,6 +2,13 @@
|
|
| 2 |
import Button from '@/components/ui/Button.vue'
|
| 3 |
|
| 4 |
defineProps<{ block: { buttons?: { label?: string; intent?: string }[] } }>()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
</script>
|
| 6 |
|
| 7 |
<template>
|
|
@@ -12,8 +19,8 @@ defineProps<{ block: { buttons?: { label?: string; intent?: string }[] } }>()
|
|
| 12 |
type="button"
|
| 13 |
variant="outline"
|
| 14 |
size="sm"
|
| 15 |
-
|
| 16 |
-
|
| 17 |
>
|
| 18 |
{{ b.label || 'Action' }}
|
| 19 |
</Button>
|
|
|
|
| 2 |
import Button from '@/components/ui/Button.vue'
|
| 3 |
|
| 4 |
defineProps<{ block: { buttons?: { label?: string; intent?: string }[] } }>()
|
| 5 |
+
const emit = defineEmits<{ (e: 'action', text: string): void }>()
|
| 6 |
+
|
| 7 |
+
function click(b: { label?: string; intent?: string }) {
|
| 8 |
+
// Send the human-readable label as the follow-up prompt (fall back to the intent code).
|
| 9 |
+
const text = String(b.label || b.intent || '').trim()
|
| 10 |
+
if (text) emit('action', text)
|
| 11 |
+
}
|
| 12 |
</script>
|
| 13 |
|
| 14 |
<template>
|
|
|
|
| 19 |
type="button"
|
| 20 |
variant="outline"
|
| 21 |
size="sm"
|
| 22 |
+
:title="b.intent ? `Ask: ${b.label || b.intent}` : undefined"
|
| 23 |
+
@click="click(b)"
|
| 24 |
>
|
| 25 |
{{ b.label || 'Action' }}
|
| 26 |
</Button>
|
vivek/frontend-vue/src/lib/analyticsStore.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type DoneEventForAnalytics = {
|
|
| 4 |
ts: number
|
| 5 |
strategy: string
|
| 6 |
elapsed: number | null | undefined
|
| 7 |
-
|
| 8 |
}
|
| 9 |
|
| 10 |
export type RewardEventForAnalytics = {
|
|
@@ -32,15 +32,15 @@ const MAX_EVENTS = 300
|
|
| 32 |
export function ingestDone(evt: {
|
| 33 |
strategy: string
|
| 34 |
elapsed?: number | null
|
| 35 |
-
|
| 36 |
-
|
| 37 |
}) {
|
| 38 |
-
const
|
| 39 |
analyticsState.doneEvents.push({
|
| 40 |
ts: Date.now(),
|
| 41 |
strategy: evt.strategy,
|
| 42 |
elapsed: evt.elapsed,
|
| 43 |
-
|
| 44 |
})
|
| 45 |
if (analyticsState.doneEvents.length > MAX_EVENTS) {
|
| 46 |
analyticsState.doneEvents.splice(0, analyticsState.doneEvents.length - MAX_EVENTS)
|
|
@@ -63,7 +63,7 @@ export function ingestReward(evt: { strategy: string; reward: number; predictedR
|
|
| 63 |
export function computeWidgetRenderRate(): number {
|
| 64 |
const ds = analyticsState.doneEvents
|
| 65 |
if (!ds.length) return 0
|
| 66 |
-
const withWidgets = ds.filter((d) => (d.
|
| 67 |
return withWidgets / ds.length
|
| 68 |
}
|
| 69 |
|
|
|
|
| 4 |
ts: number
|
| 5 |
strategy: string
|
| 6 |
elapsed: number | null | undefined
|
| 7 |
+
widgetSchema: string
|
| 8 |
}
|
| 9 |
|
| 10 |
export type RewardEventForAnalytics = {
|
|
|
|
| 32 |
export function ingestDone(evt: {
|
| 33 |
strategy: string
|
| 34 |
elapsed?: number | null
|
| 35 |
+
widget_schema?: string
|
| 36 |
+
widgetSchema?: string
|
| 37 |
}) {
|
| 38 |
+
const widgetSchema = (evt.widgetSchema ?? evt.widget_schema ?? '') as string
|
| 39 |
analyticsState.doneEvents.push({
|
| 40 |
ts: Date.now(),
|
| 41 |
strategy: evt.strategy,
|
| 42 |
elapsed: evt.elapsed,
|
| 43 |
+
widgetSchema,
|
| 44 |
})
|
| 45 |
if (analyticsState.doneEvents.length > MAX_EVENTS) {
|
| 46 |
analyticsState.doneEvents.splice(0, analyticsState.doneEvents.length - MAX_EVENTS)
|
|
|
|
| 63 |
export function computeWidgetRenderRate(): number {
|
| 64 |
const ds = analyticsState.doneEvents
|
| 65 |
if (!ds.length) return 0
|
| 66 |
+
const withWidgets = ds.filter((d) => (d.widgetSchema ?? '').trim().length > 0).length
|
| 67 |
return withWidgets / ds.length
|
| 68 |
}
|
| 69 |
|
vivek/frontend-vue/src/lib/echartsOption.ts
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared ECharts option builder — the SINGLE source for chart configuration.
|
| 3 |
+
*
|
| 4 |
+
* Used by:
|
| 5 |
+
* - WidgetSchemaChart.vue (live interactive render)
|
| 6 |
+
* - exportWidgetHtml.ts (standalone HTML export)
|
| 7 |
+
*
|
| 8 |
+
* Pure functions only (no Vue, no DOM): given the chart spec + a `dark` flag,
|
| 9 |
+
* return the ECharts `option`. This guarantees the exported HTML matches what
|
| 10 |
+
* the user sees on screen.
|
| 11 |
+
*/
|
| 12 |
+
import type { EChartsOption } from 'echarts'
|
| 13 |
+
|
| 14 |
+
export type ChartSeries = { name?: string; color?: string; kind?: string; values?: (number | number[])[] }
|
| 15 |
+
|
| 16 |
+
export type TreeNodeSpec = { name?: string; label?: string; value?: number; children?: TreeNodeSpec[] }
|
| 17 |
+
|
| 18 |
+
export type ChartSpec = {
|
| 19 |
+
kind?: string
|
| 20 |
+
x_label?: string
|
| 21 |
+
y_label?: string
|
| 22 |
+
max?: number
|
| 23 |
+
x_labels?: string[]
|
| 24 |
+
y_labels?: string[]
|
| 25 |
+
categories?: string[]
|
| 26 |
+
labels?: string[]
|
| 27 |
+
matrix?: number[][]
|
| 28 |
+
series?: ChartSeries[]
|
| 29 |
+
x_categories?: string[]
|
| 30 |
+
items?: { label?: string; name?: string; value?: number }[]
|
| 31 |
+
// tree / mindmap / org: a single hierarchical root
|
| 32 |
+
root?: TreeNodeSpec
|
| 33 |
+
candles?: (number | string)[][]
|
| 34 |
+
boxes?: (number | string)[][]
|
| 35 |
+
nodes?: ({ name?: string } | string)[]
|
| 36 |
+
links?: { source?: string | number; target?: string | number; value?: number }[]
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const COLOR_MAP: Record<string, string> = {
|
| 40 |
+
blue: '#3b82f6',
|
| 41 |
+
orange: '#f97316',
|
| 42 |
+
green: '#22c55e',
|
| 43 |
+
red: '#ef4444',
|
| 44 |
+
purple: '#a855f7',
|
| 45 |
+
teal: '#14b8a6',
|
| 46 |
+
yellow: '#eab308',
|
| 47 |
+
pink: '#ec4899',
|
| 48 |
+
indigo: '#4f46e5',
|
| 49 |
+
cyan: '#0891b2',
|
| 50 |
+
gray: '#64748b',
|
| 51 |
+
}
|
| 52 |
+
const PALETTE = ['#3b82f6', '#f97316', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#eab308', '#ec4899']
|
| 53 |
+
const POSITIVE = '#16a34a'
|
| 54 |
+
const NEGATIVE = '#dc2626'
|
| 55 |
+
|
| 56 |
+
function resolveColor(c?: string, i = 0): string {
|
| 57 |
+
if (!c) return PALETTE[i % PALETTE.length]
|
| 58 |
+
return COLOR_MAP[c.toLowerCase()] || c
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function toNum(v: unknown): number | null {
|
| 62 |
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null
|
| 63 |
+
if (typeof v === 'string') {
|
| 64 |
+
const m = v.replace(/,/g, '').match(/-?\d+(\.\d+)?/)
|
| 65 |
+
return m ? parseFloat(m[0]) : null
|
| 66 |
+
}
|
| 67 |
+
return null
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function chartKind(chart: ChartSpec): string {
|
| 71 |
+
return String(chart?.kind || 'line').toLowerCase()
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function cartesianSeries(chart: ChartSpec) {
|
| 75 |
+
const raw = chart?.series || []
|
| 76 |
+
return raw
|
| 77 |
+
.map((s, i) => {
|
| 78 |
+
const vals = Array.isArray(s.values) ? s.values : []
|
| 79 |
+
let pts: [number, number | null][] = []
|
| 80 |
+
if (vals.length && !Array.isArray(vals[0])) {
|
| 81 |
+
pts = (vals as unknown[]).map((v, j) => [j, toNum(v)])
|
| 82 |
+
} else {
|
| 83 |
+
pts = (vals as unknown[])
|
| 84 |
+
.filter((p) => Array.isArray(p) && (p as unknown[]).length >= 2)
|
| 85 |
+
.map((p) => [(toNum((p as unknown[])[0]) ?? 0), toNum((p as unknown[])[1])] as [number, number | null])
|
| 86 |
+
}
|
| 87 |
+
return { name: s.name || `Series ${i + 1}`, color: resolveColor(s.color, i), kind: s.kind, points: pts }
|
| 88 |
+
})
|
| 89 |
+
.filter((s) => s.points.some((p) => p[1] != null))
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function heatmapData(chart: ChartSpec) {
|
| 93 |
+
const xs = chart?.x_labels || chart?.categories || chart?.labels || []
|
| 94 |
+
const ys = chart?.y_labels || chart?.categories || chart?.labels || []
|
| 95 |
+
const m = chart?.matrix
|
| 96 |
+
const data: [number, number, number][] = []
|
| 97 |
+
let vmin = Infinity
|
| 98 |
+
let vmax = -Infinity
|
| 99 |
+
if (Array.isArray(m)) {
|
| 100 |
+
for (let r = 0; r < m.length; r++) {
|
| 101 |
+
const row = Array.isArray(m[r]) ? m[r] : []
|
| 102 |
+
for (let c = 0; c < row.length; c++) {
|
| 103 |
+
const v = Number(row[c])
|
| 104 |
+
if (!Number.isFinite(v)) continue
|
| 105 |
+
data.push([c, r, v])
|
| 106 |
+
if (v < vmin) vmin = v
|
| 107 |
+
if (v > vmax) vmax = v
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
if (!Number.isFinite(vmin)) {
|
| 112 |
+
vmin = 0
|
| 113 |
+
vmax = 1
|
| 114 |
+
}
|
| 115 |
+
return { xs, ys, data, vmin, vmax }
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function pieData(chart: ChartSpec) {
|
| 119 |
+
const items = chart?.items || []
|
| 120 |
+
if (items.length) {
|
| 121 |
+
return items.map((it, i) => ({ name: it.label || it.name || `Item ${i + 1}`, value: toNum(it.value) ?? 0 }))
|
| 122 |
+
}
|
| 123 |
+
const s = (chart?.series || [])[0]
|
| 124 |
+
if (s?.values?.length) {
|
| 125 |
+
const cats = chart?.x_categories
|
| 126 |
+
return s.values.map((p, i) => ({
|
| 127 |
+
name: cats?.[i] ?? String(i + 1),
|
| 128 |
+
value: toNum(Array.isArray(p) ? p[1] : p) ?? 0,
|
| 129 |
+
}))
|
| 130 |
+
}
|
| 131 |
+
return []
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function radarData(chart: ChartSpec) {
|
| 135 |
+
const cats = chart?.x_categories || chart?.categories || chart?.labels || []
|
| 136 |
+
const series = cartesianSeries(chart)
|
| 137 |
+
const allVals = series.flatMap((s) => s.points.map((p) => p[1])).filter((v): v is number => v != null)
|
| 138 |
+
const max = allVals.length ? Math.max(...allVals) * 1.1 : 1
|
| 139 |
+
const names = cats.length ? cats : series[0]?.points.map((_, i) => `#${i + 1}`) || []
|
| 140 |
+
const indicator = names.map((name) => ({ name, max }))
|
| 141 |
+
const data = series.map((s) => ({ name: s.name, value: s.points.map((p) => p[1] ?? 0) }))
|
| 142 |
+
return { indicator, data }
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function gaugeData(chart: ChartSpec, title: string) {
|
| 146 |
+
const d = pieData(chart)
|
| 147 |
+
let value = 0
|
| 148 |
+
let name = title || 'Value'
|
| 149 |
+
if (d.length) {
|
| 150 |
+
value = d[0].value
|
| 151 |
+
name = d[0].name
|
| 152 |
+
} else {
|
| 153 |
+
const s = cartesianSeries(chart)[0]
|
| 154 |
+
const last = s?.points[s.points.length - 1]
|
| 155 |
+
if (last && last[1] != null) value = last[1]
|
| 156 |
+
}
|
| 157 |
+
const max = Number(chart?.max) || (value >= 0 && value <= 100 ? 100 : value * 1.3 || 1)
|
| 158 |
+
return { value, name, max }
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/** 3D series: each series' values are [x, y, z] triples. */
|
| 162 |
+
function series3d(chart: ChartSpec) {
|
| 163 |
+
return (chart?.series || [])
|
| 164 |
+
.map((s, i) => ({
|
| 165 |
+
name: s.name || `Series ${i + 1}`,
|
| 166 |
+
color: resolveColor(s.color, i),
|
| 167 |
+
data: (Array.isArray(s.values) ? s.values : [])
|
| 168 |
+
.filter((v) => Array.isArray(v) && (v as number[]).length >= 3)
|
| 169 |
+
.map((v) => (v as number[]).slice(0, 3).map((x) => toNum(x) ?? 0)),
|
| 170 |
+
}))
|
| 171 |
+
.filter((s) => s.data.length > 0)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/** Normalize a hierarchical node (tree/mindmap/org): ensure `name`, recurse `children`. */
|
| 175 |
+
function normalizeTreeNode(n: TreeNodeSpec | undefined, depth = 0): { name: string; value?: number; children?: any[] } | null {
|
| 176 |
+
if (!n || typeof n !== 'object' || depth > 8) return null
|
| 177 |
+
const name = String(n.name || n.label || '').trim()
|
| 178 |
+
const kids = Array.isArray(n.children)
|
| 179 |
+
? n.children.map((c) => normalizeTreeNode(c, depth + 1)).filter(Boolean)
|
| 180 |
+
: []
|
| 181 |
+
if (!name && kids.length === 0) return null
|
| 182 |
+
const out: { name: string; value?: number; children?: any[] } = { name: name || '·' }
|
| 183 |
+
if (typeof n.value === 'number') out.value = n.value
|
| 184 |
+
if (kids.length) out.children = kids
|
| 185 |
+
return out
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
function treeRoot(chart: ChartSpec) {
|
| 189 |
+
const c = chart as ChartSpec & { tree?: TreeNodeSpec; data?: unknown; children?: TreeNodeSpec[]; name?: string }
|
| 190 |
+
// Accept common variants the model may emit: root | tree | data | a top-level node | items as children.
|
| 191 |
+
let raw: TreeNodeSpec | undefined = c.root || c.tree
|
| 192 |
+
if (!raw && c.data && typeof c.data === 'object') raw = (Array.isArray(c.data) ? c.data[0] : c.data) as TreeNodeSpec
|
| 193 |
+
if (!raw && (c.name || c.children)) raw = { name: c.name, children: c.children }
|
| 194 |
+
if (!raw && Array.isArray(chart?.items) && chart.items.length) {
|
| 195 |
+
raw = { name: '', children: chart.items.map((it) => ({ name: it.label || it.name, value: it.value })) }
|
| 196 |
+
}
|
| 197 |
+
return normalizeTreeNode(raw)
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const GL_KINDS = new Set(['scatter3d', 'bar3d', 'line3d'])
|
| 201 |
+
export function isGlKind(kind?: string): boolean {
|
| 202 |
+
return GL_KINDS.has(String(kind || '').toLowerCase())
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
export function chartHasRenderableData(chart: ChartSpec): boolean {
|
| 206 |
+
const k = chartKind(chart)
|
| 207 |
+
const c = chart || {}
|
| 208 |
+
if (k === 'heatmap') return heatmapData(chart).data.length > 0
|
| 209 |
+
if (k === 'candlestick') return Array.isArray(c.candles) && c.candles.length > 0
|
| 210 |
+
if (k === 'boxplot') return Array.isArray(c.boxes) && c.boxes.length > 0
|
| 211 |
+
if (k === 'sankey' || k === 'graph') return Array.isArray(c.links) && c.links.length > 0
|
| 212 |
+
if (k === 'treemap' || k === 'sunburst') return (c.items?.length || 0) > 0 || pieData(chart).length > 0
|
| 213 |
+
if (k === 'pie' || k === 'donut' || k === 'funnel' || k === 'waterfall' || k === 'rose') return pieData(chart).length > 0
|
| 214 |
+
if (k === 'tree' || k === 'mindmap' || k === 'org' || k === 'orgchart') return treeRoot(chart) != null
|
| 215 |
+
if (k === 'scatter3d' || k === 'bar3d' || k === 'line3d') return series3d(chart).length > 0
|
| 216 |
+
if (k === 'gauge') return pieData(chart).length > 0 || cartesianSeries(chart).length > 0
|
| 217 |
+
return cartesianSeries(chart).length > 0
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
export function buildEChartsOption(chart: ChartSpec, title = '', opts: { dark?: boolean } = {}): EChartsOption {
|
| 221 |
+
const dark = !!opts.dark
|
| 222 |
+
const tc = dark ? '#8d93aa' : '#5a5f72'
|
| 223 |
+
const k = chartKind(chart)
|
| 224 |
+
|
| 225 |
+
if (k === 'heatmap') {
|
| 226 |
+
const { xs, ys, data, vmin, vmax } = heatmapData(chart)
|
| 227 |
+
const absMax = Math.max(Math.abs(vmin), Math.abs(vmax)) || 1
|
| 228 |
+
const symmetric = vmin < 0
|
| 229 |
+
return {
|
| 230 |
+
animation: true,
|
| 231 |
+
backgroundColor: 'transparent',
|
| 232 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 233 |
+
tooltip: {
|
| 234 |
+
position: 'top',
|
| 235 |
+
formatter: (p: any) =>
|
| 236 |
+
`${ys[p.value?.[1]] ?? ''} × ${xs[p.value?.[0]] ?? ''}: ${Number(p.value?.[2]).toFixed(2)}`,
|
| 237 |
+
},
|
| 238 |
+
grid: { left: 60, right: 20, top: 16, bottom: 64, containLabel: true },
|
| 239 |
+
xAxis: {
|
| 240 |
+
type: 'category',
|
| 241 |
+
data: xs,
|
| 242 |
+
splitArea: { show: true },
|
| 243 |
+
axisLabel: { fontSize: 10, rotate: xs.length > 5 ? 30 : 0 },
|
| 244 |
+
},
|
| 245 |
+
yAxis: { type: 'category', data: ys, splitArea: { show: true }, axisLabel: { fontSize: 10 } },
|
| 246 |
+
visualMap: {
|
| 247 |
+
min: symmetric ? -absMax : vmin,
|
| 248 |
+
max: symmetric ? absMax : vmax,
|
| 249 |
+
calculable: true,
|
| 250 |
+
orient: 'horizontal',
|
| 251 |
+
left: 'center',
|
| 252 |
+
bottom: 0,
|
| 253 |
+
itemHeight: 80,
|
| 254 |
+
textStyle: { color: tc, fontSize: 10 },
|
| 255 |
+
inRange: { color: symmetric ? ['#ef4444', '#f8fafc', '#3b82f6'] : ['#dbeafe', '#3b82f6', '#1e3a8a'] },
|
| 256 |
+
},
|
| 257 |
+
series: [
|
| 258 |
+
{
|
| 259 |
+
type: 'heatmap',
|
| 260 |
+
data,
|
| 261 |
+
label: {
|
| 262 |
+
show: xs.length <= 10 && ys.length <= 10,
|
| 263 |
+
fontSize: 10,
|
| 264 |
+
formatter: (p: any) => (typeof p.value?.[2] === 'number' ? p.value[2].toFixed(2) : ''),
|
| 265 |
+
},
|
| 266 |
+
emphasis: { itemStyle: { shadowBlur: 8, shadowColor: 'rgba(0,0,0,0.3)' } },
|
| 267 |
+
},
|
| 268 |
+
],
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
if (k === 'pie' || k === 'donut') {
|
| 273 |
+
const d = pieData(chart)
|
| 274 |
+
return {
|
| 275 |
+
animation: true,
|
| 276 |
+
backgroundColor: 'transparent',
|
| 277 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 278 |
+
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
| 279 |
+
legend: { bottom: 0, textStyle: { color: tc, fontSize: 10 } },
|
| 280 |
+
series: [
|
| 281 |
+
{
|
| 282 |
+
type: 'pie',
|
| 283 |
+
radius: k === 'donut' ? ['42%', '70%'] : '65%',
|
| 284 |
+
center: ['50%', '46%'],
|
| 285 |
+
data: d.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } })),
|
| 286 |
+
label: { fontSize: 10, color: tc },
|
| 287 |
+
},
|
| 288 |
+
],
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if (k === 'funnel') {
|
| 293 |
+
const d = pieData(chart)
|
| 294 |
+
return {
|
| 295 |
+
animation: true,
|
| 296 |
+
backgroundColor: 'transparent',
|
| 297 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 298 |
+
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
| 299 |
+
legend: { bottom: 0, textStyle: { color: tc, fontSize: 10 } },
|
| 300 |
+
series: [
|
| 301 |
+
{
|
| 302 |
+
type: 'funnel',
|
| 303 |
+
left: '10%',
|
| 304 |
+
right: '10%',
|
| 305 |
+
top: 20,
|
| 306 |
+
bottom: 40,
|
| 307 |
+
sort: 'descending',
|
| 308 |
+
gap: 2,
|
| 309 |
+
label: { show: true, position: 'inside', fontSize: 10 },
|
| 310 |
+
data: d.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } })),
|
| 311 |
+
},
|
| 312 |
+
],
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
if (k === 'gauge') {
|
| 317 |
+
const g = gaugeData(chart, title)
|
| 318 |
+
return {
|
| 319 |
+
animation: true,
|
| 320 |
+
backgroundColor: 'transparent',
|
| 321 |
+
series: [
|
| 322 |
+
{
|
| 323 |
+
type: 'gauge',
|
| 324 |
+
min: 0,
|
| 325 |
+
max: g.max,
|
| 326 |
+
progress: { show: true, width: 14 },
|
| 327 |
+
axisLine: { lineStyle: { width: 14 } },
|
| 328 |
+
axisLabel: { fontSize: 9, color: tc },
|
| 329 |
+
detail: { valueAnimation: true, fontSize: 22, color: tc, formatter: '{value}' },
|
| 330 |
+
data: [{ value: Number(g.value.toFixed(2)), name: g.name }],
|
| 331 |
+
title: { fontSize: 11, color: tc },
|
| 332 |
+
},
|
| 333 |
+
],
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
if (k === 'radar') {
|
| 338 |
+
const { indicator, data } = radarData(chart)
|
| 339 |
+
return {
|
| 340 |
+
animation: true,
|
| 341 |
+
backgroundColor: 'transparent',
|
| 342 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 343 |
+
tooltip: { trigger: 'item' },
|
| 344 |
+
legend: data.length > 1 ? { bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 345 |
+
radar: { indicator, axisName: { fontSize: 10, color: tc }, splitLine: { lineStyle: { opacity: 0.3 } } },
|
| 346 |
+
series: [
|
| 347 |
+
{
|
| 348 |
+
type: 'radar',
|
| 349 |
+
data: data.map((s, i) => ({
|
| 350 |
+
...s,
|
| 351 |
+
areaStyle: { opacity: 0.12, color: PALETTE[i % PALETTE.length] },
|
| 352 |
+
lineStyle: { color: PALETTE[i % PALETTE.length] },
|
| 353 |
+
itemStyle: { color: PALETTE[i % PALETTE.length] },
|
| 354 |
+
})),
|
| 355 |
+
},
|
| 356 |
+
],
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if (k === 'candlestick') {
|
| 361 |
+
const cats = chart?.x_categories || []
|
| 362 |
+
const data = (chart?.candles || []).map((c) => (Array.isArray(c) ? c.slice(0, 4).map((v) => toNum(v) ?? 0) : []))
|
| 363 |
+
return {
|
| 364 |
+
backgroundColor: 'transparent',
|
| 365 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 366 |
+
tooltip: { trigger: 'axis' },
|
| 367 |
+
grid: { left: 48, right: 16, top: 20, bottom: 56, containLabel: true },
|
| 368 |
+
xAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10 } },
|
| 369 |
+
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 370 |
+
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 16, bottom: 8 }],
|
| 371 |
+
series: [
|
| 372 |
+
{
|
| 373 |
+
type: 'candlestick',
|
| 374 |
+
data,
|
| 375 |
+
itemStyle: { color: POSITIVE, color0: NEGATIVE, borderColor: POSITIVE, borderColor0: NEGATIVE },
|
| 376 |
+
},
|
| 377 |
+
],
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
if (k === 'boxplot') {
|
| 382 |
+
const cats = chart?.x_categories || []
|
| 383 |
+
const data = (chart?.boxes || []).map((b) => (Array.isArray(b) ? b.slice(0, 5).map((v) => toNum(v) ?? 0) : []))
|
| 384 |
+
return {
|
| 385 |
+
backgroundColor: 'transparent',
|
| 386 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 387 |
+
tooltip: { trigger: 'item' },
|
| 388 |
+
grid: { left: 48, right: 16, top: 20, bottom: 40, containLabel: true },
|
| 389 |
+
xAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10 } },
|
| 390 |
+
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 391 |
+
series: [{ type: 'boxplot', data, itemStyle: { color: 'rgba(59,130,246,0.25)', borderColor: '#3b82f6' } }],
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
if (k === 'treemap' || k === 'sunburst') {
|
| 396 |
+
const src = chart?.items?.length
|
| 397 |
+
? chart.items.map((it, i) => ({ name: it.label || it.name || `Item ${i + 1}`, value: toNum(it.value) ?? 0 }))
|
| 398 |
+
: pieData(chart)
|
| 399 |
+
const d = src.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } }))
|
| 400 |
+
return {
|
| 401 |
+
backgroundColor: 'transparent',
|
| 402 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 403 |
+
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
| 404 |
+
series: [
|
| 405 |
+
k === 'sunburst'
|
| 406 |
+
? { type: 'sunburst', data: d, radius: [0, '92%'], label: { fontSize: 10 } }
|
| 407 |
+
: { type: 'treemap', data: d, breadcrumb: { show: false }, roam: false, label: { fontSize: 11 } },
|
| 408 |
+
],
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
if (k === 'sankey' || k === 'graph') {
|
| 413 |
+
const nodes = (chart?.nodes || []).map((n) => ({ name: typeof n === 'string' ? n : n.name || '' }))
|
| 414 |
+
const links = (chart?.links || []).map((l) => ({ source: l.source, target: l.target, value: toNum(l.value) ?? 1 }))
|
| 415 |
+
const named = new Set(nodes.map((n) => n.name))
|
| 416 |
+
for (const l of links) {
|
| 417 |
+
for (const e of [l.source, l.target]) {
|
| 418 |
+
const s = String(e)
|
| 419 |
+
if (e != null && !named.has(s)) {
|
| 420 |
+
nodes.push({ name: s })
|
| 421 |
+
named.add(s)
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
return {
|
| 426 |
+
backgroundColor: 'transparent',
|
| 427 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 428 |
+
tooltip: { trigger: 'item' },
|
| 429 |
+
series: [
|
| 430 |
+
k === 'graph'
|
| 431 |
+
? { type: 'graph', layout: 'force', roam: true, data: nodes, links, label: { show: true, fontSize: 10 }, force: { repulsion: 140 } }
|
| 432 |
+
: { type: 'sankey', data: nodes, links, label: { fontSize: 10, color: tc }, emphasis: { focus: 'adjacency' }, lineStyle: { color: 'gradient', opacity: 0.5 } },
|
| 433 |
+
],
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
if (k === 'waterfall') {
|
| 438 |
+
const pts = pieData(chart)
|
| 439 |
+
const cats = pts.map((p) => p.name)
|
| 440 |
+
const base: (number | string)[] = []
|
| 441 |
+
const inc: (number | string)[] = []
|
| 442 |
+
const dec: (number | string)[] = []
|
| 443 |
+
let run = 0
|
| 444 |
+
for (const p of pts) {
|
| 445 |
+
const v = Number(p.value) || 0
|
| 446 |
+
if (v >= 0) {
|
| 447 |
+
base.push(run)
|
| 448 |
+
inc.push(v)
|
| 449 |
+
dec.push('-')
|
| 450 |
+
} else {
|
| 451 |
+
base.push(run + v)
|
| 452 |
+
inc.push('-')
|
| 453 |
+
dec.push(-v)
|
| 454 |
+
}
|
| 455 |
+
run += v
|
| 456 |
+
}
|
| 457 |
+
return {
|
| 458 |
+
backgroundColor: 'transparent',
|
| 459 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 460 |
+
tooltip: { trigger: 'axis' },
|
| 461 |
+
grid: { left: 48, right: 16, top: 20, bottom: 40, containLabel: true },
|
| 462 |
+
xAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10, rotate: cats.length > 6 ? 30 : 0 } },
|
| 463 |
+
yAxis: { type: 'value', splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 464 |
+
series: [
|
| 465 |
+
{ type: 'bar', stack: 'wf', itemStyle: { color: 'transparent' }, emphasis: { itemStyle: { color: 'transparent' } }, data: base },
|
| 466 |
+
{ type: 'bar', stack: 'wf', name: 'Increase', itemStyle: { color: POSITIVE }, data: inc },
|
| 467 |
+
{ type: 'bar', stack: 'wf', name: 'Decrease', itemStyle: { color: NEGATIVE }, data: dec },
|
| 468 |
+
],
|
| 469 |
+
}
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
if (k === 'bubble') {
|
| 473 |
+
const eb = (chart?.series || []).map((s, i) => ({
|
| 474 |
+
name: s.name || `Series ${i + 1}`,
|
| 475 |
+
type: 'scatter' as const,
|
| 476 |
+
data: (Array.isArray(s.values) ? s.values : [])
|
| 477 |
+
.filter((v) => Array.isArray(v) && (v as number[]).length >= 2)
|
| 478 |
+
.map((v) => (v as number[]).map((x) => toNum(x) ?? 0)),
|
| 479 |
+
symbolSize: (val: number[]) => Math.max(8, Math.sqrt(Math.abs(Number(val?.[2]) || 1)) * 5),
|
| 480 |
+
itemStyle: { color: PALETTE[i % PALETTE.length], opacity: 0.7 },
|
| 481 |
+
}))
|
| 482 |
+
return {
|
| 483 |
+
backgroundColor: 'transparent',
|
| 484 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 485 |
+
tooltip: { trigger: 'item' },
|
| 486 |
+
legend: eb.length > 1 ? { bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 487 |
+
grid: { left: 48, right: 24, top: 20, bottom: 40, containLabel: true },
|
| 488 |
+
xAxis: { type: 'value', name: chart?.x_label || '', nameLocation: 'middle', nameGap: 26, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 489 |
+
yAxis: { type: 'value', name: chart?.y_label || '', nameLocation: 'middle', nameGap: 36, splitLine: { lineStyle: { opacity: 0.2 } } },
|
| 490 |
+
series: eb,
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
if (k === 'rose') {
|
| 495 |
+
// Nightingale rose ("pizza") — pie with variable-radius slices.
|
| 496 |
+
const d = pieData(chart)
|
| 497 |
+
return {
|
| 498 |
+
animation: true,
|
| 499 |
+
backgroundColor: 'transparent',
|
| 500 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 501 |
+
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
| 502 |
+
legend: { bottom: 0, textStyle: { color: tc, fontSize: 10 } },
|
| 503 |
+
series: [
|
| 504 |
+
{
|
| 505 |
+
type: 'pie',
|
| 506 |
+
radius: ['18%', '72%'],
|
| 507 |
+
center: ['50%', '46%'],
|
| 508 |
+
roseType: 'area',
|
| 509 |
+
itemStyle: { borderRadius: 4 },
|
| 510 |
+
data: d.map((x, i) => ({ ...x, itemStyle: { color: PALETTE[i % PALETTE.length] } })),
|
| 511 |
+
label: { fontSize: 10, color: tc },
|
| 512 |
+
},
|
| 513 |
+
],
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
if (k === 'polar') {
|
| 518 |
+
// Polar bar — categorical bars wrapped around a circle.
|
| 519 |
+
const s = cartesianSeries(chart)
|
| 520 |
+
const cats = chart?.x_categories || s[0]?.points.map((_, i) => `#${i + 1}`) || []
|
| 521 |
+
return {
|
| 522 |
+
animation: true,
|
| 523 |
+
backgroundColor: 'transparent',
|
| 524 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 525 |
+
tooltip: { trigger: 'item' },
|
| 526 |
+
legend: s.length > 1 ? { bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 527 |
+
polar: { radius: ['12%', '72%'] },
|
| 528 |
+
angleAxis: { type: 'category', data: cats, axisLabel: { fontSize: 10 } },
|
| 529 |
+
radiusAxis: { axisLabel: { fontSize: 9 } },
|
| 530 |
+
series: s.map((ss, i) => ({
|
| 531 |
+
type: 'bar',
|
| 532 |
+
coordinateSystem: 'polar',
|
| 533 |
+
name: ss.name,
|
| 534 |
+
data: ss.points.map((p) => p[1]),
|
| 535 |
+
itemStyle: { color: ss.color || PALETTE[i % PALETTE.length] },
|
| 536 |
+
})),
|
| 537 |
+
} as EChartsOption
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
if (k === 'parallel') {
|
| 541 |
+
// Parallel coordinates — one polyline per series across the x_categories dimensions.
|
| 542 |
+
const s = cartesianSeries(chart)
|
| 543 |
+
const dims = chart?.x_categories || s[0]?.points.map((_, i) => `Dim ${i + 1}`) || []
|
| 544 |
+
return {
|
| 545 |
+
animation: true,
|
| 546 |
+
backgroundColor: 'transparent',
|
| 547 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 548 |
+
tooltip: {},
|
| 549 |
+
legend: s.length > 1 ? { bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 550 |
+
parallelAxis: dims.map((name, i) => ({ dim: i, name, nameTextStyle: { fontSize: 10 } })),
|
| 551 |
+
parallel: { left: 60, right: 40, top: 30, bottom: 40 },
|
| 552 |
+
series: s.map((ss, i) => ({
|
| 553 |
+
type: 'parallel',
|
| 554 |
+
name: ss.name,
|
| 555 |
+
lineStyle: { width: 2, opacity: 0.6, color: ss.color || PALETTE[i % PALETTE.length] },
|
| 556 |
+
data: [ss.points.map((p) => p[1] ?? 0)],
|
| 557 |
+
})),
|
| 558 |
+
} as EChartsOption
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
if (k === 'themeriver') {
|
| 562 |
+
// Streamgraph — series.values aligned to x_categories → [time, value, name] triples.
|
| 563 |
+
const cats = chart?.x_categories || []
|
| 564 |
+
const data: [string, number, string][] = []
|
| 565 |
+
for (const s of chart?.series || []) {
|
| 566 |
+
const name = s.name || 'series'
|
| 567 |
+
const vals = Array.isArray(s.values) ? s.values : []
|
| 568 |
+
vals.forEach((v, i) => {
|
| 569 |
+
const t = cats[i] ?? String(i)
|
| 570 |
+
data.push([t, toNum(Array.isArray(v) ? v[1] : v) ?? 0, name])
|
| 571 |
+
})
|
| 572 |
+
}
|
| 573 |
+
return {
|
| 574 |
+
backgroundColor: 'transparent',
|
| 575 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 576 |
+
tooltip: { trigger: 'axis', axisPointer: { type: 'line' } },
|
| 577 |
+
legend: { bottom: 0, textStyle: { color: tc, fontSize: 10 } },
|
| 578 |
+
singleAxis: { type: 'category', data: cats, top: 20, bottom: 56, axisLabel: { fontSize: 10 } },
|
| 579 |
+
series: [{ type: 'themeRiver', data, label: { show: false }, emphasis: { focus: 'self' } }],
|
| 580 |
+
} as EChartsOption
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
if (k === 'scatter3d' || k === 'bar3d' || k === 'line3d') {
|
| 584 |
+
const s3 = series3d(chart)
|
| 585 |
+
const glType = k === 'bar3d' ? 'bar3D' : k === 'line3d' ? 'line3D' : 'scatter3D'
|
| 586 |
+
return {
|
| 587 |
+
backgroundColor: 'transparent',
|
| 588 |
+
tooltip: {},
|
| 589 |
+
xAxis3D: { type: 'value', name: chart?.x_label || 'X', nameTextStyle: { color: tc } },
|
| 590 |
+
yAxis3D: { type: 'value', name: chart?.y_label || 'Y', nameTextStyle: { color: tc } },
|
| 591 |
+
zAxis3D: { type: 'value', name: 'Z', nameTextStyle: { color: tc } },
|
| 592 |
+
grid3D: {
|
| 593 |
+
boxWidth: 100,
|
| 594 |
+
boxDepth: 100,
|
| 595 |
+
axisLabel: { textStyle: { color: tc, fontSize: 9 } },
|
| 596 |
+
viewControl: { autoRotate: false, distance: 220 },
|
| 597 |
+
light: { main: { intensity: 1.2 }, ambient: { intensity: 0.3 } },
|
| 598 |
+
},
|
| 599 |
+
series: s3.map((ss, i) => ({
|
| 600 |
+
type: glType,
|
| 601 |
+
name: ss.name,
|
| 602 |
+
data: ss.data,
|
| 603 |
+
symbolSize: 10,
|
| 604 |
+
itemStyle: { color: ss.color || PALETTE[i % PALETTE.length], opacity: glType === 'scatter3D' ? 0.85 : 1 },
|
| 605 |
+
shading: glType === 'bar3D' ? 'lambert' : undefined,
|
| 606 |
+
lineStyle: glType === 'line3D' ? { width: 3 } : undefined,
|
| 607 |
+
})),
|
| 608 |
+
} as unknown as EChartsOption
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
if (k === 'tree' || k === 'mindmap' || k === 'org' || k === 'orgchart') {
|
| 612 |
+
const root = treeRoot(chart) || { name: '·' }
|
| 613 |
+
const radial = k === 'mindmap'
|
| 614 |
+
const topDown = k === 'org' || k === 'orgchart'
|
| 615 |
+
return {
|
| 616 |
+
backgroundColor: 'transparent',
|
| 617 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 618 |
+
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
|
| 619 |
+
series: [
|
| 620 |
+
{
|
| 621 |
+
type: 'tree',
|
| 622 |
+
data: [root],
|
| 623 |
+
layout: radial ? 'radial' : 'orthogonal',
|
| 624 |
+
orient: topDown ? 'TB' : 'LR',
|
| 625 |
+
roam: true,
|
| 626 |
+
symbol: 'circle',
|
| 627 |
+
symbolSize: 9,
|
| 628 |
+
itemStyle: { color: PALETTE[0] },
|
| 629 |
+
lineStyle: { color: dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.2)', width: 1.2, curveness: radial ? 0.3 : 0.5 },
|
| 630 |
+
label: {
|
| 631 |
+
position: radial ? 'right' : topDown ? 'top' : 'left',
|
| 632 |
+
verticalAlign: 'middle',
|
| 633 |
+
align: radial ? 'left' : topDown ? 'center' : 'right',
|
| 634 |
+
fontSize: 11,
|
| 635 |
+
color: tc,
|
| 636 |
+
},
|
| 637 |
+
leaves: { label: { position: topDown ? 'bottom' : 'right', align: topDown ? 'center' : 'left' } },
|
| 638 |
+
expandAndCollapse: true,
|
| 639 |
+
initialTreeDepth: -1,
|
| 640 |
+
animationDuration: 600,
|
| 641 |
+
},
|
| 642 |
+
],
|
| 643 |
+
} as EChartsOption
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
// cartesian family: line / bar / hbar / area / scatter / stacked / combo / histogram / timeseries
|
| 647 |
+
const series = cartesianSeries(chart)
|
| 648 |
+
const isHBar = k === 'hbar' || k === 'horizontal-bar' || k === 'horizontal_bar'
|
| 649 |
+
const isStacked = k === 'stacked' || k === 'stacked-bar' || k === 'stacked_bar'
|
| 650 |
+
const isCombo = k === 'combo'
|
| 651 |
+
const isBar = k === 'bar' || k === 'histogram' || isStacked || isHBar
|
| 652 |
+
const isScatter = k === 'scatter' || k === 'bubble'
|
| 653 |
+
const isArea = k === 'area' || k === 'timeseries'
|
| 654 |
+
|
| 655 |
+
const cats = chart?.x_categories
|
| 656 |
+
const useCat = Array.isArray(cats) && cats.length > 0
|
| 657 |
+
|
| 658 |
+
const eSeries = series.map((s, i) => {
|
| 659 |
+
const seriesType = (
|
| 660 |
+
isCombo ? (s.kind === 'line' ? 'line' : s.kind === 'bar' ? 'bar' : i === 0 ? 'bar' : 'line')
|
| 661 |
+
: isBar ? 'bar'
|
| 662 |
+
: isScatter ? 'scatter'
|
| 663 |
+
: 'line'
|
| 664 |
+
) as 'bar' | 'scatter' | 'line'
|
| 665 |
+
const asLine = seriesType === 'line'
|
| 666 |
+
return {
|
| 667 |
+
name: s.name,
|
| 668 |
+
type: seriesType,
|
| 669 |
+
data: useCat ? s.points.map((p) => p[1]) : s.points,
|
| 670 |
+
stack: isStacked ? 'total' : undefined,
|
| 671 |
+
smooth: asLine,
|
| 672 |
+
showSymbol: seriesType === 'scatter' || asLine,
|
| 673 |
+
symbolSize: seriesType === 'scatter' ? 10 : 6,
|
| 674 |
+
itemStyle: { color: s.color },
|
| 675 |
+
areaStyle: isArea && asLine ? { opacity: 0.18, color: s.color } : undefined,
|
| 676 |
+
lineStyle: asLine ? { width: 2 } : undefined,
|
| 677 |
+
emphasis: { focus: 'series' as const },
|
| 678 |
+
animationDuration: 900,
|
| 679 |
+
animationEasing: 'cubicOut' as const,
|
| 680 |
+
}
|
| 681 |
+
})
|
| 682 |
+
|
| 683 |
+
const legendNames = series.map((s) => s.name)
|
| 684 |
+
const valueAxis = {
|
| 685 |
+
type: 'value' as const,
|
| 686 |
+
name: (isHBar ? chart?.x_label : chart?.y_label) || '',
|
| 687 |
+
nameLocation: 'middle' as const,
|
| 688 |
+
nameGap: 36,
|
| 689 |
+
splitLine: { show: true, lineStyle: { opacity: 0.2 } },
|
| 690 |
+
}
|
| 691 |
+
const catAxis = useCat
|
| 692 |
+
? {
|
| 693 |
+
type: 'category' as const,
|
| 694 |
+
data: cats,
|
| 695 |
+
name: (isHBar ? chart?.y_label : chart?.x_label) || '',
|
| 696 |
+
nameLocation: 'middle' as const,
|
| 697 |
+
nameGap: 30,
|
| 698 |
+
axisLabel: { fontSize: 10, rotate: !isHBar && (cats as string[]).length > 6 ? 30 : 0 },
|
| 699 |
+
}
|
| 700 |
+
: {
|
| 701 |
+
type: 'value' as const,
|
| 702 |
+
name: (isHBar ? chart?.y_label : chart?.x_label) || '',
|
| 703 |
+
nameLocation: 'middle' as const,
|
| 704 |
+
nameGap: 28,
|
| 705 |
+
splitLine: { show: true, lineStyle: { opacity: 0.2 } },
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
// Interactive timeseries: zoom/pan for time-oriented line/area, especially with many points.
|
| 709 |
+
const wantsZoom =
|
| 710 |
+
!isHBar &&
|
| 711 |
+
!isScatter &&
|
| 712 |
+
(k === 'timeseries' || ((k === 'line' || k === 'area') && useCat && (cats as string[]).length > 10))
|
| 713 |
+
|
| 714 |
+
return {
|
| 715 |
+
animation: true,
|
| 716 |
+
animationDuration: 1100,
|
| 717 |
+
backgroundColor: 'transparent',
|
| 718 |
+
textStyle: { color: tc, fontSize: 11 },
|
| 719 |
+
grid: { left: 48, right: 24, top: 36, bottom: wantsZoom ? 64 : 40, containLabel: true },
|
| 720 |
+
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
| 721 |
+
legend: legendNames.length > 1 ? { data: legendNames, bottom: 0, textStyle: { color: tc, fontSize: 10 } } : undefined,
|
| 722 |
+
dataZoom: wantsZoom ? [{ type: 'inside' }, { type: 'slider', height: 16, bottom: 8 }] : undefined,
|
| 723 |
+
xAxis: isHBar ? valueAxis : catAxis,
|
| 724 |
+
yAxis: isHBar ? catAxis : valueAxis,
|
| 725 |
+
series: eSeries,
|
| 726 |
+
}
|
| 727 |
+
}
|
vivek/frontend-vue/src/lib/exportWidgetHtml.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Deterministic widget → standalone HTML exporter.
|
| 3 |
+
*
|
| 4 |
+
* NO LLM involved. Given the same widget JSON the renderer uses, this emits a
|
| 5 |
+
* self-contained .html file:
|
| 6 |
+
* - text/kpi_row/table/stat_card/progress/badge_row/image/action_row → plain HTML + inline CSS
|
| 7 |
+
* - chart → a <div> + the ECharts `option` (built by the SHARED buildEChartsOption)
|
| 8 |
+
* initialized via the ECharts CDN, so charts stay fully interactive offline.
|
| 9 |
+
*
|
| 10 |
+
* Functions in the option (custom formatters) are dropped during serialization;
|
| 11 |
+
* ECharts falls back to sensible defaults, keeping the export robust.
|
| 12 |
+
*/
|
| 13 |
+
import { buildEChartsOption, isGlKind, type ChartSpec } from '@/lib/echartsOption'
|
| 14 |
+
|
| 15 |
+
const ECHARTS_CDN = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js'
|
| 16 |
+
const ECHARTS_GL_CDN = 'https://cdn.jsdelivr.net/npm/echarts-gl@2/dist/echarts-gl.min.js'
|
| 17 |
+
|
| 18 |
+
type Block = Record<string, unknown> & { type?: string }
|
| 19 |
+
|
| 20 |
+
function esc(v: unknown): string {
|
| 21 |
+
return String(v ?? '')
|
| 22 |
+
.replace(/&/g, '&')
|
| 23 |
+
.replace(/</g, '<')
|
| 24 |
+
.replace(/>/g, '>')
|
| 25 |
+
.replace(/"/g, '"')
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/** Serialize an ECharts option to a JS literal, dropping function values. */
|
| 29 |
+
function serializeOption(option: unknown): string {
|
| 30 |
+
return JSON.stringify(option, (_k, v) => (typeof v === 'function' ? undefined : v))
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function parseLayout(raw: string): Block[] {
|
| 34 |
+
let s = String(raw || '').trim()
|
| 35 |
+
if (!s) return []
|
| 36 |
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
| 37 |
+
if (fence) s = fence[1].trim()
|
| 38 |
+
let parsed: unknown
|
| 39 |
+
try {
|
| 40 |
+
parsed = JSON.parse(s)
|
| 41 |
+
} catch {
|
| 42 |
+
const i = s.indexOf('{')
|
| 43 |
+
const j = s.lastIndexOf('}')
|
| 44 |
+
if (i < 0 || j <= i) return []
|
| 45 |
+
try {
|
| 46 |
+
parsed = JSON.parse(s.slice(i, j + 1))
|
| 47 |
+
} catch {
|
| 48 |
+
return []
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
let layout: unknown
|
| 52 |
+
if (Array.isArray(parsed)) layout = parsed
|
| 53 |
+
else if (parsed && typeof parsed === 'object') {
|
| 54 |
+
const o = parsed as Record<string, unknown>
|
| 55 |
+
layout = o.layout ?? o.blocks ?? o.components
|
| 56 |
+
if (!Array.isArray(layout) && typeof o.type === 'string') layout = [o]
|
| 57 |
+
}
|
| 58 |
+
return Array.isArray(layout) ? (layout as Block[]) : []
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const TONE_HEX: Record<string, string> = {
|
| 62 |
+
positive: '#16a34a',
|
| 63 |
+
success: '#16a34a',
|
| 64 |
+
negative: '#dc2626',
|
| 65 |
+
danger: '#dc2626',
|
| 66 |
+
warning: '#d97706',
|
| 67 |
+
info: '#2563eb',
|
| 68 |
+
neutral: '#475569',
|
| 69 |
+
default: '#475569',
|
| 70 |
+
}
|
| 71 |
+
const BAR_HEX: Record<string, string> = {
|
| 72 |
+
emerald: '#10b981',
|
| 73 |
+
amber: '#f59e0b',
|
| 74 |
+
red: '#ef4444',
|
| 75 |
+
cyan: '#06b6d4',
|
| 76 |
+
indigo: '#6366f1',
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
type ChartEntry = { id: string; option: string; gl: boolean }
|
| 80 |
+
|
| 81 |
+
/** Render one block to HTML. Charts push their option into `charts` and return a placeholder div. */
|
| 82 |
+
function renderBlock(block: Block, idx: number, charts: ChartEntry[]): string {
|
| 83 |
+
const type = String(block.type || '').toLowerCase()
|
| 84 |
+
|
| 85 |
+
if (type === 'text') {
|
| 86 |
+
return `<p class="ws-text">${esc((block as { content?: string }).content)}</p>`
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
if (type === 'kpi_row') {
|
| 90 |
+
const items = (block.items as { label?: string; value?: string; tone?: string }[]) || []
|
| 91 |
+
const cells = items
|
| 92 |
+
.map(
|
| 93 |
+
(it) =>
|
| 94 |
+
`<div class="ws-kpi"><div class="ws-kpi-val" style="color:${TONE_HEX[it.tone || 'neutral'] || '#111'}">${esc(it.value)}</div><div class="ws-kpi-lbl">${esc(it.label)}</div></div>`,
|
| 95 |
+
)
|
| 96 |
+
.join('')
|
| 97 |
+
return `<div class="ws-kpi-row">${cells}</div>`
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (type === 'stat_card') {
|
| 101 |
+
const items = (block.items as { label?: string; value?: string; subtext?: string; tone?: string }[]) || []
|
| 102 |
+
const cells = items
|
| 103 |
+
.map(
|
| 104 |
+
(it) =>
|
| 105 |
+
`<div class="ws-stat"><div class="ws-stat-lbl">${esc(it.label)}</div><div class="ws-stat-val" style="color:${TONE_HEX[it.tone || 'default'] || '#111'}">${esc(it.value)}</div>${it.subtext ? `<div class="ws-stat-sub">${esc(it.subtext)}</div>` : ''}</div>`,
|
| 106 |
+
)
|
| 107 |
+
.join('')
|
| 108 |
+
return `<div class="ws-stat-grid">${cells}</div>`
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (type === 'progress') {
|
| 112 |
+
const items = (block.items as { label?: string; value?: number; max?: number; tone?: string }[]) || []
|
| 113 |
+
const rows = items
|
| 114 |
+
.map((it) => {
|
| 115 |
+
const max = Number(it.max) || 100
|
| 116 |
+
const pct = Math.max(0, Math.min(100, ((Number(it.value) || 0) / max) * 100))
|
| 117 |
+
const color = BAR_HEX[it.tone || 'indigo'] || '#6366f1'
|
| 118 |
+
const showPct = !it.max || it.max === 100
|
| 119 |
+
return `<div class="ws-prog"><div class="ws-prog-head"><span>${esc(it.label)}</span><span>${esc(it.value)}${showPct ? '%' : ''}</span></div><div class="ws-prog-track"><div class="ws-prog-fill" style="width:${pct}%;background:${color}"></div></div></div>`
|
| 120 |
+
})
|
| 121 |
+
.join('')
|
| 122 |
+
return `<div class="ws-prog-list">${rows}</div>`
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if (type === 'badge_row') {
|
| 126 |
+
const items = (block.items as { label?: string; tone?: string }[]) || []
|
| 127 |
+
const pills = items
|
| 128 |
+
.map((it) => {
|
| 129 |
+
const c = TONE_HEX[it.tone || 'default'] || '#475569'
|
| 130 |
+
return `<span class="ws-badge" style="color:${c};border-color:${c}33;background:${c}14">${esc(it.label)}</span>`
|
| 131 |
+
})
|
| 132 |
+
.join('')
|
| 133 |
+
return `<div class="ws-badge-row">${pills}</div>`
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (type === 'table') {
|
| 137 |
+
const cols = (block.columns as unknown[]) || []
|
| 138 |
+
const rows = (block.rows as unknown[][]) || []
|
| 139 |
+
const head = `<tr>${cols.map((c) => `<th>${esc(c)}</th>`).join('')}</tr>`
|
| 140 |
+
const body = rows.map((r) => `<tr>${(Array.isArray(r) ? r : [r]).map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`).join('')
|
| 141 |
+
const title = (block as { title?: string }).title
|
| 142 |
+
return `${title ? `<div class="ws-h">${esc(title)}</div>` : ''}<table class="ws-table"><thead>${head}</thead><tbody>${body}</tbody></table>`
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (type === 'action_row') {
|
| 146 |
+
const buttons = (block.buttons as { label?: string }[]) || []
|
| 147 |
+
const btns = buttons.map((b) => `<button class="ws-action" type="button" disabled>${esc(b.label)}</button>`).join('')
|
| 148 |
+
return `<div class="ws-action-row">${btns}</div>`
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if (type === 'image') {
|
| 152 |
+
const b = block as { src?: string; alt?: string; caption?: string; fit?: string }
|
| 153 |
+
if (!b.src) return ''
|
| 154 |
+
const fit = b.fit === 'cover' ? 'cover' : 'contain'
|
| 155 |
+
return `<figure class="ws-fig"><img src="${esc(b.src)}" alt="${esc(b.alt)}" style="object-fit:${fit}" />${b.caption ? `<figcaption>${esc(b.caption)}</figcaption>` : ''}</figure>`
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if (type === 'chart') {
|
| 159 |
+
const chart = (block.chart as ChartSpec) || {}
|
| 160 |
+
const title = (block as { title?: string }).title || ''
|
| 161 |
+
const id = `ws-chart-${idx}`
|
| 162 |
+
const gl = isGlKind(chart.kind)
|
| 163 |
+
try {
|
| 164 |
+
const option = buildEChartsOption(chart, title, { dark: false })
|
| 165 |
+
charts.push({ id, option: serializeOption(option), gl })
|
| 166 |
+
const cls = gl ? 'ws-chart ws-chart-3d' : 'ws-chart'
|
| 167 |
+
return `<div class="ws-chart-wrap">${title ? `<div class="ws-h">${esc(title)}</div>` : ''}<div id="${id}" class="${cls}"></div></div>`
|
| 168 |
+
} catch {
|
| 169 |
+
return `<div class="ws-note">Chart could not be exported.</div>`
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Unknown block type — show nothing (kept consistent with the renderer's fail-safe).
|
| 174 |
+
return ''
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const STYLES = `
|
| 178 |
+
*{box-sizing:border-box}
|
| 179 |
+
body{margin:0;background:#f7f8fa;color:#111318;font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:24px}
|
| 180 |
+
.ws-doc{max-width:880px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
|
| 181 |
+
.ws-card{background:#fff;border:1px solid #e6e8ec;border-radius:14px;padding:16px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
| 182 |
+
.ws-text{margin:0;color:#2b2f38}
|
| 183 |
+
.ws-h{font-weight:600;font-size:13px;margin-bottom:8px}
|
| 184 |
+
.ws-kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px}
|
| 185 |
+
.ws-kpi{border:1px solid #eef0f3;border-radius:10px;padding:10px 12px}
|
| 186 |
+
.ws-kpi-val{font-size:20px;font-weight:700}
|
| 187 |
+
.ws-kpi-lbl{font-size:12px;color:#6b7280;margin-top:2px}
|
| 188 |
+
.ws-stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
|
| 189 |
+
.ws-stat{border:1px solid #eef0f3;border-radius:12px;padding:14px}
|
| 190 |
+
.ws-stat-lbl{font-size:12px;color:#6b7280}
|
| 191 |
+
.ws-stat-val{font-size:24px;font-weight:700;margin-top:4px}
|
| 192 |
+
.ws-stat-sub{font-size:12px;color:#9aa1ad;margin-top:2px}
|
| 193 |
+
.ws-prog-list{display:flex;flex-direction:column;gap:12px}
|
| 194 |
+
.ws-prog-head{display:flex;justify-content:space-between;font-size:12px;color:#6b7280;margin-bottom:4px}
|
| 195 |
+
.ws-prog-track{height:8px;background:#eef0f3;border-radius:99px;overflow:hidden}
|
| 196 |
+
.ws-prog-fill{height:100%;border-radius:99px}
|
| 197 |
+
.ws-badge-row{display:flex;flex-wrap:wrap;gap:8px}
|
| 198 |
+
.ws-badge{font-size:12px;font-weight:500;padding:3px 10px;border-radius:99px;border:1px solid}
|
| 199 |
+
.ws-table{width:100%;border-collapse:collapse;font-size:13px}
|
| 200 |
+
.ws-table th,.ws-table td{text-align:left;padding:8px 10px;border-bottom:1px solid #eef0f3}
|
| 201 |
+
.ws-table th{color:#6b7280;font-weight:600;background:#fafbfc}
|
| 202 |
+
.ws-action-row{display:flex;flex-wrap:wrap;gap:8px}
|
| 203 |
+
.ws-action{font-size:13px;padding:6px 12px;border:1px solid #d7dae0;border-radius:8px;background:#fff;color:#475569}
|
| 204 |
+
.ws-fig{margin:0}
|
| 205 |
+
.ws-fig img{width:100%;max-height:420px;border-radius:10px}
|
| 206 |
+
.ws-fig figcaption{font-size:12px;color:#9aa1ad;margin-top:6px;text-align:center}
|
| 207 |
+
.ws-chart{width:100%;height:380px}
|
| 208 |
+
.ws-chart-3d{height:460px}
|
| 209 |
+
.ws-note{font-size:12px;color:#9aa1ad}
|
| 210 |
+
.ws-footer{text-align:center;font-size:11px;color:#b3b9c4;margin-top:6px}
|
| 211 |
+
`
|
| 212 |
+
|
| 213 |
+
/** Build a complete, self-contained, interactive HTML document from widget JSON. */
|
| 214 |
+
export function widgetToHtml(jsonStr: string, title = 'Widget'): string {
|
| 215 |
+
const blocks = parseLayout(jsonStr)
|
| 216 |
+
const charts: ChartEntry[] = []
|
| 217 |
+
const body = blocks
|
| 218 |
+
.map((b, i) => {
|
| 219 |
+
const inner = renderBlock(b, i, charts)
|
| 220 |
+
return inner ? `<section class="ws-card">${inner}</section>` : ''
|
| 221 |
+
})
|
| 222 |
+
.filter(Boolean)
|
| 223 |
+
.join('\n')
|
| 224 |
+
|
| 225 |
+
const needsGl = charts.some((c) => c.gl)
|
| 226 |
+
const initScript = charts.length
|
| 227 |
+
? `<script src="${ECHARTS_CDN}"></script>
|
| 228 |
+
${needsGl ? `<script src="${ECHARTS_GL_CDN}"></script>` : ''}
|
| 229 |
+
<script>
|
| 230 |
+
(function(){
|
| 231 |
+
function init(){
|
| 232 |
+
if(!window.echarts){return;}
|
| 233 |
+
var defs=${JSON.stringify(charts.map((c) => c.id))};
|
| 234 |
+
var opts=[${charts.map((c) => c.option).join(',')}];
|
| 235 |
+
defs.forEach(function(id,i){
|
| 236 |
+
var el=document.getElementById(id);
|
| 237 |
+
if(!el)return;
|
| 238 |
+
var chart=echarts.init(el);
|
| 239 |
+
chart.setOption(opts[i]);
|
| 240 |
+
window.addEventListener('resize',function(){chart.resize();});
|
| 241 |
+
});
|
| 242 |
+
}
|
| 243 |
+
if(document.readyState!=='loading')init();else document.addEventListener('DOMContentLoaded',init);
|
| 244 |
+
})();
|
| 245 |
+
</script>`
|
| 246 |
+
: ''
|
| 247 |
+
|
| 248 |
+
return `<!doctype html>
|
| 249 |
+
<html lang="en">
|
| 250 |
+
<head>
|
| 251 |
+
<meta charset="utf-8" />
|
| 252 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 253 |
+
<title>${esc(title)}</title>
|
| 254 |
+
<style>${STYLES}</style>
|
| 255 |
+
</head>
|
| 256 |
+
<body>
|
| 257 |
+
<div class="ws-doc">
|
| 258 |
+
${body}
|
| 259 |
+
<div class="ws-footer">Exported widget · interactive charts via ECharts</div>
|
| 260 |
+
</div>
|
| 261 |
+
${initScript}
|
| 262 |
+
</body>
|
| 263 |
+
</html>`
|
| 264 |
+
}
|
vivek/frontend-vue/src/lib/progressiveWidget.ts
CHANGED
|
@@ -242,28 +242,3 @@ function extractBlocksFromIndex(cleaned: string, idx: number): ProgressiveSchema
|
|
| 242 |
totalSoFar: blocks.length + (pendingType ? 1 : 0),
|
| 243 |
}
|
| 244 |
}
|
| 245 |
-
|
| 246 |
-
/**
|
| 247 |
-
* For HTML-mode streaming, rebuild the iframe srcdoc periodically. We ensure
|
| 248 |
-
* unclosed tags get a best-effort close so the in-progress paint doesn't show
|
| 249 |
-
* broken markup.
|
| 250 |
-
*/
|
| 251 |
-
export function buildProgressiveHtmlDoc(raw: string): string {
|
| 252 |
-
if (!raw || !raw.trim()) return ''
|
| 253 |
-
let s = raw
|
| 254 |
-
const fenceOpen = s.match(/```(?:json|html)?\s*([\s\S]*?)(?:```|$)/i)
|
| 255 |
-
if (fenceOpen && fenceOpen[1]) s = fenceOpen[1]
|
| 256 |
-
s = s.replace(/```[\w-]*$/, '').trim()
|
| 257 |
-
if (!s) return ''
|
| 258 |
-
|
| 259 |
-
const lower = s.toLowerCase()
|
| 260 |
-
if (!lower.includes('<html')) {
|
| 261 |
-
// Wrap partial body into a minimal doc so iframes don't show default pages.
|
| 262 |
-
s = `<html><head><meta charset="utf-8"></head><body>${s}</body></html>`
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
// Best-effort close for common dangling tags.
|
| 266 |
-
if (lower.includes('<body') && !lower.includes('</body>')) s += '</body>'
|
| 267 |
-
if (lower.includes('<html') && !lower.includes('</html>')) s += '</html>'
|
| 268 |
-
return s
|
| 269 |
-
}
|
|
|
|
| 242 |
totalSoFar: blocks.length + (pendingType ? 1 : 0),
|
| 243 |
}
|
| 244 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vivek/frontend-vue/src/pages/Analytics.vue
CHANGED
|
@@ -69,8 +69,8 @@ const metrics = computed(() => {
|
|
| 69 |
const avgLatency = latencyVals.length ? latencyVals.reduce((a, b) => a + b, 0) / latencyVals.length : null
|
| 70 |
const positives = rewards.filter((r) => r.reward >= 1).length
|
| 71 |
const rewardRate = rewards.length ? positives / rewards.length : null
|
| 72 |
-
const widgetRate = done.length ? done.filter((d) => (d.
|
| 73 |
-
const withWidget = done.filter((d) => (d.
|
| 74 |
|
| 75 |
return {
|
| 76 |
totalResponses: done.length,
|
|
@@ -132,7 +132,7 @@ const previousMetrics = computed(() => {
|
|
| 132 |
.filter((x): x is number => x != null)
|
| 133 |
const avgLatency = latVals.length ? latVals.reduce((a, b) => a + b, 0) / latVals.length : null
|
| 134 |
const rewardRate = rewards.length ? rewards.filter((r) => r.reward >= 1).length / rewards.length : null
|
| 135 |
-
const widgetRate = done.length ? done.filter((d) => (d.
|
| 136 |
return {
|
| 137 |
totalResponses: done.length,
|
| 138 |
totalRewards: rewards.length,
|
|
@@ -462,7 +462,7 @@ function updateCharts() {
|
|
| 462 |
true,
|
| 463 |
)
|
| 464 |
|
| 465 |
-
const withWidget = done.filter((d) => (d.
|
| 466 |
const withoutWidget = done.length - withWidget
|
| 467 |
const jsonSchemaCount = 0
|
| 468 |
widgetSplitChart.setOption(
|
|
|
|
| 69 |
const avgLatency = latencyVals.length ? latencyVals.reduce((a, b) => a + b, 0) / latencyVals.length : null
|
| 70 |
const positives = rewards.filter((r) => r.reward >= 1).length
|
| 71 |
const rewardRate = rewards.length ? positives / rewards.length : null
|
| 72 |
+
const widgetRate = done.length ? done.filter((d) => (d.widgetSchema ?? '').trim().length > 0).length / done.length : 0
|
| 73 |
+
const withWidget = done.filter((d) => (d.widgetSchema ?? '').trim().length > 0).length
|
| 74 |
|
| 75 |
return {
|
| 76 |
totalResponses: done.length,
|
|
|
|
| 132 |
.filter((x): x is number => x != null)
|
| 133 |
const avgLatency = latVals.length ? latVals.reduce((a, b) => a + b, 0) / latVals.length : null
|
| 134 |
const rewardRate = rewards.length ? rewards.filter((r) => r.reward >= 1).length / rewards.length : null
|
| 135 |
+
const widgetRate = done.length ? done.filter((d) => (d.widgetSchema ?? '').trim().length > 0).length / done.length : null
|
| 136 |
return {
|
| 137 |
totalResponses: done.length,
|
| 138 |
totalRewards: rewards.length,
|
|
|
|
| 462 |
true,
|
| 463 |
)
|
| 464 |
|
| 465 |
+
const withWidget = done.filter((d) => (d.widgetSchema ?? '').trim().length > 0).length
|
| 466 |
const withoutWidget = done.length - withWidget
|
| 467 |
const jsonSchemaCount = 0
|
| 468 |
widgetSplitChart.setOption(
|
vivek/frontend-vue/src/pages/Chat.vue
CHANGED
|
@@ -8,8 +8,6 @@ import Button from '@/components/ui/Button.vue'
|
|
| 8 |
import PreferenceModal from '@/components/PreferenceModal.vue'
|
| 9 |
import TechPanels from '@/components/TechPanels.vue'
|
| 10 |
import WidgetSchemaRenderer from '@/components/WidgetRegistryRenderer.vue'
|
| 11 |
-
import LiveWidgetSchema from '@/components/LiveWidgetSchema.vue'
|
| 12 |
-
import LiveWidgetFrame from '@/components/LiveWidgetFrame.vue'
|
| 13 |
import { getAccessToken, clearAccessToken } from '@/lib/auth'
|
| 14 |
import { ingestDone, ingestReward } from '@/lib/analyticsStore'
|
| 15 |
import {
|
|
@@ -86,43 +84,10 @@ function schemaIsOnlyNumericTupleText(wSch: string): boolean {
|
|
| 86 |
}
|
| 87 |
}
|
| 88 |
|
| 89 |
-
|
| 90 |
-
const low = s.toLowerCase()
|
| 91 |
-
if (low.includes('<!doctype') || low.includes('<html')) return true
|
| 92 |
-
return (
|
| 93 |
-
low.includes('<div') &&
|
| 94 |
-
(low.includes('<script') || low.includes('onclick=') || low.includes('<button'))
|
| 95 |
-
)
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
function extractHtmlDocumentFromStream(s: string): string {
|
| 99 |
-
const low = s.toLowerCase()
|
| 100 |
-
const iDoc = low.indexOf('<!doctype')
|
| 101 |
-
const iHtml = low.indexOf('<html')
|
| 102 |
-
let i = iDoc >= 0 ? iDoc : iHtml
|
| 103 |
-
if (i < 0) {
|
| 104 |
-
const div = low.indexOf('<div')
|
| 105 |
-
if (div < 0) return ''
|
| 106 |
-
i = div
|
| 107 |
-
}
|
| 108 |
-
const end = low.lastIndexOf('</html>')
|
| 109 |
-
if (end >= i && end >= 0) return s.slice(i, end + '</html>'.length)
|
| 110 |
-
return s.slice(i)
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
/** Drop useless index-array "widgets"; pull real HTML from the raw stream if the model mixed outputs. */
|
| 114 |
function recoverWidgetFromStreamIfDegenerate(cur: ChatMessage) {
|
| 115 |
-
const stream = String(cur.widgetStream || '').trim()
|
| 116 |
const sch = String(cur.widgetSchema || '').trim()
|
| 117 |
-
if (
|
| 118 |
-
cur.widgetSchema = ''
|
| 119 |
-
if (String(cur.widgetHtml || '').trim()) return
|
| 120 |
-
if (!stream || !streamLooksLikeSubstantialHtml(stream)) return
|
| 121 |
-
const html = extractHtmlDocumentFromStream(stream).trim()
|
| 122 |
-
if (html) {
|
| 123 |
-
cur.widgetHtml = html
|
| 124 |
-
cur.widgetMode = 'html'
|
| 125 |
-
}
|
| 126 |
}
|
| 127 |
|
| 128 |
const isStreaming = ref(false)
|
|
@@ -136,12 +101,11 @@ type ChatMessage = {
|
|
| 136 |
content: string
|
| 137 |
strategy?: string
|
| 138 |
xVec?: number[]
|
| 139 |
-
widgetHtml?: string
|
| 140 |
widgetSchema?: string
|
| 141 |
widgetHeight?: number
|
| 142 |
widgetStream?: string
|
| 143 |
widgetStreaming?: boolean
|
| 144 |
-
widgetMode?: 'json' | '
|
| 145 |
rewardUsedUp?: boolean
|
| 146 |
rewardUsedDown?: boolean
|
| 147 |
}
|
|
@@ -151,7 +115,6 @@ type ChatApiSuccess = {
|
|
| 151 |
response?: string
|
| 152 |
strategy?: string
|
| 153 |
x_vec?: unknown[]
|
| 154 |
-
widget_html?: string
|
| 155 |
widget_schema?: string
|
| 156 |
widget_height?: number
|
| 157 |
instruction?: string
|
|
@@ -429,7 +392,6 @@ async function runChatFallback(text: string, idx: number): Promise<boolean> {
|
|
| 429 |
)
|
| 430 |
cur.strategy = typeof payload.strategy === 'string' ? payload.strategy : String(payload.strategy ?? '')
|
| 431 |
cur.xVec = Array.isArray(payload.x_vec) ? payload.x_vec.map((n: unknown) => Number(n)) : []
|
| 432 |
-
cur.widgetHtml = typeof payload.widget_html === 'string' ? payload.widget_html : ''
|
| 433 |
cur.widgetSchema = typeof payload.widget_schema === 'string' ? payload.widget_schema : ''
|
| 434 |
if (schemaIsOnlyNumericTupleText(cur.widgetSchema)) cur.widgetSchema = ''
|
| 435 |
cur.widgetHeight = payload.widget_height ? Number(payload.widget_height) : 420
|
|
@@ -466,7 +428,7 @@ async function runChatFallback(text: string, idx: number): Promise<boolean> {
|
|
| 466 |
ingestDone({
|
| 467 |
strategy: payload.strategy ?? 'unknown',
|
| 468 |
elapsed: payload.elapsed,
|
| 469 |
-
|
| 470 |
})
|
| 471 |
return true
|
| 472 |
}
|
|
@@ -524,8 +486,7 @@ function handleSseEvent(evt: Record<string, unknown>, idx: number) {
|
|
| 524 |
cur.widgetStreaming = true
|
| 525 |
if (!cur.widgetMode) {
|
| 526 |
const preview = cur.widgetStream.slice(0, 400).trim().toLowerCase()
|
| 527 |
-
if (preview.startsWith('
|
| 528 |
-
else if (preview.startsWith('{') || preview.startsWith('[') || preview.startsWith('```json')) cur.widgetMode = 'json'
|
| 529 |
}
|
| 530 |
requestAdaptiveAutoScroll()
|
| 531 |
}
|
|
@@ -539,7 +500,6 @@ function handleSseEvent(evt: Record<string, unknown>, idx: number) {
|
|
| 539 |
const e = evt as {
|
| 540 |
response?: string
|
| 541 |
strategy?: string
|
| 542 |
-
widget_html?: string
|
| 543 |
widget_schema?: string
|
| 544 |
widget_height?: number
|
| 545 |
x_vec?: number[]
|
|
@@ -555,32 +515,20 @@ function handleSseEvent(evt: Record<string, unknown>, idx: number) {
|
|
| 555 |
}
|
| 556 |
cur.content = cleanAssistantText(e.response ?? cur.content)
|
| 557 |
cur.strategy = e.strategy ?? cur.strategy
|
| 558 |
-
|
| 559 |
-
const wSch = typeof e.widget_schema === 'string' ? e.widget_schema.trim() : ''
|
| 560 |
-
cur.widgetHtml = wHtml
|
| 561 |
-
cur.widgetSchema = wSch
|
| 562 |
cur.widgetHeight = e.widget_height ? Number(e.widget_height) : 420
|
| 563 |
cur.widgetStreaming = false
|
| 564 |
|
| 565 |
recoverWidgetFromStreamIfDegenerate(cur)
|
| 566 |
|
| 567 |
-
// If the server
|
| 568 |
-
//
|
| 569 |
-
//
|
| 570 |
-
// never mounts — looks like the widget "disappeared" after completion.
|
| 571 |
const stream = String(cur.widgetStream || '').trim()
|
| 572 |
-
if (!cur.
|
| 573 |
-
|
| 574 |
-
if (!mode) {
|
| 575 |
-
const preview = stream.slice(0, 400).trim().toLowerCase()
|
| 576 |
-
if (preview.startsWith('<') || preview.startsWith('<!doctype')) mode = 'html'
|
| 577 |
-
else if (preview.startsWith('{') || preview.startsWith('[') || preview.startsWith('```json')) mode = 'json'
|
| 578 |
-
}
|
| 579 |
-
if (mode === 'html') cur.widgetHtml = stream
|
| 580 |
-
else if (!schemaIsOnlyNumericTupleText(stream)) cur.widgetSchema = stream
|
| 581 |
-
if (mode && (cur.widgetHtml || cur.widgetSchema)) cur.widgetMode = mode as 'json' | 'html' | ''
|
| 582 |
}
|
| 583 |
-
|
| 584 |
cur.xVec = Array.isArray(e.x_vec) ? e.x_vec.map((n) => Number(n)) : []
|
| 585 |
cur.rewardUsedUp = false
|
| 586 |
cur.rewardUsedDown = false
|
|
@@ -618,11 +566,19 @@ function handleSseEvent(evt: Record<string, unknown>, idx: number) {
|
|
| 618 |
ingestDone({
|
| 619 |
strategy: e.strategy ?? cur.strategy ?? 'unknown',
|
| 620 |
elapsed: e.elapsed,
|
| 621 |
-
|
| 622 |
})
|
| 623 |
}
|
| 624 |
}
|
| 625 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
async function onSend() {
|
| 627 |
const token = getAccessToken()
|
| 628 |
if (!token) {
|
|
@@ -721,22 +677,13 @@ async function onSend() {
|
|
| 721 |
if (evt.type === 'done') {
|
| 722 |
finalized = true
|
| 723 |
const cur = messages.value[idx]
|
| 724 |
-
// Redundant safety: promote streamed
|
| 725 |
-
const wHtml = typeof (evt as { widget_html?: string }).widget_html === 'string' ? (evt as { widget_html: string }).widget_html.trim() : ''
|
| 726 |
const wSch = typeof (evt as { widget_schema?: string }).widget_schema === 'string' ? (evt as { widget_schema: string }).widget_schema.trim() : ''
|
| 727 |
const stream = String(cur.widgetStream || '').trim()
|
| 728 |
-
if (!
|
| 729 |
-
|
| 730 |
-
if (!mode) {
|
| 731 |
-
const preview = stream.slice(0, 400).trim().toLowerCase()
|
| 732 |
-
if (preview.startsWith('<') || preview.startsWith('<!doctype')) mode = 'html'
|
| 733 |
-
else if (preview.startsWith('{') || preview.startsWith('[') || preview.startsWith('```json')) mode = 'json'
|
| 734 |
-
}
|
| 735 |
-
if (mode === 'html') cur.widgetHtml = stream
|
| 736 |
-
else if (!schemaIsOnlyNumericTupleText(stream)) cur.widgetSchema = stream
|
| 737 |
-
if (mode && (cur.widgetHtml || cur.widgetSchema)) cur.widgetMode = mode as 'json' | 'html' | ''
|
| 738 |
}
|
| 739 |
-
|
| 740 |
recoverWidgetFromStreamIfDegenerate(cur)
|
| 741 |
}
|
| 742 |
}
|
|
@@ -773,13 +720,11 @@ async function onSend() {
|
|
| 773 |
type HistoryPair = {
|
| 774 |
user: string
|
| 775 |
assistant: string
|
| 776 |
-
widget_html?: string
|
| 777 |
widget_schema?: string
|
| 778 |
widget_height?: number
|
| 779 |
}
|
| 780 |
|
| 781 |
function assistantFromHistoryPair(p: HistoryPair): ChatMessage {
|
| 782 |
-
const wHtml = String(p.widget_html ?? '').trim()
|
| 783 |
const wSch = String(p.widget_schema ?? '').trim()
|
| 784 |
const wh = Number(p.widget_height) || 420
|
| 785 |
const base: ChatMessage = {
|
|
@@ -787,10 +732,7 @@ function assistantFromHistoryPair(p: HistoryPair): ChatMessage {
|
|
| 787 |
content: cleanAssistantText(p.assistant),
|
| 788 |
widgetHeight: wh,
|
| 789 |
}
|
| 790 |
-
if (
|
| 791 |
-
base.widgetHtml = wHtml
|
| 792 |
-
base.widgetMode = 'html'
|
| 793 |
-
} else if (wSch && !schemaIsOnlyNumericTupleText(wSch)) {
|
| 794 |
base.widgetSchema = wSch
|
| 795 |
base.widgetMode = 'json'
|
| 796 |
}
|
|
@@ -902,10 +844,59 @@ watch(isStreaming, (v) => {
|
|
| 902 |
else killAnimationsOf(streamPulseEl.value)
|
| 903 |
})
|
| 904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
function cleanAssistantText(raw: string | undefined | null) {
|
| 906 |
const s = String(raw ?? '')
|
| 907 |
const noWidgetBlock = s.replace(/<WIDGET>[\s\S]*?<\/WIDGET>/gi, '')
|
| 908 |
-
|
|
|
|
| 909 |
}
|
| 910 |
|
| 911 |
/**
|
|
@@ -957,26 +948,11 @@ function onAdaptiveScroll() {
|
|
| 957 |
adaptiveStickToBottom.value = isNearBottom(adaptiveScrollEl.value)
|
| 958 |
}
|
| 959 |
|
| 960 |
-
function widgetFrameHeight(height?: number): number {
|
| 961 |
-
const raw = Number(height || 420)
|
| 962 |
-
if (!Number.isFinite(raw)) return 420
|
| 963 |
-
return Math.min(Math.max(raw, 300), 520)
|
| 964 |
-
}
|
| 965 |
-
|
| 966 |
function widgetDownloadBase(m: ChatMessage, idx: number): string {
|
| 967 |
const part = (m.strategy || 'adaptive').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'widget'
|
| 968 |
return `widget-${part}-${idx + 1}`
|
| 969 |
}
|
| 970 |
|
| 971 |
-
function downloadWidgetHtml(html: string, base: string) {
|
| 972 |
-
const body = String(html || '').trim()
|
| 973 |
-
if (!body) {
|
| 974 |
-
showToast({ title: 'Nothing to download', message: 'Widget HTML is empty.' })
|
| 975 |
-
return
|
| 976 |
-
}
|
| 977 |
-
downloadTextAsFile(body, `${base}.html`, 'text/html;charset=utf-8')
|
| 978 |
-
}
|
| 979 |
-
|
| 980 |
function downloadWidgetJson(jsonStr: string, base: string) {
|
| 981 |
const body = prettifyJsonIfPossible(String(jsonStr || '').trim())
|
| 982 |
if (!body) {
|
|
@@ -986,27 +962,13 @@ function downloadWidgetJson(jsonStr: string, base: string) {
|
|
| 986 |
downloadTextAsFile(body, `${base}.json`, 'application/json;charset=utf-8')
|
| 987 |
}
|
| 988 |
|
| 989 |
-
function effectiveStreamWidgetMode(m: ChatMessage): 'html' | 'json' {
|
| 990 |
-
if (m.widgetMode === 'html') return 'html'
|
| 991 |
-
if (m.widgetMode === 'json') return 'json'
|
| 992 |
-
const preview = String(m.widgetStream || '').slice(0, 400).trim().toLowerCase()
|
| 993 |
-
if (preview.startsWith('<') || preview.startsWith('<!doctype')) return 'html'
|
| 994 |
-
return 'json'
|
| 995 |
-
}
|
| 996 |
-
|
| 997 |
function downloadLiveWidgetDraft(m: ChatMessage, idx: number) {
|
| 998 |
const stream = String(m.widgetStream || '').trim()
|
| 999 |
if (!stream) {
|
| 1000 |
showToast({ title: 'Nothing to download', message: 'Widget is still loading.' })
|
| 1001 |
return
|
| 1002 |
}
|
| 1003 |
-
|
| 1004 |
-
if (effectiveStreamWidgetMode(m) === 'html') downloadWidgetHtml(stream, base)
|
| 1005 |
-
else downloadWidgetJson(stream, base)
|
| 1006 |
-
}
|
| 1007 |
-
|
| 1008 |
-
function downloadFinalWidgetHtml(m: ChatMessage, idx: number) {
|
| 1009 |
-
downloadWidgetHtml(m.widgetHtml || '', widgetDownloadBase(m, idx))
|
| 1010 |
}
|
| 1011 |
</script>
|
| 1012 |
|
|
@@ -1153,10 +1115,10 @@ function downloadFinalWidgetHtml(m: ChatMessage, idx: number) {
|
|
| 1153 |
</div>
|
| 1154 |
</template>
|
| 1155 |
<template v-else-if="m.role === 'assistant' && assistantStreamPlain(idx)">
|
| 1156 |
-
<div class="whitespace-pre-wrap">{{ m.content }}</div>
|
| 1157 |
</template>
|
| 1158 |
<template v-else-if="m.role === 'assistant'">
|
| 1159 |
-
<div class="assistant-markdown" v-html="renderAssistantMarkdown(m.content)" />
|
| 1160 |
</template>
|
| 1161 |
<template v-else>
|
| 1162 |
<div class="whitespace-pre-wrap">{{ m.content }}</div>
|
|
@@ -1171,7 +1133,6 @@ function downloadFinalWidgetHtml(m: ChatMessage, idx: number) {
|
|
| 1171 |
v-if="
|
| 1172 |
m.role === 'assistant' &&
|
| 1173 |
m.widgetStreaming &&
|
| 1174 |
-
!m.widgetHtml &&
|
| 1175 |
!m.widgetSchema &&
|
| 1176 |
(m.widgetStream || (widgetGenerating && widgetGeneratingIdx === idx))
|
| 1177 |
"
|
|
@@ -1210,62 +1171,21 @@ function downloadFinalWidgetHtml(m: ChatMessage, idx: number) {
|
|
| 1210 |
</div>
|
| 1211 |
</div>
|
| 1212 |
<div class="p-3">
|
| 1213 |
-
<
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
:
|
| 1218 |
-
:
|
| 1219 |
-
|
| 1220 |
-
<LiveWidgetSchema
|
| 1221 |
-
v-else
|
| 1222 |
-
:raw-stream="m.widgetStream || ''"
|
| 1223 |
-
:finalized="false"
|
| 1224 |
/>
|
| 1225 |
</div>
|
| 1226 |
</div>
|
| 1227 |
</Motion>
|
| 1228 |
|
| 1229 |
-
<!-- Final widget (HTML iframe mode). -->
|
| 1230 |
-
<Motion
|
| 1231 |
-
v-if="m.role === 'assistant' && m.widgetHtml && String(m.widgetHtml).trim()"
|
| 1232 |
-
tag="div"
|
| 1233 |
-
class="mt-2"
|
| 1234 |
-
:initial="{ opacity: 0, y: 16 }"
|
| 1235 |
-
:animate="{ opacity: 1, y: 0 }"
|
| 1236 |
-
:transition="MOTION_BASE"
|
| 1237 |
-
>
|
| 1238 |
-
<div
|
| 1239 |
-
class="rounded-2xl border bg-card overflow-hidden shadow-sm transition-shadow duration-300 hover:shadow-md"
|
| 1240 |
-
>
|
| 1241 |
-
<div class="px-4 py-2 border-b text-xs text-muted-foreground flex items-center justify-between gap-2">
|
| 1242 |
-
<div class="flex items-center gap-2 min-w-0">
|
| 1243 |
-
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" />
|
| 1244 |
-
<span class="truncate">Interactive widget</span>
|
| 1245 |
-
</div>
|
| 1246 |
-
<Button
|
| 1247 |
-
type="button"
|
| 1248 |
-
variant="outline"
|
| 1249 |
-
size="sm"
|
| 1250 |
-
class="h-7 px-2 shrink-0"
|
| 1251 |
-
title="Download widget as HTML"
|
| 1252 |
-
@click="downloadFinalWidgetHtml(m, idx)"
|
| 1253 |
-
>
|
| 1254 |
-
<ArrowDownTrayIcon class="h-3.5 w-3.5" />
|
| 1255 |
-
</Button>
|
| 1256 |
-
</div>
|
| 1257 |
-
<iframe
|
| 1258 |
-
:srcdoc="m.widgetHtml"
|
| 1259 |
-
sandbox="allow-scripts allow-same-origin"
|
| 1260 |
-
class="w-full widget-frame border-0"
|
| 1261 |
-
:style="{ height: `${widgetFrameHeight(m.widgetHeight)}px`, maxHeight: '56vh' }"
|
| 1262 |
-
/>
|
| 1263 |
-
</div>
|
| 1264 |
-
</Motion>
|
| 1265 |
-
|
| 1266 |
<!-- Final widget (JSON schema mode). -->
|
| 1267 |
<Motion
|
| 1268 |
-
v-if="m.role === 'assistant' && m.widgetSchema && String(m.widgetSchema).trim()
|
| 1269 |
tag="div"
|
| 1270 |
class="mt-2"
|
| 1271 |
:initial="{ opacity: 0, y: 16 }"
|
|
@@ -1280,7 +1200,11 @@ function downloadFinalWidgetHtml(m: ChatMessage, idx: number) {
|
|
| 1280 |
Interactive widget
|
| 1281 |
</div>
|
| 1282 |
<div class="p-4">
|
| 1283 |
-
<WidgetSchemaRenderer
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1284 |
</div>
|
| 1285 |
</div>
|
| 1286 |
</Motion>
|
|
|
|
| 8 |
import PreferenceModal from '@/components/PreferenceModal.vue'
|
| 9 |
import TechPanels from '@/components/TechPanels.vue'
|
| 10 |
import WidgetSchemaRenderer from '@/components/WidgetRegistryRenderer.vue'
|
|
|
|
|
|
|
| 11 |
import { getAccessToken, clearAccessToken } from '@/lib/auth'
|
| 12 |
import { ingestDone, ingestReward } from '@/lib/analyticsStore'
|
| 13 |
import {
|
|
|
|
| 84 |
}
|
| 85 |
}
|
| 86 |
|
| 87 |
+
/** Drop useless index-array "widgets" (e.g. bare [0,1,2] tuples) that aren't real content. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
function recoverWidgetFromStreamIfDegenerate(cur: ChatMessage) {
|
|
|
|
| 89 |
const sch = String(cur.widgetSchema || '').trim()
|
| 90 |
+
if (schemaIsOnlyNumericTupleText(sch)) cur.widgetSchema = ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
const isStreaming = ref(false)
|
|
|
|
| 101 |
content: string
|
| 102 |
strategy?: string
|
| 103 |
xVec?: number[]
|
|
|
|
| 104 |
widgetSchema?: string
|
| 105 |
widgetHeight?: number
|
| 106 |
widgetStream?: string
|
| 107 |
widgetStreaming?: boolean
|
| 108 |
+
widgetMode?: 'json' | ''
|
| 109 |
rewardUsedUp?: boolean
|
| 110 |
rewardUsedDown?: boolean
|
| 111 |
}
|
|
|
|
| 115 |
response?: string
|
| 116 |
strategy?: string
|
| 117 |
x_vec?: unknown[]
|
|
|
|
| 118 |
widget_schema?: string
|
| 119 |
widget_height?: number
|
| 120 |
instruction?: string
|
|
|
|
| 392 |
)
|
| 393 |
cur.strategy = typeof payload.strategy === 'string' ? payload.strategy : String(payload.strategy ?? '')
|
| 394 |
cur.xVec = Array.isArray(payload.x_vec) ? payload.x_vec.map((n: unknown) => Number(n)) : []
|
|
|
|
| 395 |
cur.widgetSchema = typeof payload.widget_schema === 'string' ? payload.widget_schema : ''
|
| 396 |
if (schemaIsOnlyNumericTupleText(cur.widgetSchema)) cur.widgetSchema = ''
|
| 397 |
cur.widgetHeight = payload.widget_height ? Number(payload.widget_height) : 420
|
|
|
|
| 428 |
ingestDone({
|
| 429 |
strategy: payload.strategy ?? 'unknown',
|
| 430 |
elapsed: payload.elapsed,
|
| 431 |
+
widget_schema: payload.widget_schema ?? '',
|
| 432 |
})
|
| 433 |
return true
|
| 434 |
}
|
|
|
|
| 486 |
cur.widgetStreaming = true
|
| 487 |
if (!cur.widgetMode) {
|
| 488 |
const preview = cur.widgetStream.slice(0, 400).trim().toLowerCase()
|
| 489 |
+
if (preview.startsWith('{') || preview.startsWith('[') || preview.startsWith('```json')) cur.widgetMode = 'json'
|
|
|
|
| 490 |
}
|
| 491 |
requestAdaptiveAutoScroll()
|
| 492 |
}
|
|
|
|
| 500 |
const e = evt as {
|
| 501 |
response?: string
|
| 502 |
strategy?: string
|
|
|
|
| 503 |
widget_schema?: string
|
| 504 |
widget_height?: number
|
| 505 |
x_vec?: number[]
|
|
|
|
| 515 |
}
|
| 516 |
cur.content = cleanAssistantText(e.response ?? cur.content)
|
| 517 |
cur.strategy = e.strategy ?? cur.strategy
|
| 518 |
+
cur.widgetSchema = typeof e.widget_schema === 'string' ? e.widget_schema.trim() : ''
|
|
|
|
|
|
|
|
|
|
| 519 |
cur.widgetHeight = e.widget_height ? Number(e.widget_height) : 420
|
| 520 |
cur.widgetStreaming = false
|
| 521 |
|
| 522 |
recoverWidgetFromStreamIfDegenerate(cur)
|
| 523 |
|
| 524 |
+
// If the server cleared the finalized schema but we already streamed JSON into
|
| 525 |
+
// `widgetStream`, promote that stream so the panel still mounts (avoids the
|
| 526 |
+
// "widget disappeared after completion" symptom).
|
|
|
|
| 527 |
const stream = String(cur.widgetStream || '').trim()
|
| 528 |
+
if (!cur.widgetSchema && stream && !schemaIsOnlyNumericTupleText(stream)) {
|
| 529 |
+
cur.widgetSchema = stream
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
}
|
| 531 |
+
cur.widgetMode = cur.widgetSchema?.trim() ? 'json' : ''
|
| 532 |
cur.xVec = Array.isArray(e.x_vec) ? e.x_vec.map((n) => Number(n)) : []
|
| 533 |
cur.rewardUsedUp = false
|
| 534 |
cur.rewardUsedDown = false
|
|
|
|
| 566 |
ingestDone({
|
| 567 |
strategy: e.strategy ?? cur.strategy ?? 'unknown',
|
| 568 |
elapsed: e.elapsed,
|
| 569 |
+
widget_schema: e.widget_schema ?? '',
|
| 570 |
})
|
| 571 |
}
|
| 572 |
}
|
| 573 |
|
| 574 |
+
/** An action_row button was clicked — send its label as the next prompt. */
|
| 575 |
+
function onWidgetAction(text: string) {
|
| 576 |
+
const t = String(text || '').trim()
|
| 577 |
+
if (!t || sending.value || isStreaming.value) return
|
| 578 |
+
input.value = t
|
| 579 |
+
void onSend()
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
async function onSend() {
|
| 583 |
const token = getAccessToken()
|
| 584 |
if (!token) {
|
|
|
|
| 677 |
if (evt.type === 'done') {
|
| 678 |
finalized = true
|
| 679 |
const cur = messages.value[idx]
|
| 680 |
+
// Redundant safety: promote streamed JSON if the done payload left the schema empty.
|
|
|
|
| 681 |
const wSch = typeof (evt as { widget_schema?: string }).widget_schema === 'string' ? (evt as { widget_schema: string }).widget_schema.trim() : ''
|
| 682 |
const stream = String(cur.widgetStream || '').trim()
|
| 683 |
+
if (!wSch && stream && !schemaIsOnlyNumericTupleText(stream)) {
|
| 684 |
+
cur.widgetSchema = stream
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
}
|
| 686 |
+
cur.widgetMode = cur.widgetSchema?.trim() ? 'json' : ''
|
| 687 |
recoverWidgetFromStreamIfDegenerate(cur)
|
| 688 |
}
|
| 689 |
}
|
|
|
|
| 720 |
type HistoryPair = {
|
| 721 |
user: string
|
| 722 |
assistant: string
|
|
|
|
| 723 |
widget_schema?: string
|
| 724 |
widget_height?: number
|
| 725 |
}
|
| 726 |
|
| 727 |
function assistantFromHistoryPair(p: HistoryPair): ChatMessage {
|
|
|
|
| 728 |
const wSch = String(p.widget_schema ?? '').trim()
|
| 729 |
const wh = Number(p.widget_height) || 420
|
| 730 |
const base: ChatMessage = {
|
|
|
|
| 732 |
content: cleanAssistantText(p.assistant),
|
| 733 |
widgetHeight: wh,
|
| 734 |
}
|
| 735 |
+
if (wSch && !schemaIsOnlyNumericTupleText(wSch)) {
|
|
|
|
|
|
|
|
|
|
| 736 |
base.widgetSchema = wSch
|
| 737 |
base.widgetMode = 'json'
|
| 738 |
}
|
|
|
|
| 844 |
else killAnimationsOf(streamPulseEl.value)
|
| 845 |
})
|
| 846 |
|
| 847 |
+
/**
|
| 848 |
+
* Strip any widget JSON the model leaked into the prose <RESPONSE>. The response is
|
| 849 |
+
* meant to be human text only — JSON must NEVER reach the UI. Handles fenced blocks and
|
| 850 |
+
* bare objects/arrays (balanced or truncated mid-stream).
|
| 851 |
+
*/
|
| 852 |
+
function stripLeakedJson(text: string): string {
|
| 853 |
+
let s = text
|
| 854 |
+
// 1) fenced code blocks whose content looks like widget JSON
|
| 855 |
+
s = s.replace(/```[\w-]*\s*([\s\S]*?)```/g, (m, inner) => {
|
| 856 |
+
const t = String(inner).trim()
|
| 857 |
+
return /^[[{]/.test(t) && /"(?:type|layout|items|tone|series|chart|version)"/.test(t) ? '' : m
|
| 858 |
+
})
|
| 859 |
+
// 2) bare JSON widget/block blobs — balance-match (string-aware); truncated → cut to end
|
| 860 |
+
const opener = /[{[]/g
|
| 861 |
+
let match: RegExpExecArray | null
|
| 862 |
+
while ((match = opener.exec(s)) !== null) {
|
| 863 |
+
const start = match.index
|
| 864 |
+
const head = s.slice(start, start + 400)
|
| 865 |
+
if (!/"(?:type|layout|version)"\s*:/.test(head)) continue
|
| 866 |
+
const open = s[start]
|
| 867 |
+
const close = open === '{' ? '}' : ']'
|
| 868 |
+
let depth = 0
|
| 869 |
+
let inStr = false
|
| 870 |
+
let esc = false
|
| 871 |
+
let j = start
|
| 872 |
+
for (; j < s.length; j++) {
|
| 873 |
+
const ch = s[j]
|
| 874 |
+
if (inStr) {
|
| 875 |
+
if (esc) esc = false
|
| 876 |
+
else if (ch === '\\') esc = true
|
| 877 |
+
else if (ch === '"') inStr = false
|
| 878 |
+
} else if (ch === '"') inStr = true
|
| 879 |
+
else if (ch === open) depth++
|
| 880 |
+
else if (ch === close) {
|
| 881 |
+
depth--
|
| 882 |
+
if (depth === 0) {
|
| 883 |
+
j++
|
| 884 |
+
break
|
| 885 |
+
}
|
| 886 |
+
}
|
| 887 |
+
}
|
| 888 |
+
const end = depth !== 0 ? s.length : j // unbalanced (truncated) → remove to end
|
| 889 |
+
s = s.slice(0, start) + s.slice(end)
|
| 890 |
+
opener.lastIndex = start
|
| 891 |
+
}
|
| 892 |
+
return s
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
function cleanAssistantText(raw: string | undefined | null) {
|
| 896 |
const s = String(raw ?? '')
|
| 897 |
const noWidgetBlock = s.replace(/<WIDGET>[\s\S]*?<\/WIDGET>/gi, '')
|
| 898 |
+
const noTags = noWidgetBlock.replace(/<\/?WIDGET>/gi, '')
|
| 899 |
+
return stripLeakedJson(noTags).trim()
|
| 900 |
}
|
| 901 |
|
| 902 |
/**
|
|
|
|
| 948 |
adaptiveStickToBottom.value = isNearBottom(adaptiveScrollEl.value)
|
| 949 |
}
|
| 950 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
function widgetDownloadBase(m: ChatMessage, idx: number): string {
|
| 952 |
const part = (m.strategy || 'adaptive').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'widget'
|
| 953 |
return `widget-${part}-${idx + 1}`
|
| 954 |
}
|
| 955 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
function downloadWidgetJson(jsonStr: string, base: string) {
|
| 957 |
const body = prettifyJsonIfPossible(String(jsonStr || '').trim())
|
| 958 |
if (!body) {
|
|
|
|
| 962 |
downloadTextAsFile(body, `${base}.json`, 'application/json;charset=utf-8')
|
| 963 |
}
|
| 964 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 965 |
function downloadLiveWidgetDraft(m: ChatMessage, idx: number) {
|
| 966 |
const stream = String(m.widgetStream || '').trim()
|
| 967 |
if (!stream) {
|
| 968 |
showToast({ title: 'Nothing to download', message: 'Widget is still loading.' })
|
| 969 |
return
|
| 970 |
}
|
| 971 |
+
downloadWidgetJson(stream, `${widgetDownloadBase(m, idx)}-draft`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
}
|
| 973 |
</script>
|
| 974 |
|
|
|
|
| 1115 |
</div>
|
| 1116 |
</template>
|
| 1117 |
<template v-else-if="m.role === 'assistant' && assistantStreamPlain(idx)">
|
| 1118 |
+
<div class="whitespace-pre-wrap">{{ cleanAssistantText(m.content) }}</div>
|
| 1119 |
</template>
|
| 1120 |
<template v-else-if="m.role === 'assistant'">
|
| 1121 |
+
<div class="assistant-markdown" v-html="renderAssistantMarkdown(cleanAssistantText(m.content))" />
|
| 1122 |
</template>
|
| 1123 |
<template v-else>
|
| 1124 |
<div class="whitespace-pre-wrap">{{ m.content }}</div>
|
|
|
|
| 1133 |
v-if="
|
| 1134 |
m.role === 'assistant' &&
|
| 1135 |
m.widgetStreaming &&
|
|
|
|
| 1136 |
!m.widgetSchema &&
|
| 1137 |
(m.widgetStream || (widgetGenerating && widgetGeneratingIdx === idx))
|
| 1138 |
"
|
|
|
|
| 1171 |
</div>
|
| 1172 |
</div>
|
| 1173 |
<div class="p-3">
|
| 1174 |
+
<!-- Live block-by-block build: render complete blocks from the partial stream
|
| 1175 |
+
(salvage parser drops the in-progress block); the chart appears the moment
|
| 1176 |
+
its JSON closes. -->
|
| 1177 |
+
<WidgetSchemaRenderer
|
| 1178 |
+
:json-str="m.widgetStream || ''"
|
| 1179 |
+
:show-download="false"
|
| 1180 |
+
:streaming="true"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
/>
|
| 1182 |
</div>
|
| 1183 |
</div>
|
| 1184 |
</Motion>
|
| 1185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1186 |
<!-- Final widget (JSON schema mode). -->
|
| 1187 |
<Motion
|
| 1188 |
+
v-if="m.role === 'assistant' && m.widgetSchema && String(m.widgetSchema).trim()"
|
| 1189 |
tag="div"
|
| 1190 |
class="mt-2"
|
| 1191 |
:initial="{ opacity: 0, y: 16 }"
|
|
|
|
| 1200 |
Interactive widget
|
| 1201 |
</div>
|
| 1202 |
<div class="p-4">
|
| 1203 |
+
<WidgetSchemaRenderer
|
| 1204 |
+
:json-str="m.widgetSchema"
|
| 1205 |
+
:download-base="widgetDownloadBase(m, idx)"
|
| 1206 |
+
@action="onWidgetAction"
|
| 1207 |
+
/>
|
| 1208 |
</div>
|
| 1209 |
</div>
|
| 1210 |
</Motion>
|
vivek/frontend-vue/src/widget-registry.json
CHANGED
|
@@ -54,7 +54,18 @@
|
|
| 54 |
"graph",
|
| 55 |
"boxplot",
|
| 56 |
"bubble",
|
| 57 |
-
"histogram"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
],
|
| 59 |
"spec": [
|
| 60 |
"Pick \"kind\" to fit the data; ONLY these kinds render:",
|
|
@@ -79,6 +90,17 @@
|
|
| 79 |
"- sankey | graph (flows / network) -> \"nodes\" + \"links\":",
|
| 80 |
" { \"type\": \"chart\", \"chart\": { \"kind\": \"sankey\", \"nodes\": [ {\"name\":\"Revenue\"}, {\"name\":\"COGS\"}, {\"name\":\"Gross Profit\"} ], \"links\": [ {\"source\":\"Revenue\",\"target\":\"COGS\",\"value\":40}, {\"source\":\"Revenue\",\"target\":\"Gross Profit\",\"value\":60} ] } }",
|
| 81 |
"- histogram (frequency) -> like bar: \"x_categories\" (bins) + one series of counts.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
"For a correlation matrix you MUST use kind \"heatmap\" with \"matrix\" (never a line/bar series)."
|
| 83 |
],
|
| 84 |
"example": {
|
|
@@ -133,16 +155,22 @@
|
|
| 133 |
},
|
| 134 |
{
|
| 135 |
"type": "action_row",
|
| 136 |
-
"whenToUse": "
|
| 137 |
"spec": [
|
| 138 |
-
"{ \"type\": \"action_row\", \"
|
|
|
|
|
|
|
| 139 |
],
|
| 140 |
"example": {
|
| 141 |
"type": "action_row",
|
| 142 |
"buttons": [
|
| 143 |
{
|
| 144 |
-
"label": "Show
|
| 145 |
-
"intent": "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
]
|
| 148 |
}
|
|
|
|
| 54 |
"graph",
|
| 55 |
"boxplot",
|
| 56 |
"bubble",
|
| 57 |
+
"histogram",
|
| 58 |
+
"timeseries",
|
| 59 |
+
"rose",
|
| 60 |
+
"polar",
|
| 61 |
+
"parallel",
|
| 62 |
+
"themeriver",
|
| 63 |
+
"tree",
|
| 64 |
+
"mindmap",
|
| 65 |
+
"org",
|
| 66 |
+
"scatter3d",
|
| 67 |
+
"bar3d",
|
| 68 |
+
"line3d"
|
| 69 |
],
|
| 70 |
"spec": [
|
| 71 |
"Pick \"kind\" to fit the data; ONLY these kinds render:",
|
|
|
|
| 90 |
"- sankey | graph (flows / network) -> \"nodes\" + \"links\":",
|
| 91 |
" { \"type\": \"chart\", \"chart\": { \"kind\": \"sankey\", \"nodes\": [ {\"name\":\"Revenue\"}, {\"name\":\"COGS\"}, {\"name\":\"Gross Profit\"} ], \"links\": [ {\"source\":\"Revenue\",\"target\":\"COGS\",\"value\":40}, {\"source\":\"Revenue\",\"target\":\"Gross Profit\",\"value\":60} ] } }",
|
| 92 |
"- histogram (frequency) -> like bar: \"x_categories\" (bins) + one series of counts.",
|
| 93 |
+
"- timeseries (interactive time trend with zoom/pan) -> like line: \"x_categories\" (dates) + series of numbers. Best for many points over time.",
|
| 94 |
+
"- rose (nightingale / 'pizza' chart — share with variable-radius slices) -> use \"items\": [ { \"label\", \"value\" } ].",
|
| 95 |
+
"- polar (categorical bars around a circle) -> \"x_categories\" + series of numbers (like bar).",
|
| 96 |
+
"- parallel (compare items across many dimensions) -> \"x_categories\" = dimension names; one series per item with \"values\" aligned to them.",
|
| 97 |
+
" { \"type\": \"chart\", \"chart\": { \"kind\": \"parallel\", \"x_categories\": [\"P/E\",\"Margin\",\"Growth\"], \"series\": [ { \"name\": \"AAPL\", \"values\": [28, 25, 8] }, { \"name\": \"MSFT\", \"values\": [34, 42, 14] } ] } }",
|
| 98 |
+
"- themeriver (streamgraph of categories over time; keep to a handful of time points) -> \"x_categories\" (time) + one series per category with \"values\" aligned to them.",
|
| 99 |
+
"- tree | mindmap | org (hierarchy / mind map / org chart) -> a single \"root\" node with nested \"children\":",
|
| 100 |
+
" { \"type\": \"chart\", \"chart\": { \"kind\": \"tree\", \"root\": { \"name\": \"Apple\", \"children\": [ { \"name\": \"Products\", \"children\": [ {\"name\":\"iPhone\"}, {\"name\":\"Mac\"} ] }, { \"name\": \"Services\" } ] } } }",
|
| 101 |
+
" (use \"mindmap\" for a radial layout, \"org\" for top-down. Keep to a readable number of nodes.)",
|
| 102 |
+
"- scatter3d | bar3d | line3d (THREE numeric axes; keep to a handful of points) -> series with [x, y, z] triples:",
|
| 103 |
+
" { \"type\": \"chart\", \"chart\": { \"kind\": \"scatter3d\", \"x_label\": \"Risk\", \"y_label\": \"Return\", \"series\": [ { \"name\": \"Funds\", \"values\": [ [8,12,500], [14,18,1200] ] } ] } }",
|
| 104 |
"For a correlation matrix you MUST use kind \"heatmap\" with \"matrix\" (never a line/bar series)."
|
| 105 |
],
|
| 106 |
"example": {
|
|
|
|
| 155 |
},
|
| 156 |
{
|
| 157 |
"type": "action_row",
|
| 158 |
+
"whenToUse": "Buttons to RE-VISUALIZE the SAME data already shown as a different supported chart kind. Each button's label is sent back as the next prompt.",
|
| 159 |
"spec": [
|
| 160 |
+
"{ \"type\": \"action_row\", \"buttons\": [ { \"label\": \"Show as <supported chart kind>\", \"intent\": \"...\" }, ... ] }",
|
| 161 |
+
"VISUALS ONLY — buttons MUST only offer to redraw the data ALREADY shown using a different supported chart kind (e.g. \"Show as bar chart\", \"View as treemap\", \"Show as pie\", \"View as horizontal bars\", \"Show as line trend\").",
|
| 162 |
+
"NEVER propose actions that require data you may not have: no new tickers/companies, no new time periods, no \"compare X vs Y\", no growth/peers/forecasts, no \"explain ...\". Only chart-type changes of the current data."
|
| 163 |
],
|
| 164 |
"example": {
|
| 165 |
"type": "action_row",
|
| 166 |
"buttons": [
|
| 167 |
{
|
| 168 |
+
"label": "Show as treemap",
|
| 169 |
+
"intent": "view_treemap"
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"label": "View as horizontal bars",
|
| 173 |
+
"intent": "view_hbar"
|
| 174 |
}
|
| 175 |
]
|
| 176 |
}
|
vivek/strategies.json
CHANGED
|
@@ -40,7 +40,7 @@
|
|
| 40 |
{
|
| 41 |
"id": "visualization",
|
| 42 |
"label": "Visualization",
|
| 43 |
-
"instruction": "
|
| 44 |
"enabled": true
|
| 45 |
},
|
| 46 |
{
|
|
|
|
| 40 |
{
|
| 41 |
"id": "visualization",
|
| 42 |
"label": "Visualization",
|
| 43 |
+
"instruction": "Write a brief 1-2 sentence lead-in that frames what the visual shows. The chart/widget carries the visualization. NEVER draw ASCII charts, tree diagrams, or text-art in the response.",
|
| 44 |
"enabled": true
|
| 45 |
},
|
| 46 |
{
|