Chuyển tới nội dung chính

Visualize Embed

Hệ thống hiển thị visual content inline trong chat message. Hỗ trợ 3 mode: Native Component (JSON), SVG, và HTML.


Flow tổng quan

Agent gọi visualize_show_chart(widget_code, title, ...)
→ Backend detect content_type (component / svg / html)
→ Save artifact storage
→ Tool result trả artifact_id
→ Agent ghi "visualization:{artifact_id}" vào text response
→ Frontend parse pattern → fetch artifact → render

Message Detection & Rendering Flow

1. Text parsing — widgetParser.js

Frontend parseContentWithWidgets() scan AI response text cho pattern:

visualization:visual_abc123

Regex: /^visualization:(visual_[a-z0-9]+)$/gm

Khi detect → trả {type: 'visualization', artifactId: 'visual_abc123'}.

File: utils/widgetParser.js

2. MessageBlockRenderer

TextBlockFlat component parse text → render parts:

// MessageBlockRenderer.jsx
const parts = parseContentWithWidgets(textContent, isStreaming);

parts.map(part => {
if (part.type === 'text') return <MarkdownBlock />;
if (part.type === 'visualization') return <VisualizeWidget artifactId={part.artifactId} />;
});

3. VisualizeWidget — fetch & route

VisualizeWidget(artifactId)
→ fetchArtifact(`/v2/artifacts/${artifactId}`)
→ artifact.data = { content_type, widget_code, title }
→ content_type === 'svg' → SvgRenderer
→ content_type === 'component' → ComponentRenderer
→ content_type === 'html' → HtmlRenderer

API endpoint: GET /v2/artifacts/{artifact_id}

4. Components that call API (realtime data)

ComponentAPI CallEndpointMô tả
AssetCardcallWidgetApi('get_historical_prices_range', {...})POST /api/widget/financeFetch 1Y price history cho mini chart

Tất cả components khác render static data từ widget_code JSON — không gọi API.

callWidgetApi là proxy endpoint:

POST /api/widget/finance
Body: { "api": "get_historical_prices_range", "params": { "tickers": ["HPG"], "interval": "1d", ... } }

Backend forward request tới MCP server, trả data về frontend.


Content Types

Typewidget_code contentDetectRender
componentJSON string có "component" keystartsWith("{") + has "component"ComponentRenderer → Native React component
svgRaw SVG markupstartsWith("<svg")SvgRendererdangerouslySetInnerHTML + DOMPurify
htmlRaw HTML fragmentCòn lạiHtmlRenderer<iframe srcdoc> sandboxed

Tất cả đều truyền qua field widget_code của visualize_show_chart. Backend auto-detect content_type từ nội dung.


Native Components

Native components render bằng React, không qua iframe. Theme-consistent, dark mode ready, performance tốt nhất.

Cách hoạt động

widget_code = '{"component": "stat", "props": {...}}'
→ ComponentRenderer parse JSON
→ Lookup registry → render component
→ ActionButton render CTA (nếu có context)

Registry

ComponentFileMô tả
statNativeStat.jsxGrid KPI cards (2-4 metrics)
donut_chartDonutChart.jsxPie/donut chart (Highcharts)
line_chartLineChart.jsxLine chart + stat cards + markers (Highcharts)
listNativeList.jsxFlexible list, simple hoặc grouped
valuation_chartValuationChart.jsxBiểu đồ định giá (green/yellow/red zones)
asset_cardAssetCard.jsxTicker info + mini chart (tự fetch timeseries)
peer_comparisonPeerComparison.jsxBar chart so sánh ngành (Highcharts)
card_listCardList.jsxHorizontal scrollable cards (3 variants)

File structure

src/components/widgets/
VisualizeWidget.jsx ← Main entry: route svg/html/component
visualizeTheme.js ← CSS variables, color ramps, SVG classes
native/
ComponentRenderer.jsx ← JSON → lookup registry → render + ActionButton
registry.js ← Component map
context.js ← Context enum → CTA URL builder
ActionButton.jsx ← Shared CTA button
formatValue.js ← Number/percent/currency formatting
NativeStat.jsx
NativeList.jsx
DonutChart.jsx
LineChart.jsx
ValuationChart.jsx
AssetCard.jsx
PeerComparison.jsx
CardList.jsx

Component Schema & Examples

stat

Grid summary numbers.

