alita-sdk 0.3.379__py3-none-any.whl → 0.3.627__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +5 -0
  4. alita_sdk/cli/agent/default.py +258 -0
  5. alita_sdk/cli/agent_executor.py +156 -0
  6. alita_sdk/cli/agent_loader.py +245 -0
  7. alita_sdk/cli/agent_ui.py +228 -0
  8. alita_sdk/cli/agents.py +3113 -0
  9. alita_sdk/cli/callbacks.py +647 -0
  10. alita_sdk/cli/cli.py +168 -0
  11. alita_sdk/cli/config.py +306 -0
  12. alita_sdk/cli/context/__init__.py +30 -0
  13. alita_sdk/cli/context/cleanup.py +198 -0
  14. alita_sdk/cli/context/manager.py +731 -0
  15. alita_sdk/cli/context/message.py +285 -0
  16. alita_sdk/cli/context/strategies.py +289 -0
  17. alita_sdk/cli/context/token_estimation.py +127 -0
  18. alita_sdk/cli/formatting.py +182 -0
  19. alita_sdk/cli/input_handler.py +419 -0
  20. alita_sdk/cli/inventory.py +1073 -0
  21. alita_sdk/cli/mcp_loader.py +315 -0
  22. alita_sdk/cli/testcases/__init__.py +94 -0
  23. alita_sdk/cli/testcases/data_generation.py +119 -0
  24. alita_sdk/cli/testcases/discovery.py +96 -0
  25. alita_sdk/cli/testcases/executor.py +84 -0
  26. alita_sdk/cli/testcases/logger.py +85 -0
  27. alita_sdk/cli/testcases/parser.py +172 -0
  28. alita_sdk/cli/testcases/prompts.py +91 -0
  29. alita_sdk/cli/testcases/reporting.py +125 -0
  30. alita_sdk/cli/testcases/setup.py +108 -0
  31. alita_sdk/cli/testcases/test_runner.py +282 -0
  32. alita_sdk/cli/testcases/utils.py +39 -0
  33. alita_sdk/cli/testcases/validation.py +90 -0
  34. alita_sdk/cli/testcases/workflow.py +196 -0
  35. alita_sdk/cli/toolkit.py +327 -0
  36. alita_sdk/cli/toolkit_loader.py +85 -0
  37. alita_sdk/cli/tools/__init__.py +43 -0
  38. alita_sdk/cli/tools/approval.py +224 -0
  39. alita_sdk/cli/tools/filesystem.py +1751 -0
  40. alita_sdk/cli/tools/planning.py +389 -0
  41. alita_sdk/cli/tools/terminal.py +414 -0
  42. alita_sdk/community/__init__.py +72 -12
  43. alita_sdk/community/inventory/__init__.py +236 -0
  44. alita_sdk/community/inventory/config.py +257 -0
  45. alita_sdk/community/inventory/enrichment.py +2137 -0
  46. alita_sdk/community/inventory/extractors.py +1469 -0
  47. alita_sdk/community/inventory/ingestion.py +3172 -0
  48. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  49. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  50. alita_sdk/community/inventory/parsers/base.py +295 -0
  51. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  52. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  53. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  54. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  55. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  56. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  57. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  58. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  59. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  60. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  61. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  62. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  63. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  64. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  65. alita_sdk/community/inventory/patterns/loader.py +348 -0
  66. alita_sdk/community/inventory/patterns/registry.py +198 -0
  67. alita_sdk/community/inventory/presets.py +535 -0
  68. alita_sdk/community/inventory/retrieval.py +1403 -0
  69. alita_sdk/community/inventory/toolkit.py +173 -0
  70. alita_sdk/community/inventory/toolkit_utils.py +176 -0
  71. alita_sdk/community/inventory/visualize.py +1370 -0
  72. alita_sdk/configurations/__init__.py +1 -1
  73. alita_sdk/configurations/ado.py +141 -20
  74. alita_sdk/configurations/bitbucket.py +94 -2
  75. alita_sdk/configurations/confluence.py +130 -1
  76. alita_sdk/configurations/figma.py +76 -0
  77. alita_sdk/configurations/gitlab.py +91 -0
  78. alita_sdk/configurations/jira.py +103 -0
  79. alita_sdk/configurations/openapi.py +329 -0
  80. alita_sdk/configurations/qtest.py +72 -1
  81. alita_sdk/configurations/report_portal.py +96 -0
  82. alita_sdk/configurations/sharepoint.py +148 -0
  83. alita_sdk/configurations/testio.py +83 -0
  84. alita_sdk/configurations/testrail.py +88 -0
  85. alita_sdk/configurations/xray.py +93 -0
  86. alita_sdk/configurations/zephyr_enterprise.py +93 -0
  87. alita_sdk/configurations/zephyr_essential.py +75 -0
  88. alita_sdk/runtime/clients/artifact.py +3 -3
  89. alita_sdk/runtime/clients/client.py +388 -46
  90. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  91. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  92. alita_sdk/runtime/clients/sandbox_client.py +8 -21
  93. alita_sdk/runtime/langchain/_constants_bkup.py +1318 -0
  94. alita_sdk/runtime/langchain/assistant.py +157 -39
  95. alita_sdk/runtime/langchain/constants.py +647 -1
  96. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  97. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
  98. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
  99. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +10 -4
  100. alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +226 -7
  101. alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +5 -2
  102. alita_sdk/runtime/langchain/document_loaders/constants.py +40 -19
  103. alita_sdk/runtime/langchain/langraph_agent.py +405 -84
  104. alita_sdk/runtime/langchain/utils.py +106 -7
  105. alita_sdk/runtime/llms/preloaded.py +2 -6
  106. alita_sdk/runtime/models/mcp_models.py +61 -0
  107. alita_sdk/runtime/skills/__init__.py +91 -0
  108. alita_sdk/runtime/skills/callbacks.py +498 -0
  109. alita_sdk/runtime/skills/discovery.py +540 -0
  110. alita_sdk/runtime/skills/executor.py +610 -0
  111. alita_sdk/runtime/skills/input_builder.py +371 -0
  112. alita_sdk/runtime/skills/models.py +330 -0
  113. alita_sdk/runtime/skills/registry.py +355 -0
  114. alita_sdk/runtime/skills/skill_runner.py +330 -0
  115. alita_sdk/runtime/toolkits/__init__.py +31 -0
  116. alita_sdk/runtime/toolkits/application.py +29 -10
  117. alita_sdk/runtime/toolkits/artifact.py +20 -11
  118. alita_sdk/runtime/toolkits/datasource.py +13 -6
  119. alita_sdk/runtime/toolkits/mcp.py +783 -0
  120. alita_sdk/runtime/toolkits/mcp_config.py +1048 -0
  121. alita_sdk/runtime/toolkits/planning.py +178 -0
  122. alita_sdk/runtime/toolkits/skill_router.py +238 -0
  123. alita_sdk/runtime/toolkits/subgraph.py +251 -6
  124. alita_sdk/runtime/toolkits/tools.py +356 -69
  125. alita_sdk/runtime/toolkits/vectorstore.py +11 -5
  126. alita_sdk/runtime/tools/__init__.py +10 -3
  127. alita_sdk/runtime/tools/application.py +27 -6
  128. alita_sdk/runtime/tools/artifact.py +511 -28
  129. alita_sdk/runtime/tools/data_analysis.py +183 -0
  130. alita_sdk/runtime/tools/function.py +67 -35
  131. alita_sdk/runtime/tools/graph.py +10 -4
  132. alita_sdk/runtime/tools/image_generation.py +148 -46
  133. alita_sdk/runtime/tools/llm.py +1003 -128
  134. alita_sdk/runtime/tools/loop.py +3 -1
  135. alita_sdk/runtime/tools/loop_output.py +3 -1
  136. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  137. alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
  138. alita_sdk/runtime/tools/mcp_server_tool.py +8 -5
  139. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  140. alita_sdk/runtime/tools/planning/models.py +246 -0
  141. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  142. alita_sdk/runtime/tools/router.py +2 -4
  143. alita_sdk/runtime/tools/sandbox.py +65 -48
  144. alita_sdk/runtime/tools/skill_router.py +776 -0
  145. alita_sdk/runtime/tools/tool.py +3 -1
  146. alita_sdk/runtime/tools/vectorstore.py +9 -3
  147. alita_sdk/runtime/tools/vectorstore_base.py +70 -14
  148. alita_sdk/runtime/utils/AlitaCallback.py +137 -21
  149. alita_sdk/runtime/utils/constants.py +5 -1
  150. alita_sdk/runtime/utils/mcp_client.py +492 -0
  151. alita_sdk/runtime/utils/mcp_oauth.py +361 -0
  152. alita_sdk/runtime/utils/mcp_sse_client.py +434 -0
  153. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  154. alita_sdk/runtime/utils/serialization.py +155 -0
  155. alita_sdk/runtime/utils/streamlit.py +40 -13
  156. alita_sdk/runtime/utils/toolkit_utils.py +30 -9
  157. alita_sdk/runtime/utils/utils.py +36 -0
  158. alita_sdk/tools/__init__.py +134 -35
  159. alita_sdk/tools/ado/repos/__init__.py +51 -32
  160. alita_sdk/tools/ado/repos/repos_wrapper.py +148 -89
  161. alita_sdk/tools/ado/test_plan/__init__.py +25 -9
  162. alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +23 -1
  163. alita_sdk/tools/ado/utils.py +1 -18
  164. alita_sdk/tools/ado/wiki/__init__.py +25 -12
  165. alita_sdk/tools/ado/wiki/ado_wrapper.py +291 -22
  166. alita_sdk/tools/ado/work_item/__init__.py +26 -13
  167. alita_sdk/tools/ado/work_item/ado_wrapper.py +73 -11
  168. alita_sdk/tools/advanced_jira_mining/__init__.py +11 -8
  169. alita_sdk/tools/aws/delta_lake/__init__.py +13 -9
  170. alita_sdk/tools/aws/delta_lake/tool.py +5 -1
  171. alita_sdk/tools/azure_ai/search/__init__.py +11 -8
  172. alita_sdk/tools/azure_ai/search/api_wrapper.py +1 -1
  173. alita_sdk/tools/base/tool.py +5 -1
  174. alita_sdk/tools/base_indexer_toolkit.py +271 -84
  175. alita_sdk/tools/bitbucket/__init__.py +17 -11
  176. alita_sdk/tools/bitbucket/api_wrapper.py +59 -11
  177. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +49 -35
  178. alita_sdk/tools/browser/__init__.py +5 -4
  179. alita_sdk/tools/carrier/__init__.py +5 -6
  180. alita_sdk/tools/carrier/backend_reports_tool.py +6 -6
  181. alita_sdk/tools/carrier/run_ui_test_tool.py +6 -6
  182. alita_sdk/tools/carrier/ui_reports_tool.py +5 -5
  183. alita_sdk/tools/chunkers/__init__.py +3 -1
  184. alita_sdk/tools/chunkers/code/treesitter/treesitter.py +37 -13
  185. alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
  186. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  187. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  188. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  189. alita_sdk/tools/cloud/aws/__init__.py +10 -7
  190. alita_sdk/tools/cloud/azure/__init__.py +10 -7
  191. alita_sdk/tools/cloud/gcp/__init__.py +10 -7
  192. alita_sdk/tools/cloud/k8s/__init__.py +10 -7
  193. alita_sdk/tools/code/linter/__init__.py +10 -8
  194. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  195. alita_sdk/tools/code/sonar/__init__.py +11 -8
  196. alita_sdk/tools/code_indexer_toolkit.py +82 -22
  197. alita_sdk/tools/confluence/__init__.py +22 -16
  198. alita_sdk/tools/confluence/api_wrapper.py +107 -30
  199. alita_sdk/tools/confluence/loader.py +14 -2
  200. alita_sdk/tools/custom_open_api/__init__.py +12 -5
  201. alita_sdk/tools/elastic/__init__.py +11 -8
  202. alita_sdk/tools/elitea_base.py +493 -30
  203. alita_sdk/tools/figma/__init__.py +58 -11
  204. alita_sdk/tools/figma/api_wrapper.py +1235 -143
  205. alita_sdk/tools/figma/figma_client.py +73 -0
  206. alita_sdk/tools/figma/toon_tools.py +2748 -0
  207. alita_sdk/tools/github/__init__.py +14 -15
  208. alita_sdk/tools/github/github_client.py +224 -100
  209. alita_sdk/tools/github/graphql_client_wrapper.py +119 -33
  210. alita_sdk/tools/github/schemas.py +14 -5
  211. alita_sdk/tools/github/tool.py +5 -1
  212. alita_sdk/tools/github/tool_prompts.py +9 -22
  213. alita_sdk/tools/gitlab/__init__.py +16 -11
  214. alita_sdk/tools/gitlab/api_wrapper.py +218 -48
  215. alita_sdk/tools/gitlab_org/__init__.py +10 -9
  216. alita_sdk/tools/gitlab_org/api_wrapper.py +63 -64
  217. alita_sdk/tools/google/bigquery/__init__.py +13 -12
  218. alita_sdk/tools/google/bigquery/tool.py +5 -1
  219. alita_sdk/tools/google_places/__init__.py +11 -8
  220. alita_sdk/tools/google_places/api_wrapper.py +1 -1
  221. alita_sdk/tools/jira/__init__.py +17 -10
  222. alita_sdk/tools/jira/api_wrapper.py +92 -41
  223. alita_sdk/tools/keycloak/__init__.py +11 -8
  224. alita_sdk/tools/localgit/__init__.py +9 -3
  225. alita_sdk/tools/localgit/local_git.py +62 -54
  226. alita_sdk/tools/localgit/tool.py +5 -1
  227. alita_sdk/tools/memory/__init__.py +12 -4
  228. alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
  229. alita_sdk/tools/ocr/__init__.py +11 -8
  230. alita_sdk/tools/openapi/__init__.py +491 -106
  231. alita_sdk/tools/openapi/api_wrapper.py +1368 -0
  232. alita_sdk/tools/openapi/tool.py +20 -0
  233. alita_sdk/tools/pandas/__init__.py +20 -12
  234. alita_sdk/tools/pandas/api_wrapper.py +38 -25
  235. alita_sdk/tools/pandas/dataframe/generator/base.py +3 -1
  236. alita_sdk/tools/postman/__init__.py +10 -9
  237. alita_sdk/tools/pptx/__init__.py +11 -10
  238. alita_sdk/tools/pptx/pptx_wrapper.py +1 -1
  239. alita_sdk/tools/qtest/__init__.py +31 -11
  240. alita_sdk/tools/qtest/api_wrapper.py +2135 -86
  241. alita_sdk/tools/rally/__init__.py +10 -9
  242. alita_sdk/tools/rally/api_wrapper.py +1 -1
  243. alita_sdk/tools/report_portal/__init__.py +12 -8
  244. alita_sdk/tools/salesforce/__init__.py +10 -8
  245. alita_sdk/tools/servicenow/__init__.py +17 -15
  246. alita_sdk/tools/servicenow/api_wrapper.py +1 -1
  247. alita_sdk/tools/sharepoint/__init__.py +10 -7
  248. alita_sdk/tools/sharepoint/api_wrapper.py +129 -38
  249. alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
  250. alita_sdk/tools/sharepoint/utils.py +8 -2
  251. alita_sdk/tools/slack/__init__.py +10 -7
  252. alita_sdk/tools/slack/api_wrapper.py +2 -2
  253. alita_sdk/tools/sql/__init__.py +12 -9
  254. alita_sdk/tools/testio/__init__.py +10 -7
  255. alita_sdk/tools/testrail/__init__.py +11 -10
  256. alita_sdk/tools/testrail/api_wrapper.py +1 -1
  257. alita_sdk/tools/utils/__init__.py +9 -4
  258. alita_sdk/tools/utils/content_parser.py +103 -18
  259. alita_sdk/tools/utils/text_operations.py +410 -0
  260. alita_sdk/tools/utils/tool_prompts.py +79 -0
  261. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +30 -13
  262. alita_sdk/tools/xray/__init__.py +13 -9
  263. alita_sdk/tools/yagmail/__init__.py +9 -3
  264. alita_sdk/tools/zephyr/__init__.py +10 -7
  265. alita_sdk/tools/zephyr_enterprise/__init__.py +11 -7
  266. alita_sdk/tools/zephyr_essential/__init__.py +10 -7
  267. alita_sdk/tools/zephyr_essential/api_wrapper.py +30 -13
  268. alita_sdk/tools/zephyr_essential/client.py +2 -2
  269. alita_sdk/tools/zephyr_scale/__init__.py +11 -8
  270. alita_sdk/tools/zephyr_scale/api_wrapper.py +2 -2
  271. alita_sdk/tools/zephyr_squad/__init__.py +10 -7
  272. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/METADATA +154 -8
  273. alita_sdk-0.3.627.dist-info/RECORD +468 -0
  274. alita_sdk-0.3.627.dist-info/entry_points.txt +2 -0
  275. alita_sdk-0.3.379.dist-info/RECORD +0 -360
  276. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/WHEEL +0 -0
  277. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/licenses/LICENSE +0 -0
  278. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/top_level.txt +0 -0
