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

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"
}
]
}
FieldGiá trịMô tả
tickers["HPG", "VNM"]Danh sách mã
info_types["price", "summary", "fundamentals"]Loại thông tin
date0 = realtime, unix timestamp = lịch sửThời điểm
interval"1h", "1d", "1w", "1mo"Khung thời gian
from_timestampunix timestamp, -1 = autoBắt đầu
to_timestampunix timestamp, 0 = nowKế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"
]
}
}
FieldGiá 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", nullNhó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"
]
}
}
FieldGiá 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"
}
]
}
}
FieldGiá 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
size1–20, default 8Số kỳ hiển thị
layout"single" (1 ticker), "comparison" (nhiều ticker)Layout
series[].indicatortên chỉ tiêu chính xácChỉ 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"
}
}
FieldGiá trịMô tả
metricsxem bên dướiChỉ 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"
}
]
}
}
}

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ì.

ToolWidget TypeĐiều Kiện
get_ticker_infostock_infoGiá + indicators thực tế
get_historical_prices_rangestock_infoOHLCV chart lịch sử
get_index_quotesstock_infotype = "index"
get_market_overviewstock_infoTổng quan thị trường
get_company_newsnews_feedTin tức công ty/thị trường
get_analysis_reportsanalysis_reportBáo cáo phân tích
get_financial_reportsfinancial_chartBCTC, tỷ số tài chính
get_company_industry_peer_comparisonpeer_comparisonSo sánh ngành
get_valuation_analysisvaluationĐị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