{
"component": "stat",
"props": {
"title": "Tổng quan HPG",
"columns": 3,
"context": "stock",
"items": [
{
"label": "Giá hiện tại",
"value": 26700,
"format": "number",
"suffix": "đ"
},
{
"label": "Thay đổi 7D",
"value": -2.12,
"format": "percent"
},
{
"label": "KLGD",
"value": 21335000,
"format": "compact"
}
]
}
}

items[] props:

FieldTypeDescription
labelstringLabel phía trên value
valueanyGiá trị hiển thị
formatstringnumber | currency | percent | compact | date
prefixstringTrước value
suffixstringSau value
changenumber% thay đổi (auto xanh/đỏ)
statusstringsuccess | danger | warning | info
descriptionstringText nhỏ dưới value
Format types
  • number26,700 (vi-VN locale)
  • currency26,700đ
  • percent+2.12% hoặc -2.12% (auto sign + color)
  • compact21.3M, 1.2B, 500K
  • date20/03/2026

list

Flexible list — simple hoặc grouped.

Simple list:

{
"component": "list",
"props": {
"title": "Danh mục của bạn",
"context": "portfolio",
"items": [
{
"primary": "HPG",
"secondary": "500 cổ phiếu",
"right_value": "14,250,000đ",
"change": 12.8
},
{
"primary": "VNM",
"secondary": "200 cổ phiếu",
"right_value": "15,800,000đ",
"change": -5.2
}
]
}
}

Grouped list (MUA/BÁN):

{
"component": "list",
"props": {
"title": "Kế hoạch giao dịch",
"variant": "grouped",
"context": "portfolio",
"groups": [
{
"label": "MUA",
"status": "success",
"items": [
{
"primary": "HPG",
"right_value": "500 cổ phiếu",
"meta": "Giá ước tính: 28,100đ"
},
{
"primary": "FPT",
"right_value": "200 cổ phiếu",
"meta": "Giá ước tính: 82,500đ"
}
]
},
{
"label": "BÁN",
"status": "danger",
"items": [
{
"primary": "VNM",
"right_value": "100 cổ phiếu",
"meta": "Giá ước tính: 78,400đ"
}
]
}
],
"footer": [
{
"label": "Tổng MUA",
"value": "-12,750,000đ",
"status": "danger"
},
{
"label": "Cash sau GD",
"value": "-48,200,000đ",
"status": "danger"
}
]
}
}

items[] props:

FieldTypeDescription
primarystringText chính (bold, bên trái)
secondarystringSubtitle dưới primary
metastringText nhỏ xám
right_valueanyValue bên phải (bold)
right_formatstringFormat cho right_value
right_labelstringLabel dưới right_value
changenumber% thay đổi (auto xanh/đỏ)
Layout

Left = identifier + detail. Right = value + change. Cả 2 bên chia đều width.


valuation_chart

Biểu đồ định giá Simplize — 3 zones (xanh/vàng/đỏ), safety margin, methods table.

{
"component": "valuation_chart",
"props": {
"ticker": "HPG",
"company_name": "Công ty CP Tập đoàn Hòa Phát",
"current_price": 26700,
"intrinsic_value": 24243,
"currency": "vnđ",
"context": "valuation",
"methods": [
{
"name": "P/E",
"value": 22500,
"weight": 30
},
{
"name": "P/B",
"value": 25000,
"weight": 20
},
{
"name": "Chiết khấu dòng tiền",
"value": 24800,
"weight": 50
}
]
}
}

Client auto-calculate: upside/downside %, safety margin, zone boundaries (±20%).

Required props: ticker, current_price, intrinsic_value. Tất cả còn lại optional.


asset_card

Ticker info card — header + mini chart (tự fetch 1Y timeseries) + KPIs.

{
"component": "asset_card",
"props": {
"type": "stock",
"context": "stock",
"items": [
{
"ticker": "HPG",
"name": "Tập đoàn Hòa Phát",
"exchange": "HOSE",
"price": 26700,
"change": -100,
"change_percent": -0.37,
"metrics": [
{
"label": "Vốn hóa",
"value": "204.9T"
},
{
"label": "P/E",
"value": "13.26"
},
{
"label": "P/B",
"value": "1.59"
},
{
"label": "EPS",
"value": "2,013"
},
{
"label": "ROE",
"value": "12.6%"
},
{
"label": "Khối lượng",
"value": "36.8M"
}
]
}
]
}
}
  • type: stock | index | crypto | commodity | gold
  • Multiple tickers → tabs tự động
  • Mini chart tự fetch từ get_historical_prices_range API
  • Metrics grid 3 cột với dividers