@@ -2,37 +2,107 @@ import functools
2
2
  import json
3
3
  import logging
4
4
  import re
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
6
  from enum import Enum
6
7
  from typing import Dict, List, Generator, Optional, Union
7
8
  from urllib.parse import urlparse, parse_qs
8
9
 
9
10
  import requests
10
- from FigmaPy import FigmaPy
11
11
  from langchain_core.documents import Document
12
12
  from langchain_core.tools import ToolException
13
13
  from pydantic import Field, PrivateAttr, create_model, model_validator, SecretStr
14
14
 
15
+
16
+ # User-friendly error messages for common Figma API errors
17
+ FIGMA_ERROR_MESSAGES = {
18
+ 429: "Figma API rate limit exceeded. Please wait a moment and try again.",
19
+ 403: "Access denied. Please check your Figma API token has access to this file.",
20
+ 404: "File or node not found. Please verify the file key or node ID is correct.",
21
+ 401: "Authentication failed. Please check your Figma API token is valid.",
22
+ 500: "Figma server error. Please try again later.",
23
+ 503: "Figma service temporarily unavailable. Please try again later.",
24
+ }
25
+
26
+
27
+ def _handle_figma_error(e: ToolException) -> str:
28
+ """
29
+ Convert a ToolException from Figma API into a user-friendly error message.
30
+ Returns a clean error string without technical details.
31
+ """
32
+ error_str = str(e)
33
+
34
+ # Extract status code from error message
35
+ for code, message in FIGMA_ERROR_MESSAGES.items():
36
+ if f"error {code}:" in error_str.lower() or f"status\": {code}" in error_str:
37
+ return message
38
+
39
+ # Handle other common patterns
40
+ if "rate limit" in error_str.lower():
41
+ return FIGMA_ERROR_MESSAGES[429]
42
+ if "not found" in error_str.lower():
43
+ return FIGMA_ERROR_MESSAGES[404]
44
+ if "forbidden" in error_str.lower() or "access denied" in error_str.lower():
45
+ return FIGMA_ERROR_MESSAGES[403]
46
+ if "unauthorized" in error_str.lower():
47
+ return FIGMA_ERROR_MESSAGES[401]
48
+
49
+ # Fallback: return a generic but clean message
50
+ return f"Figma API request failed. Please try again or check your file key and permissions."
51
+
15
52
  from ..non_code_indexer_toolkit import NonCodeIndexerToolkit
16
53
  from ..utils.available_tools_decorator import extend_with_parent_available_tools
17
- from ..utils.content_parser import load_content_from_bytes
54
+ from ..utils.content_parser import _load_content_from_bytes_with_prompt
55
+ from .figma_client import AlitaFigmaPy
56
+ from .toon_tools import (
57
+ TOONSerializer,
58
+ process_page_to_toon_data,
59
+ process_frame_to_toon_data,
60
+ extract_text_by_role,
61
+ extract_components,
62
+ detect_sequences,
63
+ group_variants,
64
+ infer_cta_destination,
65
+ FrameDetailTOONSchema,
66
+ AnalyzeFileSchema,
67
+ )
18
68
 
19
- GLOBAL_LIMIT = 10000
69
+ GLOBAL_LIMIT = 1000000
20
70
  GLOBAL_RETAIN = ['id', 'name', 'type', 'document', 'children']
21
71
  GLOBAL_REMOVE = []
22
- GLOBAL_DEPTH_START = 4
72
+ GLOBAL_DEPTH_START = 1
23
73
  GLOBAL_DEPTH_END = 6
