Skip to main content

adk_ui/tools/
render_layout.rs

1use crate::schema::*;
2use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
3use adk_core::{Result, Tool, ToolContext};
4use async_trait::async_trait;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::sync::Arc;
10
11/// A section in a dashboard layout.
12///
13/// Each section has a `type` field that determines which other fields are used:
14/// - `"stats"`: Uses `stats` field for label/value/status items
15/// - `"text"`: Uses `text` field for plain text content
16/// - `"alert"`: Uses `message` and `severity` fields
17/// - `"table"`: Uses `columns` and `rows` fields
18/// - `"chart"`: Uses `chart_type`, `data`, `x_key`, `y_keys` fields
19/// - `"key_value"`: Uses `pairs` field for key-value display
20/// - `"list"`: Uses `items` and `ordered` fields
21/// - `"code_block"`: Uses `code` and `language` fields
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23pub struct DashboardSection {
24    /// Section title displayed as card header
25    pub title: String,
26    /// Type of content: "stats", "table", "chart", "alert", "text", "key_value", "list", "code_block"
27    #[serde(rename = "type")]
28    pub section_type: String,
29    /// For stats sections: list of label/value pairs with optional status
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub stats: Option<Vec<StatItem>>,
32    /// For text sections: the text content
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub text: Option<String>,
35    /// For alert sections: the message to display
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub message: Option<String>,
38    /// For alert sections: severity level ("info", "success", "warning", "error")
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub severity: Option<String>,
41    /// For table sections: column definitions
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub columns: Option<Vec<ColumnSpec>>,
44    /// For table sections: row data as key-value maps
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub rows: Option<Vec<HashMap<String, Value>>>,
47    /// For chart sections: chart type ("bar", "line", "area", "pie")
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub chart_type: Option<String>,
50    /// For chart sections: data points as key-value maps
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub data: Option<Vec<HashMap<String, Value>>>,
53    /// For chart sections: key for x-axis values
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub x_key: Option<String>,
56    /// For chart sections: keys for y-axis values
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub y_keys: Option<Vec<String>>,
59    /// For key_value sections: list of key-value pairs
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub pairs: Option<Vec<KeyValueItem>>,
62    /// For list sections: list of text items
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub items: Option<Vec<String>>,
65    /// For list sections: whether to display as ordered list (default: false)
66    #[serde(default)]
67    pub ordered: bool,
68    /// For code_block sections: the code content
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub code: Option<String>,
71    /// For code_block sections: programming language for syntax highlighting
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub language: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77pub struct StatItem {
78    /// Label displayed for this stat
79    pub label: String,
80    /// Value displayed for this stat
81    pub value: String,
82    /// Optional status indicator: "operational"/"ok"/"success" (green), "degraded"/"warning" (yellow), "down"/"error"/"outage" (red)
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub status: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
88pub struct ColumnSpec {
89    /// Column header text
90    pub header: String,
91    /// Key to access data from row objects
92    pub key: String,
93}
94
95/// Key-value pair for key_value sections
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
97pub struct KeyValueItem {
98    /// Display label
99    pub key: String,
100    /// Display value
101    pub value: String,
102}
103
104/// Parameters for the render_layout tool
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct RenderLayoutParams {
107    /// Dashboard/layout title
108    pub title: String,
109    /// Optional description
110    #[serde(default)]
111    pub description: Option<String>,
112    /// Sections to display
113    pub sections: Vec<DashboardSection>,
114    /// Theme: "light", "dark", or "system" (default: "light")
115    #[serde(default)]
116    pub theme: Option<String>,
117    /// Optional protocol output configuration.
118    #[serde(flatten)]
119    pub protocol: LegacyProtocolOptions,
120}
121
122/// Tool for rendering complex multi-component layouts.
123///
124/// Creates dashboard-style layouts with multiple sections, each containing
125/// different types of content. Ideal for status pages, admin dashboards,
126/// and multi-section displays.
127///
128/// # Supported Section Types
129///
130/// - `stats`: Status indicators with labels, values, and optional status colors
131/// - `text`: Plain text content
132/// - `alert`: Notification banners with severity levels
133/// - `table`: Tabular data with columns and rows
134/// - `chart`: Data visualizations (bar, line, area, pie)
135/// - `key_value`: Key-value pair displays
136/// - `list`: Ordered or unordered lists
137/// - `code_block`: Code snippets with syntax highlighting
138///
139/// # Example JSON Parameters
140///
141/// ```json
142/// {
143///   "title": "System Status",
144///   "sections": [
145///     {
146///       "title": "Services",
147///       "type": "stats",
148///       "stats": [
149///         { "label": "API", "value": "Healthy", "status": "operational" },
150///         { "label": "Database", "value": "Degraded", "status": "warning" }
151///       ]
152///     },
153///     {
154///       "title": "Configuration",
155///       "type": "key_value",
156///       "pairs": [
157///         { "key": "Version", "value": "1.2.3" },
158///         { "key": "Region", "value": "us-east-1" }
159///       ]
160///     }
161///   ]
162/// }
163/// ```
164pub struct RenderLayoutTool;
165
166impl RenderLayoutTool {
167    pub fn new() -> Self {
168        Self
169    }
170}
171
172impl Default for RenderLayoutTool {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178#[async_trait]
179impl Tool for RenderLayoutTool {
180    fn name(&self) -> &str {
181        "render_layout"
182    }
183
184    fn description(&self) -> &str {
185        r#"Render a dashboard layout with multiple sections. Output example:
186┌─────────────────────────────────────────────┐
187│ System Status                               │
188├─────────────────────────────────────────────┤
189│ CPU: 45% ✓  │ Memory: 78% ⚠  │ Disk: 92% ✗ │
190├─────────────────────────────────────────────┤
191│ [Chart: Usage over time]                    │
192├─────────────────────────────────────────────┤
193│ Region: us-east-1  │  Version: 1.2.3        │
194└─────────────────────────────────────────────┘
195Section types: stats (label/value/status), table, chart, alert, text, key_value, list, code_block."#
196    }
197
198    fn parameters_schema(&self) -> Option<Value> {
199        Some(super::generate_gemini_schema::<RenderLayoutParams>())
200    }
201
202    async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
203        let params: RenderLayoutParams = serde_json::from_value(args.clone()).map_err(|e| {
204            adk_core::AdkError::Tool(format!("Invalid parameters: {}. Got: {}", e, args))
205        })?;
206        let protocol_options = params.protocol.clone();
207
208        let mut components = Vec::new();
209
210        // Title
211        components.push(Component::Text(Text {
212            id: None,
213            content: params.title,
214            variant: TextVariant::H2,
215        }));
216
217        // Description
218        if let Some(desc) = params.description {
219            components.push(Component::Text(Text {
220                id: None,
221                content: desc,
222                variant: TextVariant::Caption,
223            }));
224        }
225
226        // Build sections
227        for section in params.sections {
228            let section_component = build_section_component(section);
229            components.push(section_component);
230        }
231
232        let mut ui = UiResponse::new(components);
233
234        // Apply theme if specified
235        if let Some(theme_str) = params.theme {
236            let theme = match theme_str.to_lowercase().as_str() {
237                "dark" => Theme::Dark,
238                "system" => Theme::System,
239                _ => Theme::Light,
240            };
241            ui = ui.with_theme(theme);
242        }
243
244        render_ui_response_with_protocol(ui, &protocol_options, "layout")
245    }
246}
247
248fn build_section_component(section: DashboardSection) -> Component {
249    let mut card_content: Vec<Component> = Vec::new();
250
251    match section.section_type.as_str() {
252        "stats" => {
253            if let Some(stats) = section.stats {
254                // Create a nice stats display
255                for stat in stats {
256                    let status_indicator = match stat.status.as_deref() {
257                        Some("operational") | Some("ok") | Some("success") => "🟢 ",
258                        Some("degraded") | Some("warning") => "🟡 ",
259                        Some("down") | Some("error") | Some("outage") => "🔴 ",
260                        _ => "",
261                    };
262                    card_content.push(Component::Text(Text {
263                        id: None,
264                        content: format!("{}{}: {}", status_indicator, stat.label, stat.value),
265                        variant: TextVariant::Body,
266                    }));
267                }
268            }
269        }
270        "text" => {
271            if let Some(text) = section.text {
272                card_content.push(Component::Text(Text {
273                    id: None,
274                    content: text,
275                    variant: TextVariant::Body,
276                }));
277            }
278        }
279        "alert" => {
280            let variant = match section.severity.as_deref() {
281                Some("success") => AlertVariant::Success,
282                Some("warning") => AlertVariant::Warning,
283                Some("error") => AlertVariant::Error,
284                _ => AlertVariant::Info,
285            };
286            return Component::Alert(Alert {
287                id: None,
288                title: section.title,
289                description: section.message,
290                variant,
291            });
292        }
293        "table" => {
294            if let (Some(cols), Some(rows)) = (section.columns, section.rows) {
295                let table_columns: Vec<TableColumn> = cols
296                    .into_iter()
297                    .map(|c| TableColumn { header: c.header, accessor_key: c.key, sortable: true })
298                    .collect();
299                card_content.push(Component::Table(Table {
300                    id: None,
301                    columns: table_columns,
302                    data: rows,
303                    sortable: false,
304                    page_size: None,
305                    striped: false,
306                }));
307            }
308        }
309        "chart" => {
310            if let (Some(data), Some(x), Some(y)) = (section.data, section.x_key, section.y_keys) {
311                let kind = match section.chart_type.as_deref() {
312                    Some("line") => ChartKind::Line,
313                    Some("area") => ChartKind::Area,
314                    Some("pie") => ChartKind::Pie,
315                    _ => ChartKind::Bar,
316                };
317                card_content.push(Component::Chart(Chart {
318                    id: None,
319                    title: None,
320                    kind,
321                    data,
322                    x_key: x,
323                    y_keys: y,
324                    x_label: None,
325                    y_label: None,
326                    show_legend: true,
327                    colors: None,
328                }));
329            }
330        }
331        "key_value" => {
332            if let Some(pairs) = section.pairs {
333                let kv_pairs: Vec<KeyValuePair> = pairs
334                    .into_iter()
335                    .map(|p| KeyValuePair { key: p.key, value: p.value })
336                    .collect();
337                card_content.push(Component::KeyValue(KeyValue { id: None, pairs: kv_pairs }));
338            }
339        }
340        "list" => {
341            if let Some(items) = section.items {
342                card_content.push(Component::List(List {
343                    id: None,
344                    items,
345                    ordered: section.ordered,
346                }));
347            }
348        }
349        "code_block" => {
350            if let Some(code) = section.code {
351                card_content.push(Component::CodeBlock(CodeBlock {
352                    id: None,
353                    code,
354                    language: section.language,
355                }));
356            }
357        }
358        _ => {
359            // Fallback: show raw text for unknown section types
360            card_content.push(Component::Text(Text {
361                id: None,
362                content: format!("Unknown section type: {}", section.section_type),
363                variant: TextVariant::Caption,
364            }));
365        }
366    }
367
368    // If no content was added, add a placeholder
369    if card_content.is_empty() {
370        card_content.push(Component::Text(Text {
371            id: None,
372            content: "(No content)".to_string(),
373            variant: TextVariant::Caption,
374        }));
375    }
376
377    Component::Card(Card {
378        id: None,
379        title: Some(section.title),
380        description: None,
381        content: card_content,
382        footer: None,
383    })
384}