peer_comparison

Bar chart so sánh ticker vs ngành vs peers (Highcharts).

{
"component": "peer_comparison",
"props": {
"ticker": "HPG",
"context": "peer",
"metrics": [
{
"name": "P/B",
"ticker_value": 1.59,
"industry_value": 1.2,
"peers": [
{
"ticker": "NKG",
"value": 0.8
},
{
"ticker": "HSG",
"value": 1.1
},
{
"ticker": "TVN",
"value": 0.9
}
]
}
]
}
}
  • Colors: cam (ticker), xanh (ngành), xanh nhạt (peers)
  • Reference line ngang tại ticker value
  • Grid 2 cột khi nhiều metrics

donut_chart

Pie/donut chart cho tỷ trọng, phân bổ (Highcharts).

{
"component": "donut_chart",
"props": {
"title": "Tỷ trọng danh mục",
"context": "portfolio",
"center_label": "Cổ phiếu",
"center_value": "69%",
"items": [
{
"label": "Cổ phiếu",
"value": 69,
"color": "#3B82F6"
},
{
"label": "Chứng chỉ quỹ",
"value": 12,
"color": "#22C55E"
},
{
"label": "Vàng",
"value": 9,
"color": "#EAB308"
},
{
"label": "Tiền mặt",
"value": 10,
"color": "#EF4444"
}
]
}
}
  • Legend bên phải (Highcharts native)
  • Center text (renderer)
  • Colors auto-assign nếu không truyền

line_chart

Line chart với stat cards + buy/sell markers (Highcharts).

{
"component": "line_chart",
"props": {
"title": "Hiệu suất đầu tư",
"context": "portfolio",
"stat": [
{
"label": "Tổng lợi nhuận",
"value": "+404.4%",
"status": "success"
},
{
"label": "Lãi kép/năm",
"value": "+32.6%",
"status": "success"
},
{
"label": "Tỷ lệ GD lãi",
"value": "65.6%"
}
],
"series": [
{
"name": "Giá cổ phiếu",
"data": [
[
1695600000,
100
],
[
1698278400,
115
],
[
1700870400,
120
]
],
"color": "#22C55E"
}
],
"markers": [
{
"x": 1698278400,
"type": "buy",
"label": "Mua"
},
{
"x": 1703030400,
"type": "sell",
"label": "Bán"
}
]
}
}
  • Stat cards grid 3 cột phía trên chart (bg-gray-100)
  • Values +/- auto color (xanh/đỏ)
  • Markers: buy (▲ xanh), sell (▼ đỏ), event (● xám)
  • data: array of [timestamp_seconds, value]

card_list

Horizontal scrollable cards — 3 variants.

variant media — news, web search:

{
"component": "card_list",
"props": {
"variant": "media",
"items": [
{
"title": "Thép Hòa Phát Dung Quất lắp đặt điện mặt trời",
"image": "https://...",
"source": "bnews.vn",
"time": "11 giờ trước",
"url": "https://..."
}
]
}
}

variant report — analysis reports:

{
"component": "card_list",
"props": {
"variant": "report",
"items": [
{
"ticker": "HPG",
"title": "HPG – BCPT – Vững vàng bước vào chu kì mới",
"badge": "KHẢ QUAN",
"badge_status": "success",
"value": "35,600",
"value_label": "Giá mục tiêu",
"source": "MBS",
"time": "21 ngày",
"url": "https://..."
}
]
}
}

variant default — generic cards:

{
"component": "card_list",
"props": {
"items": [
{
"title": "Card title",
"description": "Description text",
"image": "https://...",
"url": "https://..."
}
]
}
}
  • Scroll buttons (hover to show)
  • badge_status: success (xanh) | danger (đỏ) | warning (vàng) | info (xanh dương)

Context — CTA Button

Tất cả components hỗ trợ context field (optional). Khi có context, ActionButton render CTA link cuối component.

ContextLabelURL
stockXem phân tích 360 trên Simplize/co-phieu/{ticker}/phan-tich
valuationXem phân tích 360 trên Simplize/co-phieu/{ticker}/phan-tich
portfolioXem danh mục trên Simplize/portfolio/{id}
nebulaXem chi tiết Nebula/nebula
watchlistXem danh sách quan sát/watchlist
marketXem thị trường/market
peerXem so sánh ngành trên Simplize/co-phieu/{ticker}/phan-tich

