@larkup/workspace-app 0.0.0-stage → 0.2.0

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 (440) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +4 -2
  3. package/assets/github.svg +3 -0
  4. package/assets/icons/Confluence.png +0 -0
  5. package/assets/icons/Instagram.png +0 -0
  6. package/assets/icons/Northflank.png +0 -0
  7. package/assets/icons/Salesforce.png +0 -0
  8. package/assets/icons/agentskills.png +0 -0
  9. package/assets/icons/airtable.png +0 -0
  10. package/assets/icons/asana.png +0 -0
  11. package/assets/icons/ashby.png +0 -0
  12. package/assets/icons/audio.png +0 -0
  13. package/assets/icons/aws-kendra.png +0 -0
  14. package/assets/icons/aws.svg +38 -0
  15. package/assets/icons/azure-ai-search.png +0 -0
  16. package/assets/icons/azure.svg +1 -0
  17. package/assets/icons/bigquery.png +0 -0
  18. package/assets/icons/bing.png +0 -0
  19. package/assets/icons/bitbucket.png +0 -0
  20. package/assets/icons/brave.png +0 -0
  21. package/assets/icons/browserbase.png +0 -0
  22. package/assets/icons/calendly.png +0 -0
  23. package/assets/icons/chroma.png +0 -0
  24. package/assets/icons/cloudflare.png +0 -0
  25. package/assets/icons/cohere.svg +30 -0
  26. package/assets/icons/databricks.png +0 -0
  27. package/assets/icons/daytonaio.png +0 -0
  28. package/assets/icons/daytonaio2.png +0 -0
  29. package/assets/icons/deepgram.png +0 -0
  30. package/assets/icons/deepl.png +0 -0
  31. package/assets/icons/digital-ocean.webp +0 -0
  32. package/assets/icons/discord.png +0 -0
  33. package/assets/icons/e2b-2.png +0 -0
  34. package/assets/icons/e2b.png +0 -0
  35. package/assets/icons/elevenlabs.png +0 -0
  36. package/assets/icons/email.png +0 -0
  37. package/assets/icons/exa.png +0 -0
  38. package/assets/icons/excel.png +0 -0
  39. package/assets/icons/fal.png +0 -0
  40. package/assets/icons/firecrawl.png +0 -0
  41. package/assets/icons/firecrawl2.png +0 -0
  42. package/assets/icons/flyio.jpeg +0 -0
  43. package/assets/icons/gcp.svg +1 -0
  44. package/assets/icons/gemini.svg +1 -0
  45. package/assets/icons/gmail.png +0 -0
  46. package/assets/icons/google-analytics.png +0 -0
  47. package/assets/icons/google-calender.png +0 -0
  48. package/assets/icons/google-docs.png +0 -0
  49. package/assets/icons/google-drive.png +0 -0
  50. package/assets/icons/google-maps.png +0 -0
  51. package/assets/icons/google-meet.png +0 -0
  52. package/assets/icons/google-sheets.png +0 -0
  53. package/assets/icons/google-slides.png +0 -0
  54. package/assets/icons/google-tasks.png +0 -0
  55. package/assets/icons/google.png +0 -0
  56. package/assets/icons/grok.png +0 -0
  57. package/assets/icons/hubspot.png +0 -0
  58. package/assets/icons/image.png +0 -0
  59. package/assets/icons/jina.svg +1 -0
  60. package/assets/icons/jira.png +0 -0
  61. package/assets/icons/lancedb2.png +0 -0
  62. package/assets/icons/linear.png +0 -0
  63. package/assets/icons/linux.png +0 -0
  64. package/assets/icons/looker.png +0 -0
  65. package/assets/icons/luma.png +0 -0
  66. package/assets/icons/mac.png +0 -0
  67. package/assets/icons/markdown.png +0 -0
  68. package/assets/icons/mcp.png +0 -0
  69. package/assets/icons/mcp.svg +1 -0
  70. package/assets/icons/messenger.svg +1 -0
  71. package/assets/icons/microsoft-intra.png +0 -0
  72. package/assets/icons/microsoft-onenote.png +0 -0
  73. package/assets/icons/microsoft-planner.png +0 -0
  74. package/assets/icons/microsoft-teams.png +0 -0
  75. package/assets/icons/miro.png +0 -0
  76. package/assets/icons/mistral.svg +469 -0
  77. package/assets/icons/modal.png +0 -0
  78. package/assets/icons/ngrok.png +0 -0
  79. package/assets/icons/nomic.png +0 -0
  80. package/assets/icons/onedrive.png +0 -0
  81. package/assets/icons/openai.svg +1 -0
  82. package/assets/icons/outlook-calendar.png +0 -0
  83. package/assets/icons/outlook-email.png +0 -0
  84. package/assets/icons/outlook.png +0 -0
  85. package/assets/icons/pdf.png +0 -0
  86. package/assets/icons/personio.png +0 -0
  87. package/assets/icons/pgvector2.png +0 -0
  88. package/assets/icons/phone.png +0 -0
  89. package/assets/icons/pinecone.png +0 -0
  90. package/assets/icons/pipedrive.png +0 -0
  91. package/assets/icons/power-pi.png +0 -0
  92. package/assets/icons/pylon.png +0 -0
  93. package/assets/icons/python.png +0 -0
  94. package/assets/icons/qdrant.svg +35 -0
  95. package/assets/icons/revai.png +0 -0
  96. package/assets/icons/revai.svg +1 -0
  97. package/assets/icons/sap.png +0 -0
  98. package/assets/icons/serpapi.svg +34 -0
  99. package/assets/icons/serper.png +0 -0
  100. package/assets/icons/servicenow.png +0 -0
  101. package/assets/icons/sharepoint.png +0 -0
  102. package/assets/icons/slack.png +0 -0
  103. package/assets/icons/snowflake.png +0 -0
  104. package/assets/icons/statistia.png +0 -0
  105. package/assets/icons/stripe.png +0 -0
  106. package/assets/icons/supabase.png +0 -0
  107. package/assets/icons/tableau.png +0 -0
  108. package/assets/icons/tavily.png +0 -0
  109. package/assets/icons/telegram.png +0 -0
  110. package/assets/icons/typescript.png +0 -0
  111. package/assets/icons/vercel copy.svg +1 -0
  112. package/assets/icons/vercel.svg +1 -0
  113. package/assets/icons/vertex-ai.png +0 -0
  114. package/assets/icons/video.png +0 -0
  115. package/assets/icons/voyage-light.png +0 -0
  116. package/assets/icons/weaviate.webp +0 -0
  117. package/assets/icons/webcontainers.svg +11 -0
  118. package/assets/icons/webhook.png +0 -0
  119. package/assets/icons/whatsapp copy.png +0 -0
  120. package/assets/icons/whatsapp.png +0 -0
  121. package/assets/icons/windows.png +0 -0
  122. package/assets/icons/word.png +0 -0
  123. package/assets/icons/zendesk.png +0 -0
  124. package/assets/icons/zoom.png +0 -0
  125. package/assets/notion-white.png +0 -0
  126. package/assets/notion.png +0 -0
  127. package/dist-types/asset-server.d.ts +3 -0
  128. package/dist-types/asset-server.d.ts.map +1 -0
  129. package/dist-types/components/alerts/generic-alert.d.ts +15 -0
  130. package/dist-types/components/alerts/generic-alert.d.ts.map +1 -0
  131. package/dist-types/components/chat/canvas/doc-editor-provider.d.ts +62 -0
  132. package/dist-types/components/chat/canvas/doc-editor-provider.d.ts.map +1 -0
  133. package/dist-types/components/chat/canvas/document-canvas.d.ts +7 -0
  134. package/dist-types/components/chat/canvas/document-canvas.d.ts.map +1 -0
  135. package/dist-types/components/chat/canvas/document-field.d.ts +13 -0
  136. package/dist-types/components/chat/canvas/document-field.d.ts.map +1 -0
  137. package/dist-types/components/chat/canvas/document-viewer.d.ts +2 -0
  138. package/dist-types/components/chat/canvas/document-viewer.d.ts.map +1 -0
  139. package/dist-types/components/chat/canvas/file-attachment-button.d.ts +9 -0
  140. package/dist-types/components/chat/canvas/file-attachment-button.d.ts.map +1 -0
  141. package/dist-types/components/chat/chat-workspace.d.ts +4 -0
  142. package/dist-types/components/chat/chat-workspace.d.ts.map +1 -0
  143. package/dist-types/components/chat/message-actions.d.ts +11 -0
  144. package/dist-types/components/chat/message-actions.d.ts.map +1 -0
  145. package/dist-types/components/chat/message-item.d.ts +20 -0
  146. package/dist-types/components/chat/message-item.d.ts.map +1 -0
  147. package/dist-types/components/chat/message-queue.d.ts +15 -0
  148. package/dist-types/components/chat/message-queue.d.ts.map +1 -0
  149. package/dist-types/components/chat/tools/chat-chart.d.ts +6 -0
  150. package/dist-types/components/chat/tools/chat-chart.d.ts.map +1 -0
  151. package/dist-types/components/chat/tools/chat-citations.d.ts +32 -0
  152. package/dist-types/components/chat/tools/chat-citations.d.ts.map +1 -0
  153. package/dist-types/components/chat/tools/chat-data-table.d.ts +14 -0
  154. package/dist-types/components/chat/tools/chat-data-table.d.ts.map +1 -0
  155. package/dist-types/components/chat/tools/chat-file-export.d.ts +5 -0
  156. package/dist-types/components/chat/tools/chat-file-export.d.ts.map +1 -0
  157. package/dist-types/components/chat/tools/chat-media-preview.d.ts +35 -0
  158. package/dist-types/components/chat/tools/chat-media-preview.d.ts.map +1 -0
  159. package/dist-types/components/chat/tools/chat-sandbox-result.d.ts +17 -0
  160. package/dist-types/components/chat/tools/chat-sandbox-result.d.ts.map +1 -0
  161. package/dist-types/components/chat/tools/chat-signature-request.d.ts +13 -0
  162. package/dist-types/components/chat/tools/chat-signature-request.d.ts.map +1 -0
  163. package/dist-types/components/chat/tools/chat-tabs.d.ts +17 -0
  164. package/dist-types/components/chat/tools/chat-tabs.d.ts.map +1 -0
  165. package/dist-types/components/chat/tools/corpus-data-result.d.ts +36 -0
  166. package/dist-types/components/chat/tools/corpus-data-result.d.ts.map +1 -0
  167. package/dist-types/components/chat/tools/knowledge-base-result.d.ts +14 -0
  168. package/dist-types/components/chat/tools/knowledge-base-result.d.ts.map +1 -0
  169. package/dist-types/components/chat/tools/visual-placement-modal.d.ts +23 -0
  170. package/dist-types/components/chat/tools/visual-placement-modal.d.ts.map +1 -0
  171. package/dist-types/components/configure/custom-model-modal.d.ts +13 -0
  172. package/dist-types/components/configure/custom-model-modal.d.ts.map +1 -0
  173. package/dist-types/components/configure/store-fields.d.ts +11 -0
  174. package/dist-types/components/configure/store-fields.d.ts.map +1 -0
  175. package/dist-types/components/data/corpus-panel.d.ts +12 -0
  176. package/dist-types/components/data/corpus-panel.d.ts.map +1 -0
  177. package/dist-types/components/data/data-entry-mode-switch.d.ts +13 -0
  178. package/dist-types/components/data/data-entry-mode-switch.d.ts.map +1 -0
  179. package/dist-types/components/data/data-primary-action.d.ts +7 -0
  180. package/dist-types/components/data/data-primary-action.d.ts.map +1 -0
  181. package/dist-types/components/data/data-workspace.d.ts +6 -0
  182. package/dist-types/components/data/data-workspace.d.ts.map +1 -0
  183. package/dist-types/components/data/groups-panel.d.ts +5 -0
  184. package/dist-types/components/data/groups-panel.d.ts.map +1 -0
  185. package/dist-types/components/data/integration-resources-panel.d.ts +9 -0
  186. package/dist-types/components/data/integration-resources-panel.d.ts.map +1 -0
  187. package/dist-types/components/data/integrations-panel.d.ts +5 -0
  188. package/dist-types/components/data/integrations-panel.d.ts.map +1 -0
  189. package/dist-types/components/data/jobs-panel.d.ts +6 -0
  190. package/dist-types/components/data/jobs-panel.d.ts.map +1 -0
  191. package/dist-types/components/data/jobs-tracking-button.d.ts +2 -0
  192. package/dist-types/components/data/jobs-tracking-button.d.ts.map +1 -0
  193. package/dist-types/components/data/live-media-progress.d.ts +9 -0
  194. package/dist-types/components/data/live-media-progress.d.ts.map +1 -0
  195. package/dist-types/components/data/media-indexing-jobs-panel.d.ts +34 -0
  196. package/dist-types/components/data/media-indexing-jobs-panel.d.ts.map +1 -0
  197. package/dist-types/components/data/media-panel.d.ts +58 -0
  198. package/dist-types/components/data/media-panel.d.ts.map +1 -0
  199. package/dist-types/components/data/notion-panel.d.ts +6 -0
  200. package/dist-types/components/data/notion-panel.d.ts.map +1 -0
  201. package/dist-types/components/data/paste-panel.d.ts +7 -0
  202. package/dist-types/components/data/paste-panel.d.ts.map +1 -0
  203. package/dist-types/components/data/project-data-page.d.ts +3 -0
  204. package/dist-types/components/data/project-data-page.d.ts.map +1 -0
  205. package/dist-types/components/data/scrape-panel.d.ts +9 -0
  206. package/dist-types/components/data/scrape-panel.d.ts.map +1 -0
  207. package/dist-types/components/data/tabular-preview.d.ts +10 -0
  208. package/dist-types/components/data/tabular-preview.d.ts.map +1 -0
  209. package/dist-types/components/data/tool-indexing-dialog.d.ts +54 -0
  210. package/dist-types/components/data/tool-indexing-dialog.d.ts.map +1 -0
  211. package/dist-types/components/data/upload-panel.d.ts +7 -0
  212. package/dist-types/components/data/upload-panel.d.ts.map +1 -0
  213. package/dist-types/components/data/web-crawler-status.d.ts +3 -0
  214. package/dist-types/components/data/web-crawler-status.d.ts.map +1 -0
  215. package/dist-types/components/projects/project-form-dialog.d.ts +10 -0
  216. package/dist-types/components/projects/project-form-dialog.d.ts.map +1 -0
  217. package/dist-types/components/projects/project-provider.d.ts +34 -0
  218. package/dist-types/components/projects/project-provider.d.ts.map +1 -0
  219. package/dist-types/components/settings/general/agent-customization-section.d.ts +2 -0
  220. package/dist-types/components/settings/general/agent-customization-section.d.ts.map +1 -0
  221. package/dist-types/components/settings/general/mcp-connections-section.d.ts +2 -0
  222. package/dist-types/components/settings/general/mcp-connections-section.d.ts.map +1 -0
  223. package/dist-types/components/settings/general/model-combobox.d.ts +24 -0
  224. package/dist-types/components/settings/general/model-combobox.d.ts.map +1 -0
  225. package/dist-types/components/settings/general/models-section.d.ts +2 -0
  226. package/dist-types/components/settings/general/models-section.d.ts.map +1 -0
  227. package/dist-types/components/settings/general/sandbox-section.d.ts +7 -0
  228. package/dist-types/components/settings/general/sandbox-section.d.ts.map +1 -0
  229. package/dist-types/components/settings/general/search-scraping-section.d.ts +2 -0
  230. package/dist-types/components/settings/general/search-scraping-section.d.ts.map +1 -0
  231. package/dist-types/components/settings/general/storage-indexing-section.d.ts +2 -0
  232. package/dist-types/components/settings/general/storage-indexing-section.d.ts.map +1 -0
  233. package/dist-types/components/ui/alert-dialog.d.ts +19 -0
  234. package/dist-types/components/ui/alert-dialog.d.ts.map +1 -0
  235. package/dist-types/components/ui/alert.d.ts +11 -0
  236. package/dist-types/components/ui/alert.d.ts.map +1 -0
  237. package/dist-types/components/ui/badge.d.ts +8 -0
  238. package/dist-types/components/ui/badge.d.ts.map +1 -0
  239. package/dist-types/components/ui/card.d.ts +2 -0
  240. package/dist-types/components/ui/card.d.ts.map +1 -0
  241. package/dist-types/components/ui/checkbox.d.ts +4 -0
  242. package/dist-types/components/ui/checkbox.d.ts.map +1 -0
  243. package/dist-types/components/ui/collapsible.d.ts +6 -0
  244. package/dist-types/components/ui/collapsible.d.ts.map +1 -0
  245. package/dist-types/components/ui/command.d.ts +20 -0
  246. package/dist-types/components/ui/command.d.ts.map +1 -0
  247. package/dist-types/components/ui/dialog.d.ts +18 -0
  248. package/dist-types/components/ui/dialog.d.ts.map +1 -0
  249. package/dist-types/components/ui/dropdown-menu.d.ts +30 -0
  250. package/dist-types/components/ui/dropdown-menu.d.ts.map +1 -0
  251. package/dist-types/components/ui/input-group.d.ts +19 -0
  252. package/dist-types/components/ui/input-group.d.ts.map +1 -0
  253. package/dist-types/components/ui/input.d.ts +4 -0
  254. package/dist-types/components/ui/input.d.ts.map +1 -0
  255. package/dist-types/components/ui/line-tabs.d.ts +15 -0
  256. package/dist-types/components/ui/line-tabs.d.ts.map +1 -0
  257. package/dist-types/components/ui/popover.d.ts +10 -0
  258. package/dist-types/components/ui/popover.d.ts.map +1 -0
  259. package/dist-types/components/ui/progress.d.ts +8 -0
  260. package/dist-types/components/ui/progress.d.ts.map +1 -0
  261. package/dist-types/components/ui/scroll-area.d.ts +6 -0
  262. package/dist-types/components/ui/scroll-area.d.ts.map +1 -0
  263. package/dist-types/components/ui/sheet.d.ts +15 -0
  264. package/dist-types/components/ui/sheet.d.ts.map +1 -0
  265. package/dist-types/components/ui/slider.d.ts +4 -0
  266. package/dist-types/components/ui/slider.d.ts.map +1 -0
  267. package/dist-types/components/ui/switch.d.ts +6 -0
  268. package/dist-types/components/ui/switch.d.ts.map +1 -0
  269. package/dist-types/components/ui/table.d.ts +2 -0
  270. package/dist-types/components/ui/table.d.ts.map +1 -0
  271. package/dist-types/components/ui/tabs.d.ts +11 -0
  272. package/dist-types/components/ui/tabs.d.ts.map +1 -0
  273. package/dist-types/components/ui/textarea.d.ts +2 -0
  274. package/dist-types/components/ui/textarea.d.ts.map +1 -0
  275. package/dist-types/components/ui/tooltip.d.ts +7 -0
  276. package/dist-types/components/ui/tooltip.d.ts.map +1 -0
  277. package/dist-types/hooks/use-copy-to-clipboard.d.ts +5 -0
  278. package/dist-types/hooks/use-copy-to-clipboard.d.ts.map +1 -0
  279. package/dist-types/hooks/use-integration-auth.d.ts +10 -0
  280. package/dist-types/hooks/use-integration-auth.d.ts.map +1 -0
  281. package/dist-types/hooks/use-live-media-progress.d.ts +14 -0
  282. package/dist-types/hooks/use-live-media-progress.d.ts.map +1 -0
  283. package/dist-types/hooks/use-notion-auth.d.ts +9 -0
  284. package/dist-types/hooks/use-notion-auth.d.ts.map +1 -0
  285. package/dist-types/host.d.ts +20 -0
  286. package/dist-types/host.d.ts.map +1 -0
  287. package/dist-types/lib/browser-cache.d.ts +7 -0
  288. package/dist-types/lib/browser-cache.d.ts.map +1 -0
  289. package/dist-types/lib/chat/chart-config.d.ts +2 -0
  290. package/dist-types/lib/chat/chart-config.d.ts.map +1 -0
  291. package/dist-types/lib/chat/data-export.d.ts +2 -0
  292. package/dist-types/lib/chat/data-export.d.ts.map +1 -0
  293. package/dist-types/lib/chat/live-tool-progress.d.ts +44 -0
  294. package/dist-types/lib/chat/live-tool-progress.d.ts.map +1 -0
  295. package/dist-types/lib/chat/message-feedback.d.ts +2 -0
  296. package/dist-types/lib/chat/message-feedback.d.ts.map +1 -0
  297. package/dist-types/lib/chat/table-presentation.d.ts +2 -0
  298. package/dist-types/lib/chat/table-presentation.d.ts.map +1 -0
  299. package/dist-types/lib/chat/tool-output.d.ts +3 -0
  300. package/dist-types/lib/chat/tool-output.d.ts.map +1 -0
  301. package/dist-types/lib/chat-supporting-clip.d.ts +11 -0
  302. package/dist-types/lib/chat-supporting-clip.d.ts.map +1 -0
  303. package/dist-types/lib/constants/stores.d.ts +6 -0
  304. package/dist-types/lib/constants/stores.d.ts.map +1 -0
  305. package/dist-types/lib/constants/tools.d.ts +27 -0
  306. package/dist-types/lib/constants/tools.d.ts.map +1 -0
  307. package/dist-types/lib/error-formatter.d.ts +2 -0
  308. package/dist-types/lib/error-formatter.d.ts.map +1 -0
  309. package/dist-types/lib/jobs-client.d.ts +7 -0
  310. package/dist-types/lib/jobs-client.d.ts.map +1 -0
  311. package/dist-types/lib/media/pdf.d.ts +6 -0
  312. package/dist-types/lib/media/pdf.d.ts.map +1 -0
  313. package/dist-types/lib/media/progress.d.ts +61 -0
  314. package/dist-types/lib/media/progress.d.ts.map +1 -0
  315. package/dist-types/lib/media/source-selection.d.ts +10 -0
  316. package/dist-types/lib/media/source-selection.d.ts.map +1 -0
  317. package/dist-types/lib/media/tool-indexing-estimate.d.ts +13 -0
  318. package/dist-types/lib/media/tool-indexing-estimate.d.ts.map +1 -0
  319. package/dist-types/lib/media/video-intelligence-capacity.d.ts +2 -0
  320. package/dist-types/lib/media/video-intelligence-capacity.d.ts.map +1 -0
  321. package/dist-types/lib/media/video-runtime-scope.d.ts +2 -0
  322. package/dist-types/lib/media/video-runtime-scope.d.ts.map +1 -0
  323. package/dist-types/lib/shared/error-formatter.d.ts +2 -0
  324. package/dist-types/lib/shared/error-formatter.d.ts.map +1 -0
  325. package/dist-types/lib/utils.d.ts +3 -0
  326. package/dist-types/lib/utils.d.ts.map +1 -0
  327. package/dist-types/storage.d.ts +5 -0
  328. package/dist-types/storage.d.ts.map +1 -0
  329. package/dist-types/store/chat-store.d.ts +19 -0
  330. package/dist-types/store/chat-store.d.ts.map +1 -0
  331. package/dist-types/store/scrape-store.d.ts +42 -0
  332. package/dist-types/store/scrape-store.d.ts.map +1 -0
  333. package/package.json +579 -3
  334. package/scripts/build-assets.mjs +12 -0
  335. package/src/asset-data.json +1 -0
  336. package/src/asset-server.ts +19 -0
  337. package/src/components/alerts/generic-alert.tsx +76 -0
  338. package/src/components/chat/canvas/doc-editor-provider.tsx +338 -0
  339. package/src/components/chat/canvas/document-canvas.tsx +98 -0
  340. package/src/components/chat/canvas/document-field.tsx +167 -0
  341. package/src/components/chat/canvas/document-viewer.tsx +446 -0
  342. package/src/components/chat/canvas/file-attachment-button.tsx +88 -0
  343. package/src/components/chat/chat-workspace.tsx +1552 -0
  344. package/src/components/chat/message-actions.tsx +96 -0
  345. package/src/components/chat/message-item.tsx +1398 -0
  346. package/src/components/chat/message-queue.tsx +86 -0
  347. package/src/components/chat/tools/chat-chart.tsx +558 -0
  348. package/src/components/chat/tools/chat-citations.tsx +152 -0
  349. package/src/components/chat/tools/chat-data-table.tsx +373 -0
  350. package/src/components/chat/tools/chat-file-export.tsx +49 -0
  351. package/src/components/chat/tools/chat-media-preview.tsx +466 -0
  352. package/src/components/chat/tools/chat-sandbox-result.tsx +130 -0
  353. package/src/components/chat/tools/chat-signature-request.tsx +810 -0
  354. package/src/components/chat/tools/chat-tabs.tsx +59 -0
  355. package/src/components/chat/tools/corpus-data-result.tsx +194 -0
  356. package/src/components/chat/tools/knowledge-base-result.tsx +345 -0
  357. package/src/components/chat/tools/visual-placement-modal.tsx +308 -0
  358. package/src/components/configure/custom-model-modal.tsx +209 -0
  359. package/src/components/configure/store-fields.tsx +129 -0
  360. package/src/components/data/corpus-panel.tsx +1223 -0
  361. package/src/components/data/data-entry-mode-switch.tsx +53 -0
  362. package/src/components/data/data-primary-action.ts +6 -0
  363. package/src/components/data/data-workspace.tsx +623 -0
  364. package/src/components/data/groups-panel.tsx +530 -0
  365. package/src/components/data/integration-resources-panel.tsx +282 -0
  366. package/src/components/data/integrations-panel.tsx +392 -0
  367. package/src/components/data/jobs-panel.tsx +490 -0
  368. package/src/components/data/jobs-tracking-button.tsx +224 -0
  369. package/src/components/data/live-media-progress.tsx +79 -0
  370. package/src/components/data/media-indexing-jobs-panel.tsx +190 -0
  371. package/src/components/data/media-panel.tsx +2905 -0
  372. package/src/components/data/notion-panel.tsx +408 -0
  373. package/src/components/data/paste-panel.tsx +82 -0
  374. package/src/components/data/project-data-page.tsx +55 -0
  375. package/src/components/data/scrape-panel.tsx +1290 -0
  376. package/src/components/data/tabular-preview.tsx +205 -0
  377. package/src/components/data/tool-indexing-dialog.tsx +210 -0
  378. package/src/components/data/upload-panel.tsx +1313 -0
  379. package/src/components/data/web-crawler-status.tsx +132 -0
  380. package/src/components/projects/project-form-dialog.tsx +97 -0
  381. package/src/components/projects/project-provider.tsx +143 -0
  382. package/src/components/settings/general/agent-customization-section.tsx +620 -0
  383. package/src/components/settings/general/mcp-connections-section.tsx +907 -0
  384. package/src/components/settings/general/model-combobox.tsx +235 -0
  385. package/src/components/settings/general/models-section.tsx +909 -0
  386. package/src/components/settings/general/sandbox-section.tsx +394 -0
  387. package/src/components/settings/general/search-scraping-section.tsx +757 -0
  388. package/src/components/settings/general/storage-indexing-section.tsx +394 -0
  389. package/src/components/ui/alert-dialog.tsx +162 -0
  390. package/src/components/ui/alert.tsx +69 -0
  391. package/src/components/ui/badge.tsx +49 -0
  392. package/src/components/ui/card.tsx +9 -0
  393. package/src/components/ui/checkbox.tsx +28 -0
  394. package/src/components/ui/collapsible.tsx +17 -0
  395. package/src/components/ui/command.tsx +175 -0
  396. package/src/components/ui/dialog.tsx +138 -0
  397. package/src/components/ui/dropdown-menu.tsx +267 -0
  398. package/src/components/ui/input-group.tsx +146 -0
  399. package/src/components/ui/input.tsx +19 -0
  400. package/src/components/ui/line-tabs.tsx +49 -0
  401. package/src/components/ui/popover.tsx +77 -0
  402. package/src/components/ui/progress.tsx +66 -0
  403. package/src/components/ui/scroll-area.tsx +51 -0
  404. package/src/components/ui/sheet.tsx +125 -0
  405. package/src/components/ui/slider.tsx +52 -0
  406. package/src/components/ui/switch.tsx +32 -0
  407. package/src/components/ui/table.tsx +10 -0
  408. package/src/components/ui/tabs.tsx +77 -0
  409. package/src/components/ui/textarea.tsx +1 -0
  410. package/src/components/ui/tooltip.tsx +54 -0
  411. package/src/hooks/use-copy-to-clipboard.ts +25 -0
  412. package/src/hooks/use-integration-auth.ts +39 -0
  413. package/src/hooks/use-live-media-progress.ts +71 -0
  414. package/src/hooks/use-notion-auth.ts +66 -0
  415. package/src/host.ts +55 -0
  416. package/src/lib/browser-cache.ts +17 -0
  417. package/src/lib/chat/chart-config.ts +1 -0
  418. package/src/lib/chat/data-export.ts +1 -0
  419. package/src/lib/chat/live-tool-progress.ts +69 -0
  420. package/src/lib/chat/message-feedback.ts +1 -0
  421. package/src/lib/chat/table-presentation.ts +1 -0
  422. package/src/lib/chat/tool-output.ts +14 -0
  423. package/src/lib/chat-supporting-clip.ts +16 -0
  424. package/src/lib/constants/stores.ts +12 -0
  425. package/src/lib/constants/tools.ts +178 -0
  426. package/src/lib/error-formatter.ts +30 -0
  427. package/src/lib/jobs-client.ts +27 -0
  428. package/src/lib/media/pdf.ts +57 -0
  429. package/src/lib/media/progress.ts +209 -0
  430. package/src/lib/media/source-selection.ts +47 -0
  431. package/src/lib/media/tool-indexing-estimate.ts +39 -0
  432. package/src/lib/media/video-intelligence-capacity.ts +1 -0
  433. package/src/lib/media/video-runtime-scope.ts +1 -0
  434. package/src/lib/shared/error-formatter.ts +1 -0
  435. package/src/lib/utils.ts +6 -0
  436. package/src/source.css +1 -0
  437. package/src/storage.ts +16 -0
  438. package/src/store/chat-store.ts +19 -0
  439. package/src/store/scrape-store.ts +76 -0
  440. package/src/styles.d.ts +1 -0
