saivivek6 commited on
Commit
669d696
·
1 Parent(s): abd3ca3

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 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 {"pie", "donut", "funnel", "treemap", "sunburst", "waterfall", "gauge"}:
 
 
 
 
 
 
 
 
 
 
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
- - Use action_row buttons to request follow-ups via intent strings (e.g., "explain_methodology", "show_risks").
 
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
- - Use action_row buttons to request follow-ups via intent strings (e.g., "explain_methodology", "show_risks").
 
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
- "Output ONLY one markdown fenced code block (triple backticks). Inside: ASCII bar chart (#) and/or "
651
- "aligned text columns. Do not put raw JSON in <RESPONSE>. No prose outside that single code block."
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
- widget_mode = getattr(config, "WIDGET_MODE", "json").strip().lower()
769
- widget_format_line = (
770
- "Complete self-contained HTML document for the interactive widget"
771
- if widget_mode != "json"
772
- else "JSON UI schema ONLY (no HTML) for the widget"
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 with rich, dynamic interactive output widgets should feel alive, responsive, and tailored to each question (not repetitive templates).
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 response you produce TWO sections in one generation.
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. Generate a widget wherever there is possibility, based on content — never skip a widget because the text format is "table" or "prose". Widget choice (chart, calculator, table) depends on content, not on the primitive.
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": "Return a simple TEXT visualization only (ASCII bar chart or small table-of-values). Put it in a fenced code block. No extra prose outside the code block.",
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
- def _should_generate_widget(message: str) -> bool:
211
- """Lightweight intent gate so small-talk / explainers do not force widgets."""
212
- import re as _re
213
-
214
- text = (message or "").strip().lower()
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, "widget_required": widget_required,
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
- if widget_mode == "json":
1018
- widget_schema, widget_html, widget_height, tag = _dispatch_json_mode_widget(widget_payload_raw)
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
- widget_debug = "combined_no_schema" if widget_mode == "json" else "combined_no_widget_tag"
 
 
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
- if widget_mode == "json":
1197
- widget_schema, widget_html, widget_height, tag = _dispatch_json_mode_widget(widget_payload_raw)
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-DxH9q0Bt.js"></script>
9
  <link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-olIhRSij.js">
10
- <link rel="stylesheet" crossorigin href="/assets/index-39HK7jxF.css">
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.widgetHtml ?? '').trim().length > 0).length
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 < 0 || j <= i) return null
44
- try {
45
- parsed = JSON.parse(s.slice(i, j + 1))
46
- } catch {
47
- return null
 
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
- if (!Array.isArray(layout)) return null
60
- const normalized = (layout as unknown[]).map((b) => normalizeWidgetBlock(b)) as Block[]
61
- // Drop bare numeric-array text blocks (e.g. tic-tac-toe win lines "[0,1,2]") — not real content.
62
- const NUMERIC_ARRAY_RE = /^\s*\[\s*-?\d+(\.\d+)?(\s*,\s*-?\d+(\.\d+)?)*\s*\]\s*$/
63
- return normalized.filter(
64
- (b) =>
65
- !(
66
- String(b.type || '').toLowerCase() === 'text' &&
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
- <p class="font-medium text-foreground/90 mb-1">Could not parse widget JSON</p>
150
- <pre class="max-h-48 overflow-auto whitespace-pre-wrap break-all text-[10px] leading-snug">{{
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
- function _toNum(v: unknown): number | null {
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
- const tc = dark ? '#8d93aa' : '#5a5f72'
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
- disabled
16
- :title="b.intent ? `Intent: ${b.intent}` : undefined"
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
- widgetHtml: string
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
- widget_html?: string
36
- widgetHtml?: string
37
  }) {
38
- const widgetHtml = (evt.widgetHtml ?? evt.widget_html ?? '') as string
39
  analyticsState.doneEvents.push({
40
  ts: Date.now(),
41
  strategy: evt.strategy,
42
  elapsed: evt.elapsed,
43
- widgetHtml,
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.widgetHtml ?? '').trim().length > 0).length
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, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;')
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.widgetHtml ?? '').trim().length > 0).length / done.length : 0
73
- const withWidget = done.filter((d) => (d.widgetHtml ?? '').trim().length > 0).length
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.widgetHtml ?? '').trim().length > 0).length / done.length : null
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.widgetHtml ?? '').trim().length > 0).length
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
- function streamLooksLikeSubstantialHtml(s: string): boolean {
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 (!schemaIsOnlyNumericTupleText(sch)) return
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' | 'html' | ''
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
- widget_html: payload.widget_html ?? '',
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('<') || preview.startsWith('<!doctype')) cur.widgetMode = 'html'
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
- const wHtml = typeof e.widget_html === 'string' ? e.widget_html.trim() : ''
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 omits or clears finalized widget fields but we already streamed
568
- // payload into `widgetStream`, promote that stream into the final slot. Otherwise
569
- // the live panel hides (widgetStreaming=false) and the final iframe/schema panel
570
- // never mounts — looks like the widget "disappeared" after completion.
571
  const stream = String(cur.widgetStream || '').trim()
572
- if (!cur.widgetHtml && !cur.widgetSchema && stream) {
573
- let mode = cur.widgetMode || ''
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
- if (!cur.widgetHtml?.trim() && !cur.widgetSchema?.trim()) cur.widgetMode = ''
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
- widget_html: e.widget_html ?? '',
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 widget if done payload left both empty.
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 (!wHtml && !wSch && stream) {
729
- let mode = cur.widgetMode || ''
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
- if (!cur.widgetHtml?.trim() && !cur.widgetSchema?.trim()) cur.widgetMode = ''
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 (wHtml) {
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
- return noWidgetBlock.replace(/<\/?WIDGET>/gi, '').trim()
 
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
- const base = `${widgetDownloadBase(m, idx)}-draft`
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
- <LiveWidgetFrame
1214
- v-if="m.widgetMode === 'html'"
1215
- :raw-stream="m.widgetStream || ''"
1216
- :final-html="m.widgetHtml || ''"
1217
- :finalized="!!m.widgetHtml"
1218
- :height="widgetFrameHeight(m.widgetHeight)"
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() && !m.widgetHtml"
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 :json-str="m.widgetSchema" :download-base="widgetDownloadBase(m, idx)" />
 
 
 
 
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": "Suggested follow-up actions / next prompts as buttons.",
137
  "spec": [
138
- "{ \"type\": \"action_row\", \"id\": \"...\", \"buttons\": [ { \"id\": \"...\", \"label\": \"...\", \"intent\": \"...\" }, ... ] }"
 
 
139
  ],
140
  "example": {
141
  "type": "action_row",
142
  "buttons": [
143
  {
144
- "label": "Show risks",
145
- "intent": "show_risks"
 
 
 
 
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": "Return a simple TEXT visualization only (ASCII bar chart or small table-of-values). Put it in a fenced code block. No extra prose outside the code block.",
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
  {