74
+ DEFAULT_NUMBER_OF_THREADS = 5 # valid range for number_of_threads is 1..5
75
+ # Default prompts for image analysis and summarization reused across toolkit and wrapper
76
+ DEFAULT_FIGMA_IMAGES_PROMPT: Dict[str, str] = {
77
+ "prompt": (
78
+ "You are an AI model for image analysis. For each image, first identify its type "
79
+ "(diagram, screenshot, photograph, illustration/drawing, text-centric, or mixed), "
80
+ "then describe all visible elements and extract any readable text. For diagrams, "
81
+ "capture titles, labels, legends, axes, and all numerical values, and summarize key "
82
+ "patterns or trends. For screenshots, describe the interface or page, key UI elements, "
83
+ "and any conversations or messages with participants and timestamps if visible. For "
84
+ "photos and illustrations, describe the setting, main objects/people, their actions, "
85
+ "style, colors, and composition. Be precise and thorough; when something is unclear or "
86
+ "illegible, state that explicitly instead of guessing."
87
+ )
88
+ }
89
+ DEFAULT_FIGMA_SUMMARY_PROMPT: Dict[str, str] = {
90
+ "prompt": (
91
+ "You are summarizing a visual design document exported from Figma as a sequence of images and text. "
92
+ "Provide a clear, concise overview of the main purpose, key elements, and notable changes or variations in the screens. "
93
+ "Infer a likely user flow or sequence of steps across the screens, calling out entry points, decisions, and outcomes. "
94
+ "Explain how this design could impact planning, development, testing, and review activities in a typical software lifecycle. "
95
+ "Return the result as structured Markdown with headings and bullet lists so it can be reused in SDLC documentation."
96
+ )
97
+ }
24
98
  EXTRA_PARAMS = (
25
99
  Optional[Dict[str, Union[str, int, List, None]]],
26
100
  Field(
27
101
  description=(
28
- "Additional parameters for customizing response processing:\n"
29
- "- `limit`: Maximum size of the output in characters.\n"
30
- "- `regexp`: Regex pattern to filter or clean the output.\n"
31
- "- `fields_retain`: List of field names to always keep in the output, on levels starting from `depth_start`.\n"
32
- "- `fields_remove`: List of field names to exclude from the output, unless also present in `fields_retain`.\n"
33
- "- `depth_start`: The depth in the object hierarchy at which field filtering begins (fields are retained or removed).\n"
34
- "- `depth_end`: The depth at which all fields are ignored and recursion stops.\n"
35
- "Use these parameters to control the granularity and size of the returned data, especially for large or deeply nested objects."
102
+ "Optional output controls: `limit` (max characters, always applied), `regexp` (regex cleanup on text), "
103
+ "`fields_retain`/`fields_remove` (which keys to keep or drop), and `depth_start`/`depth_end` (depth range "
104
+ "where that key filtering is applied). Field/depth filters are only used when the serialized JSON result "
105
+ "exceeds `limit` to reduce its size."
36
106
  ),
37
107
  default={
38
108
  "limit": GLOBAL_LIMIT, "regexp": None,
@@ -179,6 +249,52 @@ class ArgsSchema(Enum):
179
249
  ),
180
250
  extra_params=EXTRA_PARAMS,
181
251
  )
252
+ FileSummary = create_model(
253
+ "FileSummary",
254
+ url=(
255
+ Optional[str],
256
+ Field(
257
+ description=(
258
+ "Full Figma URL with file key and optional node-id. "
259
+ "Example: 'https://www.figma.com/file/<FILE_KEY>/...?...node-id=<NODE_ID>'. "
260
+ "If provided and valid, URL is used and file_key/node_ids arguments are ignored."
261
+ ),
262
+ default=None,
263
+ ),
264
+ ),
265
+ file_key=(
266
+ Optional[str],
267
+ Field(
268
+ description=(
269
+ "Explicit file key used only when URL is not provided."
270
+ ),
271
+ default=None,
272
+ examples=["Fp24FuzPwH0L74ODSrCnQo"],
273
+ ),
274
+ ),
275
+ include_node_ids=(
276
+ Optional[str],
277
+ Field(
278
+ description=(
279
+ "Optional comma-separated top-level node ids (pages) to include when URL has no node-id and URL is not set. "
280
+ "Example: '8:6,1:7'."
281
+ ),
282
+ default=None,
283
+ examples=["8:6,1:7"],
284
+ ),
285
+ ),
286
+ exclude_node_ids=(
287
+ Optional[str],
288
+ Field(
289
+ description=(
290
+ "Optional comma-separated top-level node ids (pages) to exclude when URL has no node-id and URL is not set. "
291
+ "Applied only when include_node_ids is not provided."
292
+ ),
293
+ default=None,
294
+ examples=["8:6,1:7"],
295
+ ),
296
+ ),
297
+ )
182
298
 
183
299
 
184
300
  class FigmaApiWrapper(NonCodeIndexerToolkit):
@@ -188,79 +304,159 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
188
304
  global_regexp: Optional[str] = Field(default=None)
189
305
  global_fields_retain: Optional[List[str]] = GLOBAL_RETAIN
190
306
  global_fields_remove: Optional[List[str]] = GLOBAL_REMOVE
191
- global_depth_start: Optional[int] = GLOBAL_DEPTH_START
192
- global_depth_end: Optional[int] = GLOBAL_DEPTH_END
193
- _client: Optional[FigmaPy] = PrivateAttr()
307
+ global_depth_start: Optional[int] = Field(default=GLOBAL_DEPTH_START)
308
+ global_depth_end: Optional[int] = Field(default=GLOBAL_DEPTH_END)
309
+ # prompt-related configuration, populated from FigmaToolkit.toolkit_config_schema
310
+ apply_images_prompt: Optional[bool] = Field(default=True)
311
+ images_prompt: Optional[Dict[str, str]] = Field(default=DEFAULT_FIGMA_IMAGES_PROMPT)
312
+ apply_summary_prompt: Optional[bool] = Field(default=True)
313
+ summary_prompt: Optional[Dict[str, str]] = Field(default=DEFAULT_FIGMA_SUMMARY_PROMPT)
314
+ # concurrency configuration, populated from toolkit config like images_prompt
315
+ number_of_threads: Optional[int] = Field(default=DEFAULT_NUMBER_OF_THREADS, ge=1, le=5)
316
+ _client: Optional[AlitaFigmaPy] = PrivateAttr()
317
+
318
+ def _parse_figma_url(self, url: str) -> tuple[str, Optional[List[str]]]:
319
+ """Parse and validate a Figma URL.
320
+
321
+ Returns a tuple of (file_key, node_ids_from_url or None).
322
+ Raises ToolException with a clear message if the URL is malformed.
323
+ """
324
+ try:
325
+ parsed = urlparse(url)
326
+
327
+ # Basic structural validation
328
+ if not parsed.scheme or not parsed.netloc:
329
+ raise ToolException(
330
+ "Figma URL must include protocol and host (e.g., https://www.figma.com/file/...). "
331
+ f"Got: {url}"
332
+ )
333
+
334
+ path_parts = parsed.path.strip('/').split('/') if parsed.path else []
335
+
336
+ # Supported URL patterns:
337
+ # - /file/<file_key>/...
338
+ # - /design/<file_key>/... (older / embedded variant)
339
+ if len(path_parts) < 2 or path_parts[0] not in {"file", "design"}:
340
+ raise ToolException(
341
+ "Unsupported Figma URL format. Expected path like '/file/<FILE_KEY>/...' or "
342
+ "'/design/<FILE_KEY>/...'. "
343
+ f"Got path: '{parsed.path}' from URL: {url}"
344
+ )
345
+
346
+ file_key = path_parts[1]
347
+ if not file_key:
348
+ raise ToolException(
349
+ "Figma URL is missing the file key segment after '/file/' or '/design/'. "
350
+ f"Got path: '{parsed.path}' from URL: {url}"
351
+ )
352
+
353
+ # Optional node-id is passed via query parameter
354
+ query_params = parse_qs(parsed.query or "")
355
+ node_ids_from_url = query_params.get("node-id", []) or None
356
+
357
+ return file_key, node_ids_from_url
358
+
359
+ except ToolException:
360
+ # Re-raise our own clear ToolException as-is
361
+ raise
362
+ except Exception as e:
363
+ # Catch any unexpected parsing issues and wrap them clearly
364
+ raise ToolException(
365
+ "Unexpected error while processing Figma URL. "
366
+ "Please provide a valid Figma file or page URL, for example: "
367
+ "'https://www.figma.com/file/<FILE_KEY>/...'? "
368
+ f"Original error: {e}"
369
+ )
194
370
 
195
371
  def _base_loader(
196
- self,
197
- file_or_page_url: Optional[str] = None,
198
- project_id: Optional[str] = None,
199
- file_keys_include: Optional[List[str]] = None,
200
- file_keys_exclude: Optional[List[str]] = None,
201
- node_ids_include: Optional[List[str]] = None,
202
- node_ids_exclude: Optional[List[str]] = None,
203
- node_types_include: Optional[List[str]] = None,
204
- node_types_exclude: Optional[List[str]] = None,
205
- **kwargs
372
+ self,
373
+ urls_or_file_keys: Optional[str] = None,
374
+ node_ids_include: Optional[List[str]] = None,
375
+ node_ids_exclude: Optional[List[str]] = None,
376
+ node_types_include: Optional[List[str]] = None,
377
+ node_types_exclude: Optional[List[str]] = None,
378
+ number_of_threads: Optional[int] = None,
379
+ **kwargs,
206
380
  ) -> Generator[Document, None, None]:
207
- if file_or_page_url:
208
- # If URL is provided and valid, extract and override file_keys_include and node_ids_include
381
+ """Base loader used by the indexer tool.
382
+
383
+ Args:
384
+ urls_or_file_keys: Comma-separated list of Figma file URLs or raw file keys. Each
385
+ entry can be:
386
+ - a full Figma URL (https://www.figma.com/file/... or /design/...) optionally
387
+ with a node-id query parameter, or
388
+ - a bare file key string.
389
+ URL entries are parsed via _parse_figma_url; raw keys are used as-is.
390
+ node_ids_include: Optional list of top-level node IDs (pages) to include when an
391
+ entry does not specify node-id in the URL and is not otherwise constrained.
392
+ node_ids_exclude: Optional list of top-level node IDs (pages) to exclude when
393
+ node_ids_include is not provided.
394
+ node_types_include: Optional list of node types to include within each page.
395
+ node_types_exclude: Optional list of node types to exclude when node_types_include
396
+ is not provided.
397
+ number_of_threads: Optional override for number of worker threads to use when
398
+ processing images.
399
+ """
400
+ if not urls_or_file_keys:
401
+ raise ValueError("You must provide urls_or_file_keys with at least one URL or file key.")
402
+
403
+ # Parse the comma-separated entries into concrete (file_key, per_entry_node_ids_include)
404
+ entries = [item.strip() for item in urls_or_file_keys.split(',') if item.strip()]
405
+ if not entries:
406
+ raise ValueError("You must provide urls_or_file_keys with at least one non-empty value.")
407
+
408
+ # Validate number_of_threads override once and pass via metadata
409
+ metadata_threads_override: Optional[int] = None
410
+ if isinstance(number_of_threads, int) and 1 <= number_of_threads <= 5:
411
+ metadata_threads_override = number_of_threads
412
+
413
+ for entry in entries:
414
+ per_file_node_ids_include: Optional[List[str]] = None
415
+ file_key: Optional[str] = None
416
+
417
+ # Heuristic: treat as URL if it has a scheme and figma.com host
418
+ if entry.startswith("http://") or entry.startswith("https://"):
419
+ file_key, node_ids_from_url = self._parse_figma_url(entry)
420
+ per_file_node_ids_include = node_ids_from_url
421
+ else:
422
+ # Assume this is a raw file key
423
+ file_key = entry
424
+
425
+ if not file_key:
426
+ continue
427
+
428
+ # If URL-derived node IDs exist, they take precedence over global include list
429
+ effective_node_ids_include = per_file_node_ids_include or node_ids_include or []
430
+
431
+ self._log_tool_event(f"Loading file `{file_key}`")
209
432
  try:
210
- parsed = urlparse(file_or_page_url)
211
- path_parts = parsed.path.strip('/').split('/')
212
-
213
- # Check if the path matches the expected format
214
- if len(path_parts) >= 2 and path_parts[0] == 'design':
215
- file_keys_include = [path_parts[1]]
216
- if len(path_parts) == 3:
217
- # To ensure url structure matches Figma's format with 3 path segments
218
- query_params = parse_qs(parsed.query)
219
- if "node-id" in query_params:
220
- node_ids_include = query_params.get('node-id', [])
221
- except Exception as e:
433
+ file = self._client.get_file(file_key, geometry='depth=1')
434
+ except ToolException as e:
435
+ # Enrich the error message with the file_key for easier troubleshooting
222
436
  raise ToolException(
223
- f"Unexpected error while processing Figma url {file_or_page_url}: {e}")
224
-
225
- # If both include and exclude are provided, use only include
226
- if file_keys_include:
227
- self._log_tool_event(f"Loading files: {file_keys_include}")
228
- for file_key in file_keys_include:
229
- self._log_tool_event(f"Loading file `{file_key}`")
230
- file = self._client.get_file(file_key, geometry='depth=1') # fetch only top-level structure (only pages without inner components)
231
- if not file:
232
- raise ToolException(f"Unexpected error while retrieving file {file_key}. Please try specifying the node-id of an inner page.")
233
- metadata = {
234
- 'id': file_key,
235
- 'file_key': file_key,
236
- 'name': file.name,
237
- 'updated_on': file.last_modified,
238
- 'figma_pages_include': node_ids_include or [],
239
- 'figma_pages_exclude': node_ids_exclude or [],
240
- 'figma_nodes_include': node_types_include or [],
241
- 'figma_nodes_exclude': node_types_exclude or []
242
- }
243
- yield Document(page_content=json.dumps(metadata), metadata=metadata)
244
- elif project_id:
245
- self._log_tool_event(f"Loading project files from project `{project_id}`")
246
- files = json.loads(self.get_project_files(project_id)).get('files', [])
247
- for file in files:
248
- if file_keys_exclude and file.get('key', '') in file_keys_exclude:
249
- continue
250
- yield Document(page_content=json.dumps(file), metadata={
251
- 'id': file.get('key', ''),
252
- 'file_key': file.get('key', ''),
253
- 'name': file.get('name', ''),
254
- 'updated_on': file.get('last_modified', ''),
255
- 'figma_pages_include': node_ids_include or [],
256
- 'figma_pages_exclude': node_ids_exclude or [],
257
- 'figma_nodes_include': node_types_include or [],
258
- 'figma_nodes_exclude': node_types_exclude or []
259
- })
260
- elif file_keys_exclude or node_ids_exclude:
261
- raise ValueError("Excludes without parent (project_id or file_keys_include) do not make sense.")
262
- else:
263
- raise ValueError("You must provide at least project_id or file_keys_include.")
437
+ f"Failed to retrieve Figma file '{file_key}'. Original error: {e}"
438
+ ) from e
439
+
440
+ if not file:
441
+ raise ToolException(
442
+ f"Unexpected error while retrieving file {file_key}. Please try specifying the node-id of an inner page."
443
+ )
444
+
445
+ metadata = {
446
+ 'id': file_key,
447
+ 'file_key': file_key,
448
+ 'name': file.name,
449
+ 'updated_on': file.last_modified,
450
+ 'figma_pages_include': effective_node_ids_include,
451
+ 'figma_pages_exclude': node_ids_exclude or [],
452
+ 'figma_nodes_include': node_types_include or [],
453
+ 'figma_nodes_exclude': node_types_exclude or [],
454
+ }
455
+
456
+ if metadata_threads_override is not None:
457
+ metadata['number_of_threads_override'] = metadata_threads_override
458
+
459
+ yield Document(page_content=json.dumps(metadata), metadata=metadata)
264
460
 
