Widget System — Parse, Render & Integration Guide
Tổng Quan
Widget là generative UI components được agent nhúng vào text response. Thay vì chỉ trả về văn bản, agent có thể nhúng các widget tương tác (biểu đồ giá, báo cáo tài chính, tin tức...) trực tiếp trong câu trả lời.
Hai Nguồn Widget
1. Nhúng trong text block ← phổ biến
AI response text: "Cổ phiếu HPG đang giao dịch..."
<widget type="stock_info" mode="realtime" params='{"tickers":["HPG"]}'></widget>
2. Artifact trong tool_result ← dành cho web_search và tool đặc biệt
tool_result.artifact = { sources: [...] }
Phần 1 — Widget Nhúng Trong Text
Cú Pháp Nhúng Widget
Server trả về hai format:
Format Attributes (Preferred)
<widget type="stock_info" mode="realtime" params='{"tickers":["HPG"]}'></widget>
Format JSON (Legacy)
<widget>{"type":"stock_info","mode":"realtime","params":{"tickers":["HPG"]}}</widget>
Cả hai format đều valid. Client phải hỗ trợ cả hai.
Cấu Trúc Widget Config
Widget Realtime
Client tự gọi API để lấy data mới nhất:
{
"type": "stock_info",
"mode": "realtime",
"params": {
"tickers": [
"HPG",
"VNM"
],
"info_types": [
"price",
"fundamentals"
],
"interval": "1d"
}
}
Widget Static (từ Artifact)
Client fetch artifact từ DB:
{
"artifact_id": "widget_abc123",
"mode": "static"
}
Widget Hybrid
Client fetch artifact + gọi API để merge data mới:
{
"artifact_id": "widget_abc123",
"api": "get_financial_reports",
"params": {
"tickers": [
"HPG"
]
}
}
Phần 2 — Các Loại Widget (6 Types)
1. stock_info — Biểu Đồ Giá & Thông Tin Cổ Phiếu
Dùng khi: Hỏi về giá cổ phiếu, chỉ số thị trường, hàng hóa, crypto.
{
"type": "stock_info",
"mode": "realtime",
"params": [
{
"tickers": [
"HPG"
],
"info_types": [
"price",
"fundamentals"
],
"date": 0,
"interval": "1d",
"from_timestamp": -1,
"to_timestamp": 0,
"type": "stock"
}
]
}
| Field | Giá trị | Mô tả |
|---|---|---|
tickers | ["HPG", "VNM"] | Danh sách mã |
info_types | ["price", "summary", "fundamentals"] | Loại thông tin |
date | 0 = realtime, unix timestamp = lịch sử | Thời điểm |
interval | "1h", "1d", "1w", "1mo" | Khung thời gian |
from_timestamp | unix timestamp, -1 = auto | Bắt đầu |
to_timestamp | unix timestamp, 0 = now | Kết thúc |
type | "stock", "index", "commodity", "crypto" | Loại tài sản |
params là array — có thể truyền nhiều config cùng lúc cho multi-ticker với interval khác nhau.
2. news_feed — Tin Tức Thị Trường
Dùng khi: Hỏi về tin tức công ty, thị trường chứng khoán.
{
"type": "news_feed",
"mode": "realtime",
"params": {
"tickers": [
"HPG"
],
"layout": "cards",
"group_by": "ticker",
"ids": [
"article_id_1",
"article_id_2"
],
"featured_ids": [
"article_id_3"
]
}
}
| Field | Giá trị | Mô tả |
|---|---|---|
tickers | ["HPG", "VNM"] | Lọc theo mã (null = tất cả) |
layout | "cards", "slider", "list" | Kiểu hiển thị |
group_by | "ticker", "category", null | Nhóm bài viết |
ids | ["id1", "id2"] | Bài viết LLM đã chọn |
featured_ids | ["id3"] | Bài viết nổi bật (hero card) |
3. analysis_report — Báo Cáo Phân Tích
Dùng khi: Hỏi về khuyến nghị, giá mục tiêu từ các công ty chứng khoán.
{
"type": "analysis_report",
"mode": "realtime",
"params": {
"tickers": [
"HPG",
"MBB"
],
"layout": "slider",
"ids": [
"report_id_1"
]
}
}
| Field | Giá trị | Mô tả |
|---|---|---|
tickers | ["HPG"] | Lọc theo mã |
layout | "slider", "cards", "list" | Kiểu hiển thị |
ids | ["id1"] | Report IDs cụ thể (tùy chọn) |
4. financial_chart — Biểu Đồ Tài Chính
Dùng khi: Hỏi về báo cáo tài chính (doanh thu, lợi nhuận, ROE, v.v.)
{
"type": "financial_chart",
"mode": "realtime",
"layout": "single",
"params": {
"tickers": [
"HPG"
],
"report_types": [
"income_statement",
"ratios"
],
"period": "Q",
"size": 8,
"series": [
{
"indicator": "Doanh thu thuần",
"type": "column",
"yAxis": "left"
},
{
"indicator": "Biên lợi nhuận gộp",
"type": "line",
"yAxis": "right"
}
]
}
}
| Field | Giá trị | Mô tả |
|---|---|---|
report_types | "balance_sheet", "income_statement", "cash_flow", "ratios" | Loại báo cáo |
period | "Q" (quý), "Y" (năm) | Kỳ báo cáo |
size | 1–20, default 8 | Số kỳ hiển thị |
layout | "single" (1 ticker), "comparison" (nhiều ticker) | Layout |
series[].indicator | tên chỉ tiêu chính xác | Chỉ tiêu muốn vẽ |
series[].type | "column", "line", "area", "spline" | Loại biểu đồ |
series[].yAxis | "left" (Tỷ đồng), "right" (%) | Trục Y |
Quy tắc series:
- Cùng đơn vị (Tỷ đồng) → left axis, dạng column
- Tỷ lệ phần trăm / tỷ suất → right axis, dạng line
- Tăng trưởng → right axis, dạng line
5. peer_comparison — So Sánh Ngành
Dùng khi: Hỏi về so sánh công ty với peers, định giá ngành.
{
"type": "peer_comparison",
"mode": "realtime",
"layout": "single",
"params": {
"tickers": [
"HPG"
],
"metrics": [
"PE",
"PB",
"ROE"
],
"peers": "MBB,VCB,BID"
}
}
| Field | Giá trị | Mô tả |
|---|---|---|
metrics | xem bên dưới | Chỉ số so sánh |
peers | "MBB,VCB" (comma-separated) | Peers tùy chỉnh (optional) |
layout | "single" (1 metric), "grid" (nhiều metric) | Layout |
Metrics hỗ trợ: PE, PB, EV_EBITDA, ROE, ROA, profit_margin, revenue_growth, net_income_growth, debt_to_equity, NIM (ngân hàng), NPL_ratio (ngân hàng)
6. valuation — Định Giá Cổ Phiếu
Dùng khi: Hỏi về định giá nội tại, cổ phiếu rẻ/đắt.
{
"type": "valuation",
"mode": "realtime",
"params": {
"tickers": [
"HPG"
]
}
}
Phần 3 — Tool Result & Artifact
Artifact Trong Tool Result
Một số tool trả kèm artifact trong content_block_start:
{
"type": "content_block_start",
"index": 3,
"content_block": {
"type": "tool_result",
"tool_use_id": "toolu_01XyzAbc",
"content": "Tìm thấy 5 kết quả liên quan...",
"status": "success",
"artifact": {
"sources": [
{
"url": "https://simplize.vn/co-phieu/HPG",
"title": "Cổ phiếu HPG — Hòa Phát",
"domain": "simplize.vn",
"favicon": "https://simplize.vn/favicon.ico"
}
]
}
}
}
Artifact Của Web Search
Tool web_search luôn trả artifact với danh sách nguồn:
{
"artifact": {
"sources": [
{
"url": "https://example.com/article",
"title": "Tiêu đề bài viết",
"domain": "example.com",
"favicon": "https://example.com/favicon.ico"
}
]
}
}
Frontend render: Hiển thị danh sách sources dạng clickable links với favicon + domain.
Merge Artifact Vào Tool Block
// Khi nhận content_block_stop của tool_result
toolUseBlock.toolResult = toolResultBlock.content;
toolUseBlock.toolResultError = toolResultBlock.status === 'error' || toolResultBlock.status === 'cancelled';
if (toolResultBlock.artifact) {
toolUseBlock.artifact = toolResultBlock.artifact; // ← merge artifact
}
Phần 4 — Frontend Implementation
Bước 1: Parse Widget Từ Text
Widgets nằm trong text block của assistant. Client phải extract trước khi render:
function parseWidgets(text, isStreaming = false) {
const parts = [];
// Regex match cả hai format
const widgetRegex = /<widget([^>]*)>(.*?)<\/widget>/gs;
let lastIndex = 0;
let match;
while ((match = widgetRegex.exec(text)) !== null) {
// Text trước widget
if (match.index > lastIndex) {
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
}
const config = parseWidgetConfig(match[1], match[2]);
if (isStreaming && !config) {
parts.push({ type: 'widget_loading' });
} else if (config) {
parts.push({ type: 'widget', config });
}
lastIndex = match.index + match[0].length;
}
// Text sau widget cuối
if (lastIndex < text.length) {
parts.push({ type: 'text', content: text.slice(lastIndex) });
}
return parts;
}
function parseWidgetConfig(attrs, innerContent) {
// Format attributes: type="stock_info" params='...'
if (attrs.trim()) {
const typeMatch = attrs.match(/type="([^"]+)"/);
const paramsMatch = attrs.match(/params='([^']+)'/);
if (typeMatch) {
return {
type: typeMatch[1],
mode: 'realtime',
params: paramsMatch ? JSON.parse(paramsMatch[1]) : {},
};
}
// artifact_id format
const artifactMatch = attrs.match(/artifact_id="([^"]+)"/);
if (artifactMatch) {
return { artifact_id: artifactMatch[1] };
}
}
// Format JSON
if (innerContent.trim()) {
try {
return JSON.parse(innerContent.trim());
} catch {
return null;
}
}
return null;
}
Bước 2: Render Parts (Text + Widget)
function TextBlock({ text, isStreaming }) {
const parts = useMemo(
() => parseWidgets(text, isStreaming),
[text, isStreaming]
);
return (
<div>
{parts.map((part, index) => {
if (part.type === 'text') {
return <MarkdownText key={index}>{part.content}</MarkdownText>;
}
if (part.type === 'widget') {
return <WidgetRenderer key={index} config={part.config} />;
}
if (part.type === 'widget_loading') {
return <WidgetSkeleton key={index} />;
}
return null;
})}
</div>
);
}
Bước 3: WidgetRenderer — Xác Định Mode & Gọi API
// Map widget type → backend API
const TYPE_TO_API = {
stock_info: 'get_ticker_info',
news_feed: 'get_news',
analysis_report: 'get_analysis_reports',
financial_chart: 'get_financial_reports',
peer_comparison: 'get_peer_comparison',
valuation: 'get_valuation_analysis',
};
// Widget Registry — map type → component
const WIDGET_REGISTRY = {
stock_info: StockInfoCard,
news_feed: NewsFeedWidget,
analysis_report: AnalysisReportWidget,
financial_chart: FinancialChartWidget,
peer_comparison: PeerComparisonWidget,
valuation: ValuationWidget,
};
function WidgetRenderer({ config }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
loadWidget(config);
}, [config]);
async function loadWidget(config) {
setLoading(true);
try {
let widgetData;
if (config.artifact_id) {
// STATIC mode
widgetData = await fetchWidgetArtifact(config.artifact_id);
// HYBRID mode: artifact có api field
if (widgetData?.api) {
const fresh = await callWidgetApi(widgetData.api, widgetData.params);
widgetData = mergeArtifactWithData(widgetData, fresh);
}
} else {
// REALTIME mode
const api = TYPE_TO_API[config.type];
widgetData = await callWidgetApi(api, config.params);
}
setData(widgetData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
if (loading) return <WidgetSkeleton />;
if (error) return <WidgetError message={error} />;
const Component = WIDGET_REGISTRY[config.type] ?? GenericWidget;
return <Component data={data} config={config} />;
}
API Calls Cho Widget
// Realtime: gọi backend proxy
async function callWidgetApi(api, params) {
const res = await fetch('/api/widget/finance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api, params }),
});
return res.json();
}
// Static: fetch artifact từ DB
async function fetchWidgetArtifact(artifactId, resolveRealtime = true) {
const res = await fetch(
`/api/widget/artifact/${artifactId}?resolve_realtime=${resolveRealtime}`
);
return res.json();
}
Phần 5 — Flutter (Dart) Implementation
Parse Widget Từ Text
class WidgetPart {
final String type; // 'text', 'widget', 'widget_loading'
final String? content;
final Map<String, dynamic>? config;
const WidgetPart({required this.type, this.content, this.config});
}
List<WidgetPart> parseWidgets(String text, {bool isStreaming = false}) {
final parts = <WidgetPart>[];
final widgetRegex = RegExp(r'<widget([^>]*)>(.*?)<\/widget>', dotAll: true);
int lastIndex = 0;
for (final match in widgetRegex.allMatches(text)) {
if (match.start > lastIndex) {
parts.add(WidgetPart(
type: 'text',
content: text.substring(lastIndex, match.start),
));
}
final config = _parseWidgetConfig(match.group(1) ?? '', match.group(2) ?? '');
if (isStreaming && config == null) {
parts.add(const WidgetPart(type: 'widget_loading'));
} else if (config != null) {
parts.add(WidgetPart(type: 'widget', config: config));
}
lastIndex = match.end;
}
if (lastIndex < text.length) {
parts.add(WidgetPart(type: 'text', content: text.substring(lastIndex)));
}
return parts;
}
Map<String, dynamic>? _parseWidgetConfig(String attrs, String inner) {
// Format attributes
final typeMatch = RegExp(r'type="([^"]+)"').firstMatch(attrs);
if (typeMatch != null) {
final paramsMatch = RegExp(r"params='([^']+)'").firstMatch(attrs);
return {
'type': typeMatch.group(1),
'mode': 'realtime',
'params': paramsMatch != null
? jsonDecode(paramsMatch.group(1)!)
: <String, dynamic>{},
};
}
final artifactMatch = RegExp(r'artifact_id="([^"]+)"').firstMatch(attrs);
if (artifactMatch != null) {
return {'artifact_id': artifactMatch.group(1)};
}
// JSON format
if (inner.trim().isNotEmpty) {
try {
return jsonDecode(inner.trim()) as Map<String, dynamic>;
} catch (_) {
return null;
}
}
return null;
}
WidgetRenderer Widget
class WidgetRenderer extends StatefulWidget {
final Map<String, dynamic> config;
const WidgetRenderer({required this.config});
@override
State<WidgetRenderer> createState() => _WidgetRendererState();
}
class _WidgetRendererState extends State<WidgetRenderer> {
static const _typeToApi = {
'stock_info': 'get_ticker_info',
'news_feed': 'get_news',
'analysis_report': 'get_analysis_reports',
'financial_chart': 'get_financial_reports',
'peer_comparison': 'get_peer_comparison',
'valuation': 'get_valuation_analysis',
};
Map<String, dynamic>? _data;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadWidget();
}
Future<void> _loadWidget() async {
try {
Map<String, dynamic> data;
if (widget.config.containsKey('artifact_id')) {
// Static or hybrid mode
data = await WidgetApi.fetchArtifact(widget.config['artifact_id'] as String);
if (data.containsKey('api')) {
final fresh = await WidgetApi.callApi(data['api'] as String, data['params']);
data = {...data, ...fresh};
}
} else {
// Realtime mode
final api = _typeToApi[widget.config['type'] as String];
if (api == null) throw Exception('Unknown widget type: ${widget.config['type']}');
data = await WidgetApi.callApi(api, widget.config['params']);
}
setState(() {
_data = data;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_loading) return const WidgetSkeleton();
if (_error != null) return WidgetError(message: _error!);
return _buildWidget();
}
Widget _buildWidget() {
final type = widget.config['type'] as String?;
switch (type) {
case 'stock_info': return StockInfoCard(data: _data!, config: widget.config);
case 'news_feed': return NewsFeedWidget(data: _data!, config: widget.config);
case 'financial_chart': return FinancialChartWidget(data: _data!, config: widget.config);
case 'peer_comparison': return PeerComparisonWidget(data: _data!, config: widget.config);
case 'valuation': return ValuationWidget(data: _data!, config: widget.config);
case 'analysis_report': return AnalysisReportWidget(data: _data!, config: widget.config);
default: return GenericWidget(data: _data!, config: widget.config);
}
}
}
Phần 6 — Tool-to-Widget Mapping
Bảng tra cứu: tool nào trả widget gì.
| Tool | Widget Type | Điều Kiện |
|---|---|---|
get_ticker_info | stock_info | Giá + indicators thực tế |
get_historical_prices_range | stock_info | OHLCV chart lịch sử |
get_index_quotes | stock_info | type = "index" |
get_market_overview | stock_info | Tổng quan thị trường |
get_company_news | news_feed | Tin tức công ty/thị trường |
get_analysis_reports | analysis_report | Báo cáo phân tích |
get_financial_reports | financial_chart | BCTC, tỷ số tài chính |
get_company_industry_peer_comparison | peer_comparison | So sánh ngành |
get_valuation_analysis | valuation | Định giá nội tại |
web_search | (artifact.sources) | Sources hiển thị trong tool block |
Phần 7 — Response Format Đầy Đủ Từ Tool
Khi tool trả về có widget, tool_result.content chứa text summary còn widget config nằm trong artifact:
Ví Dụ: get_ticker_info Response
{
"type": "content_block_start",
"index": 4,
"content_block": {
"type": "tool_result",
"tool_use_id": "toolu_01XyzAbc",
"tool_name": "get_ticker_info",
"content": "HPG - Tập đoàn Hòa Phát\nGiá: 25,500 VND (+2.0%)\nPE: 8.5 | PB: 1.2 | ROE: 15.5%",
"status": "success",
"artifact": {
"widget": {
"type": "stock_info",
"mode": "realtime",
"params": [
{
"tickers": [
"HPG"
],
"info_types": [
"price",
"fundamentals"
],
"date": 0,
"interval": "1d",
"from_timestamp": -1,
"to_timestamp": 0,
"type": "stock"
}
]
}
}
}
}
Sau Khi AI Xử Lý
AI dùng tool result data để viết response và nhúng widget vào text:
Cổ phiếu **HPG** đang giao dịch ở mức 25,500 VND (+2.0%), định giá hợp lý với PE=8.5.
<widget type="stock_info" mode="realtime" params='[{"tickers":["HPG"],"info_types":["price","fundamentals"],"interval":"1d","type":"stock"}]'></widget>
Về mặt kỹ thuật, HPG đang trong xu hướng tăng ngắn hạn...
Phần 8 — Streaming Widget (Khi Đang Stream)
Widget thường xuất hiện khi stream text block đang chạy. Client cần handle trường hợp tag widget chưa đóng:
// Khi isStreaming = true và tag chưa đóng hoàn toàn:
// "...đây là biểu đồ <widget type=\"stock_info\"" ← chưa đóng tag
// → parseWidgets() trả về widget_loading placeholder
// Khi stream xong (content_block_stop):
// "<widget type=\"stock_info\" ...></widget>"
// → parseWidgets() trả về widget đầy đủ → WidgetRenderer load data
// Placeholder khi đang chờ
function WidgetSkeleton() {
return (
<div className="w-full h-48 bg-gray-100 rounded-lg animate-pulse flex items-center justify-center">
<span className="text-gray-400 text-sm">Đang tải widget...</span>
</div>
);
}
Phần 9 — Tóm Tắt Data Flow
1. Agent gọi tool (ví dụ: get_ticker_info)
→ content_block_start (tool_use)
→ content_block_stop
2. Tool thực thi, trả kết quả
→ content_block_start (tool_result)
content: "HPG: 25,500 VND..."
artifact.widget: { type: "stock_info", params: [...] }
→ content_block_stop
3. Client merge artifact vào tool_use block
→ toolUseBlock.artifact = toolResultBlock.artifact
4. Agent viết response text với widget nhúng
→ content_block_start (text block)
→ content_block_delta (stream text + <widget> tag)
→ content_block_stop
5. Client parse text → tìm <widget> tag
→ parseWidgets(text) → [TextPart, WidgetPart, TextPart]
6. WidgetRenderer nhận config
→ Mode realtime: gọi /api/widget/finance
→ Mode static: fetch /api/widget/artifact/{id}
7. Route đến component theo type
stock_info → StockInfoCard
news_feed → NewsFeedWidget
financial_chart → FinancialChartWidget
peer_comparison → PeerComparisonWidget
valuation → ValuationWidget
analysis_report → AnalysisReportWidget