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)
| Component | API Call | Endpoint | Mô tả |
|---|---|---|---|
AssetCard | callWidgetApi('get_historical_prices_range', {...}) | POST /api/widget/finance | Fetch 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
| Type | widget_code content | Detect | Render |
|---|---|---|---|
component | JSON string có "component" key | startsWith("{") + has "component" | ComponentRenderer → Native React component |
svg | Raw SVG markup | startsWith("<svg") | SvgRenderer → dangerouslySetInnerHTML + DOMPurify |
html | Raw HTML fragment | Còn lại | HtmlRenderer → <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
| Component | File | Mô tả |
|---|---|---|
stat | NativeStat.jsx | Grid KPI cards (2-4 metrics) |
donut_chart | DonutChart.jsx | Pie/donut chart (Highcharts) |
line_chart | LineChart.jsx | Line chart + stat cards + markers (Highcharts) |
list | NativeList.jsx | Flexible list, simple hoặc grouped |
valuation_chart | ValuationChart.jsx | Biểu đồ định giá (green/yellow/red zones) |
asset_card | AssetCard.jsx | Ticker info + mini chart (tự fetch timeseries) |
peer_comparison | PeerComparison.jsx | Bar chart so sánh ngành (Highcharts) |
card_list | CardList.jsx | Horizontal 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:
| Field | Type | Description |
|---|---|---|
label | string | Label phía trên value |
value | any | Giá trị hiển thị |
format | string | number | currency | percent | compact | date |
prefix | string | Trước value |
suffix | string | Sau value |
change | number | % thay đổi (auto xanh/đỏ) |
status | string | success | danger | warning | info |
description | string | Text nhỏ dưới value |
number→26,700(vi-VN locale)currency→26,700đpercent→+2.12%hoặc-2.12%(auto sign + color)compact→21.3M,1.2B,500Kdate→20/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:
| Field | Type | Description |
|---|---|---|
primary | string | Text chính (bold, bên trái) |
secondary | string | Subtitle dưới primary |
meta | string | Text nhỏ xám |
right_value | any | Value bên phải (bold) |
right_format | string | Format cho right_value |
right_label | string | Label dưới right_value |
change | number | % thay đổi (auto xanh/đỏ) |
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_rangeAPI - 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.
| Context | Label | URL |
|---|---|---|
stock | Xem phân tích 360 trên Simplize | /co-phieu/{ticker}/phan-tich |
valuation | Xem phân tích 360 trên Simplize | /co-phieu/{ticker}/phan-tich |
portfolio | Xem danh mục trên Simplize | /portfolio/{id} |
nebula | Xem chi tiết Nebula | /nebula |
watchlist | Xem danh sách quan sát | /watchlist |
market | Xem thị trường | /market |
peer | Xem 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:
| Variable | Value | Usage |
|---|---|---|
--color-background-primary | #ffffff | White surfaces |
--color-background-secondary | #f5f5f4 | Card backgrounds |
--color-text-primary | #1a1a1a | Main text |
--color-text-secondary | #5f5e5a | Muted text |
--color-text-tertiary | #888780 | Hints |
--color-text-info | #006CEC | Simplize blue |
--color-text-warning | #D97706 | Warning/orange |
--color-border-tertiary | rgba(0,0,0,0.15) | Default borders |
Dark mode: Auto-switch, inverted values.
Color Ramps (Simplize branded)
| Ramp | 50 (fill) | 400 | 600 (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
| Class | Description |
|---|---|
.t | Text 14px regular, primary color |
.ts | Text 12px regular, secondary color |
.th | Text 14px medium (500), primary color |
.box | Neutral rect (bg-secondary fill, border stroke) |
.node | Clickable group with hover effect |
.arr | Arrow line (2px, secondary color) |
.leader | Dashed 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 iframeResizeObserverauto-resize iframe height- Dark mode reactive qua
useDarkMode()hook — iframe re-render khi theme switch
Thêm component mới
- Tạo
native/MyComponent.jsx - Add vào
native/registry.js - Add JSON schema vào
visualize_tools.py→COMPONENT_SCHEMAS - Add docs vào
MODULE_NATIVEstring - (Optional) Add context URL vào
native/context.js - (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_svgkhông support CSS variables (var(--xxx)) — phải resolve thành hardcoded hex- Không support
classselectors đầ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');
}
}
| Feature | Web (React) | Flutter |
|---|---|---|
| SVG | dangerouslySetInnerHTML + DOMPurify | flutter_svg + inject inline styles |
| HTML | <iframe srcdoc> | WebView + loadHtmlString |
| CSS Variables | Supported natively | Must resolve to hardcoded values |
sendPrompt() | window.parent.postMessage | JavaScriptChannel bridge |
| Dark mode | useDarkMode() hook reactive | Theme.of(context).brightness |
| Native components | React components | Flutter widgets |
| Highcharts | Loaded in iframe/page | Use 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
| Tool | Stream | Mô tả |
|---|---|---|
visualize_read_me | DEBUG_MODE only | Internal setup — hide in production |
visualize_show_chart | Always (content hidden) | Show tool name, hide widget_code |
File: core/config/tool_stream_config.py
Xem Visualize Skill Guide để biết cách viết skill prompt sử dụng Visualize (bao gồm SVG, HTML, Native).