265
461
  def has_image_representation(self, node):
266
462
  node_type = node.get('type', '').lower()
@@ -294,7 +490,11 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
294
490
  # try to fetch only specified pages/nodes in one request
295
491
  file = self._get_file_nodes(file_key,','.join(node_ids_include)) # attempt to fetch only specified pages/nodes in one request
296
492
  if file:
297
- return [node['document'] for node in file.get('nodes', {}).values() if 'document' in node]
493
+ return [
494
+ node["document"]
495
+ for node in (file.get("nodes") or {}).values()
496
+ if node is not None and "document" in node
497
+ ]
298
498
  else:
299
499
  #
300
500
  file = self._client.get_file(file_key)
@@ -319,7 +519,69 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
319
519
  result.append(page_res)
320
520
  return result
321
521
 
322
- def _process_document(self, document: Document) -> Generator[Document, None, None]:
522
+ def _process_single_image(
523
+ self,
524
+ file_key: str,
525
+ document: Document,
526
+ node_id: str,
527
+ image_url: str,
528
+ prompt: str,
529
+ ) -> Optional[Document]:
530
+ """Download and process a single Figma image node.
531
+ This helper is used by `_process_document` (optionally in parallel via threads).
532
+ """
533
+ if not image_url:
534
+ logging.warning(f"Image URL not found for node_id {node_id} in file {file_key}. Skipping.")
535
+ return None
536
+
537
+ logging.info(f"File {file_key}: downloading image node {node_id}.")
538
+
539
+ try:
540
+ response = requests.get(image_url)
541
+ except Exception as exc:
542
+ logging.warning(f"Failed to download image for node {node_id} in file {file_key}: {exc}")
543
+ return None
544
+
545
+ if response.status_code != 200:
546
+ logging.warning(
547
+ f"Unexpected status code {response.status_code} when downloading image "
548
+ f"for node {node_id} in file {file_key}."
549
+ )
550
+ return None
551
+
552
+ content_type = response.headers.get('Content-Type', '')
553
+ if 'text/html' in content_type.lower():
554
+ logging.warning(f"Received HTML instead of image content for node {node_id} in file {file_key}.")
555
+ return None
556
+
557
+ extension = (f".{content_type.split('/')[-1]}" if content_type.startswith('image') else '.txt')
558
+ logging.info(f"File {file_key}: processing image node {node_id}.")
559
+ page_content = _load_content_from_bytes_with_prompt(
560
+ file_content=response.content,
561
+ extension=extension,
562
+ llm=self.llm,
563
+ prompt=prompt,
564
+ )
565
+
566
+ logging.info(f"File {file_key}: finished image node {node_id}.")
567
+
568
+ return Document(
569
+ page_content=page_content,
570
+ metadata={
571
+ 'id': node_id,
572
+ 'updated_on': document.metadata.get('updated_on', ''),
573
+ 'file_key': file_key,
574
+ 'node_id': node_id,
575
+ 'image_url': image_url,
576
+ 'type': 'image',
577
+ },
578
+ )
579
+
580
+ def _process_document(
581
+ self,
582
+ document: Document,
583
+ prompt: str = "",
584
+ ) -> Generator[Document, None, None]:
323
585
  file_key = document.metadata.get('id', '')
324
586
  self._log_tool_event(f"Loading details (images) for `{file_key}`")
325
587
  figma_pages = self._load_pages(document)
@@ -343,47 +605,105 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
343
605
  image_nodes.append(node['id'])
344
606
  else:
345
607
  text_nodes[node['id']] = self.get_texts_recursive(node)
346
- # process image nodes
608
+ total_nodes = len(image_nodes) + len(text_nodes)
609
+ # mutable counter so it can be updated from helper calls (even when used in threads)
610
+ counted_nodes_ref: Dict[str, int] = {"value": 0}
611
+
612
+ # Resolve number_of_threads override from document metadata, falling back to class field
613
+ override_threads = document.metadata.get('number_of_threads_override')
614
+ if isinstance(override_threads, int) and 1 <= override_threads <= 5:
615
+ number_of_threads = override_threads
616
+ else:
617
+ threads_cfg = getattr(self, "number_of_threads", DEFAULT_NUMBER_OF_THREADS)
618
+ if isinstance(threads_cfg, int) and 1 <= threads_cfg <= 5:
619
+ number_of_threads = threads_cfg
620
+ else:
621
+ number_of_threads = DEFAULT_NUMBER_OF_THREADS
622
+
623
+ # --- Process image nodes (potential bottleneck) with optional threading ---
347
624
  if image_nodes:
348
625
  file_images = self._client.get_file_images(file_key, image_nodes)
349
626
  images = self._client.get_file_images(file_key, image_nodes).images or {} if file_images else {}
350
627
  total_images = len(images)
351
628
  if total_images == 0:
352
629
  logging.info(f"No images found for file {file_key}.")