URL tự build từ context + props (ticker, portfolio_id...). Frontend own URL patterns — backend chỉ truyền enum string.

File: native/context.js


Theme System

File visualizeTheme.js cung cấp CSS variables, color ramps, và pre-built classes cho cả SVG và HTML modes.

Color Variables

Light mode:

VariableValueUsage
--color-background-primary#ffffffWhite surfaces
--color-background-secondary#f5f5f4Card backgrounds
--color-text-primary#1a1a1aMain text
--color-text-secondary#5f5e5aMuted text
--color-text-tertiary#888780Hints
--color-text-info#006CECSimplize blue
--color-text-warning#D97706Warning/orange
--color-border-tertiaryrgba(0,0,0,0.15)Default borders

Dark mode: Auto-switch, inverted values.

Color Ramps (Simplize branded)

Ramp50 (fill)400600 (stroke)800
c-blue#E6F2FF#3B82F6#006CEC#002C5C
c-orange#FFF7ED#F97316#EA580C#9A3412
c-teal#F0FDFA#14B8A6#0D9488#115E59
c-violet#EDE9FE#8B5CF6#7C3AED#5B21B6
c-gray#F3F4F6#9CA3AF#6B7280#374151
c-green#F0FDF4#22C55E#16A34A#166534
c-amber#FFFBEB#F59E0B#D97706#92400E
c-red#FEF2F2#EF4444#DC2626#991B1B
c-pink#FDF2F8#EC4899#DB2777#9D174D

Light mode: 50 fill + 600 stroke + 800 title. Dark mode: inverted.

Short Aliases

--p: var(--color-text-primary);
--s: var(--color-text-secondary);
--t: var(--color-text-tertiary);
--bg2: var(--color-background-secondary);
--b: var(--color-border-tertiary);

SVG Mode

SVG render inline qua dangerouslySetInnerHTML + DOMPurify sanitize.

Theme CSS inject qua <style> tag — getSvgThemeCSS(isDark).

Pre-built SVG classes

ClassDescription
.tText 14px regular, primary color
.tsText 12px regular, secondary color
.thText 14px medium (500), primary color
.boxNeutral rect (bg-secondary fill, border stroke)
.nodeClickable group with hover effect
.arrArrow line (2px, secondary color)
.leaderDashed leader line (1px, tertiary color)
.c-{ramp}Color ramp: c-blue, c-orange, c-teal, c-violet, c-gray, c-green, c-amber, c-red, c-pink

SVG Ramp Selectors

Hỗ trợ 2 levels nesting:

