1use crate::a2ui::{stable_child_id, stable_id};
2use crate::compat::{Result, Tool, ToolContext};
3use crate::schema::*;
4use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::sync::Arc;
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct RenderFormParams {
14 pub title: String,
16 #[serde(default)]
18 pub description: Option<String>,
19 pub fields: Vec<FormField>,
21 #[serde(default = "default_submit_action")]
23 pub submit_action: String,
24 #[serde(default = "default_submit_label")]
26 pub submit_label: String,
27 #[serde(default)]
29 pub theme: Option<String>,
30 #[serde(default)]
32 pub data_path_prefix: Option<String>,
33 #[serde(flatten)]
35 pub protocol: LegacyProtocolOptions,
36}
37
38fn default_submit_action() -> String {
39 "form_submit".to_string()
40}
41
42fn default_submit_label() -> String {
43 "Submit".to_string()
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
47pub struct FormField {
48 pub name: String,
50 #[serde(default)]
52 pub path: Option<String>,
53 pub label: String,
55 #[serde(rename = "type", default = "default_field_type")]
57 pub field_type: String,
58 #[serde(default)]
60 pub placeholder: Option<String>,
61 #[serde(default)]
63 pub required: bool,
64 #[serde(default)]
66 pub options: Vec<SelectOption>,
67}
68
69fn default_field_type() -> String {
70 "text".to_string()
71}
72
73pub(crate) fn build_form_content(
74 form_id: &str,
75 fields: Vec<FormField>,
76 data_path_prefix: Option<&str>,
77 submit_action: &str,
78 submit_label: &str,
79) -> Vec<Component> {
80 let mut form_content = Vec::new();
81
82 for field in fields {
83 let field_path = field.path.clone().unwrap_or_else(|| {
84 if let Some(prefix) = data_path_prefix {
85 let trimmed = prefix.trim_end_matches('/');
86 format!("{}/{}", trimmed, field.name)
87 } else {
88 field.name.clone()
89 }
90 });
91 let field_id = stable_child_id(form_id, &format!("field:{}", field_path));
92 let component = match field.field_type.as_str() {
93 "number" => Component::NumberInput(NumberInput {
94 id: Some(field_id),
95 name: field_path,
96 label: field.label,
97 min: None,
98 max: None,
99 step: None,
100 required: field.required,
101 default_value: None,
102 error: None,
103 }),
104 "select" => Component::Select(Select {
105 id: Some(field_id),
106 name: field_path,
107 label: field.label,
108 options: field.options,
109 required: field.required,
110 error: None,
111 }),
112 "textarea" => Component::Textarea(Textarea {
113 id: Some(field_id),
114 name: field_path,
115 label: field.label,
116 placeholder: field.placeholder,
117 rows: 4,
118 required: field.required,
119 default_value: None,
120 error: None,
121 }),
122 _ => Component::TextInput(TextInput {
123 id: Some(field_id),
124 name: field_path,
125 label: field.label,
126 input_type: field.field_type,
127 placeholder: field.placeholder,
128 required: field.required,
129 default_value: None,
130 min_length: None,
131 max_length: None,
132 error: None,
133 }),
134 };
135 form_content.push(component);
136 }
137
138 form_content.push(Component::Button(Button {
139 id: Some(stable_child_id(form_id, "submit")),
140 label: submit_label.to_string(),
141 action_id: submit_action.to_string(),
142 variant: ButtonVariant::Primary,
143 disabled: false,
144 icon: None,
145 }));
146
147 form_content
148}
149
150pub struct RenderFormTool;
179
180impl RenderFormTool {
181 pub fn new() -> Self {
182 Self
183 }
184}
185
186impl Default for RenderFormTool {
187 fn default() -> Self {
188 Self::new()
189 }
190}
191
192#[async_trait]
193impl Tool for RenderFormTool {
194 fn name(&self) -> &str {
195 "render_form"
196 }
197
198 fn description(&self) -> &str {
199 r#"Render a form to collect user input. Output example:
200┌─────────────────────────┐
201│ Registration Form │
202│ ─────────────────────── │
203│ Name*: [___________] │
204│ Email*: [___________] │
205│ Password*: [___________]│
206│ [Register] │
207└─────────────────────────┘
208Use field types: text, email, password, number, select, textarea. Set required=true for mandatory fields."#
209 }
210
211 fn parameters_schema(&self) -> Option<Value> {
212 Some(super::generate_gemini_schema::<RenderFormParams>())
213 }
214
215 async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
216 let params: RenderFormParams = serde_json::from_value(args)
217 .map_err(|e| crate::compat::AdkError::tool(format!("Invalid parameters: {}", e)))?;
218 let protocol_options = params.protocol.clone();
219
220 let form_id = stable_id(&format!("form:{}:{}", params.title, params.submit_action));
221 let form_content = build_form_content(
222 &form_id,
223 params.fields,
224 params.data_path_prefix.as_deref(),
225 ¶ms.submit_action,
226 ¶ms.submit_label,
227 );
228
229 let mut ui = UiResponse::new(vec![Component::Card(Card {
231 id: Some(form_id),
232 title: Some(params.title),
233 description: params.description,
234 content: form_content,
235 footer: None,
236 })]);
237
238 if let Some(theme_str) = params.theme {
240 let theme = match theme_str.to_lowercase().as_str() {
241 "dark" => Theme::Dark,
242 "system" => Theme::System,
243 _ => Theme::Light,
244 };
245 ui = ui.with_theme(theme);
246 }
247
248 let surface_id = protocol_options.resolved_surface_id("form");
249 let output = render_ui_response_with_protocol(ui, &protocol_options, "form")?;
250 let surface_ref = crate::surface_runtime::next_surface_ref(&ctx, surface_id);
251 crate::surface_runtime::record_surface_ref(&ctx, &surface_ref);
252 Ok(output)
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::compat::{Content, EventActions, ReadonlyContext};
260 use async_trait::async_trait;
261 use std::sync::{Arc, Mutex};
262
263 struct TestContext {
264 content: Content,
265 actions: Mutex<EventActions>,
266 }
267
268 impl TestContext {
269 fn new() -> Self {
270 Self {
271 content: Content::new("user"),
272 actions: Mutex::new(EventActions::default()),
273 }
274 }
275 }
276
277 #[async_trait]
278 impl ReadonlyContext for TestContext {
279 fn invocation_id(&self) -> &str {
280 "test"
281 }
282 fn agent_name(&self) -> &str {
283 "test"
284 }
285 fn user_id(&self) -> &str {
286 "user"
287 }
288 fn app_name(&self) -> &str {
289 "app"
290 }
291 fn session_id(&self) -> &str {
292 "session"
293 }
294 fn branch(&self) -> &str {
295 ""
296 }
297 fn user_content(&self) -> &Content {
298 &self.content
299 }
300 }
301
302 #[async_trait]
303 impl crate::compat::CallbackContext for TestContext {
304 fn artifacts(&self) -> Option<Arc<dyn crate::compat::Artifacts>> {
305 None
306 }
307 }
308
309 #[async_trait]
310 impl ToolContext for TestContext {
311 fn function_call_id(&self) -> &str {
312 "call-123"
313 }
314 fn actions(&self) -> EventActions {
315 self.actions.lock().unwrap().clone()
316 }
317 fn set_actions(&self, actions: EventActions) {
318 *self.actions.lock().unwrap() = actions;
319 }
320 async fn search_memory(&self, _query: &str) -> Result<Vec<crate::compat::MemoryEntry>> {
321 Ok(vec![])
322 }
323 }
324
325 #[tokio::test]
326 async fn render_form_applies_binding_paths_and_ids() {
327 let tool = RenderFormTool::new();
328 let args = serde_json::json!({
329 "title": "Profile",
330 "fields": [
331 { "name": "email", "label": "Email", "type": "email" },
332 { "name": "name", "label": "Name", "type": "text", "path": "/account/name" }
333 ],
334 "submit_action": "save_profile",
335 "data_path_prefix": "/user"
336 });
337
338 let ctx: Arc<dyn ToolContext> = Arc::new(TestContext::new());
339 let value = tool.execute(ctx, args).await.unwrap();
340 let ui: UiResponse = serde_json::from_value(value).unwrap();
341
342 let card = match &ui.components[0] {
343 Component::Card(card) => card,
344 _ => panic!("expected card"),
345 };
346
347 assert!(card.id.is_some());
348 let field_names: Vec<String> = card
349 .content
350 .iter()
351 .filter_map(|component| match component {
352 Component::TextInput(input) => Some(input.name.clone()),
353 _ => None,
354 })
355 .collect();
356
357 assert!(field_names.contains(&"/user/email".to_string()));
358 assert!(field_names.contains(&"/account/name".to_string()));
359 }
360}