353
- return
354
- progress_step = max(1, total_images // 10)
355
- for idx, (node_id, image_url) in enumerate(images.items(), 1):
356
- if not image_url:
357
- logging.warning(f"Image URL not found for node_id {node_id} in file {file_key}. Skipping.")
358
- continue
359
- response = requests.get(image_url)
360
- if response.status_code == 200:
361
- content_type = response.headers.get('Content-Type', '')
362
- if 'text/html' not in content_type.lower():
363
- extension = f".{content_type.split('/')[-1]}" if content_type.startswith('image') else '.txt'
364
- page_content = load_content_from_bytes(
365
- file_content=response.content,
366
- extension=extension, llm=self.llm)
367
- yield Document(
368
- page_content=page_content,
369
- metadata={
370
- 'id': node_id,
371
- 'updated_on': document.metadata.get('updated_on', ''),
372
- 'file_key': file_key,
373
- 'node_id': node_id,
374
- 'image_url': image_url,
375
- 'type': 'image'
376
- }
630
+ else:
631
+ self._log_tool_event(
632
+ f"File {file_key}: starting download/processing for total {total_nodes} nodes"
633
+ )
634
+
635
+ # Decide how many workers to use (bounded by total_images and configuration).
636
+ max_workers = number_of_threads
637
+ max_workers = max(1, min(max_workers, total_images))
638
+
639
+ if max_workers == 1:
640
+ # Keep original sequential behavior
641
+ for node_id, image_url in images.items():
642
+ doc = self._process_single_image(
643
+ file_key=file_key,
644
+ document=document,
645
+ node_id=node_id,
646
+ image_url=image_url,
647
+ prompt=prompt,
377
648
  )
378
- if idx % progress_step == 0 or idx == total_images:
379
- percent = int((idx / total_images) * 100)
380
- msg = f"Processed {idx}/{total_images} images ({percent}%) for file {file_key}."
381
- logging.info(msg)
382
- self._log_tool_event(msg)
383
- # process text nodes
649
+ counted_nodes_ref["value"] += 1
650
+ if doc is not None:
651
+ self._log_tool_event(
652
+ f"File {file_key}: processing image node {node_id} "
653
+ f"({counted_nodes_ref['value']}/{total_nodes} in {max_workers} threads)."
654
+ )
655
+ yield doc
656
+ else:
657
+ # Parallelize image download/processing with a thread pool
658
+ self._log_tool_event(
659
+ f"File {file_key}: using up to {max_workers} worker threads for image nodes."
660
+ )
661
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
662
+ future_to_node = {
663
+ executor.submit(
664
+ self._process_single_image,
665
+ file_key,
666
+ document,
667
+ node_id,
668
+ image_url,
669
+ prompt,
670
+ ): node_id
671
+ for node_id, image_url in images.items()
672
+ }
673
+ for future in as_completed(future_to_node):
674
+ node_id = future_to_node[future]
675
+ try:
676
+ doc = future.result()
677
+ except Exception as exc: # safeguard
678
+ logging.warning(
679
+ f"File {file_key}: unexpected error while processing image node {node_id}: {exc}"
680
+ )
681
+ continue
682
+ finally:
683
+ # Count every attempted node, even if it failed or produced no doc,
684
+ # so that progress always reaches total_nodes.
685
+ counted_nodes_ref["value"] += 1
686
+
687
+ if doc is not None:
688
+ self._log_tool_event(
689
+ f"File {file_key}: processing image node {node_id} "
690
+ f"({counted_nodes_ref['value']}/{total_nodes} in {max_workers} threads)."
691
+ )
692
+ yield doc
693
+
694
+ logging.info(
695
+ f"File {file_key}: completed processing of {total_images} image nodes."
696
+ )
697
+
698
+ # --- Process text nodes (fast) ---
384
699
  if text_nodes:
385
700
  for node_id, texts in text_nodes.items():
701
+ counted_nodes_ref["value"] += 1
702
+ current_index = counted_nodes_ref["value"]
386
703
  if texts:
704
+ self._log_tool_event(
705
+ f"File {file_key} : processing text node {node_id} ({current_index}/{total_nodes})."
706
+ )
387
707
  yield Document(
388
708
  page_content="\n".join(texts),
389
709
  metadata={
@@ -391,40 +711,62 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
391
711
  'updated_on': document.metadata.get('updated_on', ''),
392
712
  'file_key': file_key,
393
713
  'node_id': node_id,
394
- 'type': 'text'
395
- }
714
+ 'type': 'text',
715
+ },
396
716
  )
397
717
 
398
- def _remove_metadata_keys(self):
399
- return super()._remove_metadata_keys() + ['figma_pages_include', 'figma_pages_exclude', 'figma_nodes_include', 'figma_nodes_exclude']
400
-
401
718
  def _index_tool_params(self):
402
719
  """Return the parameters for indexing data."""
403
720
  return {
404
- "file_or_page_url": (Optional[str], Field(
405
- description="Url to file or page to index: i.e. https://www.figma.com/design/[YOUR_FILE_KEY]/Login-page-designs?node-id=[YOUR_PAGE_ID]",
406
- default=None)),
407
- "project_id": (Optional[str], Field(
408
- description="ID of the project to list files from: i.e. 55391681",
409
- default=None)),
410
- 'file_keys_include': (Optional[List[str]], Field(
411
- description="List of file keys to include in index if project_id is not provided: i.e. ['Fp24FuzPwH0L74ODSrCnQo', 'jmhAr6q78dJoMRqt48zisY']",
412
- default=None)),
413
- 'file_keys_exclude': (Optional[List[str]], Field(
414
- description="List of file keys to exclude from index. It is applied only if project_id is provided and file_keys_include is not provided: i.e. ['Fp24FuzPwH0L74ODSrCnQo', 'jmhAr6q78dJoMRqt48zisY']",
415
- default=None)),
721
+ "urls_or_file_keys": (str, Field(
722
+ description=(
723
+ "Comma-separated list of Figma file URLs or raw file keys to index. "
724
+ "Each entry may be a full Figma URL (with optional node-id query) or a file key. "
725
+ "Example: 'https://www.figma.com/file/<FILE_KEY>/...?node-id=<NODE_ID>,Fp24FuzPwH0L74ODSrCnQo'."
726
+ ))),
727
+ 'number_of_threads': (Optional[int], Field(
728
+ description=(
729
+ "Optional override for the number of worker threads used when indexing Figma images. "
730
+ f"Valid values are from 1 to 5. Default is {DEFAULT_NUMBER_OF_THREADS}."
731
+ ),
732
+ default=DEFAULT_NUMBER_OF_THREADS,
733
+ ge=1,
734
+ le=5,
735
+ )),
416
736
  'node_ids_include': (Optional[List[str]], Field(
417
- description="List of top-level nodes (pages) in file to include in index. It is node-id from figma url: i.e. ['123-56', '7651-9230'].",
418
- default=None)),
737
+ description=(
738
+ "List of top-level node IDs (pages) to include in the index. Values should match "
739
+ "Figma node-id format like ['123-56', '7651-9230']. These include rules are applied "
740
+ "for each entry in urls_or_file_keys when the URL does not specify node-id and for "
741
+ "each raw file_key entry."
742
+ ),
743
+ default=None,
744
+ )),
419
745
  'node_ids_exclude': (Optional[List[str]], Field(
420
- description="List of top-level nodes (pages) in file to exclude from index. It is applied only if node_ids_include is not provided. It is node-id from figma url: i.e. ['Fp24FuzPwH0L74ODSrCnQo', 'jmhAr6q78dJoMRqt48zisY']",
421
- default=None)),
746
+ description=(
747
+ "List of top-level node IDs (pages) to exclude from the index when node_ids_include "
748
+ "is not provided. Values should match Figma node-id format. These exclude rules are "
749
+ "applied for each entry in urls_or_file_keys (URLs without node-id and raw fileKey "
750
+ "entries)."
751
+ ),
752
+ default=None,
753
+ )),
422
754
  'node_types_include': (Optional[List[str]], Field(
423
- description="List type of nodes to include in index: i.e. ['FRAME', 'COMPONENT', 'RECTANGLE', 'COMPONENT_SET', 'INSTANCE', 'VECTOR', ...].",
424
- default=None)),
755
+ description=(
756
+ "List of node types to include in the index, e.g. ['FRAME', 'COMPONENT', 'RECTANGLE', "
757
+ "'COMPONENT_SET', 'INSTANCE', 'VECTOR', ...]. If provided, only these types are indexed "
758
+ "for each page loaded from each urls_or_file_keys entry."
759
+ ),
760
+ default=None,
761
+ )),
425
762
  'node_types_exclude': (Optional[List[str]], Field(
426
- description="List type of nodes to exclude from index. It is applied only if node_types_include is not provided: i.e. ['FRAME', 'COMPONENT', 'RECTANGLE', 'COMPONENT_SET', 'INSTANCE', 'VECTOR', ...]",
427
- default=None))
763
+ description=(
764
+ "List of node types to exclude from the index when node_types_include is not provided. "
765
+ "These exclude rules are applied to nodes within each page loaded from each "
766
+ "urls_or_file_keys entry."
767
+ ),
768
+ default=None,
769
+ )),
428
770
  }
429
771
 
