1use crate::a2ui::{stable_child_id, stable_id};
2use crate::schema::*;
3use crate::tools::{LegacyProtocolOptions, render_ui_response_with_protocol};
4use adk_core::{Result, Tool, ToolContext};
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 struct RenderFormTool;
102
103impl RenderFormTool {
104 pub fn new() -> Self {
105 Self
106 }
107}
108
109impl Default for RenderFormTool {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115#[async_trait]
116impl Tool for RenderFormTool {
117 fn name(&self) -> &str {
118 "render_form"
119 }
120
121 fn description(&self) -> &str {
122 r#"Render a form to collect user input. Output example:
123┌─────────────────────────┐
124│ Registration Form │
125│ ─────────────────────── │
126│ Name*: [___________] │
127│ Email*: [___________] │
128│ Password*: [___________]│
129│ [Register] │
130└─────────────────────────┘
131Use field types: text, email, password, number, select, textarea. Set required=true for mandatory fields."#
132 }
133
134 fn parameters_schema(&self) -> Option<Value> {
135 Some(super::generate_gemini_schema::<RenderFormParams>())
136 }
137
138 async fn execute(&self, _ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
139 let params: RenderFormParams = serde_json::from_value(args)
140 .map_err(|e| adk_core::AdkError::Tool(format!("Invalid parameters: {}", e)))?;
141 let protocol_options = params.protocol.clone();
142
143 let form_id = stable_id(&format!("form:{}:{}", params.title, params.submit_action));
144 let mut form_content: Vec<Component> = Vec::new();
146
147 for field in params.fields {
148 let field_path = field.path.clone().unwrap_or_else(|| {
149 if let Some(prefix) = ¶ms.data_path_prefix {
150 let trimmed = prefix.trim_end_matches('/');
151 format!("{}/{}", trimmed, field.name)
152 } else {
153 field.name.clone()
154 }
155 });
156 let field_id = stable_child_id(&form_id, &format!("field:{}", field_path));
157 let component = match field.field_type.as_str() {
158 "number" => Component::NumberInput(NumberInput {
159 id: Some(field_id),
160 name: field_path,
161 label: field.label,
162 min: None,
163 max: None,
164 step: None,
165 required: field.required,
166 default_value: None,
167 error: None,
168 }),
169 "select" => Component::Select(Select {
170 id: Some(field_id),
171 name: field_path,
172 label: field.label,
173 options: field.options,
174 required: field.required,
175 error: None,
176 }),
177 "textarea" => Component::Textarea(Textarea {
178 id: Some(field_id),
179 name: field_path,
180 label: field.label,
181 placeholder: field.placeholder,
182 rows: 4,
183 required: field.required,
184 default_value: None,
185 error: None,
186 }),
187 _ => Component::TextInput(TextInput {
188 id: Some(field_id),
189 name: field_path,
190 label: field.label,
191 input_type: field.field_type.clone(),
192 placeholder: field.placeholder,
193 required: field.required,
194 default_value: None,
195 min_length: None,
196 max_length: None,
197 error: None,
198 }),
199 };
200 form_content.push(component);
201 }
202
203 form_content.push(Component::Button(Button {
205 id: Some(stable_child_id(&form_id, "submit")),
206 label: params.submit_label,
207 action_id: params.submit_action,
208 variant: ButtonVariant::Primary,
209 disabled: false,
210 icon: None,
211 }));
212
213 let mut ui = UiResponse::new(vec![Component::Card(Card {
215 id: Some(form_id),
216 title: Some(params.title),
217 description: params.description,
218 content: form_content,
219 footer: None,
220 })]);
221
222 if let Some(theme_str) = params.theme {
224 let theme = match theme_str.to_lowercase().as_str() {
225 "dark" => Theme::Dark,
226 "system" => Theme::System,
227 _ => Theme::Light,
228 };
229 ui = ui.with_theme(theme);
230 }
231
232 render_ui_response_with_protocol(ui, &protocol_options, "form")
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use adk_core::{Content, EventActions, ReadonlyContext};
240 use async_trait::async_trait;
241 use std::sync::{Arc, Mutex};
242
243 struct TestContext {
244 content: Content,
245 actions: Mutex<EventActions>,
246 }
247
248 impl TestContext {
249 fn new() -> Self {
250 Self { content: Content::new("user"), actions: Mutex::new(EventActions::default()) }
251 }
252 }
253
254 #[async_trait]
255 impl ReadonlyContext for TestContext {
256 fn invocation_id(&self) -> &str {
257 "test"
258 }
259 fn agent_name(&self) -> &str {
260 "test"
261 }
262 fn user_id(&self) -> &str {
263 "user"
264 }
265 fn app_name(&self) -> &str {
266 "app"
267 }
268 fn session_id(&self) -> &str {
269 "session"
270 }
271 fn branch(&self) -> &str {
272 ""
273 }
274 fn user_content(&self) -> &Content {
275 &self.content
276 }
277 }
278
279 #[async_trait]
280 impl adk_core::CallbackContext for TestContext {
281 fn artifacts(&self) -> Option<Arc<dyn adk_core::Artifacts>> {
282 None
283 }
284 }
285
286 #[async_trait]
287 impl ToolContext for TestContext {
288 fn function_call_id(&self) -> &str {
289 "call-123"
290 }
291 fn actions(&self) -> EventActions {
292 self.actions.lock().unwrap().clone()
293 }
294 fn set_actions(&self, actions: EventActions) {
295 *self.actions.lock().unwrap() = actions;
296 }
297 async fn search_memory(&self, _query: &str) -> Result<Vec<adk_core::MemoryEntry>> {
298 Ok(vec![])
299 }
300 }
301
302 #[tokio::test]
303 async fn render_form_applies_binding_paths_and_ids() {
304 let tool = RenderFormTool::new();
305 let args = serde_json::json!({
306 "title": "Profile",
307 "fields": [
308 { "name": "email", "label": "Email", "type": "email" },
309 { "name": "name", "label": "Name", "type": "text", "path": "/account/name" }
310 ],
311 "submit_action": "save_profile",
312 "data_path_prefix": "/user"
313 });
314
315 let ctx: Arc<dyn ToolContext> = Arc::new(TestContext::new());
316 let value = tool.execute(ctx, args).await.unwrap();
317 let ui: UiResponse = serde_json::from_value(value).unwrap();
318
319 let card = match &ui.components[0] {
320 Component::Card(card) => card,
321 _ => panic!("expected card"),
322 };
323
324 assert!(card.id.is_some());
325 let field_names: Vec<String> = card
326 .content
327 .iter()
328 .filter_map(|component| match component {
329 Component::TextInput(input) => Some(input.name.clone()),
330 _ => None,
331 })
332 .collect();
333
334 assert!(field_names.contains(&"/user/email".to_string()));
335 assert!(field_names.contains(&"/account/name".to_string()));
336 }
337}