@@ -0,0 +1,1290 @@
1
+ 'use client';
2
+
3
+ import { workspaceStorageKey, workspaceUrl, workspaceFetch as fetch } from '../../host';
4
+ import { useMemo, useState, useEffect, useRef } from 'react';
5
+ import { toast } from 'sonner';
6
+ import {
7
+ RECENT_ADD_HISTORY_CLEARED_EVENT,
8
+ SCRAPE_RECENT_QUERIES_STORAGE_KEY,
9
+ } from '../../lib/browser-cache';
10
+ import { formatErrorMessage } from '../../lib/shared/error-formatter';
11
+ import {
12
+ Globe,
13
+ Loader2,
14
+ Search,
15
+ ChevronDown,
16
+ ChevronsDown,
17
+ Info,
18
+ Clock,
19
+ X,
20
+ Trash2,
21
+ Settings,
22
+ Plus,
23
+ ChevronLeft,
24
+ ChevronRight,
25
+ } from 'lucide-react';
26
+ import { useScrapeStore, type SearchState } from '../../store/scrape-store';
27
+ import type { CrawlJob, CrawlScope, SearchResultItem } from '@larkup/core/types';
28
+ import { Button } from '@larkup/workspace-ui/button';
29
+ import { Input } from '../ui/input';
30
+ import { Label } from '@larkup/workspace-ui/label';
31
+ import { Checkbox } from '../ui/checkbox';
32
+
33
+ import { Switch } from '../ui/switch';
34
+ import {
35
+ Select,
36
+ SelectContent,
37
+ SelectItem,
38
+ SelectTrigger,
39
+ SelectValue,
40
+ } from '@larkup/workspace-ui/select';
41
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
42
+ import { cn } from '../../lib/utils';
43
+ import useSWR from 'swr';
44
+ import { useRouter } from 'next/navigation';
45
+ import type { RagConfig } from '@larkup/core/types';
46
+ import type { DataPrimaryAction } from './data-primary-action';
47
+ import { DataEntryModeSwitch } from './data-entry-mode-switch';
48
+
49
+ const fetcher = (url: string) => fetch(url).then((r) => r.json() as Promise<{ config: RagConfig }>);
50
+
51
+ type LocalCrawlerState = {
52
+ running: boolean;
53
+ mode?: 'native' | 'firecrawl';
54
+ lastError?: string;
55
+ };
56
+
57
+ type LocalCrawlerResponse = {
58
+ state: LocalCrawlerState;
59
+ starting?: boolean;
60
+ };
61
+
62
+ function domainOf(url: string) {
63
+ try {
64
+ return new URL(url).hostname;
65
+ } catch {
66
+ return url;
67
+ }
68
+ }
69
+
70
+ function formatDuration(seconds: number): string {
71
+ if (seconds < 60) return `~${Math.ceil(seconds)}s`;
72
+ if (seconds < 3600) return `~${Math.ceil(seconds / 60)}min`;
73
+ const h = Math.floor(seconds / 3600);
74
+ const m = Math.ceil((seconds % 3600) / 60);
75
+ return `~${h}h ${m}min`;
76
+ }
77
+
78
+ /** Estimate ETL duration based on URL count, scope, and page limit */
79
+ function estimateEtlDuration(
80
+ urlCount: number,
81
+ scope: CrawlScope,
82
+ pageLimit: number,
83
+ ): { totalPages: number; estimatedSeconds: number } {
84
+ const pagesPerUrl = scope === 'domain' ? pageLimit : 1;
85
+ const totalPages = urlCount * pagesPerUrl;
86
+ const perPageSeconds = scope === 'domain' ? 5 : 3;
87
+ const estimatedSeconds = totalPages * perPageSeconds;
88
+ return { totalPages, estimatedSeconds };
89
+ }
90
+
91
+ export function ScrapePanel({
92
+ disabled,
93
+ onStarted,
94
+ onActionChange,
95
+ groupId,
96
+ }: {
97
+ disabled?: boolean;
98
+ onStarted: (job: CrawlJob) => void;
99
+ onActionChange?: (action: DataPrimaryAction | null) => void;
100
+ groupId?: string;
101
+ }) {
102
+ const groupIdRef = useRef(groupId ?? 'default');
103
+ groupIdRef.current = groupId ?? 'default';
104
+ const router = useRouter();
105
+ const {
106
+ query,
107
+ setQuery,
108
+ searchState,
109
+ setSearchState,
110
+ selected,
111
+ setSelected,
112
+ scope,
113
+ setScope,
114
+ pageLimit,
115
+ setPageLimit,
116
+ searchLimit,
117
+ setSearchLimit,
118
+ showAdvanced,
119
+ setShowAdvanced,
120
+ serperTotalForQuery,
121
+ setSerperTotalForQuery,
122
+ flush: flushSearch,
123
+ } = useScrapeStore();
124
+
125
+ const [searching, setSearching] = useState(false);
126
+ const [loadingMore, setLoadingMore] = useState(false);
127
+ const [gatheringAll, setGatheringAll] = useState(false);
128
+ const [gatherProgress, setGatherProgress] = useState(0);
129
+ const [manualUrl, setManualUrl] = useState('');
130
+ const [manualUrlList, setManualUrlList] = useState<string[]>([]);
131
+
132
+ // Search results can contain private source URLs. Clear the module-level
133
+ // store whenever this project-scoped panel unmounts so a project switch
134
+ // cannot briefly render another project's search state.
135
+ useEffect(() => () => flushSearch(), [flushSearch]);
136
+ const [starting, setStarting] = useState(false);
137
+ /** When true: only the exact custom URLs are scraped — no deep crawl/pagination */
138
+ const [specificUrls, setSpecificUrls] = useState(false);
139
+ const [firecrawlConfigured, setFirecrawlConfigured] = useState<boolean | null>(null);
140
+ const [crawlerState, setCrawlerState] = useState<LocalCrawlerState | null>(null);
141
+ const [crawlerStarting, setCrawlerStarting] = useState(false);
142
+ const crawlerPolling = useRef<Promise<boolean> | null>(null);
143
+ const { data: configData } = useSWR(workspaceUrl('/api/config'), fetcher);
144
+ const activeProvider = configData?.config?.webSearchProvider || 'tavily';
145
+ const crawlerProvider = configData?.config?.webCrawlerProvider || 'local';
146
+ const [cachedQueries, setCachedQueries] = useState<string[]>([]);
147
+ const [showDropdown, setShowDropdown] = useState(false);
148
+ const [displayPage, setDisplayPage] = useState(1);
149
+ const inputRef = useRef<HTMLInputElement>(null);
150
+ const dropdownRef = useRef<HTMLDivElement>(null);
151
+
152
+ function showCrawlerSettings() {
153
+ toast.error('Web crawler is not available. Choose a crawler provider in Settings.', {
154
+ action: {
155
+ label: 'Open settings',
156
+ onClick: () => router.push('/settings?section=general#web-crawler'),
157
+ },
158
+ });
159
+ }
160
+
161
+ async function readCrawlerStatus(): Promise<LocalCrawlerResponse> {
162
+ const res = await fetch(workspaceUrl('/api/firecrawl/local'), { cache: 'no-store' });
163
+ const body = (await res.json()) as LocalCrawlerResponse & { error?: string };
164
+ if (!res.ok) throw new Error(body.error || 'Could not read crawler status.');
165
+ setCrawlerState(body.state);
166
+ setCrawlerStarting(Boolean(body.starting));
167
+ return body;
168
+ }
169
+
170
+ function pollCrawlerUntilReady(): Promise<boolean> {
171
+ if (crawlerPolling.current) return crawlerPolling.current;
172
+
173
+ crawlerPolling.current = (async () => {
174
+ for (let attempt = 0; attempt < 45; attempt++) {
175
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
176
+ const status = await readCrawlerStatus();
177
+ if (status.state.running) return true;
178
+ if (!status.starting) return false;
179
+ }
180
+ setCrawlerStarting(false);
181
+ setCrawlerState((current) => ({
182
+ ...(current ?? { running: false }),
183
+ lastError: 'The website crawler is still starting. Try again in a moment.',
184
+ }));
185
+ return false;
186
+ })().finally(() => {
187
+ crawlerPolling.current = null;
188
+ });
189
+
190
+ return crawlerPolling.current;
191
+ }
192
+
193
+ async function warmCrawler(): Promise<LocalCrawlerResponse | null> {
194
+ if (crawlerProvider === 'cloud') return null;
195
+ try {
196
+ const res = await fetch(workspaceUrl('/api/firecrawl/local'), {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({ action: 'start' }),
200
+ });
201
+ const body = (await res.json()) as LocalCrawlerResponse & { error?: string };
202
+ if (!res.ok) throw new Error(body.error || 'Could not prepare the crawler.');
203
+ setCrawlerState(body.state);
204
+ setCrawlerStarting(Boolean(body.starting));
205
+ if (body.starting) void pollCrawlerUntilReady();
206
+ return body;
207
+ } catch (error) {
208
+ setCrawlerStarting(false);
209
+ setCrawlerState((current) => ({
210
+ ...(current ?? { running: false }),
211
+ lastError: formatErrorMessage(error),
212
+ }));
213
+ return null;
214
+ }
215
+ }
216
+
217
+ async function ensureCrawlerReady() {
218
+ if (crawlerProvider === 'cloud') return true;
219
+ if (crawlerState?.running) return true;
220
+
221
+ const started = await warmCrawler();
222
+ if (!started) return false;
223
+ if (started.state.running) return true;
224
+ // Keep a slow runtime startup in the background without making the Add
225
+ // website action look frozen. The built-in crawler normally starts at once.
226
+ const ready = started.starting
227
+ ? await Promise.race([
228
+ pollCrawlerUntilReady(),
229
+ new Promise<false>((resolve) => setTimeout(() => resolve(false), 8_000)),
230
+ ])
231
+ : false;
232
+ if (!ready && started.starting) return true;
233
+ if (!ready) {
234
+ toast.error(started.state.lastError || 'The website crawler is not ready yet.');
235
+ }
236
+ return ready;
237
+ }
238
+
239
+ useEffect(() => {
240
+ function handleClickOutside(event: MouseEvent) {
241
+ if (
242
+ inputRef.current &&
243
+ !inputRef.current.contains(event.target as Node) &&
244
+ dropdownRef.current &&
245
+ !dropdownRef.current.contains(event.target as Node)
246
+ ) {
247
+ setShowDropdown(false);
248
+ }
249
+ }
250
+ document.addEventListener('mousedown', handleClickOutside);
251
+ return () => document.removeEventListener('mousedown', handleClickOutside);
252
+ }, []);
253
+
254
+ useEffect(() => {
255
+ try {
256
+ const saved = localStorage.getItem(workspaceStorageKey(SCRAPE_RECENT_QUERIES_STORAGE_KEY));
257
+ if (saved) setCachedQueries(JSON.parse(saved));
258
+ } catch {
259
+ // ignore
260
+ }
261
+ }, []);
262
+
263
+ useEffect(() => {
264
+ const clearRecentQueries = () => {
265
+ setCachedQueries([]);
266
+ setShowDropdown(false);
267
+ };
268
+ const handleStorage = (event: StorageEvent) => {
269
+ if (
270
+ event.key === workspaceStorageKey(SCRAPE_RECENT_QUERIES_STORAGE_KEY) ||
271
+ event.key === null
272
+ ) {
273
+ clearRecentQueries();
274
+ }
275
+ };
276
+ window.addEventListener(RECENT_ADD_HISTORY_CLEARED_EVENT, clearRecentQueries);
277
+ window.addEventListener('storage', handleStorage);
278
+ return () => {
279
+ window.removeEventListener(RECENT_ADD_HISTORY_CLEARED_EVENT, clearRecentQueries);
280
+ window.removeEventListener('storage', handleStorage);
281
+ };
282
+ }, []);
283
+
284
+ function saveQuery(q: string) {
285
+ const trimmed = q.trim();
286
+ if (!trimmed) return;
287
+ setCachedQueries((prev) => {
288
+ const newCache = [trimmed, ...prev.filter((item) => item !== trimmed)].slice(0, 10);
289
+ try {
290
+ localStorage.setItem(
291
+ workspaceStorageKey(SCRAPE_RECENT_QUERIES_STORAGE_KEY),
292
+ JSON.stringify(newCache),
293
+ );
294
+ } catch {
295
+ // ignore
296
+ }
297
+ return newCache;
298
+ });
299
+ }
300
+
301
+ function removeQuery(q: string, e: React.MouseEvent) {
302
+ e.stopPropagation();
303
+ setCachedQueries((prev) => {
304
+ const newCache = prev.filter((item) => item !== q);
305
+ try {
306
+ localStorage.setItem(
307
+ workspaceStorageKey(SCRAPE_RECENT_QUERIES_STORAGE_KEY),
308
+ JSON.stringify(newCache),
309
+ );
310
+ } catch {
311
+ // ignore
312
+ }
313
+ return newCache;
314
+ });
315
+ }
316
+
317
+ useEffect(() => {
318
+ // Check the selected web-search provider.
319
+ fetch(workspaceUrl('/api/search'))
320
+ .then((r) => r.json())
321
+ .then((d) => setFirecrawlConfigured(d.configured ?? false))
322
+ .catch(() => setFirecrawlConfigured(false));
323
+ }, []);
324
+
325
+ useEffect(() => {
326
+ if (crawlerProvider !== 'local') return;
327
+ void (async () => {
328
+ try {
329
+ const status = await readCrawlerStatus();
330
+ if (!status.state.running) await warmCrawler();
331
+ } catch {
332
+ await warmCrawler();
333
+ }
334
+ })();
335
+ }, [crawlerProvider]);
336
+
337
+ const selectedUrls = useMemo(() => Object.keys(selected).filter((u) => selected[u]), [selected]);
338
+
339
+ /** Search through the configured crawler endpoint. */
340
+ async function searchFirecrawl(q: string, isMulti: boolean) {
341
+ try {
342
+ const res = await fetch(workspaceUrl('/api/search'), {
343
+ method: 'POST',
344
+ headers: { 'Content-Type': 'application/json' },
345
+ body: JSON.stringify({ query: q, limit: searchLimit }),
346
+ });
347
+ const data = await res.json();
348
+ if (!res.ok) throw new Error(data.error ?? 'Search failed');
349
+ const newItems = (data.results as SearchResultItem[]) ?? [];
350
+
351
+ setSearchState((prev) => {
352
+ const existingResults = prev ? prev.results : [];
353
+ const existingUrls = new Set(existingResults.map((r) => r.url));
354
+ const fresh = newItems.filter((r) => !existingUrls.has(r.url));
355
+ return {
356
+ results: [...existingResults, ...fresh],
357
+ totalResults: isMulti ? (prev?.totalResults ?? 0) + fresh.length : fresh.length,
358
+ totalResultsIsEstimate: false,
359
+ currentPage: 1,
360
+ totalPages: 1,
361
+ hasMore: false,
362
+ query: isMulti ? 'Multiple queries' : q,
363
+ searchProvider: 'firecrawl',
364
+ };
365
+ });
366
+
367
+ const newSelected = Object.fromEntries(newItems.map((r) => [r.url, true]));
368
+ setSelected((prev) => ({ ...prev, ...newSelected }));
369
+
370
+ if (newItems.length === 0 && !isMulti) toast.message('No results. Try different keywords.');
371
+
372
+ if (activeProvider && !isMulti) {
373
+ fetchProviderTotalCount(q, activeProvider);
374
+ }
375
+ } catch (err) {
376
+ const message = formatErrorMessage(err);
377
+ if (/crawler|firecrawl|not running|api key/i.test(message)) {
378
+ showCrawlerSettings();
379
+ } else {
380
+ toast.error(message);
381
+ }
382
+ }
383
+ }
384
+
385
+ /** Search using generic provider. */
386
+ async function searchGeneric(
387
+ q: string,
388
+ provider: string,
389
+ page: number,
390
+ isMulti: boolean,
391
+ appendPagination = false,
392
+ ) {
393
+ if (appendPagination) {
394
+ setLoadingMore(true);
395
+ }
396
+
397
+ const endpoint =
398
+ provider === 'serper' || provider === 'google'
399
+ ? workspaceUrl('/api/search/google')
400
+ : provider === 'brave'
401
+ ? workspaceUrl('/api/search/brave')
402
+ : provider === 'bing'
403
+ ? workspaceUrl('/api/search/bing')
404
+ : provider === 'tavily'
405
+ ? workspaceUrl('/api/search/tavily')
406
+ : provider === 'exa'
407
+ ? workspaceUrl('/api/search/exa')
408
+ : null;
409
+
410
+ if (!endpoint) {
411
+ toast.error('Invalid search provider');
412
+ if (appendPagination) setLoadingMore(false);
413
+ return;
414
+ }
415
+
416
+ try {
417
+ const res = await fetch(endpoint, {
418
+ method: 'POST',
419
+ headers: { 'Content-Type': 'application/json' },
420
+ body: JSON.stringify({ query: q, page }),
421
+ });
422
+ const data = await res.json();
423
+
424
+ if (!res.ok) throw new Error(data.error ?? 'Search failed');
425
+
426
+ const newItems: SearchResultItem[] = (data.items ?? []).map(
427
+ (item: { url: string; title: string; description?: string }) => ({
428
+ url: item.url,
429
+ title: item.title || item.url,
430
+ description: item.description,
431
+ }),
432
+ );
433
+
434
+ setSearchState((prev) => {
435
+ const existingResults = prev ? prev.results : [];
436
+ const existingUrls = new Set(existingResults.map((r) => r.url));
437
+ const fresh = newItems.filter((r) => !existingUrls.has(r.url));
438
+ return {
439
+ results: [...existingResults, ...fresh],
440
+ totalResults: (prev?.totalResults ?? 0) + (data.totalResults ?? 0),
441
+ totalResultsIsEstimate: data.totalResultsIsEstimate ?? true,
442
+ currentPage: data.currentPage ?? page,
443
+ totalPages: data.totalPages ?? 1,
444
+ hasMore: data.hasMore ?? false,
445
+ query: data.query ?? q,
446
+ searchProvider: provider as any,
447
+ };
448
+ });
449
+
450
+ const newSelected = Object.fromEntries(newItems.map((r) => [r.url, true]));
451
+ setSelected((prev) => ({ ...prev, ...newSelected }));
452
+
453
+ if (!isMulti) {
454
+ // Update total
455
+ setSerperTotalForQuery({
456
+ query: q,
457
+ total: data.totalResults ?? 0,
458
+ totalPages: data.totalPages ?? 1,
459
+ });
460
+ }
461
+
462
+ if (page === 1 && newItems.length === 0 && !isMulti) {
463
+ toast.message('No results. Try different keywords.');
464
+ }
465
+ } catch (err) {
466
+ if (page === 1) throw err;
467
+ toast.error(formatErrorMessage(err));
468
+ } finally {
469
+ if (appendPagination) setLoadingMore(false);
470
+ }
471
+ }
472
+
473
+ /** Fetch total count from provider without using it for results (saves on Firecrawl credits) */
474
+ async function fetchProviderTotalCount(q: string, provider: string) {
475
+ const endpoint =
476
+ provider === 'serper' || provider === 'google'
477
+ ? workspaceUrl('/api/search/google')
478
+ : provider === 'brave'
479
+ ? workspaceUrl('/api/search/brave')
480
+ : provider === 'bing'
481
+ ? workspaceUrl('/api/search/bing')
482
+ : provider === 'tavily'
483
+ ? workspaceUrl('/api/search/tavily')
484
+ : provider === 'exa'
485
+ ? workspaceUrl('/api/search/exa')
486
+ : null;
487
+
488
+ if (!endpoint) return;
489
+
490
+ try {
491
+ const res = await fetch(endpoint, {
492
+ method: 'POST',
493
+ headers: { 'Content-Type': 'application/json' },
494
+ body: JSON.stringify({ query: q, page: 1 }),
495
+ });
496
+ const data = await res.json();
497
+ if (res.ok) {
498
+ setSerperTotalForQuery({
499
+ query: q,
500
+ total: data.totalResults ?? 0,
501
+ totalPages: data.totalPages ?? 1,
502
+ });
503
+ }
504
+ } catch {
505
+ // Silent fail — count is optional
506
+ }
507
+ }
508
+
509
+ async function runSearch() {
510
+ setShowDropdown(false);
511
+ if (!query.trim()) {
512
+ toast.error('Enter one or more keywords to search.');
513
+ return;
514
+ }
515
+
516
+ setSerperTotalForQuery(null);
517
+ saveQuery(query);
518
+
519
+ const queries = query
520
+ .split(',')
521
+ .map((q) => q.trim())
522
+ .filter(Boolean);
523
+ if (queries.length === 0) return;
524
+
525
+ setSearching(true);
526
+ setDisplayPage(1);
527
+ // Removed setSearchState(null) so we append instead of clear
528
+
529
+ try {
530
+ if (activeProvider === 'local' || !activeProvider) {
531
+ if (!(await ensureCrawlerReady())) return;
532
+ await Promise.all(queries.map((q) => searchFirecrawl(q, queries.length > 1)));
533
+ } else {
534
+ try {
535
+ await Promise.all(
536
+ queries.map((q) => searchGeneric(q, activeProvider, 1, queries.length > 1, false)),
537
+ );
538
+ } catch (err) {
539
+ toast.info('Using local crawler…');
540
+ if (!(await ensureCrawlerReady())) return;
541
+ await Promise.all(queries.map((q) => searchFirecrawl(q, queries.length > 1)));
542
+ }
543
+ }
544
+ } finally {
545
+ setSearching(false);
546
+ }
547
+ }
548
+
549
+ async function loadNextPage() {
550
+ if (!searchState?.hasMore || loadingMore) return;
551
+ if (searchState.searchProvider !== 'firecrawl') {
552
+ await searchGeneric(
553
+ searchState.query,
554
+ searchState.searchProvider,
555
+ searchState.currentPage + 1,
556
+ false,
557
+ true,
558
+ );
559
+ }
560
+ }
561
+
562
+ /** Automatically paginate through ALL available provider results. */
563
+ async function gatherAll() {
564
+ if (!searchState || searchState.searchProvider === 'firecrawl') return;
565
+ setGatheringAll(true);
566
+ setGatherProgress(searchState.currentPage);
567
+
568
+ let page = searchState.currentPage + 1;
569
+ const total = searchState.totalPages;
570
+ const provider = searchState.searchProvider;
571
+
572
+ const endpoint =
573
+ provider === 'serper' || provider === 'google'
574
+ ? workspaceUrl('/api/search/google')
575
+ : provider === 'brave'
576
+ ? workspaceUrl('/api/search/brave')
577
+ : provider === 'bing'
578
+ ? workspaceUrl('/api/search/bing')
579
+ : provider === 'tavily'
580
+ ? workspaceUrl('/api/search/tavily')
581
+ : provider === 'exa'
582
+ ? workspaceUrl('/api/search/exa')
583
+ : null;
584
+
585
+ try {
586
+ while (page <= total) {
587
+ setGatherProgress(page);
588
+ if (!endpoint) break;
589
+ const res = await fetch(endpoint, {
590
+ method: 'POST',
591
+ headers: { 'Content-Type': 'application/json' },
592
+ body: JSON.stringify({ query: query.trim(), page }),
593
+ });
594
+ const data = await res.json();
595
+ if (!res.ok) throw new Error(data.error ?? 'Search failed');
596
+
597
+ const newItems: SearchResultItem[] = (data.items ?? []).map(
598
+ (item: { url: string; title: string; description?: string }) => ({
599
+ url: item.url,
600
+ title: item.title || item.url,
601
+ description: item.description,
602
+ }),
603
+ );
604
+
605
+ setSearchState((prev) => {
606
+ if (!prev) return prev;
607
+ const existingUrls = new Set(prev.results.map((r) => r.url));
608
+ const fresh = newItems.filter((r) => !existingUrls.has(r.url));
609
+ return {
610
+ ...prev,
611
+ results: [...prev.results, ...fresh],
612
+ totalResultsIsEstimate: data.totalResultsIsEstimate ?? prev.totalResultsIsEstimate,
613
+ currentPage: data.currentPage ?? page,
614
+ totalPages: data.totalPages ?? prev.totalPages,
615
+ hasMore: data.hasMore ?? false,
616
+ };
617
+ });
618
+
619
+ const newSelected = Object.fromEntries(newItems.map((r) => [r.url, true]));
620
+ setSelected((prev) => ({ ...prev, ...newSelected }));
621
+
622
+ if (!data.hasMore) break;
623
+ page++;
624
+ await new Promise((r) => setTimeout(r, 300));
625
+ }
626
+ toast.success('All available results gathered!');
627
+ } catch (err) {
628
+ toast.error(formatErrorMessage(err));
629
+ } finally {
630
+ setGatheringAll(false);
631
+ setGatherProgress(0);
632
+ }
633
+ }
634
+
635
+ function appendManualUrl() {
636
+ const urls = manualUrl
637
+ .split(/[\s,]+/)
638
+ .map((u) => u.trim())
639
+ .filter((u) => /^https?:\/\//i.test(u));
640
+
641
+ if (urls.length > 0) {
642
+ setManualUrlList((prev) => Array.from(new Set([...prev, ...urls])));
643
+ setManualUrl('');
644
+ } else {
645
+ toast.error('Enter at least one full URL starting with http(s)://');
646
+ }
647
+ }
648
+
649
+ function addManual() {
650
+ const currentUrls = manualUrl
651
+ .split(/[\s,]+/)
652
+ .map((u) => u.trim())
653
+ .filter((u) => /^https?:\/\//i.test(u));
654
+
655
+ const allUrls = Array.from(new Set([...manualUrlList, ...currentUrls]));
656
+
657
+ if (allUrls.length === 0) {
658
+ toast.error('Enter at least one full URL starting with http(s)://');
659
+ return;
660
+ }
661
+
662
+ void startJob(allUrls, true).then((success) => {
663
+ if (success) {
664
+ setManualUrl('');
665
+ setManualUrlList([]);
666
+ }
667
+ });
668
+ }
669
+
670
+ const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
671
+ const file = e.target.files?.[0];
672
+ if (!file) return;
673
+ const reader = new FileReader();
674
+ reader.onload = (e) => {
675
+ const text = e.target?.result as string;
676
+ if (text) {
677
+ setManualUrl((prev) => prev + (prev ? '\n' : '') + text);
678
+ }
679
+ };
680
+ reader.readAsText(file);
681
+ e.target.value = ''; // reset input
682
+ };
683
+
684
+ /** Open confirm modal before starting ETL */
685
+ function handleStartClick() {
686
+ if (selectedUrls.length === 0) {
687
+ toast.error('Select at least one URL to scrape.');
688
+ return;
689
+ }
690
+ void startJob();
691
+ }
692
+
693
+ async function startJob(urls = selectedUrls, exactUrls = false) {
694
+ setStarting(true);
695
+ const effectiveScope: CrawlScope = specificUrls || exactUrls ? 'page' : scope;
696
+ try {
697
+ if (!(await ensureCrawlerReady())) return;
698
+ const res = await fetch(workspaceUrl('/api/jobs'), {
699
+ method: 'POST',
700
+ headers: { 'Content-Type': 'application/json' },
701
+ body: JSON.stringify({
702
+ keywords: query || urls[0],
703
+ pageLimit: specificUrls || exactUrls ? 1 : pageLimit,
704
+ targets: urls.map((url) => ({ url, scope: effectiveScope })),
705
+ groupId: groupIdRef.current,
706
+ }),
707
+ });
708
+ const data = await res.json();
709
+ if (!res.ok) {
710
+ if (res.status === 409 && data.code === 'already_scraped') {
711
+ toast.info('Already in your knowledge base', {
712
+ description: 'This URL has already been scraped and added.',
713
+ });
714
+ return false;
715
+ }
716
+ throw new Error(data.error ?? 'Could not start job');
717
+ }
718
+
719
+ toast.success('Website added', {
720
+ description: 'We’ll finish it in the background.',
721
+ duration: 8000,
722
+ });
723
+
724
+ setSelected({});
725
+ setSearchState((prev) => {
726
+ if (!prev) return prev;
727
+ const urlSet = new Set(urls);
728
+ return {
729
+ ...prev,
730
+ results: prev.results.filter((r) => !urlSet.has(r.url)),
731
+ };
732
+ });
733
+ onStarted(data.job as CrawlJob);
734
+ return true;
735
+ } catch (err) {
736
+ toast.error(formatErrorMessage(err));
737
+ return false;
738
+ } finally {
739
+ setStarting(false);
740
+ }
741
+ }
742
+
743
+ const results = searchState?.results ?? [];
744
+ const gatherAllAvailable =
745
+ searchState?.searchProvider !== 'firecrawl' && searchState?.hasMore && !gatheringAll;
746
+
747
+ const ITEMS_PER_PAGE = 5;
748
+ const totalDisplayPages = Math.max(1, Math.ceil(results.length / ITEMS_PER_PAGE));
749
+ const currentDisplayResults = results.slice(
750
+ (displayPage - 1) * ITEMS_PER_PAGE,
751
+ displayPage * ITEMS_PER_PAGE,
752
+ );
753
+
754
+ useEffect(() => {
755
+ if (displayPage > totalDisplayPages && totalDisplayPages > 0) {
756
+ setDisplayPage(totalDisplayPages);
757
+ }
758
+ }, [totalDisplayPages, displayPage]);
759
+
760
+ const [manualUrlPage, setManualUrlPage] = useState(1);
761
+ const MANUAL_URL_PAGE_SIZE = 10;
762
+ const totalManualPages = Math.max(1, Math.ceil(manualUrlList.length / MANUAL_URL_PAGE_SIZE));
763
+ const currentManualUrls = manualUrlList.slice(
764
+ (manualUrlPage - 1) * MANUAL_URL_PAGE_SIZE,
765
+ manualUrlPage * MANUAL_URL_PAGE_SIZE,
766
+ );
767
+
768
+ useEffect(() => {
769
+ if (manualUrlPage > totalManualPages && totalManualPages > 0) {
770
+ setManualUrlPage(totalManualPages);
771
+ }
772
+ }, [totalManualPages, manualUrlPage]);
773
+
774
+ // Estimate for confirmation modal
775
+ const effectiveScopeForEstimate: CrawlScope = specificUrls ? 'page' : scope;
776
+ const estimate = useMemo(
777
+ () =>
778
+ estimateEtlDuration(
779
+ selectedUrls.length,
780
+ effectiveScopeForEstimate,
781
+ specificUrls ? 1 : pageLimit,
782
+ ),
783
+ [selectedUrls.length, effectiveScopeForEstimate, specificUrls, pageLimit],
784
+ );
785
+
786
+ const [inputMode, setInputMode] = useState<'search' | 'url'>('url');
787
+
788
+ useEffect(() => {
789
+ const hasSelection = selectedUrls.length > 0;
790
+ const isUrl = inputMode === 'url';
791
+ const currentUrls = manualUrl.split(/[\s,]+/).filter((u) => /^https?:\/\//i.test(u));
792
+ const allUrls = Array.from(new Set([...manualUrlList, ...currentUrls]));
793
+ const urlCount = allUrls.length;
794
+
795
+ onActionChange?.({
796
+ label: isUrl
797
+ ? urlCount > 1
798
+ ? 'Add websites'
799
+ : 'Add website'
800
+ : hasSelection
801
+ ? 'Add selected'
802
+ : 'Search topic',
803
+ onClick: () => {
804
+ if (isUrl) void addManual();
805
+ else if (hasSelection) handleStartClick();
806
+ else void runSearch();
807
+ },
808
+ disabled: disabled || (isUrl ? urlCount === 0 : !hasSelection && !query.trim()),
809
+ loading: searching || starting,
810
+ });
811
+ return () => onActionChange?.(null);
812
+ }, [
813
+ disabled,
814
+ inputMode,
815
+ manualUrl,
816
+ manualUrlList,
817
+ query,
818
+ searching,
819
+ starting,
820
+ selectedUrls.length,
821
+ groupId,
822
+ onActionChange,
823
+ ]);
824
+
825
+ return (
826
+ <div className="space-y-4">
827
+ {/* Unified input area */}
828
+ <div className="space-y-3">
829
+ {/* Mode toggle + input */}
830
+ <div className="flex items-center gap-2">
831
+ <DataEntryModeSwitch
832
+ label="Website source"
833
+ value={inputMode}
834
+ onValueChange={(value) => setInputMode(value as 'search' | 'url')}
835
+ options={[
836
+ { value: 'url', label: 'Direct URL', icon: Globe },
837
+ { value: 'search', label: 'Search', icon: Search },
838
+ ]}
839
+ />
840
+
841
+ {/* Input field */}
842
+ <div className="relative flex-1 flex items-center gap-2">
843
+ {inputMode === 'search' ? (
844
+ <div className="relative flex-1">
845
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground z-10 pointer-events-none" />
846
+ <Input
847
+ ref={inputRef}
848
+ id="kw"
849
+ autoComplete="off"
850
+ placeholder='Search for websites, e.g. "Who is the best player in the world?"'
851
+ value={query}
852
+ disabled={disabled}
853
+ className="pl-9 h-11 pr-22 bg-white"
854
+ onChange={(e) => {
855
+ setQuery(e.target.value);
856
+ setShowDropdown(true);
857
+ }}
858
+ onFocus={() => {
859
+ setShowDropdown(true);
860
+ void warmCrawler();
861
+ }}
862
+ onKeyDown={(e) => {
863
+ if (e.key === 'Enter') {
864
+ setShowDropdown(false);
865
+ runSearch();
866
+ }
867
+ }}
868
+ />
869
+ {showDropdown && cachedQueries.length > 0 && (
870
+ <div
871
+ ref={dropdownRef}
872
+ className="absolute z-10 top-full left-0 mt-1 w-full rounded-md border border-border bg-popover overflow-hidden"
873
+ >
874
+ <div className="py-1">
875
+ {cachedQueries.map((cq) => (
876
+ <div
877
+ key={cq}
878
+ className="flex items-center justify-between px-3 py-1.5 text-sm hover:bg-muted cursor-pointer group"
879
+ onClick={() => {
880
+ setQuery(cq);
881
+ setShowDropdown(false);
882
+ }}
883
+ >
884
+ <div className="flex items-center gap-2 truncate mr-2">
885
+ <Clock className="size-3 text-muted-foreground shrink-0" />
886
+ <span className="truncate text-xs">{cq}</span>
887
+ </div>
888
+ <button
889
+ className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity p-0.5"
890
+ onClick={(e) => removeQuery(cq, e)}
891
+ >
892
+ <X className="size-3" />
893
+ </button>
894
+ </div>
895
+ ))}
896
+ </div>
897
+ </div>
898
+ )}
899
+ </div>
900
+ ) : (
901
+ <>
902
+ <div className="relative flex-1 flex flex-col gap-2">
903
+ <div className="flex items-center gap-2">
904
+ <div className="relative flex-1">
905
+ <Globe className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground z-10 pointer-events-none" />
906
+ <Input
907
+ placeholder="Paste URLs (comma or space separated)"
908
+ value={manualUrl}
909
+ disabled={disabled}
910
+ className="pl-9 h-11 pr-16 bg-white"
911
+ onChange={(e) => setManualUrl(e.target.value)}
912
+ onFocus={() => void warmCrawler()}
913
+ onKeyDown={(e) => e.key === 'Enter' && appendManualUrl()}
914
+ />
915
+ </div>
916
+ <Button
917
+ size="icon"
918
+ className="h-11 w-11 shrink-0"
919
+ variant="outline"
920
+ onClick={() => appendManualUrl()}
921
+ disabled={disabled || !manualUrl.trim()}
922
+ >
923
+ <Plus className="size-4" />
924
+ </Button>
925
+ </div>
926
+ </div>
927
+ </>
928
+ )}
929
+ </div>
930
+ </div>
931
+ </div>
932
+
933
+ {/* No provider configured hint */}
934
+ {firecrawlConfigured === false && !activeProvider && (
935
+ <div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground">
936
+ <Info className="mt-0.5 size-3.5 shrink-0" />
937
+ <span>
938
+ <strong className="text-foreground">No search provider configured.</strong> Set up Use
939
+ Larkup’s built-in crawler or configure a Web Search provider in Settings.
940
+ </span>
941
+ </div>
942
+ )}
943
+
944
+ {/* Minimal Action Row */}
945
+ <div className="flex items-center justify-between mt-2">
946
+ <div className="flex items-center gap-2">
947
+ <button
948
+ type="button"
949
+ onClick={() => setShowAdvanced(!showAdvanced)}
950
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
951
+ >
952
+ {showAdvanced ? (
953
+ <ChevronDown className="size-3.5" />
954
+ ) : (
955
+ <ChevronsDown className="size-3.5" />
956
+ )}
957
+ {showAdvanced ? 'Hide advanced settings' : 'Advanced settings'}
958
+ </button>
959
+ </div>
960
+
961
+ {/* Estimation inline */}
962
+ {selectedUrls.length > 0 && (
963
+ <span className="text-xs text-muted-foreground flex items-center gap-1.5 ml-auto">
964
+ <Clock className="size-3" />
965
+ {specificUrls
966
+ ? `${selectedUrls.length} URL${selectedUrls.length !== 1 ? 's' : ''}`
967
+ : `~${estimate.totalPages.toLocaleString()} pages · ${formatDuration(
968
+ estimate.estimatedSeconds,
969
+ )}`}
970
+ </span>
971
+ )}
972
+ </div>
973
+
974
+ {/* Advanced Settings Collapsible */}
975
+ {showAdvanced && (
976
+ <div className="flex flex-col gap-4 w-full rounded-lg border border-border bg-muted/30 p-3 mt-2 animate-in slide-in-from-top-2 fade-in duration-200">
977
+ <div className="flex flex-wrap items-center justify-between gap-4">
978
+ <div className="flex ">
979
+ <div className="flex items-center gap-2">
980
+ <Label className="text-xs text-muted-foreground">Scope</Label>
981
+ <Select
982
+ defaultValue={scope}
983
+ value={scope}
984
+ onValueChange={(v) => setScope(v as CrawlScope)}
985
+ disabled={specificUrls || disabled}
986
+ >
987
+ <SelectTrigger className="w-52 bg-white h-8 text-xs">
988
+ <SelectValue />
989
+ </SelectTrigger>
990
+ <SelectContent>
991
+ <SelectItem value="page">Scrape pages only</SelectItem>
992
+ <SelectItem value="domain">Crawl whole domain</SelectItem>
993
+ </SelectContent>
994
+ </Select>
995
+ </div>
996
+
997
+ {scope === 'domain' && !specificUrls && (
998
+ <div className="flex items-center gap-2">
999
+ <Label htmlFor="limit" className="text-xs text-muted-foreground ml-4">
1000
+ Max pages
1001
+ </Label>
1002
+ <Input
1003
+ id="limit"
1004
+ type="number"
1005
+ min={1}
1006
+ max={500}
1007
+ value={pageLimit}
1008
+ disabled={specificUrls || disabled}
1009
+ onChange={(e) => setPageLimit(Number(e.target.value) || 1)}
1010
+ className="w-20 h-8 tabular-nums text-xs ml-2"
1011
+ />
1012
+ </div>
1013
+ )}
1014
+
1015
+ {inputMode === 'search' && (
1016
+ <div className="flex items-center gap-2">
1017
+ <Label htmlFor="search-limit" className="text-xs text-muted-foreground ml-4">
1018
+ Max results
1019
+ </Label>
1020
+ <Input
1021
+ id="search-limit"
1022
+ type="number"
1023
+ min={1}
1024
+ max={100}
1025
+ value={searchLimit}
1026
+ disabled={disabled}
1027
+ onChange={(e) => setSearchLimit(Number(e.target.value) || 1)}
1028
+ className="w-20 h-8 tabular-nums text-xs ml-2"
1029
+ />
1030
+ </div>
1031
+ )}
1032
+
1033
+ <div className="flex items-center gap-2 ml-4">
1034
+ <Switch
1035
+ id="specific-urls-inline"
1036
+ checked={specificUrls}
1037
+ onCheckedChange={setSpecificUrls}
1038
+ disabled={disabled}
1039
+ size="sm"
1040
+ />
1041
+ <Label
1042
+ htmlFor="specific-urls-inline"
1043
+ className="text-xs font-medium cursor-pointer flex items-center gap-1"
1044
+ >
1045
+ Exact URLs only
1046
+ <TooltipProvider delay={0}>
1047
+ <Tooltip>
1048
+ <TooltipTrigger
1049
+ type="button"
1050
+ className="text-muted-foreground hover:text-foreground"
1051
+ onClick={(e) => e.preventDefault()}
1052
+ >
1053
+ <Info className="size-3" />
1054
+ </TooltipTrigger>
1055
+ <TooltipContent>
1056
+ <p className="max-w-50 text-center">
1057
+ Scrape exact URLs only — no deep crawl or pagination.
1058
+ </p>
1059
+ </TooltipContent>
1060
+ </Tooltip>
1061
+ </TooltipProvider>
1062
+ </Label>
1063
+ </div>
1064
+ </div>
1065
+ <TooltipProvider delay={0}>
1066
+ <Tooltip>
1067
+ <TooltipTrigger
1068
+ type="button"
1069
+ className="text-muted-foreground hover:text-foreground p-1 rounded-md hover:bg-muted transition-colors"
1070
+ onClick={() => router.push('/settings?section=search-web')}
1071
+ >
1072
+ <Settings className="size-3.5" />
1073
+ </TooltipTrigger>
1074
+ <TooltipContent className="max-w-xs text-xs">
1075
+ <p>
1076
+ Customize your web search provider, or set up a custom proxy to help bypass
1077
+ scraping blocks.
1078
+ </p>
1079
+ </TooltipContent>
1080
+ </Tooltip>
1081
+ </TooltipProvider>
1082
+ </div>
1083
+ </div>
1084
+ )}
1085
+
1086
+ {manualUrlList.length > 0 && inputMode === 'url' && (
1087
+ <div className="rounded-md border border-border bg-card overflow-hidden mt-2 animate-in slide-in-from-top-2 fade-in duration-200">
1088
+ <table className="w-full text-xs">
1089
+ <thead>
1090
+ <tr className="border-b border-border bg-muted/50">
1091
+ <th className="py-2 pl-3 text-left font-medium text-muted-foreground">URL</th>
1092
+ <th className="py-2 pr-3 text-right font-medium text-muted-foreground w-12">
1093
+ Action
1094
+ </th>
1095
+ </tr>
1096
+ </thead>
1097
+ <tbody>
1098
+ {currentManualUrls.map((url) => (
1099
+ <tr
1100
+ key={url}
1101
+ className="border-b border-border/50 last:border-0 hover:bg-muted/30 transition-colors"
1102
+ >
1103
+ <td className="py-2 pl-3">
1104
+ <span className="truncate max-w-100 inline-block">{url}</span>
1105
+ </td>
1106
+ <td className="py-2 pr-3 text-right">
1107
+ <button
1108
+ type="button"
1109
+ onClick={() => setManualUrlList((prev) => prev.filter((u) => u !== url))}
1110
+ className="text-muted-foreground hover:text-destructive transition-colors rounded p-1"
1111
+ title="Remove URL"
1112
+ >
1113
+ <Trash2 className="size-3.5" />
1114
+ </button>
1115
+ </td>
1116
+ </tr>
1117
+ ))}
1118
+ </tbody>
1119
+ </table>
1120
+ {totalManualPages > 1 && (
1121
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 text-xs text-muted-foreground bg-muted/20">
1122
+ <span>
1123
+ {(manualUrlPage - 1) * MANUAL_URL_PAGE_SIZE + 1}–
1124
+ {Math.min(manualUrlPage * MANUAL_URL_PAGE_SIZE, manualUrlList.length)} of{' '}
1125
+ {manualUrlList.length}
1126
+ </span>
1127
+ <div className="flex items-center gap-1">
1128
+ <button
1129
+ disabled={manualUrlPage === 1}
1130
+ onClick={() => setManualUrlPage((p) => Math.max(1, p - 1))}
1131
+ className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors cursor-pointer"
1132
+ >
1133
+ <ChevronLeft className="size-3.5" />
1134
+ </button>
1135
+ <span className="tabular-nums font-medium mx-1">
1136
+ {manualUrlPage} / {totalManualPages}
1137
+ </span>
1138
+ <button
1139
+ disabled={manualUrlPage >= totalManualPages}
1140
+ onClick={() => setManualUrlPage((p) => Math.min(totalManualPages, p + 1))}
1141
+ className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors cursor-pointer"
1142
+ >
1143
+ <ChevronRight className="size-3.5" />
1144
+ </button>
1145
+ </div>
1146
+ </div>
1147
+ )}
1148
+ </div>
1149
+ )}
1150
+
1151
+ {/* Results list */}
1152
+ {results.length > 0 && (
1153
+ <div className="rounded-lg border border-border mb-9">
1154
+ <div className="flex items-center justify-between border-b border-border px-3 py-2">
1155
+ <span className="text-xs font-medium text-muted-foreground">
1156
+ {results.length} source{results.length === 1 ? '' : 's'} · {selectedUrls.length}{' '}
1157
+ selected
1158
+ </span>
1159
+ <div className="flex items-center gap-4">
1160
+ <button
1161
+ type="button"
1162
+ className="text-xs text-primary hover:underline"
1163
+ onClick={() => {
1164
+ const all = selectedUrls.length !== results.length;
1165
+ setSelected(all ? Object.fromEntries(results.map((r) => [r.url, true])) : {});
1166
+ }}
1167
+ >
1168
+ {selectedUrls.length === results.length ? 'Deselect all' : 'Select all'}
1169
+ </button>
1170
+ <button
1171
+ type="button"
1172
+ className="flex items-center gap-1 text-xs text-destructive hover:underline"
1173
+ onClick={flushSearch}
1174
+ >
1175
+ <Trash2 className="size-3.5" />
1176
+ Clear
1177
+ </button>
1178
+ </div>
1179
+ </div>
1180
+ <ul className="divide-y divide-border overflow-y-auto">
1181
+ {currentDisplayResults.map((r) => {
1182
+ const checked = !!selected[r.url];
1183
+ return (
1184
+ <li key={r.url}>
1185
+ <label
1186
+ className={cn(
1187
+ 'flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/50',
1188
+ 'bg-white',
1189
+ checked && 'bg-accent/50',
1190
+ )}
1191
+ >
1192
+ <Checkbox
1193
+ checked={checked}
1194
+ onCheckedChange={(v) => setSelected((prev) => ({ ...prev, [r.url]: !!v }))}
1195
+ className="mt-0.5"
1196
+ />
1197
+ <span className="min-w-0 flex-1">
1198
+ <span className="block truncate text-sm font-medium">{r.title}</span>
1199
+ <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
1200
+ <Globe className="size-3 shrink-0" />
1201
+ {domainOf(r.url)}
1202
+ </span>
1203
+ {r.description && (
1204
+ <span className="mt-0.5 line-clamp-1 block text-xs text-muted-foreground/80">
1205
+ {r.description}
1206
+ </span>
1207
+ )}
1208
+ </span>
1209
+ </label>
1210
+ </li>
1211
+ );
1212
+ })}
1213
+ </ul>
1214
+
1215
+ {/* Local Pagination footer */}
1216
+ {results.length > ITEMS_PER_PAGE && (
1217
+ <div className="flex items-center justify-between border-t border-border px-3 py-2 bg-muted/20">
1218
+ <span className="text-xs text-muted-foreground">
1219
+ Showing {(displayPage - 1) * ITEMS_PER_PAGE + 1} to{' '}
1220
+ {Math.min(displayPage * ITEMS_PER_PAGE, results.length)} of {results.length} sources
1221
+ </span>
1222
+ <div className="flex items-center gap-1">
1223
+ <Button
1224
+ variant="outline"
1225
+ size="sm"
1226
+ className="h-7 px-2 text-xs"
1227
+ disabled={displayPage === 1}
1228
+ onClick={() => setDisplayPage((p) => Math.max(1, p - 1))}
1229
+ >
1230
+ Previous
1231
+ </Button>
1232
+ <div className="flex items-center gap-2 px-2 text-xs font-medium">
1233
+ {displayPage} / {totalDisplayPages}
1234
+ </div>
1235
+ <Button
1236
+ variant="outline"
1237
+ size="sm"
1238
+ className="h-7 px-2 text-xs"
1239
+ disabled={displayPage === totalDisplayPages}
1240
+ onClick={() => setDisplayPage((p) => Math.min(totalDisplayPages, p + 1))}
1241
+ >
1242
+ Next
1243
+ </Button>
1244
+ </div>
1245
+ </div>
1246
+ )}
1247
+
1248
+ {/* API Pagination footer */}
1249
+ {searchState?.searchProvider === 'serper' &&
1250
+ (searchState.hasMore || loadingMore || gatheringAll) && (
1251
+ <div className="flex items-center justify-between border-t border-border px-3 py-2">
1252
+ <span className="text-xs text-muted-foreground">
1253
+ {gatheringAll
1254
+ ? `Gathering page ${gatherProgress} of ${searchState.totalPages}…`
1255
+ : `Page ${searchState.currentPage} of ${searchState.totalPages}`}
1256
+ </span>
1257
+ <div className="flex items-center gap-2">
1258
+ {gatherAllAvailable && (
1259
+ <Button
1260
+ size="sm"
1261
+ variant="ghost"
1262
+ className="h-7 gap-1 text-xs"
1263
+ onClick={gatherAll}
1264
+ >
1265
+ <ChevronsDown className="size-3.5" />
1266
+ Gather all ({searchState.totalPages - searchState.currentPage} more pages)
1267
+ </Button>
1268
+ )}
1269
+ <Button
1270
+ size="sm"
1271
+ variant="outline"
1272
+ className="h-7 gap-1 text-xs"
1273
+ onClick={loadNextPage}
1274
+ disabled={!searchState?.hasMore || loadingMore || gatheringAll}
1275
+ >
1276
+ {loadingMore ? (
1277
+ <Loader2 className="size-3.5 animate-spin" />
1278
+ ) : (
1279
+ <ChevronDown className="size-3.5" />
1280
+ )}
1281
+ Load next page
1282
+ </Button>
1283
+ </div>
1284
+ </div>
1285
+ )}
1286
+ </div>
1287
+ )}
1288
+ </div>
1289
+ );
1290
+ }