/* Direct child */
.c-blue > rect { fill: #E6F2FF; stroke: #006CEC; }
.c-blue > text.th { fill: #002C5C; }

/* 1 level nested (g.node inside g.c-blue) */
.c-blue > g > rect { fill: #E6F2FF; stroke: #006CEC; }
.c-blue > g > text.th { fill: #002C5C; }

Full visualizeTheme.js

Click to expand full source code
const LIGHT = {
'color-background-primary': '#ffffff',
'color-background-secondary': '#f5f5f4',
'color-background-tertiary': '#eeeee8',
'color-background-info': '#E6F2FF',
'color-background-danger': '#fcebeb',
'color-background-success': '#eaf3de',
'color-background-warning': '#FFF4E6',
'color-text-primary': '#1a1a1a',
'color-text-secondary': '#5f5e5a',
'color-text-tertiary': '#888780',
'color-text-info': '#006CEC',
'color-text-danger': '#a32d2d',
'color-text-success': '#3b6d11',
'color-text-warning': '#D97706',
'color-border-tertiary': 'rgba(0,0,0,0.15)',
'color-border-secondary': 'rgba(0,0,0,0.3)',
'color-border-primary': 'rgba(0,0,0,0.4)',
'color-border-info': '#006CEC',
};

const DARK = {
'color-background-primary': '#1a1a1a',
'color-background-secondary': '#2a2a2a',
'color-background-tertiary': '#0f0f0f',
'color-background-info': '#0c2d4a',
'color-background-danger': '#4a1c1c',
'color-background-success': '#1a3a0a',
'color-background-warning': '#3a2800',
'color-text-primary': '#e5e5e5',
'color-text-secondary': '#a0a0a0',
'color-text-tertiary': '#707070',
'color-text-info': '#4DA3FF',
'color-text-danger': '#f09595',
'color-text-success': '#97c459',
'color-text-warning': '#F59E0B',
'color-border-tertiary': 'rgba(255,255,255,0.15)',
'color-border-secondary': 'rgba(255,255,255,0.3)',
'color-border-primary': 'rgba(255,255,255,0.4)',
'color-border-info': '#4DA3FF',
};

const COLOR_RAMPS = {
blue: { 50: '#E6F2FF', 400: '#3B82F6', 600: '#006CEC', 800: '#002C5C' },
orange: { 50: '#FFF7ED', 400: '#F97316', 600: '#EA580C', 800: '#9A3412' },
teal: { 50: '#F0FDFA', 400: '#14B8A6', 600: '#0D9488', 800: '#115E59' },
violet: { 50: '#EDE9FE', 400: '#8B5CF6', 600: '#7C3AED', 800: '#5B21B6' },
gray: { 50: '#F3F4F6', 400: '#9CA3AF', 600: '#6B7280', 800: '#374151' },
green: { 50: '#F0FDF4', 400: '#22C55E', 600: '#16A34A', 800: '#166534' },
amber: { 50: '#FFFBEB', 400: '#F59E0B', 600: '#D97706', 800: '#92400E' },
red: { 50: '#FEF2F2', 400: '#EF4444', 600: '#DC2626', 800: '#991B1B' },
pink: { 50: '#FDF2F8', 400: '#EC4899', 600: '#DB2777', 800: '#9D174D' },
};

const COLOR_RAMPS_DARK = {
blue: { 50: '#002C5C', 200: '#006CEC', 100: '#E6F2FF', 600: '#3B82F6' },
orange: { 50: '#9A3412', 200: '#EA580C', 100: '#FFF7ED', 600: '#F97316' },
teal: { 50: '#115E59', 200: '#0D9488', 100: '#F0FDFA', 600: '#14B8A6' },
violet: { 50: '#5B21B6', 200: '#7C3AED', 100: '#EDE9FE', 600: '#8B5CF6' },
gray: { 50: '#374151', 200: '#6B7280', 100: '#F3F4F6', 600: '#9CA3AF' },
green: { 50: '#166534', 200: '#16A34A', 100: '#F0FDF4', 600: '#22C55E' },
amber: { 50: '#92400E', 200: '#D97706', 100: '#FFFBEB', 600: '#F59E0B' },
red: { 50: '#991B1B', 200: '#DC2626', 100: '#FEF2F2', 600: '#EF4444' },
pink: { 50: '#9D174D', 200: '#DB2777', 100: '#FDF2F8', 600: '#EC4899' },
};

function buildCssVars(vars) {
return Object.entries(vars).map(([k, v]) => `--${k}: ${v};`).join('\n ');
}

function buildRampVars(ramps) {
const lines = [];
for (const [name, stops] of Object.entries(ramps)) {
for (const [stop, color] of Object.entries(stops)) {
lines.push(`--c-${name}-${stop}: ${color};`);
}
}
return lines.join('\n ');
}

export function getThemeVarsCSS(isDark) {
const vars = isDark ? DARK : LIGHT;
const ramps = isDark ? COLOR_RAMPS_DARK : COLOR_RAMPS;
return `:root {
${buildCssVars(vars)}
${buildRampVars(ramps)}
--font-sans: system-ui, -apple-system, sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: ui-monospace, monospace;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--border-radius-xl: 16px;
--p: var(--color-text-primary);
--s: var(--color-text-secondary);
--t: var(--color-text-tertiary);
--bg2: var(--color-background-secondary);
--b: var(--color-border-tertiary);
}`;
}

function buildSvgRampRules(isDark) {
const ramps = isDark ? COLOR_RAMPS_DARK : COLOR_RAMPS;
let css = '';
for (const [name, stops] of Object.entries(ramps)) {
const fill = stops[50];
const stroke = isDark ? stops[200] : stops[600];
const titleColor = isDark ? stops[100] : stops[800];
const subtitleColor = isDark ? stops[200] : stops[600];
css += `
.c-${name} > rect, .c-${name} > circle, .c-${name} > ellipse, .c-${name} > path.fill,
.c-${name} > g > rect, .c-${name} > g > circle, .c-${name} > g > ellipse { fill: ${fill}; stroke: ${stroke}; stroke-width: 0.5px; }
.c-${name} > text, .c-${name} > g > text,
.c-${name} > text.t, .c-${name} > g > text.t,
.c-${name} > text.th, .c-${name} > g > text.th { fill: ${titleColor}; }
.c-${name} > text.ts, .c-${name} > g > text.ts { fill: ${subtitleColor}; }`;
}
return css;
}

export function getSvgThemeCSS(isDark) {
const vars = isDark ? DARK : LIGHT;
return `.visualize-svg-container {
${buildCssVars(vars)}
${buildRampVars(isDark ? COLOR_RAMPS_DARK : COLOR_RAMPS)}
--font-sans: system-ui, -apple-system, sans-serif;
--font-serif: Georgia, 'Times New Roman', serif;
--font-mono: ui-monospace, monospace;
--p: var(--color-text-primary);
--s: var(--color-text-secondary);
--t: var(--color-text-tertiary);
--bg2: var(--color-background-secondary);
--b: var(--color-border-tertiary);
}
.visualize-svg-container { overflow: visible; }
.visualize-svg-container svg { width: 100%; height: auto; display: block; overflow: visible; }
.visualize-svg-container text { font-family: var(--font-sans); }
.visualize-svg-container .t { font-size: 14px; font-weight: 400; fill: ${vars['color-text-primary']}; }
.visualize-svg-container .ts { font-size: 12px; font-weight: 400; fill: ${vars['color-text-secondary']}; }
.visualize-svg-container .th { font-size: 14px; font-weight: 500; fill: ${vars['color-text-primary']}; }
.visualize-svg-container rect.box, .visualize-svg-container .box rect { fill: ${vars['color-background-secondary']}; stroke: ${vars['color-border-tertiary']}; stroke-width: 0.5px; }
.visualize-svg-container .box, .visualize-svg-container .node { cursor: pointer; }
.visualize-svg-container .box:hover rect, .visualize-svg-container .node:hover rect { filter: brightness(0.95); }
.visualize-svg-container .arr { stroke: ${vars['color-text-secondary']}; stroke-width: 2; fill: none; }
.visualize-svg-container .leader { stroke: ${vars['color-text-tertiary']}; stroke-width: 1; stroke-dasharray: 4 3; fill: none; }
${buildSvgRampRules(isDark)}`;
}

export function getHtmlBaseCSS() {
return `* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.7;
color: var(--color-text-primary);
background: transparent;
}
input, select, textarea, button { font-family: inherit; font-size: inherit; }
input[type="range"] { height: 4px; appearance: none; background: var(--color-border-secondary); border-radius: 2px; outline: none; }
input[type="range"]::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--color-background-primary); border: 0.5px solid var(--color-border-secondary); cursor: pointer; }
button { background: transparent; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md); padding: 8px 16px; cursor: pointer; color: var(--color-text-primary); }
button:hover { background: var(--color-background-secondary); }
button:active { transform: scale(0.98); }`;
}

HTML Mode

HTML render trong <iframe srcdoc> sandboxed (allow-scripts allow-same-origin).

  • CSS variables inject vào iframe via getThemeVarsCSS(isDark)
  • Base CSS inject via getHtmlBaseCSS() (body font, form elements, buttons)
  • sendPrompt(text) global function available trong iframe
  • ResizeObserver auto-resize iframe height
  • Dark mode reactive qua useDarkMode() hook — iframe re-render khi theme switch

Thêm component mới

  1. Tạo native/MyComponent.jsx
  2. Add vào native/registry.js
  3. Add JSON schema vào visualize_tools.pyCOMPONENT_SCHEMAS
  4. Add docs vào MODULE_NATIVE string
  5. (Optional) Add context URL vào native/context.js
  6. (Optional) Add validation schema vào backend

Flutter Integration

Artifact API

Tất cả platforms dùng chung API:

GET /v2/artifacts/{artifact_id}

Response:
{
"data": {
"content_type": "svg" | "html" | "component",
"widget_code": "...",
"title": "..."
}
}

Detect visualization: trong message text

final regex = RegExp(r'^visualization:(visual_[a-z0-9]+)$', multiLine: true);
final matches = regex.allMatches(messageText);

for (final match in matches) {
final artifactId = match.group(1)!;
// Fetch artifact → render based on content_type
}

SVG Rendering

Dùng flutter_svg package. Widget code là raw SVG string — render trực tiếp.

import 'package:flutter_svg/flutter_svg.dart';

class SvgVisualizer extends StatelessWidget {
final String svgCode;

const SvgVisualizer({required this.svgCode});

@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;

// Inject theme CSS vào SVG
final themedSvg = _injectTheme(svgCode, isDark);

return SvgPicture.string(
themedSvg,
width: double.infinity,
fit: BoxFit.contain,
);
}

String _injectTheme(String svg, bool isDark) {
// Inject <style> block với CSS variables resolved thành hardcoded values
// vì SVG trong Flutter không support CSS variables

final colors = isDark ? _darkColors : _lightColors;

final style = '''
<style>
text { font-family: sans-serif; }
.t { font-size: 14px; font-weight: 400; fill: ${colors['textPrimary']}; }
.ts { font-size: 12px; font-weight: 400; fill: ${colors['textSecondary']}; }
.th { font-size: 14px; font-weight: 500; fill: ${colors['textPrimary']}; }
.box rect, rect.box { fill: ${colors['bgSecondary']}; stroke: ${colors['borderTertiary']}; stroke-width: 0.5px; }
.arr { stroke: ${colors['textSecondary']}; stroke-width: 2; fill: none; }
.leader { stroke: ${colors['textTertiary']}; stroke-width: 1; stroke-dasharray: 4 3; fill: none; }
${_buildRampStyles(isDark)}
</style>
''';

// Insert style after opening <svg> tag
return svg.replaceFirst(RegExp(r'(<svg[^>]*>)'), '\$1$style');
}

static const _lightColors = {
'textPrimary': '#1a1a1a',
'textSecondary': '#5f5e5a',
'textTertiary': '#888780',
'bgSecondary': '#f5f5f4',
'borderTertiary': 'rgba(0,0,0,0.15)',
};

static const _darkColors = {
'textPrimary': '#e5e5e5',
'textSecondary': '#a0a0a0',
'textTertiary': '#707070',
'bgSecondary': '#2a2a2a',
'borderTertiary': 'rgba(255,255,255,0.15)',
};

// Color ramps — Simplize branded
static const _ramps = {
'blue': {'50': '#E6F2FF', '600': '#006CEC', '800': '#002C5C'},
'orange': {'50': '#FFF7ED', '600': '#EA580C', '800': '#9A3412'},
'teal': {'50': '#F0FDFA', '600': '#0D9488', '800': '#115E59'},
'violet': {'50': '#EDE9FE', '600': '#7C3AED', '800': '#5B21B6'},
'gray': {'50': '#F3F4F6', '600': '#6B7280', '800': '#374151'},
'green': {'50': '#F0FDF4', '600': '#16A34A', '800': '#166534'},
'amber': {'50': '#FFFBEB', '600': '#D97706', '800': '#92400E'},
'red': {'50': '#FEF2F2', '600': '#DC2626', '800': '#991B1B'},
'pink': {'50': '#FDF2F8', '600': '#DB2777', '800': '#9D174D'},
};

static String _buildRampStyles(bool isDark) {
final buf = StringBuffer();
for (final entry in _ramps.entries) {
final name = entry.key;
final stops = entry.value;
final fill = stops['50']!;
final stroke = stops['600']!;
final title = stops['800']!;
buf.writeln('.c-$name > rect, .c-$name > g > rect { fill: $fill; stroke: $stroke; stroke-width: 0.5px; }');
buf.writeln('.c-$name > text, .c-$name > g > text { fill: $title; }');
buf.writeln('.c-$name > text.ts, .c-$name > g > text.ts { fill: $stroke; }');
}
return buf.toString();
}
}
flutter_svg limitations
  • flutter_svg không support CSS variables (var(--xxx)) — phải resolve thành hardcoded hex
  • Không support class selectors đầy đủ — inject inline <style> block vào SVG
  • onclick / sendPrompt() không hoạt động trong Flutter — chỉ hiển thị static

HTML Rendering (iframe equivalent)

Dùng webview_flutter hoặc flutter_inappwebview. Wrap HTML trong full document với theme CSS.

import 'package:webview_flutter/webview_flutter.dart';

class HtmlVisualizer extends StatefulWidget {
final String htmlCode;
final String? title;

const HtmlVisualizer({required this.htmlCode, this.title});

@override
State<HtmlVisualizer> createState() => _HtmlVisualizerState();
}

class _HtmlVisualizerState extends State<HtmlVisualizer> {
late final WebViewController _controller;
double _height = 400;

@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('FlutterBridge', onMessageReceived: (message) {
final data = jsonDecode(message.message);
if (data['type'] == 'resize') {
setState(() => _height = (data['height'] as num).toDouble().clamp(100, 2000));
}
if (data['type'] == 'sendPrompt') {
// Handle sendPrompt — inject into chat input
}
})
..loadHtmlString(_buildFullHtml());
}

String _buildFullHtml() {
final isDark = Theme.of(context).brightness == Brightness.dark;

return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${_getThemeVarsCSS(isDark)}
${_getBaseCSS()}
</style>
</head>
<body>
${widget.htmlCode}
<script>
function sendPrompt(text) {
FlutterBridge.postMessage(JSON.stringify({ type: 'sendPrompt', text: text }));
}
new ResizeObserver(function() {
var h = document.documentElement.scrollHeight;
FlutterBridge.postMessage(JSON.stringify({ type: 'resize', height: h }));
}).observe(document.body);
FlutterBridge.postMessage(JSON.stringify({ type: 'resize', height: document.documentElement.scrollHeight }));
</script>
</body>
</html>''';
}

String _getThemeVarsCSS(bool isDark) {
// Same CSS variables as visualizeTheme.js getThemeVarsCSS()
if (isDark) {
return ''':root {
--color-background-primary: #1a1a1a;
--color-background-secondary: #2a2a2a;
--color-text-primary: #e5e5e5;
--color-text-secondary: #a0a0a0;
--color-text-tertiary: #707070;
--color-border-tertiary: rgba(255,255,255,0.15);
--color-border-secondary: rgba(255,255,255,0.3);
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--border-radius-md: 8px;
--border-radius-lg: 12px;
}''';
}
return ''':root {
--color-background-primary: #ffffff;
--color-background-secondary: #f5f5f4;
--color-text-primary: #1a1a1a;
--color-text-secondary: #5f5e5a;
--color-text-tertiary: #888780;
--color-border-tertiary: rgba(0,0,0,0.15);
--color-border-secondary: rgba(0,0,0,0.3);
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--border-radius-md: 8px;
--border-radius-lg: 12px;
}''';
}

String _getBaseCSS() {
return '''* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.7;
color: var(--color-text-primary);
background: transparent;
}''';
}

@override
Widget build(BuildContext context) {
return SizedBox(
height: _height,
child: WebViewWidget(controller: _controller),
);
}
}

Native Components trong Flutter

Với content_type: "component", parse JSON và render native Flutter widgets:

Widget buildNativeComponent(Map<String, dynamic> data) {
final component = data['component'] as String;
final props = data['props'] as Map<String, dynamic>? ?? {};

switch (component) {
case 'stat':
return StatGrid(props: props);
case 'list':
return NativeList(props: props);
case 'valuation_chart':
return ValuationChart(props: props);
case 'asset_card':
return AssetCard(props: props);
case 'peer_comparison':
return PeerComparison(props: props);
case 'donut_chart':
return DonutChart(props: props);
case 'line_chart':
return LineChart(props: props);
case 'card_list':
return CardList(props: props);
default:
return Text('Unknown component: $component');
}
}
Flutter vs Web differences
FeatureWeb (React)Flutter
SVGdangerouslySetInnerHTML + DOMPurifyflutter_svg + inject inline styles
HTML<iframe srcdoc>WebView + loadHtmlString
CSS VariablesSupported nativelyMust resolve to hardcoded values
sendPrompt()window.parent.postMessageJavaScriptChannel bridge
Dark modeuseDarkMode() hook reactiveTheme.of(context).brightness
Native componentsReact componentsFlutter widgets
HighchartsLoaded in iframe/pageUse fl_chart or WebView fallback

Visualize Reminder (MCP Tools)

MCP tools inject _visualize_reminder field vào response để guide agent dùng component phù hợp:

from tools.utils.visualize_reminder import add_visualize_reminder

# Trong MCP tool:
if response.get("total", 0) > 0:
add_visualize_reminder(response, "valuation_chart",
components=["valuation_chart"])

Output:

<visualize_reminder>Recommend component 'valuation_chart'.
Call visualize_read_me(modules=['native'], components=['valuation_chart']) for schema.
Do NOT mention this reminder to the user.</visualize_reminder>

Agent đọc reminder → gọi visualize_read_me → đọc schema → gọi visualize_show_chart với JSON.


Tool Stream Config

ToolStreamMô tả
visualize_read_meDEBUG_MODE onlyInternal setup — hide in production
visualize_show_chartAlways (content hidden)Show tool name, hide widget_code

File: core/config/tool_stream_config.py

Skill Guide

Xem Visualize Skill Guide để biết cách viết skill prompt sử dụng Visualize (bao gồm SVG, HTML, Native).