1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5pub const MIME_TYPE_UI: &str = "application/vnd.adk.ui+json";
6pub const MIME_TYPE_UI_UPDATE: &str = "application/vnd.adk.ui.update+json";
7
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
9#[serde(tag = "type", rename_all = "snake_case")]
10pub enum Component {
11 Text(Text),
13 Button(Button),
14 Icon(Icon),
15 Image(Image),
16 Badge(Badge),
17
18 TextInput(TextInput),
20 NumberInput(NumberInput),
21 Select(Select),
22 MultiSelect(MultiSelect),
23 Switch(Switch),
24 DateInput(DateInput),
25 Slider(Slider),
26
27 Stack(Stack),
29 Grid(Grid),
30 Card(Card),
31 Container(Container),
32 Divider(Divider),
33 Tabs(Tabs),
34
35 Table(Table),
37 List(List),
38 KeyValue(KeyValue),
39 CodeBlock(CodeBlock),
40
41 Chart(Chart),
43
44 Alert(Alert),
46 Progress(Progress),
47 Toast(Toast),
48 Modal(Modal),
49 Spinner(Spinner),
50 Skeleton(Skeleton),
51
52 Textarea(Textarea),
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct Text {
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub id: Option<String>,
63 pub content: String,
64 #[serde(default)]
65 pub variant: TextVariant,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum TextVariant {
71 H1,
72 H2,
73 H3,
74 H4,
75 #[default]
76 Body,
77 Caption,
78 Code,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
82pub struct Button {
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub id: Option<String>,
86 pub label: String,
87 pub action_id: String,
88 #[serde(default)]
89 pub variant: ButtonVariant,
90 #[serde(default)]
91 pub disabled: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub icon: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
98#[serde(rename_all = "snake_case")]
99pub enum ButtonVariant {
100 #[default]
101 Primary,
102 Secondary,
103 Danger,
104 Ghost,
105 Outline,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109pub struct Icon {
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub id: Option<String>,
113 pub name: String, #[serde(default)]
115 pub size: u8,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
119pub struct Image {
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub id: Option<String>,
123 pub src: String,
124 pub alt: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
128pub struct Badge {
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub id: Option<String>,
132 pub label: String,
133 #[serde(default)]
134 pub variant: BadgeVariant,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
138#[serde(rename_all = "snake_case")]
139pub enum BadgeVariant {
140 #[default]
141 Default,
142 Info,
143 Success,
144 Warning,
145 Error,
146 Secondary,
147 Outline,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
153pub struct TextInput {
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub id: Option<String>,
157 pub name: String,
158 pub label: String,
159 #[serde(default = "default_input_type")]
161 pub input_type: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub placeholder: Option<String>,
164 #[serde(default)]
165 pub required: bool,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub default_value: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub min_length: Option<usize>,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub max_length: Option<usize>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub error: Option<String>,
176}
177
178fn default_input_type() -> String {
179 "text".to_string()
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
183pub struct NumberInput {
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub id: Option<String>,
187 pub name: String,
188 pub label: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub min: Option<f64>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub max: Option<f64>,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub step: Option<f64>,
195 #[serde(default)]
196 pub required: bool,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub default_value: Option<f64>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub error: Option<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
205pub struct Select {
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub id: Option<String>,
209 pub name: String,
210 pub label: String,
211 pub options: Vec<SelectOption>,
212 #[serde(default)]
213 pub required: bool,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub error: Option<String>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
219pub struct MultiSelect {
220 #[serde(skip_serializing_if = "Option::is_none")]
222 pub id: Option<String>,
223 pub name: String,
224 pub label: String,
225 pub options: Vec<SelectOption>,
226 #[serde(default)]
227 pub required: bool,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
231pub struct SelectOption {
232 pub label: String,
233 pub value: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
237pub struct Switch {
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub id: Option<String>,
241 pub name: String,
242 pub label: String,
243 #[serde(default)]
244 pub default_checked: bool,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
248pub struct DateInput {
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub id: Option<String>,
252 pub name: String,
253 pub label: String,
254 #[serde(default)]
255 pub required: bool,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
259pub struct Slider {
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub id: Option<String>,
263 pub name: String,
264 pub label: String,
265 pub min: f64,
266 pub max: f64,
267 pub step: Option<f64>,
268 pub default_value: Option<f64>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
274pub struct Stack {
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub id: Option<String>,
278 pub direction: StackDirection,
279 pub children: Vec<Component>,
280 #[serde(default)]
281 pub gap: u8,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
285#[serde(rename_all = "snake_case")]
286pub enum StackDirection {
287 Horizontal,
288 Vertical,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292pub struct Grid {
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub id: Option<String>,
296 pub columns: u8,
297 pub children: Vec<Component>,
298 #[serde(default)]
299 pub gap: u8,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
303pub struct Card {
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub id: Option<String>,
307 pub title: Option<String>,
308 pub description: Option<String>,
309 pub content: Vec<Component>,
310 pub footer: Option<Vec<Component>>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
314pub struct Container {
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub id: Option<String>,
318 pub children: Vec<Component>,
319 #[serde(default)]
320 pub padding: u8,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
324pub struct Divider {
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub id: Option<String>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
331pub struct Tabs {
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub id: Option<String>,
335 pub tabs: Vec<Tab>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
339pub struct Tab {
340 pub label: String,
341 pub content: Vec<Component>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
347pub struct Table {
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub id: Option<String>,
351 pub columns: Vec<TableColumn>,
352 pub data: Vec<HashMap<String, serde_json::Value>>,
353 #[serde(default)]
355 pub sortable: bool,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub page_size: Option<u32>,
359 #[serde(default)]
361 pub striped: bool,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
365pub struct TableColumn {
366 pub header: String,
367 pub accessor_key: String,
368 #[serde(default = "default_true")]
370 pub sortable: bool,
371}
372
373fn default_true() -> bool {
374 true
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
378pub struct List {
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub id: Option<String>,
382 pub items: Vec<String>,
383 #[serde(default)]
384 pub ordered: bool,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
388pub struct KeyValue {
389 #[serde(skip_serializing_if = "Option::is_none")]
391 pub id: Option<String>,
392 pub pairs: Vec<KeyValuePair>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
396pub struct KeyValuePair {
397 pub key: String,
398 pub value: String,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
402pub struct CodeBlock {
403 #[serde(skip_serializing_if = "Option::is_none")]
405 pub id: Option<String>,
406 pub code: String,
407 pub language: Option<String>,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
413pub struct Chart {
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub id: Option<String>,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub title: Option<String>,
419 pub kind: ChartKind,
420 pub data: Vec<HashMap<String, serde_json::Value>>,
421 pub x_key: String,
422 pub y_keys: Vec<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub x_label: Option<String>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub y_label: Option<String>,
429 #[serde(default = "default_show_legend")]
431 pub show_legend: bool,
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub colors: Option<Vec<String>>,
435}
436
437fn default_show_legend() -> bool {
438 true
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
442#[serde(rename_all = "snake_case")]
443pub enum ChartKind {
444 Bar,
445 Line,
446 Area,
447 Pie,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
453pub struct Alert {
454 #[serde(skip_serializing_if = "Option::is_none")]
456 pub id: Option<String>,
457 pub title: String,
458 pub description: Option<String>,
459 #[serde(default)]
460 pub variant: AlertVariant,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
464#[serde(rename_all = "snake_case")]
465pub enum AlertVariant {
466 #[default]
467 Info,
468 Success,
469 Warning,
470 Error,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
474pub struct Progress {
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub id: Option<String>,
478 pub value: u8, pub label: Option<String>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
483pub struct Toast {
484 #[serde(skip_serializing_if = "Option::is_none")]
485 pub id: Option<String>,
486 pub message: String,
487 #[serde(default)]
488 pub variant: AlertVariant,
489 #[serde(default = "default_toast_duration")]
491 pub duration: u32,
492 #[serde(default = "default_true")]
493 pub dismissible: bool,
494}
495
496fn default_toast_duration() -> u32 {
497 5000
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
501pub struct Modal {
502 #[serde(skip_serializing_if = "Option::is_none")]
503 pub id: Option<String>,
504 pub title: String,
505 pub content: Vec<Component>,
506 pub footer: Option<Vec<Component>>,
507 #[serde(default)]
508 pub size: ModalSize,
509 #[serde(default = "default_true")]
510 pub closable: bool,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
514#[serde(rename_all = "snake_case")]
515pub enum ModalSize {
516 Small,
517 #[default]
518 Medium,
519 Large,
520 Full,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
524pub struct Spinner {
525 #[serde(skip_serializing_if = "Option::is_none")]
526 pub id: Option<String>,
527 #[serde(default)]
528 pub size: SpinnerSize,
529 pub label: Option<String>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
533#[serde(rename_all = "snake_case")]
534pub enum SpinnerSize {
535 Small,
536 #[default]
537 Medium,
538 Large,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
542pub struct Skeleton {
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub id: Option<String>,
545 #[serde(default)]
546 pub variant: SkeletonVariant,
547 pub width: Option<String>,
548 pub height: Option<String>,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
552#[serde(rename_all = "snake_case")]
553pub enum SkeletonVariant {
554 #[default]
555 Text,
556 Circle,
557 Rectangle,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
561pub struct Textarea {
562 #[serde(skip_serializing_if = "Option::is_none")]
563 pub id: Option<String>,
564 pub name: String,
565 pub label: String,
566 pub placeholder: Option<String>,
567 #[serde(default = "default_textarea_rows")]
568 pub rows: u8,
569 #[serde(default)]
570 pub required: bool,
571 pub default_value: Option<String>,
572 #[serde(skip_serializing_if = "Option::is_none")]
573 pub error: Option<String>,
574}
575
576fn default_textarea_rows() -> u8 {
577 4
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
584#[serde(rename_all = "snake_case")]
585pub enum Theme {
586 #[default]
587 Light,
588 Dark,
589 System,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
593pub struct UiResponse {
594 #[serde(default)]
596 pub id: Option<String>,
597 #[serde(default)]
599 pub theme: Theme,
600 pub components: Vec<Component>,
602}
603
604impl UiResponse {
605 pub fn new(components: Vec<Component>) -> Self {
606 Self {
607 id: None,
608 theme: Theme::default(),
609 components,
610 }
611 }
612
613 pub fn with_theme(mut self, theme: Theme) -> Self {
614 self.theme = theme;
615 self
616 }
617
618 pub fn with_id(mut self, id: impl Into<String>) -> Self {
619 self.id = Some(id.into());
620 self
621 }
622
623 pub fn to_content(self) -> crate::compat::Content {
624 let json = serde_json::to_vec(&self).unwrap_or_default();
625 crate::compat::Content {
626 role: "model".to_string(),
627 parts: vec![crate::compat::Part::InlineData {
628 mime_type: MIME_TYPE_UI.to_string(),
629 data: json,
630 }],
631 }
632 }
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
639#[serde(tag = "action", rename_all = "snake_case")]
640pub enum UiEvent {
641 FormSubmit {
643 action_id: String,
645 data: HashMap<String, serde_json::Value>,
647 },
648 ButtonClick {
650 action_id: String,
652 },
653 InputChange {
655 name: String,
657 value: serde_json::Value,
659 },
660 TabChange {
662 index: usize,
664 },
665}
666
667impl UiEvent {
668 pub fn to_user_message(&self) -> String {
670 match self {
671 UiEvent::FormSubmit { action_id, data } => {
672 let json = serde_json::to_string_pretty(data).unwrap_or_default();
673 format!(
674 "[UI Event: Form submitted]\nAction: {}\nData:\n{}",
675 action_id, json
676 )
677 }
678 UiEvent::ButtonClick { action_id } => {
679 format!("[UI Event: Button clicked]\nAction: {}", action_id)
680 }
681 UiEvent::InputChange { name, value } => {
682 format!(
683 "[UI Event: Input changed]\nField: {}\nValue: {}",
684 name, value
685 )
686 }
687 UiEvent::TabChange { index } => {
688 format!("[UI Event: Tab changed]\nIndex: {}", index)
689 }
690 }
691 }
692
693 pub fn to_content(&self) -> crate::compat::Content {
695 crate::compat::Content::new("user").with_text(self.to_user_message())
696 }
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
703#[serde(rename_all = "snake_case")]
704pub enum UiOperation {
705 Replace,
707 Patch,
709 Append,
711 Remove,
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
720pub struct UiUpdate {
721 pub target_id: String,
723 pub operation: UiOperation,
725 #[serde(skip_serializing_if = "Option::is_none")]
727 pub payload: Option<Component>,
728}
729
730impl UiUpdate {
731 pub fn replace(target_id: impl Into<String>, component: Component) -> Self {
733 Self {
734 target_id: target_id.into(),
735 operation: UiOperation::Replace,
736 payload: Some(component),
737 }
738 }
739
740 pub fn remove(target_id: impl Into<String>) -> Self {
742 Self {
743 target_id: target_id.into(),
744 operation: UiOperation::Remove,
745 payload: None,
746 }
747 }
748
749 pub fn append(target_id: impl Into<String>, component: Component) -> Self {
751 Self {
752 target_id: target_id.into(),
753 operation: UiOperation::Append,
754 payload: Some(component),
755 }
756 }
757
758 pub fn to_content(self) -> crate::compat::Content {
760 let json = serde_json::to_vec(&self).unwrap_or_default();
761 crate::compat::Content {
762 role: "model".to_string(),
763 parts: vec![crate::compat::Part::InlineData {
764 mime_type: MIME_TYPE_UI_UPDATE.to_string(),
765 data: json,
766 }],
767 }
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 fn test_component_serialization_roundtrip() {
777 let text = Component::Text(Text {
778 id: Some("text-1".to_string()),
779 content: "Hello".to_string(),
780 variant: TextVariant::Body,
781 });
782
783 let json = serde_json::to_string(&text).unwrap();
784 let deserialized: Component = serde_json::from_str(&json).unwrap();
785
786 if let Component::Text(t) = deserialized {
787 assert_eq!(t.content, "Hello");
788 assert_eq!(t.id, Some("text-1".to_string()));
789 } else {
790 panic!("Expected Text component");
791 }
792 }
793
794 #[test]
795 fn test_ui_response_with_id() {
796 let ui = UiResponse::new(vec![])
797 .with_id("response-123")
798 .with_theme(Theme::Dark);
799
800 assert_eq!(ui.id, Some("response-123".to_string()));
801 assert!(matches!(ui.theme, Theme::Dark));
802 }
803
804 #[test]
805 fn test_badge_variants_serialize() {
806 let badge = Badge {
807 id: None,
808 label: "Test".to_string(),
809 variant: BadgeVariant::Success,
810 };
811 let json = serde_json::to_string(&badge).unwrap();
812 assert!(json.contains("success"));
813 }
814
815 #[test]
816 fn test_ui_event_to_message() {
817 let event = UiEvent::FormSubmit {
818 action_id: "submit".to_string(),
819 data: HashMap::new(),
820 };
821 let msg = event.to_user_message();
822 assert!(msg.contains("Form submitted"));
823 assert!(msg.contains("submit"));
824 }
825
826 #[test]
827 fn test_ui_update_replace() {
828 let update = UiUpdate::replace(
829 "target-1",
830 Component::Text(Text {
831 id: None,
832 content: "Updated".to_string(),
833 variant: TextVariant::Body,
834 }),
835 );
836
837 assert_eq!(update.target_id, "target-1");
838 assert!(matches!(update.operation, UiOperation::Replace));
839 assert!(update.payload.is_some());
840 }
841
842 #[test]
843 fn test_ui_update_remove() {
844 let update = UiUpdate::remove("to-delete");
845 assert_eq!(update.target_id, "to-delete");
846 assert!(matches!(update.operation, UiOperation::Remove));
847 assert!(update.payload.is_none());
848 }
849
850 #[test]
851 fn test_key_value_pairs() {
852 let kv = KeyValue {
853 id: Some("kv-1".to_string()),
854 pairs: vec![
855 KeyValuePair {
856 key: "Name".to_string(),
857 value: "Alice".to_string(),
858 },
859 KeyValuePair {
860 key: "Age".to_string(),
861 value: "30".to_string(),
862 },
863 ],
864 };
865
866 let json = serde_json::to_string(&kv).unwrap();
867 assert!(json.contains("pairs"));
868 assert!(json.contains("Alice"));
869 }
870
871 #[test]
872 fn test_component_with_id_skips_none() {
873 let text = Component::Text(Text {
874 id: None,
875 content: "No ID".to_string(),
876 variant: TextVariant::Body,
877 });
878
879 let json = serde_json::to_string(&text).unwrap();
880 assert!(!json.contains("\"id\""));
882 }
883}