adk_ui/tools/
render_layout.rs

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