430
772
  def _send_request(
@@ -476,22 +818,22 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
476
818
  except re.error as e:
477
819
  msg = f"Failed to compile regex pattern: {str(e)}"
478
820
  logging.error(msg)
479
- return ToolException(msg)
821
+ raise ToolException(msg)
480
822
 
481
823
  try:
482
824
  if token:
483
- cls._client = FigmaPy(token=token, oauth2=False)
825
+ cls._client = AlitaFigmaPy(token=token, oauth2=False)
484
826
  logging.info("Authenticated with Figma token")
485
827
  elif oauth2:
486
- cls._client = FigmaPy(token=oauth2, oauth2=True)
828
+ cls._client = AlitaFigmaPy(token=oauth2, oauth2=True)
487
829
  logging.info("Authenticated with OAuth2 token")
488
830
  else:
489
- return ToolException("You have to define Figma token.")
831
+ raise ToolException("You have to define Figma token.")
490
832
  logging.info("Successfully authenticated to Figma.")
491
833
  except Exception as e:
492
834
  msg = f"Failed to authenticate with Figma: {str(e)}"
493
835
  logging.error(msg)
494
- return ToolException(msg)
836
+ raise ToolException(msg)
495
837
 
496
838
  return values
497
839
 
@@ -654,6 +996,131 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
654
996
  """Reads a specified file by field key from Figma."""
655
997
  return self._client.get_file(file_key, geometry, version)
656
998
 
999
+ @process_output
1000
+ def get_file_summary(
1001
+ self,
1002
+ url: Optional[str] = None,
1003
+ file_key: Optional[str] = None,
1004
+ include_node_ids: Optional[str] = None,
1005
+ exclude_node_ids: Optional[str] = None,
1006
+ **kwargs,
1007
+ ):
1008
+ """Summarizes a Figma file by loading pages and nodes via URL or file key.
1009
+
1010
+ Configuration for image processing and summarization is taken from the toolkit
1011
+ configuration (see FigmaToolkit.toolkit_config_schema):
1012
+
1013
+ - self.apply_images_prompt: if True, pass self.images_prompt to the image-processing step.
1014
+ - self.images_prompt: instruction string for how to treat image-based nodes.
1015
+ - self.apply_summary_prompt: if True and self.summary_prompt is set and an LLM is configured,
1016
+ return a single summarized string; otherwise return the raw list of node documents.
1017
+ - self.summary_prompt: instruction string for LLM summarization.
1018
+
1019
+ Tool arguments mirror ArgsSchema.FileSummary and control only which file/pages are loaded.
1020
+ """
1021
+ # Prepare params for _base_loader without evaluating any logic here
1022
+ node_ids_include_list = None
1023
+ node_ids_exclude_list = None
1024
+
1025
+ if include_node_ids:
1026
+ node_ids_include_list = [nid.strip() for nid in include_node_ids.split(',') if nid.strip()]
1027
+
1028
+ if exclude_node_ids:
1029
+ node_ids_exclude_list = [nid.strip() for nid in exclude_node_ids.split(',') if nid.strip()]
1030
+
1031
+ # Delegate URL and file_key handling to _base_loader
1032
+ base_docs = self._base_loader(
1033
+ urls_or_file_keys=url or file_key,
1034
+ node_ids_include=node_ids_include_list,
1035
+ node_ids_exclude=node_ids_exclude_list,
1036
+ )
1037
+
1038
+ # Read prompt-related configuration from toolkit instance (set via wrapper_payload)
1039
+ apply_images_prompt = getattr(self, "apply_images_prompt", False)
1040
+ images_prompt = getattr(self, "images_prompt", None)
1041
+ apply_summary_prompt = getattr(self, "apply_summary_prompt", True)
1042
+ summary_prompt = getattr(self, "summary_prompt", None)
1043
+
1044
+ # Decide whether to apply images_prompt. Expect dict with 'prompt'.
1045
+ if (
1046
+ apply_images_prompt
1047
+ and isinstance(images_prompt, dict)
1048
+ and isinstance(images_prompt.get("prompt"), str)
1049
+ and images_prompt["prompt"].strip()
1050
+ ):
1051
+ images_prompt_str = images_prompt["prompt"].strip()
1052
+ else:
1053
+ images_prompt_str = ""
1054
+
1055
+ results: List[Dict] = []
1056
+ for base_doc in base_docs:
1057
+ for dep in self._process_document(
1058
+ base_doc,
1059
+ images_prompt_str,
1060
+ ):
1061
+ results.append({
1062
+ "page_content": dep.page_content,
1063
+ "metadata": dep.metadata,
1064
+ })
1065
+
1066
+ # Decide whether to apply summary_prompt
1067
+ has_summary_prompt = bool(
1068
+ isinstance(summary_prompt, dict)
1069
+ and isinstance(summary_prompt.get("prompt"), str)
1070
+ and summary_prompt["prompt"].strip()
1071
+ )
1072
+ if not apply_summary_prompt or not has_summary_prompt:
1073
+ # Return raw docs when summary is disabled or no prompt provided
1074
+ self._log_tool_event("Summary prompt not provided: returning raw documents.")
1075
+ return results
1076
+
1077
+ # If summary_prompt is enabled, generate an LLM-based summary over the loaded docs
1078
+ try:
1079
+ # Build a structured, ordered view of images and texts to help the LLM infer flows.
1080
+ blocks = []
1081
+ for item in results:
1082
+ metadata = item.get("metadata", {}) or {}
1083
+ node_type = str(metadata.get("type", "")).lower()
1084
+ node_id = metadata.get("node_id") or metadata.get("id", "")
1085
+ page_content = str(item.get("page_content", "")).strip()
1086
+
1087
+ if not page_content:
1088
+ continue
1089
+
1090
+ if node_type == "image":
1091
+ image_url = metadata.get("image_url", "")
1092
+ header = f"Image ({node_id}), {image_url}".strip().rstrip(',')
1093
+ body = page_content
1094
+ else:
1095
+ header = f"Text ({node_id})".strip()
1096
+ body = page_content
1097
+
1098
+ block = f"{header}\n{body}\n--------------------"
1099
+ blocks.append(block)
1100
+
1101
+ full_content = "\n".join(blocks) if blocks else "(no content)"
1102
+ self._log_tool_event("Invoking LLM for Figma file summary.")
1103
+
1104
+ if not getattr(self, "llm", None):
1105
+ raise RuntimeError("LLM is not configured for this toolkit; cannot apply summary_prompt.")
1106
+
1107
+ # Use the 'prompt' field from the summary_prompt dict as the instruction block
1108
+ summary_prompt_text = summary_prompt["prompt"].strip()
1109
+ prompt_text = f"{summary_prompt_text}\n\nCONTENT BEGIN\n{full_content}\nCONTENT END"
1110
+ llm_response = self.llm.invoke(prompt_text) if hasattr(self.llm, "invoke") else self.llm(prompt_text)
1111
+
1112
+ if hasattr(llm_response, "content"):
1113
+ summary_text = str(llm_response.content)
1114
+ else:
1115
+ summary_text = str(llm_response)
1116
+
1117
+ self._log_tool_event("Successfully generated LLM-based file summary.")
1118
+ return summary_text
1119
+ except Exception as e:
1120
+ logging.warning(f"Failed to apply summary_prompt in get_file_summary: {e}")
1121
+ self._log_tool_event("Falling back to raw documents due to summary_prompt failure.")
1122
+ return results
1123
+
657
1124
  @process_output
658
1125
  def get_file_versions(self, file_key: str, **kwargs):
659
1126
  """Retrieves the version history of a specified file from Figma."""
@@ -709,6 +1176,608 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
709
1176
  """Retrieves all files for a specified project ID from Figma."""
710
1177
  return self._client.get_project_files(project_id)
711
1178
 
1179
+ # -------------------------------------------------------------------------
1180
+ # TOON Format Tools (Token-Optimized Output)
1181
+ # -------------------------------------------------------------------------
1182
+
1183
+ def get_file_structure_toon(
1184
+ self,
1185
+ url: Optional[str] = None,
1186
+ file_key: Optional[str] = None,
1187
+ include_pages: Optional[str] = None,
1188
+ exclude_pages: Optional[str] = None,
1189
+ max_frames: int = 50,
1190
+ **kwargs,
1191
+ ) -> str:
1192
+ """
1193
+ Get file structure in TOON format - optimized for LLM token consumption.
1194
+
1195
+ Returns a compact, human-readable format with:
1196
+ - Page and frame hierarchy
1197
+ - Text content categorized (headings, labels, buttons, body, errors)
1198
+ - Component usage
1199
+ - Inferred screen types and states
1200
+ - Flow analysis (sequences, variants, CTA destinations)
1201
+
1202
+ TOON format uses ~70% fewer tokens than JSON for the same data.
1203
+
1204
+ Use this tool when you need to:
1205
+ - Understand overall file structure quickly
1206
+ - Generate user journey documentation
1207
+ - Analyze screen flows and navigation
1208
+ - Identify UI patterns and components
1209
+ """
1210
+ self._log_tool_event("Getting file structure in TOON format")
1211
+
1212
+ # Parse URL or use file_key
1213
+ if url:
1214
+ file_key, node_ids_from_url = self._parse_figma_url(url)
1215
+ if node_ids_from_url and not include_pages:
1216
+ include_pages = ','.join(node_ids_from_url)
1217
+
1218
+ if not file_key:
1219
+ raise ToolException("Either url or file_key must be provided")
1220
+
1221
+ # Parse include/exclude pages
1222
+ include_ids = [p.strip() for p in include_pages.split(',')] if include_pages else None
1223
+ exclude_ids = [p.strip() for p in exclude_pages.split(',')] if exclude_pages else None
1224
+
1225
+ # Get file structure (shallow fetch - only top-level pages, not full content)
1226
+ # This avoids "Request too large" errors for big files
1227
+ self._log_tool_event(f"Fetching file structure for {file_key}")
1228
+ file_data = self._client.get_file(file_key, geometry='depth=1')
1229
+
1230
+ if not file_data:
1231
+ raise ToolException(f"Failed to retrieve file {file_key}")
1232
+
1233
+ # Process pages
1234
+ pages_data = []
1235
+ all_pages = file_data.document.get('children', [])
1236
+
1237
+ for page_node in all_pages:
1238
+ page_id = page_node.get('id', '')
1239
+
1240
+ # Apply page filters
1241
+ if include_ids and page_id not in include_ids and page_id.replace(':', '-') not in include_ids:
1242
+ continue
1243
+ if exclude_ids and not include_ids:
1244
+ if page_id in exclude_ids or page_id.replace(':', '-') in exclude_ids:
1245
+ continue
1246
+
1247
+ self._log_tool_event(f"Processing page: {page_node.get('name', 'Untitled')}")
1248
+
1249
+ # Fetch full page content individually (avoids large single request)
1250
+ try:
1251
+ page_full = self._get_file_nodes(file_key, page_id)
1252
+ if page_full:
1253
+ page_content = page_full.get('nodes', {}).get(page_id, {}).get('document', page_node)
1254
+ else:
1255
+ page_content = page_node
1256
+ except Exception as e:
1257
+ self._log_tool_event(f"Warning: Could not fetch full page content for {page_id}: {e}")
1258
+ page_content = page_node
1259
+
1260
+ page_data = process_page_to_toon_data(page_content)
1261
+
1262
+ # Limit frames per page
1263
+ if len(page_data['frames']) > max_frames:
1264
+ page_data['frames'] = page_data['frames'][:max_frames]
1265
+ page_data['truncated'] = True
1266
+
1267
+ pages_data.append(page_data)
1268
+
1269
+ # Build file data structure
1270
+ toon_data = {
1271
+ 'name': file_data.name,
1272
+ 'key': file_key,
1273
+ 'pages': pages_data,
1274
+ }
1275
+
1276
+ # Serialize to TOON format
1277
+ serializer = TOONSerializer()
1278
+ result = serializer.serialize_file(toon_data)
1279
+
1280
+ self._log_tool_event("File structure extracted in TOON format")
1281
+ return result
1282
+
1283
+ def get_page_flows_toon(
1284
+ self,
1285
+ url: Optional[str] = None,
1286
+ file_key: Optional[str] = None,
1287
+ page_id: Optional[str] = None,
1288
+ **kwargs,
1289
+ ) -> str:
1290
+ """
1291
+ Analyze a single page for user flows in TOON format.
1292
+
1293
+ Returns detailed flow analysis:
1294
+ - Frame sequence detection (from naming: 01_, Step 1, etc.)
1295
+ - Screen variant grouping (Login, Login_Error, Login_Loading)
1296
+ - CTA/button destination mapping
1297
+ - Spatial ordering hints
1298
+
1299
+ Use this for in-depth flow analysis of a specific page.
1300
+ Requires a PAGE ID (not a frame ID). Use get_file_structure_toon to find page IDs.
1301
+ """
1302
+ self._log_tool_event("Analyzing page flows in TOON format")
1303
+
1304
+ # Parse URL
1305
+ if url:
1306
+ file_key, node_ids_from_url = self._parse_figma_url(url)
1307
+ if node_ids_from_url:
1308
+ page_id = node_ids_from_url[0]
1309
+
1310
+ if not file_key:
1311
+ raise ToolException("Either url or file_key must be provided")
1312
+ if not page_id:
1313
+ raise ToolException("page_id must be provided (or include node-id in URL)")
1314
+
1315
+ # Fetch node content
1316
+ self._log_tool_event(f"Fetching node {page_id} from file {file_key}")
1317
+ node_full = self._get_file_nodes(file_key, page_id)
1318
+
1319
+ if not node_full:
1320
+ raise ToolException(f"Failed to retrieve node {page_id}")
1321
+
1322
+ node_content = node_full.get('nodes', {}).get(page_id, {}).get('document', {})
1323
+ if not node_content:
1324
+ raise ToolException(f"Node {page_id} has no content")
1325
+
1326
+ # Check if this is a page (CANVAS) or a frame
1327
+ node_type = node_content.get('type', '').upper()
1328
+ if node_type != 'CANVAS':
1329
+ # This is a frame, not a page - provide helpful error
1330
+ raise ToolException(
1331
+ f"Node {page_id} is a {node_type}, not a PAGE. "
1332
+ f"This tool requires a page ID. Use get_file_structure_toon first to find page IDs "
1333
+ f"(look for PAGE: ... #<page_id>)"
1334
+ )
1335
+
1336
+ page_content = node_content
1337
+
1338
+ # Process page
1339
+ page_data = process_page_to_toon_data(page_content)
1340
+ frames = page_data.get('frames', [])
1341
+
1342
+ # Build detailed flow analysis
1343
+ lines = []
1344
+ lines.append(f"PAGE: {page_data.get('name', 'Untitled')} [id:{page_id}]")
1345
+ lines.append(f" frames: {len(frames)}")
1346
+ lines.append("")
1347
+
1348
+ # Sequence analysis
1349
+ sequences = detect_sequences(frames)
1350
+ if sequences:
1351
+ lines.append("SEQUENCES (by naming):")
1352
+ for seq in sequences:
1353
+ lines.append(f" {' > '.join(seq)}")
1354
+ lines.append("")
1355
+
1356
+ # Variant analysis
1357
+ variants = group_variants(frames)
1358
+ if variants:
1359
+ lines.append("VARIANTS (grouped screens):")
1360
+ for base, variant_list in variants.items():
1361
+ lines.append(f" {base}:")
1362
+ for v in variant_list:
1363
+ v_name = v.get('name', '')
1364
+ v_id = v.get('id', '')
1365
+ state = next((f.get('state', 'default') for f in frames if f.get('name') == v_name), 'default')
1366
+ lines.append(f" - {v_name} [{state}] #{v_id}")
1367
+ lines.append("")
1368
+
1369
+ # CTA mapping
1370
+ lines.append("CTA DESTINATIONS:")
1371
+ cta_map = {}
1372
+ for frame in frames:
1373
+ frame_name = frame.get('name', '')
1374
+ for btn in frame.get('buttons', []):
1375
+ dest = infer_cta_destination(btn)
1376
+ if dest not in cta_map:
1377
+ cta_map[dest] = []
1378
+ cta_map[dest].append(f'"{btn}" in {frame_name}')
1379
+
1380
+ for dest, ctas in cta_map.items():
1381
+ lines.append(f" > {dest}:")
1382
+ for cta in ctas[:5]: # Limit per destination
1383
+ lines.append(f" {cta}")
1384
+ lines.append("")
1385
+
1386
+ # Spatial ordering
1387
+ lines.append("SPATIAL ORDER (canvas position):")
1388
+ sorted_frames = sorted(frames, key=lambda f: (f['position']['y'], f['position']['x']))
1389
+ for i, frame in enumerate(sorted_frames[:20], 1):
1390
+ pos = frame.get('position', {})
1391
+ lines.append(f" {i}. {frame.get('name', '')} [{int(pos.get('x', 0))},{int(pos.get('y', 0))}]")
1392
+
1393
+ # Frame details
1394
+ lines.append("")
1395
+ lines.append("FRAME DETAILS:")
1396
+
1397
+ serializer = TOONSerializer()
1398
+ for frame in frames[:30]: # Limit frames
1399
+ frame_lines = serializer.serialize_frame(frame, level=1)
1400
+ lines.extend(frame_lines)
1401
+
1402
+ self._log_tool_event("Page flow analysis complete")
1403
+ return '\n'.join(lines)
1404
+
1405
+ def get_frame_detail_toon(
1406
+ self,
1407
+ file_key: str,
1408
+ frame_ids: str,
1409
+ **kwargs,
1410
+ ) -> str:
1411
+ """
1412
+ Get detailed information for specific frames in TOON format.
1413
+
1414
+ Returns per-frame:
1415
+ - All text content (headings, labels, buttons, body, errors)
1416
+ - Component hierarchy
1417
+ - Inferred screen type and state
1418
+ - Position and size
1419
+
1420
+ Use this to drill down into specific screens identified from file structure.
1421
+ """
1422
+ try:
1423
+ return self._get_frame_detail_toon_internal(file_key=file_key, frame_ids=frame_ids, **kwargs)
1424
+ except ToolException as e:
1425
+ raise ToolException(_handle_figma_error(e))
1426
+
1427
+ def _get_frame_detail_toon_internal(
1428
+ self,
1429
+ file_key: str,
1430
+ frame_ids: str,
1431
+ **kwargs,
1432
+ ) -> str:
1433
+ """Internal implementation of get_frame_detail_toon without error handling wrapper."""
1434
+ self._log_tool_event("Getting frame details in TOON format")
1435
+
1436
+ ids_list = [fid.strip() for fid in frame_ids.split(',') if fid.strip()]
1437
+ if not ids_list:
1438
+ raise ToolException("frame_ids must contain at least one frame ID")
1439
+
1440
+ # Fetch frames
1441
+ self._log_tool_event(f"Fetching {len(ids_list)} frames from file {file_key}")
1442
+ nodes_data = self._get_file_nodes(file_key, ','.join(ids_list))
1443
+
1444
+ if not nodes_data:
1445
+ raise ToolException(f"Failed to retrieve frames from file {file_key}")
1446
+
1447
+ # Process each frame
1448
+ lines = [f"FRAMES [{len(ids_list)} requested]", ""]
1449
+
1450
+ serializer = TOONSerializer()
1451
+
1452
+ for frame_id in ids_list:
1453
+ node_data = nodes_data.get('nodes', {}).get(frame_id, {})
1454
+ frame_node = node_data.get('document', {})
1455
+
1456
+ if not frame_node:
1457
+ lines.append(f"FRAME: {frame_id} [NOT FOUND]")
1458
+ lines.append("")
1459
+ continue
1460
+
1461
+ frame_data = process_frame_to_toon_data(frame_node)
1462
+ frame_lines = serializer.serialize_frame(frame_data, level=0)
1463
+ lines.extend(frame_lines)
1464
+
1465
+ # Add extra details for individual frames
1466
+ lines.append(f" ID: {frame_id}")
1467
+
1468
+ # Component breakdown
1469
+ components = frame_data.get('components', [])
1470
+ if components:
1471
+ # Count component usage
1472
+ from collections import Counter
1473
+ comp_counts = Counter(components)
1474
+ lines.append(f" COMPONENT_COUNTS:")
1475
+ for comp, count in comp_counts.most_common(10):
1476
+ lines.append(f" {comp}: {count}")
1477
+
1478
+ lines.append("")
1479
+
1480
+ self._log_tool_event("Frame details extracted")
1481
+ return '\n'.join(lines)
1482
+
1483
+ def analyze_file(
1484
+ self,
1485
+ url: Optional[str] = None,
1486
+ file_key: Optional[str] = None,
1487
+ node_id: Optional[str] = None,
1488
+ include_pages: Optional[str] = None,
1489
+ exclude_pages: Optional[str] = None,
1490
+ max_frames: int = 50,
1491
+ **kwargs,
1492
+ ) -> str:
1493
+ """
1494
+ Comprehensive Figma file analyzer with LLM-powered insights.
1495
+
1496
+ Returns detailed analysis including:
1497
+ - File/page/frame structure with all content (text, buttons, components)
1498
+ - LLM-powered screen explanations with visual insights (using frame images)
1499
+ - LLM-powered user flow analysis identifying key user journeys
1500
+ - Design insights (patterns, gaps, recommendations)
1501
+
1502
+ Drill-Down:
1503
+ - No node_id: Analyzes entire file (respecting include/exclude pages)
1504
+ - node_id=page_id: Focuses on specific page
1505
+ - node_id=frame_id: Returns detailed frame analysis
1506
+
1507
+ For targeted analysis of specific frames (2-3 frames), use get_frame_detail_toon instead.
1508
+ """
1509
+ try:
1510
+ return self._analyze_file_internal(
1511
+ url=url,
1512
+ file_key=file_key,
1513
+ node_id=node_id,
1514
+ include_pages=include_pages,
1515
+ exclude_pages=exclude_pages,
1516
+ max_frames=max_frames,
1517
+ **kwargs,
1518
+ )
1519
+ except ToolException as e:
1520
+ raise ToolException(_handle_figma_error(e))
1521
+
1522
+ def _analyze_file_internal(
1523
+ self,
1524
+ url: Optional[str] = None,
1525
+ file_key: Optional[str] = None,
1526
+ node_id: Optional[str] = None,
1527
+ include_pages: Optional[str] = None,
1528
+ exclude_pages: Optional[str] = None,
1529
+ max_frames: int = 50,
1530
+ **kwargs,
1531
+ ) -> str:
1532
+ """Internal implementation of analyze_file without error handling wrapper."""
1533
+ # Always use maximum detail level and LLM analysis
1534
+ detail_level = 3
1535
+ llm_analysis = 'detailed' if self.llm else 'none'
1536
+ self._log_tool_event(f"Getting file in TOON format (detail_level={detail_level}, llm_analysis={llm_analysis})")
1537
+
1538
+ # Parse URL if provided
1539
+ if url:
1540
+ file_key, node_ids_from_url = self._parse_figma_url(url)
1541
+ if node_ids_from_url and not node_id:
1542
+ node_id = node_ids_from_url[0]
1543
+
1544
+ if not file_key:
1545
+ raise ToolException("Either url or file_key must be provided")
1546
+
1547
+ # Convert node_id from URL format (hyphen) to API format (colon)
1548
+ if node_id:
1549
+ node_id = node_id.replace('-', ':')
1550
+
1551
+ # Check if node_id is a frame or page (for drill-down)
1552
+ node_id_is_page = False
1553
+ if node_id:
1554
+ try:
1555
+ nodes_data = self._get_file_nodes(file_key, node_id)
1556
+ if nodes_data:
1557
+ node_info = nodes_data.get('nodes', {}).get(node_id, {})
1558
+ node_doc = node_info.get('document', {})
1559
+ node_type = node_doc.get('type', '').upper()
1560
+
1561
+ if node_type == 'FRAME':
1562
+ # It's a frame - use frame detail tool (internal to avoid double-wrapping)
1563
+ return self._get_frame_detail_toon_internal(file_key=file_key, frame_ids=node_id)
1564
+ elif node_type == 'CANVAS':
1565
+ # It's a page - we'll filter to this page
1566
+ node_id_is_page = True
1567
+ except Exception:
1568
+ pass # Fall through to page/file analysis
1569
+
1570
+ # Get file structure
1571
+ file_data = self._client.get_file(file_key, geometry='depth=1')
1572
+ if not file_data:
1573
+ raise ToolException(f"Failed to retrieve file {file_key}")
1574
+
1575
+ # Determine which pages to process
1576
+ # Check if document exists and has the expected structure
1577
+ if not hasattr(file_data, 'document') or file_data.document is None:
1578
+ self._log_tool_event(f"Warning: file_data has no document attribute. Type: {type(file_data)}")
1579
+ all_pages = []
1580
+ else:
1581
+ all_pages = file_data.document.get('children', [])
1582
+ self._log_tool_event(f"File has {len(all_pages)} pages, node_id={node_id}, node_id_is_page={node_id_is_page}")
1583
+
1584
+ # Only filter by node_id if it's confirmed to be a page ID
1585
+ if node_id and node_id_is_page:
1586
+ include_pages = node_id
1587
+
1588
+ include_ids = [p.strip() for p in include_pages.split(',')] if include_pages else None
1589
+ exclude_ids = [p.strip() for p in exclude_pages.split(',')] if exclude_pages else None
1590
+
1591
+ pages_to_process = []
1592
+ for page_node in all_pages:
1593
+ page_id = page_node.get('id', '')
1594
+ if include_ids and page_id not in include_ids:
1595
+ continue
1596
+ if exclude_ids and page_id in exclude_ids:
1597
+ continue
1598
+ pages_to_process.append(page_node)
1599
+
1600
+ # Build output based on detail level
1601
+ lines = [f"FILE: {file_data.name} [key:{file_key}]"]
1602
+ serializer = TOONSerializer()
1603
+
1604
+ all_frames_for_flows = [] # Collect frames for flow analysis at Level 2+
1605
+
1606
+ if not pages_to_process:
1607
+ if not all_pages:
1608
+ lines.append(" [No pages found in file - file may be empty or access restricted]")
1609
+ else:
1610
+ lines.append(f" [All {len(all_pages)} pages filtered out by include/exclude settings]")
1611
+ self._log_tool_event(f"No pages to process. all_pages={len(all_pages)}, include_ids={include_ids}, exclude_ids={exclude_ids}")
1612
+
1613
+ self._log_tool_event(f"Processing {len(pages_to_process)} pages at detail_level={detail_level}")
1614
+
1615
+ for page_node in pages_to_process:
1616
+ page_id = page_node.get('id', '')
1617
+ page_name = page_node.get('name', 'Untitled')
1618
+
1619
+ if detail_level == 1:
1620
+ # Level 1: Structure only - just hierarchy with IDs
1621
+ lines.append(f" PAGE: {page_name} #{page_id}")
1622
+ frames = page_node.get('children', [])[:max_frames]
1623
+ for frame in frames:
1624
+ if frame.get('type', '').upper() == 'FRAME':
1625
+ frame_id = frame.get('id', '')
1626
+ frame_name = frame.get('name', 'Untitled')
1627
+ lines.append(f" FRAME: {frame_name} #{frame_id}")
1628
+ else:
1629
+ # Level 2+: Need full page content - fetch via nodes API
1630
+ page_fetch_error = None
1631
+ try:
1632
+ nodes_data = self._get_file_nodes(file_key, page_id)
1633
+ if nodes_data:
1634
+ full_page_node = nodes_data.get('nodes', {}).get(page_id, {}).get('document', {})
1635
+ if full_page_node:
1636
+ page_node = full_page_node
1637
+ except ToolException as e:
1638
+ page_fetch_error = _handle_figma_error(e)
1639
+ self._log_tool_event(f"Error fetching page {page_id}: {page_fetch_error}")
1640
+ except Exception as e:
1641
+ page_fetch_error = str(e)
1642
+ self._log_tool_event(f"Error fetching page {page_id}: {e}")
1643
+
1644
+ # Process whatever data we have (full or shallow)
1645
+ page_data = process_page_to_toon_data(page_node, max_frames=max_frames)
1646
+ frames = page_data.get('frames', [])
1647
+
1648
+ # If we had an error and got no frames, show the error
1649
+ if page_fetch_error and not frames:
1650
+ lines.append(f" PAGE: {page_name} #{page_id}")
1651
+ lines.append(f" [Error: {page_fetch_error}]")
1652
+ continue
1653
+
1654
+ if detail_level == 2:
1655
+ # Level 2: Standard - content via serialize_page
1656
+ page_lines = serializer.serialize_page(page_data, level=0)
1657
+ lines.extend(page_lines)
1658
+ else:
1659
+ # Level 3: Detailed - content + per-frame component counts
1660
+ lines.append(f"PAGE: {page_data.get('name', 'Untitled')} #{page_data.get('id', '')}")
1661
+ for frame_data in frames:
1662
+ frame_lines = serializer.serialize_frame(frame_data, level=1)
1663
+ lines.extend(frame_lines)
1664
+
1665
+ # Add detailed component counts
1666
+ components = frame_data.get('components', [])
1667
+ if components:
1668
+ from collections import Counter
1669
+ comp_counts = Counter(components)
1670
+ lines.append(f" COMPONENT_COUNTS:")
1671
+ for comp, count in comp_counts.most_common(10):
1672
+ lines.append(f" {comp}: {count}")
1673
+
1674
+ # Collect frames for flow analysis
1675
+ all_frames_for_flows.extend(frames)
1676
+
1677
+ lines.append("")
1678
+
1679
+ # Level 2+: Add global flow analysis at the end
1680
+ if detail_level >= 2 and all_frames_for_flows:
1681
+ flow_lines = serializer.serialize_flows(all_frames_for_flows, level=0)
1682
+ if flow_lines:
1683
+ lines.append("FLOWS:")
1684
+ lines.extend(flow_lines)
1685
+
1686
+ toon_output = '\n'.join(lines)
1687
+
1688
+ # Add LLM analysis if requested
1689
+ if llm_analysis and llm_analysis != 'none' and self.llm:
1690
+ self._log_tool_event(f"Running LLM analysis (level={llm_analysis})")
1691
+ try:
1692
+ # Build file_data structure for LLM analysis
1693
+ file_data_for_llm = {
1694
+ 'name': file_data.name,
1695
+ 'key': file_key,
1696
+ 'pages': [],
1697
+ }
1698
+ # Collect frame IDs for image fetching (for detailed analysis)
1699
+ all_frame_ids = []
1700
+
1701
+ # Re-use processed page data
1702
+ for page_node in pages_to_process:
1703
+ page_id = page_node.get('id', '')
1704
+ try:
1705
+ # Fetch full page if needed
1706
+ nodes_data = self._get_file_nodes(file_key, page_id)
1707
+ if nodes_data:
1708
+ full_page_node = nodes_data.get('nodes', {}).get(page_id, {}).get('document', {})
1709
+ if full_page_node:
1710
+ page_node = full_page_node
1711
+ except Exception:
1712
+ pass # Use shallow data
1713
+ page_data = process_page_to_toon_data(page_node, max_frames=max_frames)
1714
+ file_data_for_llm['pages'].append(page_data)
1715
+
1716
+ # Collect frame IDs for vision analysis
1717
+ for frame in page_data.get('frames', []):
1718
+ frame_id = frame.get('id')
1719
+ if frame_id:
1720
+ all_frame_ids.append(frame_id)
1721
+
1722
+ # Fetch frame images for vision-based analysis (detailed mode only)
1723
+ frame_images = {}
1724
+ # Use max_frames parameter to limit LLM analysis (respects user setting)
1725
+ frames_to_analyze = min(max_frames, len(all_frame_ids))
1726
+ if llm_analysis == 'detailed' and all_frame_ids:
1727
+ self._log_tool_event(f"Fetching images for {frames_to_analyze} frames (vision analysis)")
1728
+ try:
1729
+ frame_ids_to_fetch = all_frame_ids[:frames_to_analyze]
1730
+ images_response = self._client.get_file_images(
1731
+ file_key=file_key,
1732
+ ids=frame_ids_to_fetch,
1733
+ scale=1, # Scale 1 is sufficient for analysis
1734
+ format='png'
1735
+ )
1736
+ if images_response and hasattr(images_response, 'images'):
1737
+ frame_images = images_response.images or {}
1738
+ self._log_tool_event(f"Fetched {len(frame_images)} frame images")
1739
+ self._log_tool_event("Processing images and preparing for LLM analysis...")
1740
+ except Exception as img_err:
1741
+ self._log_tool_event(f"Frame image fetch failed (continuing without vision): {img_err}")
1742
+ # Continue without images - will fall back to text analysis
1743
+
1744
+ # Create status callback for progress updates
1745
+ def _status_callback(msg: str):
1746
+ self._log_tool_event(msg)
1747
+
1748
+ # Import here to avoid circular imports
1749
+ from .toon_tools import enrich_toon_with_llm_analysis
1750
+
1751
+ # Check if design insights should be included (default True)
1752
+ include_design_insights = kwargs.get('include_design_insights', True)
1753
+
1754
+ # Get parallel workers from toolkit config (or default)
1755
+ parallel_workers = getattr(self, "number_of_threads", DEFAULT_NUMBER_OF_THREADS)
1756
+ if parallel_workers is None or not isinstance(parallel_workers, int):
1757
+ parallel_workers = DEFAULT_NUMBER_OF_THREADS
1758
+ parallel_workers = max(1, min(parallel_workers, 5))
1759
+
1760
+ self._log_tool_event(f"Starting LLM analysis of {frames_to_analyze} frames with {parallel_workers} parallel workers...")
1761
+ toon_output = enrich_toon_with_llm_analysis(
1762
+ toon_output=toon_output,
1763
+ file_data=file_data_for_llm,
1764
+ llm=self.llm,
1765
+ analysis_level=llm_analysis,
1766
+ frame_images=frame_images,
1767
+ status_callback=_status_callback,
1768
+ include_design_insights=include_design_insights,
1769
+ parallel_workers=parallel_workers,
1770
+ max_frames_to_analyze=frames_to_analyze,
1771
+ )
1772
+ self._log_tool_event("LLM analysis complete")
1773
+ except Exception as e:
1774
+ self._log_tool_event(f"LLM analysis failed: {e}")
1775
+ # Return TOON output without LLM analysis on error
1776
+ toon_output += f"\n\n[LLM analysis failed: {e}]"
1777
+
1778
+ self._log_tool_event(f"File analysis complete (detail_level={detail_level})")
1779
+ return toon_output
1780
+
712
1781
  @extend_with_parent_available_tools
713
1782
  def get_available_tools(self):
714
1783
  return [
@@ -724,6 +1793,13 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
724
1793
  "args_schema": ArgsSchema.File.value,
725
1794
  "ref": self.get_file,
726
1795
  },
1796
+ # TODO disabled until new requirements
1797
+ # {
1798
+ # "name": "get_file_summary",
1799
+ # "description": self.get_file_summary.__doc__,
1800
+ # "args_schema": ArgsSchema.FileSummary.value,
1801
+ # "ref": self.get_file_summary,
1802
+ # },
727
1803
  {
728
1804
  "name": "get_file_versions",
729
1805
  "description": self.get_file_versions.__doc__,
@@ -760,4 +1836,20 @@ class FigmaApiWrapper(NonCodeIndexerToolkit):
760
1836
  "args_schema": ArgsSchema.ProjectFiles.value,
761
1837
  "ref": self.get_project_files,
762
1838
  },
1839
+ # TOON Format Tools (Token-Optimized)
1840
+ # Primary unified tool with configurable detail levels
1841
+ {
1842
+ "name": "analyze_file",
1843
+ "description": self.analyze_file.__doc__,
1844
+ "args_schema": AnalyzeFileSchema,
1845
+ "ref": self.analyze_file,
1846
+ },
1847
+ # TODO disabled until new requirements
1848
+ # # Targeted drill-down for specific frames (more efficient than level 3 for 2-3 frames)
1849
+ # {
1850
+ # "name": "get_frame_detail_toon",
1851
+ # "description": self.get_frame_detail_toon.__doc__,
1852
+ # "args_schema": FrameDetailTOONSchema,
1853
+ # "ref": self.get_frame_detail_toon,
1854
+ # },
763
1855
  ]