@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,2905 @@
1
+ 'use client';
2
+
3
+ import {
4
+ workspaceStorageKey,
5
+ workspaceStorageScope,
6
+ workspaceUrl,
7
+ workspaceFetch as fetch,
8
+ } from '../../host';
9
+ import { useRef, useState, useEffect, useMemo } from 'react';
10
+ import { motion } from 'framer-motion';
11
+ import useSWR from 'swr';
12
+ import { toast } from 'sonner';
13
+ import {
14
+ MEDIA_RECENT_URLS_STORAGE_KEY,
15
+ RECENT_ADD_HISTORY_CLEARED_EVENT,
16
+ } from '../../lib/browser-cache';
17
+ import { formatErrorMessage } from '../../lib/shared/error-formatter';
18
+ import {
19
+ describeActiveMediaStep,
20
+ completedMediaProcessingSeconds,
21
+ estimateMediaStepRemainingSeconds,
22
+ formatMediaProgressPercent,
23
+ isFinalizingMediaStep,
24
+ isMediaStepTelemetryStale,
25
+ mediaProcessingStartedAt,
26
+ primaryRunningMediaStep,
27
+ } from '../../lib/media/progress';
28
+ import {
29
+ Image as ImageIcon,
30
+ Video,
31
+ AudioLines,
32
+ Loader2,
33
+ X,
34
+ Upload,
35
+ FileUp,
36
+ Check,
37
+ AlertCircle,
38
+ Clock,
39
+ Trash2,
40
+ Store,
41
+ Link2,
42
+ Sparkles,
43
+ Pause,
44
+ Play,
45
+ ChevronLeft,
46
+ ChevronRight,
47
+ Search,
48
+ ExternalLink,
49
+ } from 'lucide-react';
50
+ import { Button } from '@larkup/workspace-ui/button';
51
+ import { cn } from '../../lib/utils';
52
+ import { Progress } from '../ui/progress';
53
+ import { Textarea } from '../ui/textarea';
54
+ import { Input } from '../ui/input';
55
+ import { useProject } from '../projects/project-provider';
56
+ import { useLiveMediaProgress } from '../../hooks/use-live-media-progress';
57
+ import { LiveMediaProgress } from './live-media-progress';
58
+ import Link from 'next/link';
59
+ import { useRouter } from 'next/navigation';
60
+ import type { DataPrimaryAction } from './data-primary-action';
61
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
62
+ import {
63
+ AlertDialog,
64
+ AlertDialogAction,
65
+ AlertDialogCancel,
66
+ AlertDialogContent,
67
+ AlertDialogDescription,
68
+ AlertDialogFooter,
69
+ AlertDialogHeader,
70
+ AlertDialogTitle,
71
+ } from '../ui/alert-dialog';
72
+ import { Alert, AlertAction, AlertDescription, AlertTitle } from '../ui/alert';
73
+ import { GenericAlert } from '../alerts/generic-alert';
74
+ import {
75
+ defaultToolIndexingValues,
76
+ ToolIndexingDialog,
77
+ type ToolIndexingSurface,
78
+ } from './tool-indexing-dialog';
79
+ import { isLocallyActiveVideoJob } from '../../lib/media/video-intelligence-capacity';
80
+ import { DataEntryModeSwitch } from './data-entry-mode-switch';
81
+ import {
82
+ selectedRemoteDuration,
83
+ selectedRemoteUrls,
84
+ type RemoteMediaEstimate,
85
+ } from '../../lib/media/source-selection';
86
+
87
+ type MediaSubTab = 'images' | 'video' | 'audio';
88
+ type MediaEntryTab = 'upload' | 'url';
89
+ type MediaPipelineStage =
90
+ 'download' | 'prepare' | 'extract' | 'transcribe' | 'vision' | 'synthesize' | 'index';
91
+
92
+ const MAX_RECENT_MEDIA_URLS = 10;
93
+
94
+ interface MediaProcessingStep {
95
+ stage: MediaPipelineStage;
96
+ status: 'waiting' | 'running' | 'completed' | 'skipped' | 'failed';
97
+ percent?: number;
98
+ current?: number;
99
+ total?: number;
100
+ unit?: string;
101
+ estimatedRemainingSeconds?: number;
102
+ elapsedSeconds?: number;
103
+ sequence?: number;
104
+ message?: string;
105
+ startedAt?: string;
106
+ updatedAt?: string;
107
+ finishedAt?: string;
108
+ }
109
+
110
+ interface MediaAsset {
111
+ id: string;
112
+ type: 'image' | 'video' | 'audio';
113
+ fileName: string;
114
+ mimeType: string;
115
+ storageUri: string;
116
+ thumbnailUri?: string;
117
+ fileSize: number;
118
+ processingStatus: 'pending' | 'processing' | 'completed' | 'failed';
119
+ processingError?: string;
120
+ processingMessage?: string;
121
+ processingPaused?: boolean;
122
+ processingProgress?: number;
123
+ processingSteps?: MediaProcessingStep[];
124
+ processingRevision?: number;
125
+ processingStartedAt?: string;
126
+ caption?: string;
127
+ documentIds: string[];
128
+ createdAt: string;
129
+ dimensions?: { width: number; height: number };
130
+ durationSecs?: number;
131
+ indexingInstructions?: string;
132
+ indexingQuality?: number;
133
+ toolInputs?: Record<string, unknown>;
134
+ activeVideoKnowledgeRevisionId?: string;
135
+ activeVideoKnowledgeManifestId?: string;
136
+ activeVideoKnowledgeJobId?: string;
137
+ activeVideoIntelligenceJobId?: string;
138
+ }
139
+
140
+ interface StagedMedia {
141
+ id: string;
142
+ file: File;
143
+ type: 'image' | 'video' | 'audio';
144
+ preview?: string;
145
+ durationSecs?: number;
146
+ }
147
+
148
+ interface RemoteEstimate extends RemoteMediaEstimate {
149
+ originalUrl: string;
150
+ title?: string;
151
+ durationSecs?: number;
152
+ singleItemDurationSecs?: number;
153
+ singleItemUrl?: string;
154
+ entryCount?: number;
155
+ mediaType: 'video' | 'audio' | 'unknown';
156
+ isYouTube?: boolean;
157
+ }
158
+
159
+ interface RecentMediaUrl {
160
+ url: string;
161
+ title?: string;
162
+ }
163
+
164
+ interface MediaApiResponse {
165
+ assets: MediaAsset[];
166
+ stats: {
167
+ total: number;
168
+ byType: Record<string, number>;
169
+ byStatus: Record<string, number>;
170
+ totalBytes: number;
171
+ };
172
+ storage: { usedBytes: number; fileCount: number };
173
+ }
174
+
175
+ interface VideoUsage {
176
+ sourceMinutesUsed: number;
177
+ sourceMinutesLimit: number | null;
178
+ activeJobs: number;
179
+ concurrentJobsLimit: number;
180
+ }
181
+
182
+ const stagedMediaByScope = new Map<string, Record<string, StagedMedia[]>>();
183
+
184
+ function formatSize(bytes: number): string {
185
+ if (bytes < 1024) return `${bytes} B`;
186
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
187
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
188
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
189
+ }
190
+
191
+ function mediaAssetUrl(assetId: string, serverId?: string, thumbnail = false): string {
192
+ const query = new URLSearchParams();
193
+ if (thumbnail) query.set('thumb', 'true');
194
+ if (serverId) query.set('serverId', serverId);
195
+ const suffix = query.toString();
196
+ return workspaceUrl(`/api/media/${assetId}${suffix ? `?${suffix}` : ''}`);
197
+ }
198
+
199
+ function getMediaType(file: File): 'image' | 'video' | 'audio' | null {
200
+ if (file.type.startsWith('image/')) return 'image';
201
+ if (file.type.startsWith('video/')) return 'video';
202
+ if (file.type.startsWith('audio/')) return 'audio';
203
+ return null;
204
+ }
205
+
206
+ function getYouTubeVideoId(url: string): string | null {
207
+ try {
208
+ const parsed = new URL(url);
209
+ const host = parsed.hostname.replace(/^www\./, '').toLowerCase();
210
+ if (host === 'youtu.be') return parsed.pathname.split('/').filter(Boolean)[0] ?? null;
211
+ if (host !== 'youtube.com' && !host.endsWith('.youtube.com')) return null;
212
+
213
+ return (
214
+ parsed.searchParams.get('v') ??
215
+ parsed.pathname.match(/^\/(?:embed|shorts|live)\/([^/?#]+)/)?.[1] ??
216
+ null
217
+ );
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ function YouTubeUrlPreview({ estimate }: { estimate: RemoteEstimate }) {
224
+ const [thumbnailUnavailable, setThumbnailUnavailable] = useState(false);
225
+ const videoId = getYouTubeVideoId(estimate.originalUrl);
226
+ const thumbnailUrl = videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null;
227
+
228
+ return (
229
+ <div className="overflow-hidden rounded-xl border border-border/70 bg-muted/20">
230
+ <div className="relative flex aspect-video items-center justify-center overflow-hidden bg-black/90">
231
+ {thumbnailUrl && !thumbnailUnavailable ? (
232
+ <img
233
+ src={thumbnailUrl}
234
+ alt="YouTube video thumbnail"
235
+ className="size-full object-cover opacity-90"
236
+ onError={() => setThumbnailUnavailable(true)}
237
+ />
238
+ ) : (
239
+ <Video className="size-8 text-white/70" aria-hidden="true" />
240
+ )}
241
+ <div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" />
242
+ <span className="absolute bottom-3 left-3 rounded-md bg-black/70 px-2 py-1 text-[11px] font-medium text-white">
243
+ YouTube video
244
+ </span>
245
+ </div>
246
+ <div className="flex flex-wrap items-center justify-between gap-3 p-3">
247
+ <div className="min-w-0">
248
+ <p className="truncate text-[13px] font-medium text-foreground">
249
+ {estimate.title || 'Preview this video on YouTube'}
250
+ </p>
251
+ <p className="mt-0.5 text-[11px] text-muted-foreground">
252
+ YouTube may block embedded playback. Open the source to preview it reliably.
253
+ </p>
254
+ </div>
255
+ <Button
256
+ variant="outline"
257
+ size="sm"
258
+ className="shrink-0 gap-1.5 text-xs"
259
+ nativeButton={false}
260
+ render={
261
+ // Styled as a button, but it navigates: Base UI's button
262
+ // primitive would otherwise announce this link as a button.
263
+ <a href={estimate.originalUrl} target="_blank" rel="noreferrer" role="link">
264
+ Open on YouTube
265
+ <ExternalLink className="size-3" />
266
+ </a>
267
+ }
268
+ />
269
+ </div>
270
+ </div>
271
+ );
272
+ }
273
+
274
+ function estimateMedia(durationSecs: number, isVideo: boolean, indexingMode = 'balanced') {
275
+ const minutes = durationSecs / 60;
276
+ const visualWindowSecs =
277
+ durationSecs >= 4 * 60 * 60 ? 15 * 60 : durationSecs > 60 * 60 ? 5 * 60 : 60;
278
+ const fastMinimumMinutes = Math.max(1, Math.ceil((60 + minutes * 4) / 60));
279
+ const fastMaximumMinutes = Math.max(fastMinimumMinutes, Math.ceil((60 + minutes * 5) / 60));
280
+ const processingMinutes = Math.max(1, Math.ceil(minutes * (isVideo ? 0.35 : 0.2)));
281
+ return {
282
+ transcriptionCost: minutes * 0.006,
283
+ processingMinutes:
284
+ isVideo && indexingMode === 'fast'
285
+ ? Math.ceil((fastMinimumMinutes + fastMaximumMinutes) / 2)
286
+ : processingMinutes,
287
+ minimumProcessingMinutes:
288
+ isVideo && indexingMode === 'fast' ? fastMinimumMinutes : processingMinutes,
289
+ maximumProcessingMinutes:
290
+ isVideo && indexingMode === 'fast' ? fastMaximumMinutes : processingMinutes,
291
+ visualScenes: isVideo ? Math.max(1, Math.ceil(durationSecs / visualWindowSecs)) : 0,
292
+ };
293
+ }
294
+
295
+ function formatEstimateRange(estimate: {
296
+ minimumProcessingMinutes: number;
297
+ maximumProcessingMinutes: number;
298
+ }): string {
299
+ return estimate.minimumProcessingMinutes === estimate.maximumProcessingMinutes
300
+ ? `${estimate.minimumProcessingMinutes} min`
301
+ : `${estimate.minimumProcessingMinutes}–${estimate.maximumProcessingMinutes} min`;
302
+ }
303
+
304
+ function qualityFrameEstimate(quality: number, durationSecs: number): number {
305
+ const intervalMap: Record<number, number> = { 20: 60, 40: 45, 60: 30, 80: 18, 100: 12 };
306
+ const maxMap: Record<number, number> = { 20: 100, 40: 250, 60: 600, 80: 900, 100: 1200 };
307
+ const tier =
308
+ quality <= 20 ? 20 : quality <= 40 ? 40 : quality <= 60 ? 60 : quality <= 80 ? 80 : 100;
309
+ const interval = intervalMap[tier];
310
+ const max = maxMap[tier];
311
+ return Math.min(max, Math.max(1, Math.ceil(durationSecs / interval)));
312
+ }
313
+
314
+ function qualityVisionCalls(quality: number, durationSecs: number): number {
315
+ const frames = qualityFrameEstimate(quality, durationSecs);
316
+ // Backend groups by 12 frames per vision API call
317
+ return Math.max(1, Math.ceil(frames / 12));
318
+ }
319
+
320
+ function qualityCostEstimate(quality: number, durationSecs: number): string {
321
+ const calls = qualityVisionCalls(quality, durationSecs);
322
+ // Transcription buffer (e.g. $0.008/min instead of $0.006)
323
+ const transcriptionCost = (durationSecs / 60) * 0.008;
324
+ // Vision buffer (e.g. $0.007/call instead of $0.003 to cover more expensive models)
325
+ const visionCost = calls * 0.007;
326
+ return (transcriptionCost + visionCost).toFixed(2);
327
+ }
328
+
329
+ function readMediaDuration(file: File): Promise<number | undefined> {
330
+ return new Promise((resolve) => {
331
+ const element = document.createElement(file.type.startsWith('video/') ? 'video' : 'audio');
332
+ const url = URL.createObjectURL(file);
333
+ let settled = false;
334
+ const finish = (duration?: number) => {
335
+ if (settled) return;
336
+ settled = true;
337
+ window.clearTimeout(timeout);
338
+ URL.revokeObjectURL(url);
339
+ resolve(duration);
340
+ };
341
+ // Some browsers never emit metadata or error events for incomplete media.
342
+ // Staging must stay responsive even when the eventual upload is rejected.
343
+ const timeout = window.setTimeout(() => finish(undefined), 3_000);
344
+ element.preload = 'metadata';
345
+ element.onloadedmetadata = () => {
346
+ finish(Number.isFinite(element.duration) ? element.duration : undefined);
347
+ };
348
+ element.onerror = () => {
349
+ finish(undefined);
350
+ };
351
+ element.src = url;
352
+ });
353
+ }
354
+
355
+ const SUB_TABS: { id: MediaSubTab; label: string; icon: typeof ImageIcon; accept: string }[] = [
356
+ { id: 'images', label: 'Images', icon: ImageIcon, accept: 'image/*' },
357
+ { id: 'video', label: 'Video', icon: Video, accept: 'video/*' },
358
+ { id: 'audio', label: 'Audio', icon: AudioLines, accept: 'audio/*' },
359
+ ];
360
+
361
+ const TAB_TO_TYPE: Record<MediaSubTab, 'image' | 'video' | 'audio'> = {
362
+ images: 'image',
363
+ video: 'video',
364
+ audio: 'audio',
365
+ };
366
+
367
+ const fetcher = (url: string) => fetch(url).then((r) => r.json() as Promise<MediaApiResponse>);
368
+
369
+ const toolFetcher = (url: string) => fetch(url).then((r) => r.json() as Promise<{ tools: any[] }>);
370
+
371
+ function isActiveJob(asset: MediaAsset): boolean {
372
+ return isLocallyActiveVideoJob(asset);
373
+ }
374
+
375
+ const INDEXING_PROGRESS_CLASS =
376
+ '[&_[data-slot=progress-indicator]]:bg-emerald-500 dark:[&_[data-slot=progress-indicator]]:bg-emerald-400';
377
+
378
+ export function MediaPanel({
379
+ onAdded,
380
+ onIndexed,
381
+ onProcessingStarted,
382
+ onActionChange,
383
+ groupId = 'default',
384
+ }: {
385
+ onAdded: () => void;
386
+ onIndexed: (asset: MediaAsset) => void;
387
+ onProcessingStarted?: () => void;
388
+ onActionChange?: (action: DataPrimaryAction | null) => void;
389
+ groupId?: string;
390
+ }) {
391
+ const [activeTab, setActiveTab] = useState<MediaSubTab>('images');
392
+ const [entryTab, setEntryTab] = useState<MediaEntryTab>('upload');
393
+ const [videoAudioToolJustInstalled, setVideoAudioToolJustInstalled] = useState(false);
394
+ const [usageRequestOpen, setUsageRequestOpen] = useState(false);
395
+ const [usageRequestEmail, setUsageRequestEmail] = useState('');
396
+ const [usageRequestNote, setUsageRequestNote] = useState('');
397
+ const [sendingUsageRequest, setSendingUsageRequest] = useState(false);
398
+ const mediaType = TAB_TO_TYPE[activeTab];
399
+ const previousStatusesRef = useRef<Map<string, MediaAsset['processingStatus']> | null>(null);
400
+ const { activeProject } = useProject();
401
+ const serverId = activeProject?.id;
402
+ const serverQuery = serverId ? `&serverId=${encodeURIComponent(serverId)}` : '';
403
+ const router = useRouter();
404
+
405
+ const { data, mutate, isLoading } = useSWR(
406
+ workspaceUrl(`/api/media?type=all${serverQuery}`),
407
+ fetcher,
408
+ {
409
+ revalidateOnFocus: false,
410
+ keepPreviousData: true,
411
+ },
412
+ );
413
+
414
+ useEffect(() => {
415
+ previousStatusesRef.current = null;
416
+ const stream = new EventSource(workspaceUrl(`/api/media/stream?type=all${serverQuery}`));
417
+ let fallbackTimer: ReturnType<typeof setInterval> | undefined;
418
+ const handleUpdate = (event: MessageEvent<string>) => {
419
+ try {
420
+ const nextData = JSON.parse(event.data) as MediaApiResponse;
421
+ const previousStatuses = previousStatusesRef.current;
422
+ const nextStatuses = new Map(
423
+ nextData.assets.map((asset) => [asset.id, asset.processingStatus]),
424
+ );
425
+
426
+ // The first SSE snapshot establishes the baseline. Subsequent updates
427
+ // let us react only to a job that just finished, not old completed media.
428
+ if (previousStatuses) {
429
+ for (const asset of nextData.assets) {
430
+ const previousStatus = previousStatuses.get(asset.id);
431
+ if (
432
+ asset.processingStatus === 'completed' &&
433
+ (previousStatus === 'pending' || previousStatus === 'processing')
434
+ ) {
435
+ onIndexed(asset);
436
+ try {
437
+ const ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
438
+ const osc = ctx.createOscillator();
439
+ const gain = ctx.createGain();
440
+ osc.connect(gain);
441
+ gain.connect(ctx.destination);
442
+ osc.type = 'sine';
443
+ osc.frequency.setValueAtTime(880, ctx.currentTime);
444
+ gain.gain.setValueAtTime(0.05, ctx.currentTime);
445
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
446
+ osc.start(ctx.currentTime);
447
+ osc.stop(ctx.currentTime + 0.1);
448
+ } catch (e) {
449
+ // Ignore audio context errors
450
+ }
451
+ }
452
+ if (
453
+ asset.processingStatus === 'failed' &&
454
+ (previousStatus === 'pending' || previousStatus === 'processing')
455
+ ) {
456
+ const msg =
457
+ asset.processingError ||
458
+ asset.processingMessage ||
459
+ `Failed to index ${asset.fileName}`;
460
+ if (
461
+ msg.toLowerCase().includes('api key') ||
462
+ msg.toLowerCase().includes('configuration') ||
463
+ msg.toLowerCase().includes('missing provider') ||
464
+ msg.toLowerCase().includes('choose an audio provider')
465
+ ) {
466
+ toast.error(`Indexing failed: ${asset.fileName}`, {
467
+ description: msg,
468
+ duration: 10000,
469
+ action: {
470
+ label: 'Go to Settings',
471
+ onClick: () => router.push('/settings?section=tool-settings'),
472
+ },
473
+ });
474
+ } else {
475
+ toast.error(`Indexing failed: ${asset.fileName}`, {
476
+ description: msg,
477
+ duration: 8000,
478
+ });
479
+ }
480
+ }
481
+ }
482
+ }
483
+ previousStatusesRef.current = nextStatuses;
484
+ void mutate(nextData, { revalidate: false });
485
+ } catch {
486
+ // Ignore a malformed update and keep the last successful snapshot.
487
+ }
488
+ };
489
+
490
+ stream.onopen = () => {
491
+ if (fallbackTimer) clearInterval(fallbackTimer);
492
+ fallbackTimer = undefined;
493
+ };
494
+ stream.onerror = () => {
495
+ void mutate();
496
+ fallbackTimer ??= setInterval(() => void mutate(), 5_000);
497
+ };
498
+
499
+ // Removed manual visibilitychange mutate to prevent SWR cache invalidation
500
+ // The EventSource automatically reconnects and synchronizes state natively.
501
+ stream.addEventListener('media-update', handleUpdate);
502
+ return () => {
503
+ if (fallbackTimer) clearInterval(fallbackTimer);
504
+ stream.removeEventListener('media-update', handleUpdate);
505
+ stream.close();
506
+ };
507
+ }, [mediaType, mutate, onIndexed, serverQuery]);
508
+
509
+ const {
510
+ data: toolsData,
511
+ mutate: mutateTools,
512
+ isLoading: toolsLoading,
513
+ } = useSWR(workspaceUrl('/api/marketplace'), toolFetcher);
514
+
515
+ const configUrl = serverId
516
+ ? workspaceUrl(`/api/config?serverId=${encodeURIComponent(serverId)}`)
517
+ : workspaceUrl('/api/config');
518
+ const { data: configData } = useSWR(configUrl, (url) => fetch(url).then((r) => r.json()));
519
+
520
+ // Check if the Video Intelligence tool is installed and enabled.
521
+ const videoAudioTool = toolsData?.tools?.find((t: any) => t.id === 'video-audio');
522
+ const videoIntelligenceTool = toolsData?.tools?.find(
523
+ (tool: any) => tool.id === 'video-intelligence',
524
+ );
525
+ const indexingSurface = useMemo(
526
+ () =>
527
+ toolsData?.tools
528
+ ?.filter((tool: any) => tool.status === 'installed')
529
+ .flatMap((tool: any) =>
530
+ (tool.ui?.surfaces ?? []).map((surface: ToolIndexingSurface) => ({
531
+ ...surface,
532
+ toolId: tool.id,
533
+ })),
534
+ )
535
+ .find(
536
+ (surface: ToolIndexingSurface & { toolId: string }) =>
537
+ surface.slot === 'data-indexing' && surface.appliesTo?.includes(mediaType),
538
+ ),
539
+ [mediaType, toolsData?.tools],
540
+ );
541
+ const modernVideoInstalled = videoIntelligenceTool?.status === 'installed';
542
+ const legacyVideoAudioInstalled =
543
+ videoAudioTool?.status === 'installed' || videoAudioToolJustInstalled;
544
+ const isToolInstalled = activeTab === 'video' ? modernVideoInstalled : legacyVideoAudioInstalled;
545
+ const enabledTools = configData?.config?.enabledTools;
546
+ const videoAudioConfig = configData?.config?.toolConfigs?.['video-audio'] ?? {};
547
+ const audioProvider =
548
+ typeof videoAudioConfig.audioProvider === 'string' ? videoAudioConfig.audioProvider : '';
549
+ const audioApiKey =
550
+ typeof videoAudioConfig.audioApiKey === 'string' ? videoAudioConfig.audioApiKey : '';
551
+ const audioConfigured = Boolean(audioProvider && (audioProvider === 'local' || audioApiKey));
552
+ const isToolEnabled = enabledTools
553
+ ? enabledTools.length === 0 ||
554
+ enabledTools.includes(
555
+ activeTab === 'video' && modernVideoInstalled ? 'video-intelligence' : 'video-audio',
556
+ )
557
+ : true;
558
+
559
+ const needsTool =
560
+ activeTab === 'video'
561
+ ? !modernVideoInstalled
562
+ : activeTab === 'audio' && !legacyVideoAudioInstalled;
563
+ const isToolDisabled = activeTab !== 'images' && isToolInstalled && !isToolEnabled;
564
+ const { data: videoUsage, mutate: mutateVideoUsage } = useSWR<VideoUsage>(
565
+ modernVideoInstalled
566
+ ? workspaceUrl(
567
+ `/api/tools/video-intelligence/usage${
568
+ serverId ? `?serverId=${encodeURIComponent(serverId)}` : ''
569
+ }`,
570
+ )
571
+ : null,
572
+ (url) => fetch(url).then((response) => (response.ok ? response.json() : null)),
573
+ { refreshInterval: modernVideoInstalled ? 30_000 : 0 },
574
+ );
575
+ const usageRatio =
576
+ videoUsage?.sourceMinutesLimit === 0
577
+ ? 1
578
+ : videoUsage?.sourceMinutesLimit && videoUsage.sourceMinutesLimit > 0
579
+ ? videoUsage.sourceMinutesUsed / videoUsage.sourceMinutesLimit
580
+ : 0;
581
+ const usageWarning = usageRatio >= 0.8;
582
+
583
+ const assets = data?.assets ?? [];
584
+ const activeVideoAssets = assets.filter((asset) => asset.type === 'video' && isActiveJob(asset));
585
+ const videoConcurrencyLimit = Math.max(1, videoUsage?.concurrentJobsLimit ?? 1);
586
+ // A cached cloud count is advisory and may briefly remain at one after the
587
+ // prior job finished. Local live assets drive the button; the POST route
588
+ // performs the authoritative, reconciled concurrency check.
589
+ const videoIndexingAtCapacity = activeVideoAssets.length >= videoConcurrencyLimit;
590
+ const storageBytes = data?.storage?.usedBytes ?? 0;
591
+
592
+ async function installMediaTool(type: 'video' | 'audio') {
593
+ const toolId = type === 'video' ? 'video-intelligence' : 'video-audio';
594
+ const toolName = type === 'video' ? 'Video Intelligence' : 'Video & Audio';
595
+ const toastId = toast.loading(`Installing ${toolName}…`);
596
+ try {
597
+ const query = serverId ? `?serverId=${encodeURIComponent(serverId)}` : '';
598
+ const response = await fetch(workspaceUrl(`/api/marketplace/${toolId}${query}`), {
599
+ method: 'POST',
600
+ });
601
+ const body = await response.json();
602
+ if (!response.ok) throw new Error(body.error || 'Install failed');
603
+ if (type === 'audio') setVideoAudioToolJustInstalled(true);
604
+ await mutateTools();
605
+ toast.success(`${toolName} installed`, {
606
+ id: toastId,
607
+ description:
608
+ type === 'video'
609
+ ? 'Larkup Cloud is ready by default. No GPU or server setup is needed.'
610
+ : 'Choose an audio provider before indexing audio.',
611
+ action: {
612
+ label: 'Open settings',
613
+ onClick: () => router.push('/settings?section=tool-settings'),
614
+ },
615
+ });
616
+ } catch (error) {
617
+ toast.error(error instanceof Error ? error.message : 'Install failed', { id: toastId });
618
+ }
619
+ }
620
+
621
+ async function requestMoreVideoUsage() {
622
+ setSendingUsageRequest(true);
623
+ try {
624
+ const response = await fetch(
625
+ workspaceUrl(
626
+ `/api/tools/video-intelligence/usage-request${
627
+ serverId ? `?serverId=${encodeURIComponent(serverId)}` : ''
628
+ }`,
629
+ ),
630
+ {
631
+ method: 'POST',
632
+ headers: { 'Content-Type': 'application/json' },
633
+ body: JSON.stringify({
634
+ email: usageRequestEmail,
635
+ note: usageRequestNote,
636
+ usage: videoUsage,
637
+ }),
638
+ },
639
+ );
640
+ const body = await response.json().catch(() => ({}));
641
+ if (!response.ok) throw new Error(body.error || 'Could not send the usage request.');
642
+ toast.success('Usage request sent', {
643
+ description: 'We will contact you about additional Video Intelligence capacity.',
644
+ });
645
+ setUsageRequestOpen(false);
646
+ setUsageRequestNote('');
647
+ } catch (error) {
648
+ toast.error(error instanceof Error ? error.message : 'Could not send the usage request.');
649
+ } finally {
650
+ setSendingUsageRequest(false);
651
+ }
652
+ }
653
+
654
+ return (
655
+ <div className="space-y-5">
656
+ {/* Keep the task choice small. File type is detected after selection. */}
657
+ <div className="flex flex-wrap items-center justify-between gap-4 mb-2">
658
+ <DataEntryModeSwitch
659
+ label="Media source"
660
+ value={entryTab}
661
+ onValueChange={(value) => setEntryTab(value as MediaEntryTab)}
662
+ options={[
663
+ { value: 'upload', label: 'Upload', icon: Upload },
664
+ { value: 'url', label: 'From URL', icon: Link2 },
665
+ ]}
666
+ />
667
+
668
+ {/* Storage usage (subtle) */}
669
+ {storageBytes > 0 && (
670
+ <div className="text-[11px] text-muted-foreground/50 tabular-nums">
671
+ {formatSize(storageBytes)} used
672
+ </div>
673
+ )}
674
+ </div>
675
+
676
+ {!toolsLoading && !modernVideoInstalled && activeTab !== 'video' ? (
677
+ <VideoToolInstallCallout
678
+ toolName={videoIntelligenceTool?.name ?? 'Video Intelligence'}
679
+ onInstall={() => void installMediaTool('video')}
680
+ />
681
+ ) : null}
682
+
683
+ {activeTab === 'video' && usageWarning ? (
684
+ <Alert variant={usageRatio >= 1 ? 'destructive' : 'default'}>
685
+ <AlertCircle />
686
+ <AlertTitle>
687
+ {usageRatio >= 1
688
+ ? 'Video Intelligence allowance reached'
689
+ : 'Video Intelligence allowance is nearly used'}
690
+ </AlertTitle>
691
+ <AlertDescription>
692
+ {Math.ceil(videoUsage?.sourceMinutesUsed ?? 0)} of {videoUsage?.sourceMinutesLimit}{' '}
693
+ cloud video minutes have been used this month.
694
+ </AlertDescription>
695
+ <AlertAction>
696
+ <Button size="sm" variant="outline" onClick={() => setUsageRequestOpen(true)}>
697
+ Request more usage
698
+ </Button>
699
+ </AlertAction>
700
+ </Alert>
701
+ ) : null}
702
+
703
+ {/* Content: either install prompt or upload + gallery */}
704
+ {needsTool ? (
705
+ <InstallPrompt
706
+ toolId={activeTab === 'video' ? 'video-intelligence' : 'video-audio'}
707
+ toolName={
708
+ activeTab === 'video'
709
+ ? (videoIntelligenceTool?.name ?? 'Video Intelligence')
710
+ : (videoAudioTool?.name ?? 'Video Intelligence')
711
+ }
712
+ toolDescription={
713
+ activeTab === 'video'
714
+ ? (videoIntelligenceTool?.description ??
715
+ 'Index video with timestamped speech, OCR, objects, and tracks.')
716
+ : (videoAudioTool?.description ??
717
+ 'Process video and audio files with transcription and frame analysis.')
718
+ }
719
+ installSize={
720
+ activeTab === 'video'
721
+ ? (videoIntelligenceTool?.installSize ?? '~120 KB client')
722
+ : (videoAudioTool?.installSize ?? '~15 MB')
723
+ }
724
+ systemDeps={
725
+ activeTab === 'video' ? videoIntelligenceTool?.systemDeps : videoAudioTool?.systemDeps
726
+ }
727
+ successDescription={
728
+ activeTab === 'video'
729
+ ? 'Larkup Cloud is selected automatically. No GPU or server setup is needed.'
730
+ : 'Choose an audio provider in Installed Tools before indexing audio.'
731
+ }
732
+ serverId={serverId}
733
+ onInstallComplete={() => mutateTools()}
734
+ />
735
+ ) : isToolDisabled ? (
736
+ <DisabledPrompt />
737
+ ) : (
738
+ <MediaContent
739
+ mediaType={mediaType}
740
+ tab={activeTab}
741
+ assets={assets}
742
+ isLoading={isLoading}
743
+ storageUsedBytes={data?.storage?.usedBytes ?? 0}
744
+ serverId={serverId}
745
+ onMutate={() => mutate()}
746
+ onProcessingStarted={onProcessingStarted}
747
+ onUploadComplete={() => {
748
+ mutate();
749
+ onAdded();
750
+ }}
751
+ audioConfigured={audioConfigured}
752
+ onConfigureAudio={() => router.push('/settings?section=tool-settings')}
753
+ entryTab={entryTab}
754
+ onMediaTypeDetected={(type) => setActiveTab(type === 'image' ? 'images' : type)}
755
+ videoToolInstalled={modernVideoInstalled}
756
+ audioToolInstalled={legacyVideoAudioInstalled}
757
+ toolLoading={toolsLoading}
758
+ onToolRequired={(type) => void installMediaTool(type)}
759
+ onActionChange={onActionChange}
760
+ onRefreshToolConfiguration={() => void mutateTools()}
761
+ videoIndexingAtCapacity={videoIndexingAtCapacity}
762
+ videoConcurrencyLimit={videoConcurrencyLimit}
763
+ onVideoJobCancelled={() => void mutateVideoUsage()}
764
+ groupId={groupId}
765
+ indexingSurface={indexingSurface}
766
+ />
767
+ )}
768
+ <GenericAlert
769
+ open={usageRequestOpen}
770
+ onOpenChange={setUsageRequestOpen}
771
+ title="Request more video capacity"
772
+ description={
773
+ <div className="space-y-3">
774
+ <p>
775
+ Tell us where we should contact you. Your current usage summary is included
776
+ automatically.
777
+ </p>
778
+ <Input
779
+ aria-label="Work email"
780
+ type="email"
781
+ value={usageRequestEmail}
782
+ onChange={(event) => setUsageRequestEmail(event.target.value)}
783
+ placeholder="you@company.com"
784
+ />
785
+ <Textarea
786
+ aria-label="Capacity request details"
787
+ value={usageRequestNote}
788
+ onChange={(event) => setUsageRequestNote(event.target.value)}
789
+ placeholder="How many video minutes or concurrent jobs do you need?"
790
+ rows={3}
791
+ />
792
+ </div>
793
+ }
794
+ cancelText="Not now"
795
+ actionText={sendingUsageRequest ? 'Sending…' : 'Send request'}
796
+ onAction={() => void requestMoreVideoUsage()}
797
+ icon={<AlertCircle className="size-5 text-amber-600" />}
798
+ />
799
+ </div>
800
+ );
801
+ }
802
+
803
+ /* Install prompt (for Video & Audio tab when tool not installed) */
804
+ function InstallPrompt({
805
+ toolId,
806
+ toolName,
807
+ toolDescription,
808
+ installSize,
809
+ systemDeps,
810
+ successDescription,
811
+ serverId,
812
+ onInstallComplete,
813
+ }: {
814
+ toolId: string;
815
+ toolName: string;
816
+ toolDescription: string;
817
+ installSize: string;
818
+ systemDeps?: string[];
819
+ successDescription: string;
820
+ serverId?: string;
821
+ onInstallComplete: () => void;
822
+ }) {
823
+ const [installing, setInstalling] = useState(false);
824
+
825
+ const handleInstall = async () => {
826
+ setInstalling(true);
827
+ const toastId = toast.loading('Downloading and installing tool…', {
828
+ description: 'This may take a moment on first install.',
829
+ });
830
+ try {
831
+ const query = serverId ? `?serverId=${encodeURIComponent(serverId)}` : '';
832
+ const res = await fetch(workspaceUrl(`/api/marketplace/${toolId}${query}`), {
833
+ method: 'POST',
834
+ });
835
+ if (!res.ok) {
836
+ const err = await res.json();
837
+ throw new Error(err.error || 'Install failed');
838
+ }
839
+ toast.dismiss(toastId);
840
+ toast.success(`${toolName} installed`, {
841
+ description: successDescription,
842
+ });
843
+ onInstallComplete();
844
+ } catch (err) {
845
+ toast.dismiss(toastId);
846
+ toast.error(err instanceof Error ? err.message : 'Failed to install tool');
847
+ } finally {
848
+ setInstalling(false);
849
+ }
850
+ };
851
+
852
+ return (
853
+ <div className="flex flex-col items-center justify-center py-16 text-center">
854
+ <div className="flex size-14 items-center border border-border/90 justify-center rounded-xl bg-white">
855
+ <Store className="size-6 text-muted-foreground" />
856
+ </div>
857
+ <h3 className="mt-4 text-sm font-medium text-foreground">{toolName}</h3>
858
+ <p className="mt-1.5 max-w-sm text-[13px] leading-relaxed text-muted-foreground">
859
+ {toolDescription}
860
+ </p>
861
+ <div className="mt-3 flex items-center gap-3 text-[11px] text-muted-foreground/90">
862
+ <span>{installSize}</span>
863
+ {/* {systemDeps?.length ? (
864
+ <>
865
+ <span>·</span>
866
+ <span>Requires: {systemDeps.join(', ')}</span>
867
+ </>
868
+ ) : null} */}
869
+ </div>
870
+ <Button
871
+ variant="default"
872
+ size="sm"
873
+ className="mt-5 gap-1.5 text-[12px]"
874
+ disabled={installing}
875
+ onClick={handleInstall}
876
+ >
877
+ {installing ? (
878
+ <Loader2 className="size-3.5 animate-spin" />
879
+ ) : (
880
+ <Store className="size-3.5" />
881
+ )}
882
+ {installing ? 'Installing...' : 'Install directly'}
883
+ </Button>
884
+ </div>
885
+ );
886
+ }
887
+
888
+ function VideoToolInstallCallout({
889
+ toolName,
890
+ onInstall,
891
+ }: {
892
+ toolName: string;
893
+ onInstall: () => void;
894
+ }) {
895
+ return (
896
+ <div className="flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 sm:flex-row sm:items-center sm:justify-between">
897
+ <div className="flex min-w-0 items-start gap-3">
898
+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
899
+ <Video className="size-4" />
900
+ </div>
901
+ <div>
902
+ <p className="text-sm font-medium text-foreground">Want to index a video?</p>
903
+ <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
904
+ Install {toolName} first. It uses Larkup Cloud by default, so no local GPU is needed.
905
+ </p>
906
+ </div>
907
+ </div>
908
+ <Button size="sm" className="shrink-0 gap-1.5" onClick={onInstall}>
909
+ <Store className="size-3.5" />
910
+ Install video tool
911
+ </Button>
912
+ </div>
913
+ );
914
+ }
915
+
916
+ /* Media panel helpers. */
917
+ /* Disabled prompt (for Video & Audio tab when tool is disabled) */
918
+ /* End media panel helpers. */
919
+
920
+ function DisabledPrompt() {
921
+ return (
922
+ <div className="flex flex-col items-center justify-center py-16 text-center">
923
+ <div className="flex size-14 items-center border border-border/90 justify-center rounded-xl bg-white">
924
+ <AlertCircle className="size-6 text-muted-foreground" />
925
+ </div>
926
+ <h3 className="mt-4 text-sm font-medium text-foreground">
927
+ Video Intelligence Processing Disabled
928
+ </h3>
929
+ <p className="mt-1.5 max-w-sm text-[13px] leading-relaxed text-muted-foreground">
930
+ The Video Intelligence tool is currently disabled in your agent settings. You cannot index
931
+ new video or audio files until it is re-enabled.
932
+ </p>
933
+ <Button
934
+ variant="default"
935
+ size="sm"
936
+ className="mt-5 text-[12px]"
937
+ render={<Link href="/settings?tab=prompts">Go to Settings</Link>}
938
+ nativeButton={false}
939
+ />
940
+ </div>
941
+ );
942
+ }
943
+
944
+ function MediaContent({
945
+ mediaType,
946
+ tab,
947
+ assets,
948
+ isLoading,
949
+ storageUsedBytes,
950
+ serverId,
951
+ onMutate,
952
+ onProcessingStarted,
953
+ onUploadComplete,
954
+ audioConfigured,
955
+ onConfigureAudio,
956
+ entryTab,
957
+ onMediaTypeDetected,
958
+ videoToolInstalled,
959
+ audioToolInstalled,
960
+ toolLoading,
961
+ onToolRequired,
962
+ onActionChange,
963
+ onRefreshToolConfiguration,
964
+ videoIndexingAtCapacity,
965
+ videoConcurrencyLimit,
966
+ onVideoJobCancelled,
967
+ groupId,
968
+ indexingSurface,
969
+ }: {
970
+ mediaType: 'image' | 'video' | 'audio';
971
+ tab: MediaSubTab;
972
+ assets: MediaAsset[];
973
+ isLoading: boolean;
974
+ storageUsedBytes: number;
975
+ serverId?: string;
976
+ onMutate: () => void | Promise<unknown>;
977
+ onProcessingStarted?: () => void;
978
+ onUploadComplete: () => void;
979
+ audioConfigured: boolean;
980
+ onConfigureAudio: () => void;
981
+ entryTab: MediaEntryTab;
982
+ onMediaTypeDetected: (type: 'image' | 'video' | 'audio') => void;
983
+ videoToolInstalled: boolean;
984
+ audioToolInstalled: boolean;
985
+ toolLoading: boolean;
986
+ onToolRequired: (type: 'video' | 'audio') => void;
987
+ onActionChange?: (action: DataPrimaryAction | null) => void;
988
+ onRefreshToolConfiguration: () => void;
989
+ videoIndexingAtCapacity: boolean;
990
+ videoConcurrencyLimit: number;
991
+ onVideoJobCancelled: () => void;
992
+ groupId: string;
993
+ indexingSurface?: ToolIndexingSurface & { toolId: string };
994
+ }) {
995
+ const inputRef = useRef<HTMLInputElement>(null);
996
+ const groupIdRef = useRef(groupId);
997
+ groupIdRef.current = groupId;
998
+ const urlInputRef = useRef<HTMLInputElement>(null);
999
+ const recentUrlsRef = useRef<HTMLDivElement>(null);
1000
+ const router = useRouter();
1001
+ const [libraryTab, setLibraryTab] = useState<MediaSubTab>('images');
1002
+ const [librarySearch, setLibrarySearch] = useState('');
1003
+ const [libraryPage, setLibraryPage] = useState(0);
1004
+ const scope = useRef(workspaceStorageScope()).current;
1005
+ const recentStorageKey = useRef(workspaceStorageKey(MEDIA_RECENT_URLS_STORAGE_KEY)).current;
1006
+ if (!stagedMediaByScope.has(scope))
1007
+ stagedMediaByScope.set(scope, { image: [], video: [], audio: [] });
1008
+ const globalStagedMedia = stagedMediaByScope.get(scope)!;
1009
+ const [staged, setStagedState] = useState<StagedMedia[]>(globalStagedMedia[mediaType] || []);
1010
+
1011
+ useEffect(() => {
1012
+ setStagedState(globalStagedMedia[mediaType] || []);
1013
+ }, [mediaType]);
1014
+
1015
+ const setStaged = (val: React.SetStateAction<StagedMedia[]>) => {
1016
+ setStagedState((prev) => {
1017
+ const next = typeof val === 'function' ? (val as Function)(prev) : val;
1018
+ globalStagedMedia[mediaType] = next;
1019
+ return next;
1020
+ });
1021
+ };
1022
+
1023
+ const [dragging, setDragging] = useState(false);
1024
+ const [uploading, setUploading] = useState(false);
1025
+ const [progress, setProgress] = useState<{
1026
+ message?: string;
1027
+ current: number;
1028
+ total: number;
1029
+ } | null>(null);
1030
+ const [urlsText, setUrlsText] = useState('');
1031
+ const [recentUrls, setRecentUrls] = useState<RecentMediaUrl[]>([]);
1032
+ const [showRecentUrls, setShowRecentUrls] = useState(false);
1033
+ const [remoteEstimates, setRemoteEstimates] = useState<RemoteEstimate[] | null>(null);
1034
+ const [remoteType, setRemoteType] = useState<'image' | 'video' | 'audio'>(mediaType);
1035
+ const [checkingUrls, setCheckingUrls] = useState(false);
1036
+ const [importingUrls, setImportingUrls] = useState(false);
1037
+ const [playlistAlertOpen, setPlaylistAlertOpen] = useState(false);
1038
+ const [assetToRemove, setAssetToRemove] = useState<MediaAsset | null>(null);
1039
+ const [indexingBriefOpen, setIndexingBriefOpen] = useState(false);
1040
+ const [indexingAssetIds, setIndexingAssetIds] = useState<string[] | null>(null);
1041
+ const [urlIndexingRequest, setUrlIndexingRequest] = useState<{ ignorePlaylist: boolean } | null>(
1042
+ null,
1043
+ );
1044
+ const [toolIndexingValues, setToolIndexingValues] = useState<Record<string, string | boolean>>(
1045
+ {},
1046
+ );
1047
+ const defaultIndexingValues = useMemo(
1048
+ () => (indexingSurface ? defaultToolIndexingValues(indexingSurface) : {}),
1049
+ [indexingSurface],
1050
+ );
1051
+ useEffect(() => {
1052
+ setToolIndexingValues((current) => {
1053
+ const nextEntries = Object.entries(defaultIndexingValues);
1054
+ const unchanged =
1055
+ Object.keys(current).length === nextEntries.length &&
1056
+ nextEntries.every(([key, value]) => current[key] === value);
1057
+ return unchanged ? current : defaultIndexingValues;
1058
+ });
1059
+ }, [defaultIndexingValues]);
1060
+ const mediaApiUrl = serverId
1061
+ ? workspaceUrl(`/api/media?serverId=${encodeURIComponent(serverId)}`)
1062
+ : workspaceUrl('/api/media');
1063
+
1064
+ const accept = 'image/*,video/*,audio/*';
1065
+
1066
+ useEffect(() => {
1067
+ try {
1068
+ const stored = JSON.parse(localStorage.getItem(recentStorageKey) ?? '[]');
1069
+ if (!Array.isArray(stored)) return;
1070
+ setRecentUrls(
1071
+ stored
1072
+ .map((value): RecentMediaUrl | null => {
1073
+ // Keep histories saved by older releases usable while upgrading
1074
+ // them to titled entries on the next URL review.
1075
+ if (typeof value === 'string') return { url: value.trim() };
1076
+ if (!value || typeof value !== 'object' || typeof value.url !== 'string') return null;
1077
+ const title = typeof value.title === 'string' ? value.title.trim() : '';
1078
+ return { url: value.url.trim(), ...(title ? { title } : {}) };
1079
+ })
1080
+ .filter((value): value is RecentMediaUrl => Boolean(value?.url))
1081
+ .slice(0, MAX_RECENT_MEDIA_URLS),
1082
+ );
1083
+ } catch {
1084
+ // A malformed browser cache should never prevent adding media.
1085
+ }
1086
+ }, []);
1087
+
1088
+ useEffect(() => {
1089
+ const clearRecentUrls = () => {
1090
+ setRecentUrls([]);
1091
+ setShowRecentUrls(false);
1092
+ };
1093
+ const handleStorage = (event: StorageEvent) => {
1094
+ if (event.key === recentStorageKey || event.key === null) clearRecentUrls();
1095
+ };
1096
+ window.addEventListener(RECENT_ADD_HISTORY_CLEARED_EVENT, clearRecentUrls);
1097
+ window.addEventListener('storage', handleStorage);
1098
+ return () => {
1099
+ window.removeEventListener(RECENT_ADD_HISTORY_CLEARED_EVENT, clearRecentUrls);
1100
+ window.removeEventListener('storage', handleStorage);
1101
+ };
1102
+ }, []);
1103
+
1104
+ useEffect(() => {
1105
+ function closeRecentUrls(event: MouseEvent) {
1106
+ if (
1107
+ !urlInputRef.current?.contains(event.target as Node) &&
1108
+ !recentUrlsRef.current?.contains(event.target as Node)
1109
+ ) {
1110
+ setShowRecentUrls(false);
1111
+ }
1112
+ }
1113
+ document.addEventListener('mousedown', closeRecentUrls);
1114
+ return () => document.removeEventListener('mousedown', closeRecentUrls);
1115
+ }, []);
1116
+
1117
+ function saveRecentUrls(entries: Array<string | RecentMediaUrl>) {
1118
+ const normalized = entries
1119
+ .map((entry): RecentMediaUrl | null => {
1120
+ const url = typeof entry === 'string' ? entry : entry.url;
1121
+ const title = typeof entry === 'string' ? '' : entry.title?.trim() || '';
1122
+ return url.trim() ? { url: url.trim(), ...(title ? { title } : {}) } : null;
1123
+ })
1124
+ .filter((entry): entry is RecentMediaUrl => Boolean(entry));
1125
+ if (normalized.length === 0) return;
1126
+ setRecentUrls((current) => {
1127
+ const titles = new Map(current.map((entry) => [entry.url, entry.title]));
1128
+ const next = [...normalized, ...current]
1129
+ .filter(
1130
+ (entry, index, values) => values.findIndex((value) => value.url === entry.url) === index,
1131
+ )
1132
+ .map((entry) => ({
1133
+ ...entry,
1134
+ ...(entry.title ? {} : titles.get(entry.url) ? { title: titles.get(entry.url) } : {}),
1135
+ }))
1136
+ .slice(0, MAX_RECENT_MEDIA_URLS);
1137
+ try {
1138
+ localStorage.setItem(recentStorageKey, JSON.stringify(next));
1139
+ } catch {
1140
+ // Private-mode storage or a full quota should not block imports.
1141
+ }
1142
+ return next;
1143
+ });
1144
+ }
1145
+
1146
+ function removeRecentUrl(url: string, event: React.MouseEvent<HTMLButtonElement>) {
1147
+ event.stopPropagation();
1148
+ setRecentUrls((current) => {
1149
+ const next = current.filter((item) => item.url !== url);
1150
+ try {
1151
+ localStorage.setItem(recentStorageKey, JSON.stringify(next));
1152
+ } catch {
1153
+ // Browser-local history is optional.
1154
+ }
1155
+ return next;
1156
+ });
1157
+ }
1158
+
1159
+ async function handleFiles(files: FileList | File[]) {
1160
+ const detectedType = getMediaType(Array.from(files)[0]);
1161
+ if (!detectedType) {
1162
+ toast.error('Choose an image, video, or audio file.');
1163
+ return;
1164
+ }
1165
+ const inspected = await Promise.all(
1166
+ Array.from(files)
1167
+ .filter((file) => getMediaType(file) === detectedType)
1168
+ .map(async (file): Promise<StagedMedia | null> => {
1169
+ const type = getMediaType(file);
1170
+ if (!type) return null;
1171
+ const id = Math.random().toString(36).slice(2);
1172
+ const preview =
1173
+ type === 'image' || type === 'audio' ? URL.createObjectURL(file) : undefined;
1174
+ const durationSecs = type === 'image' ? undefined : await readMediaDuration(file);
1175
+ return { id, file, type, preview, durationSecs };
1176
+ }),
1177
+ );
1178
+ const newFiles = inspected.filter((item): item is StagedMedia => item !== null);
1179
+ if (newFiles.length > 0) {
1180
+ if (detectedType === mediaType) {
1181
+ setStaged((prev) => [...prev, ...newFiles]);
1182
+ } else {
1183
+ globalStagedMedia[detectedType] = [...(globalStagedMedia[detectedType] || []), ...newFiles];
1184
+ onMediaTypeDetected(detectedType);
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ function removeFile(id: string) {
1190
+ setStaged((prev) => {
1191
+ const file = prev.find((f) => f.id === id);
1192
+ if (file?.preview) URL.revokeObjectURL(file.preview);
1193
+ return prev.filter((f) => f.id !== id);
1194
+ });
1195
+ }
1196
+
1197
+ function showErrorToast(err: unknown) {
1198
+ const msg = formatErrorMessage(err);
1199
+ const normalizedMessage = msg.toLowerCase();
1200
+ if (
1201
+ normalizedMessage.includes('video intelligence tool needs an update') ||
1202
+ normalizedMessage.includes('video intelligence tool not properly installed') ||
1203
+ normalizedMessage.includes('video & audio tool needs an update') ||
1204
+ normalizedMessage.includes('video & audio tool not properly installed') ||
1205
+ normalizedMessage.includes('yt-dlp is required for youtube urls')
1206
+ ) {
1207
+ toast.error('Update Video Intelligence to continue.', {
1208
+ description:
1209
+ 'Your installed tool is out of date. Update it, then try adding the media again.',
1210
+ duration: 10_000,
1211
+ action: {
1212
+ label: 'Update tool',
1213
+ onClick: () => void updateVideoIntelligenceTool(),
1214
+ },
1215
+ });
1216
+ return;
1217
+ }
1218
+ if (
1219
+ normalizedMessage.includes('api key') ||
1220
+ normalizedMessage.includes('configuration') ||
1221
+ normalizedMessage.includes('missing provider') ||
1222
+ normalizedMessage.includes('choose an audio provider')
1223
+ ) {
1224
+ toast.error(msg, {
1225
+ duration: 10000,
1226
+ action: {
1227
+ label: 'Go to Settings',
1228
+ onClick: () => router.push('/settings?section=tool-settings'),
1229
+ },
1230
+ });
1231
+ } else {
1232
+ toast.error(msg);
1233
+ }
1234
+ }
1235
+
1236
+ async function updateVideoIntelligenceTool() {
1237
+ const toastId = toast.loading('Updating Video Intelligence…');
1238
+ try {
1239
+ const query = serverId ? `&serverId=${encodeURIComponent(serverId)}` : '';
1240
+ const response = await fetch(
1241
+ workspaceUrl(`/api/marketplace/video-intelligence?force=true${query}`),
1242
+ {
1243
+ method: 'POST',
1244
+ },
1245
+ );
1246
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
1247
+ if (!response.ok) throw new Error(body?.error || 'Could not update Video Intelligence.');
1248
+ toast.success('Video Intelligence updated', {
1249
+ id: toastId,
1250
+ description: 'Try adding the media again.',
1251
+ });
1252
+ await onRefreshToolConfiguration();
1253
+ } catch (error) {
1254
+ toast.error(error instanceof Error ? error.message : 'Could not update Video Intelligence.', {
1255
+ id: toastId,
1256
+ });
1257
+ }
1258
+ }
1259
+
1260
+ function ensureAudioConfiguration(type = mediaType): boolean {
1261
+ if (type === 'image') return true;
1262
+ if (toolLoading) {
1263
+ toast.message('Checking Video Intelligence…', {
1264
+ description: 'Please wait a moment, then try again.',
1265
+ });
1266
+ return false;
1267
+ }
1268
+ const indexingToolInstalled = type === 'video' ? videoToolInstalled : audioToolInstalled;
1269
+ if (!indexingToolInstalled) {
1270
+ onToolRequired(type);
1271
+ return false;
1272
+ }
1273
+ if (type === 'video' || audioConfigured) return true;
1274
+ toast.error('Set up an audio provider to index audio.', {
1275
+ description: 'Select a provider and add its API key in Installed Tools.',
1276
+ duration: 10_000,
1277
+ action: { label: 'Set up audio', onClick: onConfigureAudio },
1278
+ });
1279
+ return false;
1280
+ }
1281
+
1282
+ function ensureVideoIndexingCapacity(type: 'image' | 'video' | 'audio'): boolean {
1283
+ if (type !== 'video' || !videoIndexingAtCapacity) return true;
1284
+ toast.error('A video is already being indexed.', {
1285
+ description: `Your plan allows ${videoConcurrencyLimit} video indexing job${
1286
+ videoConcurrencyLimit === 1 ? '' : 's'
1287
+ } at a time. Stop the current video or wait for it to finish.`,
1288
+ });
1289
+ return false;
1290
+ }
1291
+
1292
+ async function processAssets(assetIds: string[], toolInputs?: Record<string, unknown>) {
1293
+ const PROCESS_BATCH_SIZE = 4;
1294
+ for (let i = 0; i < assetIds.length; i += PROCESS_BATCH_SIZE) {
1295
+ const batch = assetIds.slice(i, i + PROCESS_BATCH_SIZE);
1296
+ const res = await fetch(workspaceUrl('/api/media/process'), {
1297
+ method: 'POST',
1298
+ headers: { 'Content-Type': 'application/json' },
1299
+ body: JSON.stringify({ assetIds: batch, serverId, toolInputs }),
1300
+ });
1301
+
1302
+ if (!res.ok) {
1303
+ let err;
1304
+ try {
1305
+ err = await res.json();
1306
+ } catch {
1307
+ err = { error: 'Media processing failed' };
1308
+ }
1309
+ throw new Error(err.error ?? 'Media processing failed');
1310
+ }
1311
+ }
1312
+ await onMutate();
1313
+ onProcessingStarted?.();
1314
+ }
1315
+
1316
+ function parsedUrls() {
1317
+ return urlsText
1318
+ .split(/[\n,]/)
1319
+ .map((url) => url.trim())
1320
+ .filter(Boolean)
1321
+ .slice(0, 10);
1322
+ }
1323
+
1324
+ function mediaTypeFromUrl(url: string): 'image' | 'video' | 'audio' {
1325
+ const path = url.split('?')[0].toLowerCase();
1326
+ if (/\.(png|jpe?g|webp|gif|svg|avif)$/.test(path)) return 'image';
1327
+ if (/\.(mp3|wav|ogg|m4a|flac|aac)$/.test(path)) return 'audio';
1328
+ return 'video';
1329
+ }
1330
+
1331
+ async function reviewRemoteEstimate() {
1332
+ const urls = parsedUrls();
1333
+ if (urls.length === 0) return;
1334
+ const type = mediaTypeFromUrl(urls[0]);
1335
+ setRemoteType(type);
1336
+ onMediaTypeDetected(type);
1337
+ if (!ensureAudioConfiguration(type)) return;
1338
+ setCheckingUrls(true);
1339
+ try {
1340
+ if (type === 'image') {
1341
+ const estimates = urls.map((originalUrl) => ({
1342
+ originalUrl,
1343
+ mediaType: 'unknown' as const,
1344
+ }));
1345
+ setRemoteEstimates(estimates);
1346
+ saveRecentUrls(estimates.map(({ originalUrl }) => ({ url: originalUrl })));
1347
+ return;
1348
+ }
1349
+ const res = await fetch(mediaApiUrl, {
1350
+ method: 'POST',
1351
+ headers: { 'Content-Type': 'application/json' },
1352
+ body: JSON.stringify({ urls, estimateOnly: true, mediaType: type }),
1353
+ });
1354
+ const data = await res.json();
1355
+ if (!res.ok) throw new Error(data.error ?? 'Could not inspect media URLs');
1356
+ const estimates = data.estimates as RemoteEstimate[];
1357
+ setRemoteEstimates(estimates);
1358
+ saveRecentUrls(
1359
+ estimates.map((estimate) => ({ url: estimate.originalUrl, title: estimate.title })),
1360
+ );
1361
+ } catch (err) {
1362
+ showErrorToast(err);
1363
+ } finally {
1364
+ setCheckingUrls(false);
1365
+ }
1366
+ }
1367
+
1368
+ async function importRemoteUrls(
1369
+ ignorePlaylist = false,
1370
+ toolInputs?: Record<string, unknown>,
1371
+ ): Promise<boolean> {
1372
+ let urls = parsedUrls();
1373
+ if (urls.length === 0 || !remoteEstimates) return false;
1374
+ if (!ensureAudioConfiguration(remoteType)) return false;
1375
+
1376
+ urls = selectedRemoteUrls(urls, remoteEstimates, ignorePlaylist);
1377
+
1378
+ setImportingUrls(true);
1379
+ try {
1380
+ const surfaceValues = indexingSurface ? toolInputs?.[indexingSurface.toolId] : undefined;
1381
+ const indexingInstructions =
1382
+ surfaceValues &&
1383
+ typeof surfaceValues === 'object' &&
1384
+ typeof (surfaceValues as { goal?: unknown }).goal === 'string'
1385
+ ? (surfaceValues as { goal: string }).goal.trim()
1386
+ : undefined;
1387
+ const res = await fetch(mediaApiUrl, {
1388
+ method: 'POST',
1389
+ headers: { 'Content-Type': 'application/json' },
1390
+ body: JSON.stringify({
1391
+ urls,
1392
+ mediaType: remoteType,
1393
+ groupId: groupIdRef.current,
1394
+ toolInputs,
1395
+ ...(indexingInstructions ? { indexingInstructions } : {}),
1396
+ }),
1397
+ });
1398
+ const data = await res.json();
1399
+ if (!res.ok) throw new Error(data.error ?? 'Media import failed');
1400
+ const titlesByUrl = new Map(
1401
+ remoteEstimates.map((estimate) => [estimate.originalUrl, estimate.title] as const),
1402
+ );
1403
+ saveRecentUrls(urls.map((url) => ({ url, title: titlesByUrl.get(url) })));
1404
+ const ids = (data.assets as MediaAsset[]).map((asset) => asset.id);
1405
+ toast.success('Media import started', {
1406
+ description: 'Queued for download and indexing. You can safely leave in the meantime.',
1407
+ duration: 8_000,
1408
+ });
1409
+ await processAssets(ids);
1410
+ setUrlsText('');
1411
+ setRemoteEstimates(null);
1412
+ setProgress(null);
1413
+ onUploadComplete();
1414
+ return true;
1415
+ } catch (err) {
1416
+ showErrorToast(err);
1417
+ setProgress(null);
1418
+ return false;
1419
+ } finally {
1420
+ setImportingUrls(false);
1421
+ }
1422
+ }
1423
+
1424
+ async function uploadAll() {
1425
+ if (staged.length === 0) return;
1426
+ if (!ensureAudioConfiguration()) return;
1427
+ if (!ensureVideoIndexingCapacity(mediaType)) return;
1428
+ const destinationGroupId = groupIdRef.current;
1429
+ setUploading(true);
1430
+
1431
+ const BATCH_SIZE = 5;
1432
+ let uploaded = 0;
1433
+ const uploadedAssetIds: string[] = [];
1434
+ const total = staged.length;
1435
+ setProgress({ message: 'Starting upload...', current: 0, total });
1436
+
1437
+ try {
1438
+ for (let i = 0; i < staged.length; i += BATCH_SIZE) {
1439
+ setProgress({ message: 'Uploading files...', current: uploaded, total });
1440
+ const batch = staged.slice(i, i + BATCH_SIZE);
1441
+ const formData = new FormData();
1442
+ batch.forEach((item) => formData.append('file', item.file));
1443
+ formData.append(
1444
+ 'mediaDurations',
1445
+ JSON.stringify(batch.map((item) => item.durationSecs ?? null)),
1446
+ );
1447
+ const goal =
1448
+ typeof toolIndexingValues.goal === 'string' ? toolIndexingValues.goal.trim() : '';
1449
+ if (goal) {
1450
+ formData.append('indexingInstructions', goal);
1451
+ }
1452
+ formData.append('indexingQuality', '50');
1453
+ if (indexingSurface) {
1454
+ formData.append(
1455
+ 'toolInputs',
1456
+ JSON.stringify({
1457
+ [indexingSurface.toolId]: toolIndexingValues,
1458
+ }),
1459
+ );
1460
+ }
1461
+ formData.append('groupId', destinationGroupId);
1462
+
1463
+ const res = await fetch(mediaApiUrl, {
1464
+ method: 'POST',
1465
+ body: formData,
1466
+ });
1467
+
1468
+ const responseData = await res.json();
1469
+ if (!res.ok) {
1470
+ const err = responseData;
1471
+ throw new Error(err.error ?? 'Upload failed');
1472
+ }
1473
+ uploadedAssetIds.push(
1474
+ ...(responseData.assets as MediaAsset[]).map((asset: MediaAsset) => asset.id),
1475
+ );
1476
+
1477
+ uploaded += batch.length;
1478
+ setProgress({ message: 'Uploading files...', current: uploaded, total });
1479
+ }
1480
+
1481
+ if (uploadedAssetIds.length > 0) {
1482
+ setProgress({ message: 'Queueing files for indexing...', current: 0, total: uploaded });
1483
+ await processAssets(uploadedAssetIds);
1484
+ }
1485
+ const mediaLabel =
1486
+ mediaType === 'video' ? 'Video' : mediaType === 'audio' ? 'Audio' : 'Image';
1487
+ toast.success(`${mediaLabel} indexing started`, {
1488
+ description:
1489
+ mediaType === 'image'
1490
+ ? 'You can safely leave while we make it searchable.'
1491
+ : `Expected time: about ${formatEstimateRange(
1492
+ stagedEstimate,
1493
+ )}. You can safely leave while we index it.`,
1494
+ duration: 8_000,
1495
+ });
1496
+ setProgress(null);
1497
+ setIndexingBriefOpen(false);
1498
+
1499
+ staged.forEach((f) => {
1500
+ if (f.preview) URL.revokeObjectURL(f.preview);
1501
+ });
1502
+ globalStagedMedia[mediaType] = [];
1503
+ setStaged([]);
1504
+ setUploading(false);
1505
+
1506
+ onUploadComplete();
1507
+ } catch (err) {
1508
+ showErrorToast(err);
1509
+ setUploading(false);
1510
+ setProgress(null);
1511
+ }
1512
+ }
1513
+
1514
+ function beginUpload() {
1515
+ if (!ensureVideoIndexingCapacity(mediaType)) return;
1516
+ if (mediaType === 'video' && !indexingSurface) {
1517
+ onRefreshToolConfiguration();
1518
+ toast.message('Loading video indexing options…', {
1519
+ description:
1520
+ 'Wait a moment for the installed tool configuration, then try Add media again.',
1521
+ });
1522
+ return;
1523
+ }
1524
+ if (indexingSurface) {
1525
+ setIndexingAssetIds(null);
1526
+ setIndexingBriefOpen(true);
1527
+ return;
1528
+ }
1529
+ void uploadAll();
1530
+ }
1531
+
1532
+ function beginUrlIndexing(ignorePlaylist = false) {
1533
+ if (!ensureVideoIndexingCapacity(remoteType)) return;
1534
+ if (remoteType === 'video' && !indexingSurface) {
1535
+ onRefreshToolConfiguration();
1536
+ toast.message('Loading video indexing options…', {
1537
+ description:
1538
+ 'Wait a moment for the installed tool configuration, then try Add media again.',
1539
+ });
1540
+ return;
1541
+ }
1542
+ if (remoteType === 'video' && indexingSurface?.appliesTo.includes(remoteType)) {
1543
+ setIndexingAssetIds(null);
1544
+ setUrlIndexingRequest({ ignorePlaylist });
1545
+ setIndexingBriefOpen(true);
1546
+ return;
1547
+ }
1548
+ void importRemoteUrls(ignorePlaylist);
1549
+ }
1550
+
1551
+ async function handleDelete(assetId: string) {
1552
+ try {
1553
+ const query = new URLSearchParams({ id: assetId, force: 'true' });
1554
+ if (serverId) query.set('serverId', serverId);
1555
+ const response = await fetch(workspaceUrl(`/api/media?${query.toString()}`), {
1556
+ method: 'DELETE',
1557
+ });
1558
+ if (!response.ok) {
1559
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
1560
+ throw new Error(body?.error || 'Failed to delete media.');
1561
+ }
1562
+ toast.success('File removed');
1563
+ onMutate();
1564
+ onVideoJobCancelled();
1565
+ } catch (error) {
1566
+ showErrorToast(error);
1567
+ }
1568
+ }
1569
+
1570
+ async function togglePause(asset: MediaAsset) {
1571
+ try {
1572
+ const res = await fetch(workspaceUrl('/api/media/process'), {
1573
+ method: 'POST',
1574
+ headers: { 'Content-Type': 'application/json' },
1575
+ body: JSON.stringify({
1576
+ action: asset.processingPaused ? 'resume' : 'pause',
1577
+ assetId: asset.id,
1578
+ serverId,
1579
+ }),
1580
+ });
1581
+ const body = await res.json();
1582
+ if (!res.ok) throw new Error(body.error || 'Could not update media indexing.');
1583
+ toast.success(asset.processingPaused ? 'Media indexing resumed.' : 'Media indexing paused.');
1584
+ await onMutate();
1585
+ } catch (error) {
1586
+ showErrorToast(error);
1587
+ }
1588
+ }
1589
+
1590
+ async function handleRetry(asset: MediaAsset) {
1591
+ if (asset.type === 'video' && indexingSurface) {
1592
+ setIndexingAssetIds([asset.id]);
1593
+ setIndexingBriefOpen(true);
1594
+ return;
1595
+ }
1596
+ setProgress({ message: 'Retrying media processing...', current: 0, total: 1 });
1597
+ try {
1598
+ await processAssets([asset.id]);
1599
+ toast.success(
1600
+ `${
1601
+ asset.type === 'video' ? 'Video' : asset.type === 'audio' ? 'Audio' : 'Image'
1602
+ } indexing started`,
1603
+ );
1604
+ onMutate();
1605
+ } catch (err) {
1606
+ showErrorToast(err);
1607
+ } finally {
1608
+ setProgress(null);
1609
+ }
1610
+ }
1611
+
1612
+ async function confirmExistingIndexing() {
1613
+ if (!indexingAssetIds?.length) return;
1614
+ setUploading(true);
1615
+ setProgress({
1616
+ message: 'Queueing media for indexing...',
1617
+ current: 0,
1618
+ total: indexingAssetIds.length,
1619
+ });
1620
+ try {
1621
+ await processAssets(
1622
+ indexingAssetIds,
1623
+ indexingSurface ? { [indexingSurface.toolId]: toolIndexingValues } : undefined,
1624
+ );
1625
+ toast.success('Video indexing started');
1626
+ setIndexingBriefOpen(false);
1627
+ setIndexingAssetIds(null);
1628
+ } catch (error) {
1629
+ showErrorToast(error);
1630
+ } finally {
1631
+ setUploading(false);
1632
+ setProgress(null);
1633
+ }
1634
+ }
1635
+
1636
+ async function confirmUrlIndexing() {
1637
+ if (!urlIndexingRequest) return;
1638
+ const imported = await importRemoteUrls(
1639
+ urlIndexingRequest.ignorePlaylist,
1640
+ indexingSurface ? { [indexingSurface.toolId]: toolIndexingValues } : undefined,
1641
+ );
1642
+ if (imported) {
1643
+ setIndexingBriefOpen(false);
1644
+ setUrlIndexingRequest(null);
1645
+ }
1646
+ }
1647
+
1648
+ const stagedDuration = staged.reduce((total, item) => total + (item.durationSecs ?? 0), 0);
1649
+ const remoteDuration = selectedRemoteDuration(remoteEstimates, false);
1650
+ const indexingDuration = indexingAssetIds
1651
+ ? assets
1652
+ .filter((asset) => indexingAssetIds.includes(asset.id))
1653
+ .reduce((total, asset) => total + (asset.durationSecs ?? 0), 0)
1654
+ : urlIndexingRequest
1655
+ ? selectedRemoteDuration(remoteEstimates, urlIndexingRequest.ignorePlaylist)
1656
+ : stagedDuration;
1657
+ const selectedIndexingMode =
1658
+ typeof toolIndexingValues.indexingMode === 'string'
1659
+ ? toolIndexingValues.indexingMode
1660
+ : 'balanced';
1661
+ const stagedEstimate = estimateMedia(stagedDuration, mediaType === 'video', selectedIndexingMode);
1662
+ const remoteEstimate = estimateMedia(remoteDuration, mediaType === 'video', selectedIndexingMode);
1663
+ const activeAssets = assets.filter(isActiveJob);
1664
+ const libraryPageSize = 12;
1665
+ const libraryAssets = assets.filter((asset) => {
1666
+ const matchesType = asset.type === TAB_TO_TYPE[libraryTab];
1667
+ const matchesSearch = asset.fileName.toLowerCase().includes(librarySearch.trim().toLowerCase());
1668
+ return matchesType && matchesSearch;
1669
+ });
1670
+ const libraryTotalPages = Math.max(1, Math.ceil(libraryAssets.length / libraryPageSize));
1671
+ const safeLibraryPage = Math.min(libraryPage, libraryTotalPages - 1);
1672
+ const paginatedLibraryAssets = libraryAssets.slice(
1673
+ safeLibraryPage * libraryPageSize,
1674
+ (safeLibraryPage + 1) * libraryPageSize,
1675
+ );
1676
+ const libraryStart = libraryAssets.length === 0 ? 0 : safeLibraryPage * libraryPageSize + 1;
1677
+ const libraryEnd = Math.min((safeLibraryPage + 1) * libraryPageSize, libraryAssets.length);
1678
+ const hasUrls = parsedUrls().length > 0;
1679
+ const videoConfigurationLoading = mediaType === 'video' && !indexingSurface;
1680
+
1681
+ function submitUrlMedia() {
1682
+ const totalEntries =
1683
+ remoteEstimates?.reduce((sum: number, item: any) => sum + (item.entryCount ?? 1), 0) ?? 0;
1684
+ if (totalEntries > 1) {
1685
+ setPlaylistAlertOpen(true);
1686
+ } else {
1687
+ beginUrlIndexing();
1688
+ }
1689
+ }
1690
+
1691
+ useEffect(() => {
1692
+ const fromUrl = entryTab === 'url';
1693
+ onActionChange?.({
1694
+ label: fromUrl ? (remoteEstimates ? 'Add media' : 'Check URL') : 'Add media',
1695
+ onClick: () => {
1696
+ if (fromUrl) {
1697
+ if (remoteEstimates) submitUrlMedia();
1698
+ else void reviewRemoteEstimate();
1699
+ } else {
1700
+ beginUpload();
1701
+ }
1702
+ },
1703
+ disabled: fromUrl
1704
+ ? !hasUrls ||
1705
+ (remoteEstimates !== null &&
1706
+ remoteType === 'video' &&
1707
+ (!indexingSurface || videoIndexingAtCapacity))
1708
+ : staged.length === 0 ||
1709
+ videoConfigurationLoading ||
1710
+ (mediaType === 'video' && videoIndexingAtCapacity),
1711
+ loading: fromUrl ? checkingUrls || importingUrls : uploading,
1712
+ });
1713
+ return () => onActionChange?.(null);
1714
+ }, [
1715
+ entryTab,
1716
+ remoteEstimates,
1717
+ hasUrls,
1718
+ urlsText,
1719
+ checkingUrls,
1720
+ importingUrls,
1721
+ staged,
1722
+ uploading,
1723
+ mediaType,
1724
+ remoteType,
1725
+ groupId,
1726
+ indexingSurface,
1727
+ videoIndexingAtCapacity,
1728
+ onActionChange,
1729
+ ]);
1730
+
1731
+ return (
1732
+ <div className="space-y-4">
1733
+ <ToolIndexingDialog
1734
+ surface={indexingSurface ?? null}
1735
+ open={indexingBriefOpen}
1736
+ values={toolIndexingValues}
1737
+ submitting={uploading || importingUrls}
1738
+ sourceDurationSecs={indexingDuration || undefined}
1739
+ onOpenChange={(open) => {
1740
+ setIndexingBriefOpen(open);
1741
+ if (!open) {
1742
+ setIndexingAssetIds(null);
1743
+ setUrlIndexingRequest(null);
1744
+ }
1745
+ }}
1746
+ onChange={(key, value) =>
1747
+ setToolIndexingValues((current) => ({ ...current, [key]: value }))
1748
+ }
1749
+ onConfirm={() =>
1750
+ void (indexingAssetIds
1751
+ ? confirmExistingIndexing()
1752
+ : urlIndexingRequest
1753
+ ? confirmUrlIndexing()
1754
+ : uploadAll())
1755
+ }
1756
+ />
1757
+ {progress && (
1758
+ <div className="space-y-1.5 rounded-lg border border-border bg-muted/20 px-3 py-2.5">
1759
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
1760
+ <span>{progress.message || `Processing ${progress.current} of ${progress.total}`}</span>
1761
+ <span>{Math.round((progress.current / Math.max(progress.total, 1)) * 100)}%</span>
1762
+ </div>
1763
+ <Progress
1764
+ value={(progress.current / Math.max(progress.total, 1)) * 100}
1765
+ className="h-1.5"
1766
+ />
1767
+ </div>
1768
+ )}
1769
+
1770
+ {activeAssets.length > 0 ? (
1771
+ <ActiveIndexingList
1772
+ assets={activeAssets}
1773
+ onPause={togglePause}
1774
+ onRemove={setAssetToRemove}
1775
+ />
1776
+ ) : null}
1777
+
1778
+ {entryTab === 'url' ? (
1779
+ <div className="w-full">
1780
+ <div className="flex items-center gap-2">
1781
+ <div className="relative flex-1">
1782
+ <Input
1783
+ ref={urlInputRef}
1784
+ aria-label="Import media URL"
1785
+ autoComplete="off"
1786
+ value={urlsText}
1787
+ onChange={(event) => {
1788
+ setUrlsText(event.target.value);
1789
+ setRemoteEstimates(null);
1790
+ setShowRecentUrls(true);
1791
+ }}
1792
+ onFocus={() => setShowRecentUrls(true)}
1793
+ placeholder="Paste an image, video, or audio URL"
1794
+ className="h-10 bg-white pr-8 text-xs"
1795
+ />
1796
+ {showRecentUrls && recentUrls.length > 0 && (
1797
+ <div
1798
+ ref={recentUrlsRef}
1799
+ className="absolute top-full z-20 mt-1 w-full overflow-hidden rounded-md border border-border bg-popover"
1800
+ >
1801
+ <p className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
1802
+ Recent URLs
1803
+ </p>
1804
+ <div className="max-h-52 overflow-y-auto py-1">
1805
+ {recentUrls.map(({ url, title }) => (
1806
+ <div
1807
+ key={url}
1808
+ className="group flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted"
1809
+ onClick={() => {
1810
+ setUrlsText(url);
1811
+ setRemoteEstimates(null);
1812
+ setShowRecentUrls(false);
1813
+ }}
1814
+ >
1815
+ <Clock
1816
+ className="size-3 shrink-0 text-muted-foreground"
1817
+ aria-hidden="true"
1818
+ />
1819
+ <span className="min-w-0 flex-1">
1820
+ <span className="block truncate text-xs text-foreground">
1821
+ {title || url}
1822
+ </span>
1823
+ {title ? (
1824
+ <span className="block truncate text-[10px] text-muted-foreground">
1825
+ {url}
1826
+ </span>
1827
+ ) : null}
1828
+ </span>
1829
+ <button
1830
+ type="button"
1831
+ aria-label={`Remove ${url} from recent URLs`}
1832
+ className="rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 focus:opacity-100"
1833
+ onClick={(event) => removeRecentUrl(url, event)}
1834
+ >
1835
+ <X className="size-3" aria-hidden="true" />
1836
+ </button>
1837
+ </div>
1838
+ ))}
1839
+ </div>
1840
+ </div>
1841
+ )}
1842
+ </div>
1843
+ {!onActionChange && (
1844
+ <Button
1845
+ type="button"
1846
+ variant="outline"
1847
+ size="sm"
1848
+ className="h-9 text-xs"
1849
+ disabled={checkingUrls || importingUrls || parsedUrls().length === 0}
1850
+ onClick={reviewRemoteEstimate}
1851
+ >
1852
+ {checkingUrls ? (
1853
+ <Loader2 className="mr-1.5 size-3 animate-spin" />
1854
+ ) : (
1855
+ <Clock className="mr-1.5 size-3" />
1856
+ )}
1857
+ Review
1858
+ </Button>
1859
+ )}
1860
+ {!onActionChange && (
1861
+ <Button
1862
+ type="button"
1863
+ size="sm"
1864
+ className="h-9 text-xs"
1865
+ disabled={!remoteEstimates || importingUrls}
1866
+ onClick={() => {
1867
+ const totalEntries =
1868
+ remoteEstimates?.reduce(
1869
+ (sum: number, item: any) => sum + (item.entryCount ?? 1),
1870
+ 0,
1871
+ ) ?? 0;
1872
+ if (totalEntries > 1) {
1873
+ setPlaylistAlertOpen(true);
1874
+ } else {
1875
+ beginUrlIndexing();
1876
+ }
1877
+ }}
1878
+ >
1879
+ {importingUrls ? (
1880
+ <Loader2 className="mr-1.5 size-3 animate-spin" />
1881
+ ) : (
1882
+ <Sparkles className="mr-1.5 size-3" />
1883
+ )}
1884
+ Import
1885
+ </Button>
1886
+ )}
1887
+ </div>
1888
+
1889
+ <AlertDialog open={playlistAlertOpen} onOpenChange={setPlaylistAlertOpen}>
1890
+ <AlertDialogContent>
1891
+ <AlertDialogHeader>
1892
+ <AlertDialogTitle>Playlist Detected</AlertDialogTitle>
1893
+ <AlertDialogDescription>
1894
+ You entered a URL that points to a playlist. Do you want to download and index the
1895
+ entire playlist, or just the single video?
1896
+ </AlertDialogDescription>
1897
+ </AlertDialogHeader>
1898
+ <AlertDialogFooter>
1899
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
1900
+ <Button
1901
+ variant="outline"
1902
+ onClick={() => {
1903
+ setPlaylistAlertOpen(false);
1904
+ beginUrlIndexing(true);
1905
+ }}
1906
+ >
1907
+ Single Video
1908
+ </Button>
1909
+ <AlertDialogAction
1910
+ onClick={() => {
1911
+ setPlaylistAlertOpen(false);
1912
+ beginUrlIndexing(false);
1913
+ }}
1914
+ >
1915
+ Entire Playlist
1916
+ </AlertDialogAction>
1917
+ </AlertDialogFooter>
1918
+ </AlertDialogContent>
1919
+ </AlertDialog>
1920
+ <p className="mt-2 pl-1 text-[11px] leading-relaxed text-muted-foreground">
1921
+ Direct media links, YouTube videos, or playlists. Add up to 10 URLs separated by commas.
1922
+ </p>
1923
+ {remoteEstimates
1924
+ ? (() => {
1925
+ const totalEntries = remoteEstimates.reduce(
1926
+ (sum: number, item: any) => sum + (item.entryCount ?? 1),
1927
+ 0,
1928
+ );
1929
+ const isPlaylist = totalEntries > 1;
1930
+ return (
1931
+ <div className="mt-4 flex flex-col gap-4 border-t border-border/70 pt-4">
1932
+ {/* <div className="grid gap-2 sm:grid-cols-3">
1933
+ <EstimateMetric
1934
+ label="Media"
1935
+ value={`${totalEntries} item(s) · ${
1936
+ remoteDuration ? formatDuration(remoteDuration) : 'duration unavailable'
1937
+ }`}
1938
+ />
1939
+ <EstimateMetric
1940
+ label="Expected time"
1941
+ value={
1942
+ remoteDuration
1943
+ ? `~${remoteEstimate.processingMinutes} min`
1944
+ : 'Calculated during import'
1945
+ }
1946
+ />
1947
+ <EstimateMetric
1948
+ label="Estimated API cost"
1949
+ value={
1950
+ remoteDuration
1951
+ ? `~$${remoteEstimate.transcriptionCost.toFixed(2)} +`
1952
+ : 'Model-dependent'
1953
+ }
1954
+ />
1955
+ </div> */}
1956
+
1957
+ {/* Playlist warning moved to AlertDialog */}
1958
+
1959
+ {(mediaType === 'audio'
1960
+ ? remoteEstimates.slice(0, 1)
1961
+ : remoteEstimates.filter((est: any) => est.mediaType === 'video').slice(0, 1)
1962
+ ).map((est: any, i: number) => {
1963
+ if (mediaType === 'audio') {
1964
+ return (
1965
+ <div
1966
+ key={i}
1967
+ className="rounded-xl border border-border/70 bg-muted/20 p-3"
1968
+ >
1969
+ <p className="mb-2 truncate text-[11px] font-medium text-foreground">
1970
+ Preview before importing
1971
+ </p>
1972
+ <audio
1973
+ controls
1974
+ preload="metadata"
1975
+ src={est.originalUrl}
1976
+ className="h-9 w-full"
1977
+ />
1978
+ </div>
1979
+ );
1980
+ }
1981
+ if (est.isYouTube) {
1982
+ return <YouTubeUrlPreview key={i} estimate={est} />;
1983
+ } else {
1984
+ return (
1985
+ <video
1986
+ key={i}
1987
+ src={est.originalUrl}
1988
+ controls
1989
+ className="w-full max-w-sm rounded-md aspect-video bg-muted object-cover 35 border border-border"
1990
+ />
1991
+ );
1992
+ }
1993
+ })}
1994
+ </div>
1995
+ );
1996
+ })()
1997
+ : null}
1998
+ </div>
1999
+ ) : (
2000
+ <div
2001
+ onDragOver={(e) => {
2002
+ e.preventDefault();
2003
+ setDragging(true);
2004
+ }}
2005
+ onDragLeave={(e) => {
2006
+ e.preventDefault();
2007
+ setDragging(false);
2008
+ }}
2009
+ onDrop={(e) => {
2010
+ e.preventDefault();
2011
+ setDragging(false);
2012
+ if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
2013
+ }}
2014
+ onClick={() => inputRef.current?.click()}
2015
+ className={cn(
2016
+ 'flex flex-col items-center justify-center gap-2.5 rounded-xl border-2 border-dashed px-6 py-10 cursor-pointer transition-colors',
2017
+ dragging
2018
+ ? 'border-primary bg-primary/5'
2019
+ : 'border-border hover:border-primary/40 hover:bg-muted/20',
2020
+ )}
2021
+ >
2022
+ <Upload className="size-5 text-muted-foreground" />
2023
+ <div className="text-center">
2024
+ <p className="text-[13px] font-medium text-foreground">
2025
+ Drop media files here or click to browse
2026
+ </p>
2027
+ <p className="mt-0.5 text-[11px] text-muted-foreground">Images, Video, Audio</p>
2028
+ </div>
2029
+ <input
2030
+ ref={inputRef}
2031
+ type="file"
2032
+ multiple
2033
+ accept={accept}
2034
+ className="hidden"
2035
+ onChange={(e) => e.target.files && handleFiles(e.target.files)}
2036
+ />
2037
+ </div>
2038
+ )}
2039
+
2040
+ {/* Staged files */}
2041
+ {staged.length > 0 && (
2042
+ <div className="space-y-3">
2043
+ {mediaType !== 'image' && stagedDuration > 0 ? (
2044
+ <div className="grid gap-2 rounded-lg border border-border bg-muted/15 px-3 py-2.5 sm:grid-cols-3">
2045
+ <EstimateMetric label="Duration" value={formatDuration(stagedDuration)} />
2046
+ <EstimateMetric
2047
+ label="Expected time"
2048
+ value={`~${formatEstimateRange(stagedEstimate)}`}
2049
+ />
2050
+ <EstimateMetric
2051
+ label="Estimated API cost"
2052
+ value={`~$${stagedEstimate.transcriptionCost.toFixed(2)}${
2053
+ mediaType === 'video'
2054
+ ? ` + up to ${stagedEstimate.visualScenes} vision calls`
2055
+ : ''
2056
+ }`}
2057
+ />
2058
+ </div>
2059
+ ) : null}
2060
+
2061
+ {indexingSurface && staged.length > 0 ? (
2062
+ <button
2063
+ type="button"
2064
+ onClick={() => setIndexingBriefOpen(true)}
2065
+ className="flex w-full items-center justify-between rounded-lg border border-border bg-muted/10 px-3 py-3 text-left hover:bg-muted/30"
2066
+ >
2067
+ <span>
2068
+ <span className="block text-xs font-medium">{indexingSurface.title}</span>
2069
+ <span className="mt-0.5 block text-[11px] text-muted-foreground">
2070
+ {typeof toolIndexingValues.goal === 'string' && toolIndexingValues.goal.trim()
2071
+ ? 'Custom indexing instructions are ready.'
2072
+ : (indexingSurface.description ?? 'Configure this tool before indexing.')}
2073
+ </span>
2074
+ </span>
2075
+ <Sparkles className="size-4 text-primary" />
2076
+ </button>
2077
+ ) : null}
2078
+
2079
+ <div className="flex items-center justify-between">
2080
+ <span className="text-[13px] font-medium text-foreground">
2081
+ {staged.length} file{staged.length !== 1 ? 's' : ''} ready
2082
+ </span>
2083
+ <div className="flex items-center gap-3">
2084
+ <button
2085
+ type="button"
2086
+ onClick={() => {
2087
+ staged.forEach((f) => {
2088
+ if (f.preview) URL.revokeObjectURL(f.preview);
2089
+ });
2090
+ setStaged([]);
2091
+ }}
2092
+ className="text-[11px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
2093
+ >
2094
+ Clear All
2095
+ </button>
2096
+ {!onActionChange && (
2097
+ <Button
2098
+ onClick={beginUpload}
2099
+ disabled={uploading || staged.length === 0}
2100
+ size="sm"
2101
+ className="h-7 text-xs px-3"
2102
+ >
2103
+ {uploading ? (
2104
+ <Loader2 className="size-3 animate-spin mr-1.5" />
2105
+ ) : (
2106
+ <FileUp className="size-3 mr-1.5" />
2107
+ )}
2108
+ Upload {staged.length} file{staged.length !== 1 ? 's' : ''}
2109
+ </Button>
2110
+ )}
2111
+ </div>
2112
+ </div>
2113
+
2114
+ <div className="max-h-87.5 overflow-y-auto pr-1">
2115
+ {/* Grid preview for staged images */}
2116
+ {mediaType === 'image' ? (
2117
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
2118
+ {staged.slice(0, 8).map((item) => (
2119
+ <div
2120
+ key={item.id}
2121
+ className="group relative aspect-4/3 rounded-xl overflow-hidden bg-muted/30 border border-border/50"
2122
+ >
2123
+ {item.preview && (
2124
+ <img
2125
+ src={item.preview}
2126
+ alt={item.file.name}
2127
+ className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
2128
+ />
2129
+ )}
2130
+ <button
2131
+ type="button"
2132
+ onClick={(e) => {
2133
+ e.stopPropagation();
2134
+ removeFile(item.id);
2135
+ }}
2136
+ className="absolute right-2 top-2 rounded-full bg-black/50 p-1.5 text-white opacity-0 group-hover:opacity-100 transition-opacity"
2137
+ >
2138
+ <X className="size-3.5" />
2139
+ </button>
2140
+ </div>
2141
+ ))}
2142
+ {staged.length > 8 && (
2143
+ <div className="flex items-center justify-center rounded-xl bg-muted/30 border border-border/50 text-muted-foreground text-sm font-medium aspect-4/3">
2144
+ +{staged.length - 8} more
2145
+ </div>
2146
+ )}
2147
+ </div>
2148
+ ) : (
2149
+ <div className="flex flex-col gap-1.5">
2150
+ {staged.slice(0, 10).map((item) => {
2151
+ const Icon = mediaType === 'video' ? Video : AudioLines;
2152
+ return (
2153
+ <div
2154
+ key={item.id}
2155
+ className="flex items-center gap-3 rounded-lg border border-border px-3 py-2 group"
2156
+ >
2157
+ <div className="flex size-8 items-center justify-center rounded-md bg-muted">
2158
+ <Icon className="size-3.5 text-muted-foreground" />
2159
+ </div>
2160
+ <div className="min-w-0 flex-1">
2161
+ <p className="truncate text-[12px] font-medium text-foreground">
2162
+ {item.file.name}
2163
+ </p>
2164
+ <p className="text-[10px] text-muted-foreground">
2165
+ {formatSize(item.file.size)}
2166
+ </p>
2167
+ </div>
2168
+ {mediaType === 'audio' && item.preview ? (
2169
+ <audio
2170
+ aria-label={`Preview ${item.file.name}`}
2171
+ controls
2172
+ preload="metadata"
2173
+ src={item.preview}
2174
+ className="h-8 max-w-44"
2175
+ onClick={(event) => event.stopPropagation()}
2176
+ />
2177
+ ) : null}
2178
+ <button
2179
+ type="button"
2180
+ onClick={() => removeFile(item.id)}
2181
+ className="opacity-0 group-hover:opacity-100 p-1 text-muted-foreground hover:text-foreground transition-all"
2182
+ >
2183
+ <X className="size-3" />
2184
+ </button>
2185
+ </div>
2186
+ );
2187
+ })}
2188
+ {staged.length > 10 && (
2189
+ <div className="flex items-center justify-center py-2 text-xs text-muted-foreground border border-dashed rounded-lg bg-muted/10">
2190
+ +{staged.length - 10} more files
2191
+ </div>
2192
+ )}
2193
+ </div>
2194
+ )}
2195
+ </div>
2196
+ </div>
2197
+ )}
2198
+
2199
+ {/* Gallery of uploaded assets */}
2200
+ {isLoading && assets.length === 0 ? (
2201
+ <div className="flex justify-center py-10">
2202
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
2203
+ </div>
2204
+ ) : assets.length > 0 ? (
2205
+ <div className="space-y-2">
2206
+ <div className="flex items-center justify-between py-2 px-3 rounded-lg border border-border bg-muted/20">
2207
+ <h4 className="text-[13px] font-medium text-foreground">
2208
+ {assets.length} Uploaded Media
2209
+ </h4>
2210
+ <Dialog>
2211
+ <DialogTrigger
2212
+ render={
2213
+ <Button variant="outline" size="sm" className="h-7 text-xs px-3">
2214
+ View Uploads
2215
+ </Button>
2216
+ }
2217
+ />
2218
+ <DialogContent className="h-[calc(100vh-2rem)] max-h-190 w-[calc(100vw-2rem)] sm:max-w-5xl lg:max-w-6xl grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 bg-background">
2219
+ <DialogHeader className="gap-3 px-5 py-4 sm:px-6">
2220
+ <div>
2221
+ <DialogTitle>Uploaded media</DialogTitle>
2222
+ <p className="mt-1 text-xs text-muted-foreground">
2223
+ Browse, search, and manage every media upload in one place.
2224
+ </p>
2225
+ </div>
2226
+ <div className="relative w-full">
2227
+ <Search
2228
+ aria-hidden="true"
2229
+ className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
2230
+ />
2231
+ <Input
2232
+ aria-label="Search uploaded media"
2233
+ value={librarySearch}
2234
+ onChange={(event) => {
2235
+ setLibrarySearch(event.target.value);
2236
+ setLibraryPage(0);
2237
+ }}
2238
+ placeholder="Search uploads"
2239
+ className="h-9 w-full pl-9 text-sm bg-card/90"
2240
+ />
2241
+ </div>
2242
+ <div className="flex flex-wrap items-center gap-2">
2243
+ {SUB_TABS.map((subTab) => {
2244
+ const Icon = subTab.icon;
2245
+ const isActive = libraryTab === subTab.id;
2246
+ const count = assets.filter(
2247
+ (asset) => asset.type === TAB_TO_TYPE[subTab.id],
2248
+ ).length;
2249
+ return (
2250
+ <button
2251
+ key={subTab.id}
2252
+ type="button"
2253
+ onClick={() => {
2254
+ setLibraryTab(subTab.id);
2255
+ setLibraryPage(0);
2256
+ }}
2257
+ className={cn(
2258
+ 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors',
2259
+ isActive
2260
+ ? 'bg-muted text-foreground'
2261
+ : 'border border-border bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground',
2262
+ )}
2263
+ >
2264
+ <Icon className="size-3.5" strokeWidth={isActive ? 2 : 1.75} />
2265
+ {subTab.label}
2266
+ <span className="text-[10px] tabular-nums text-muted-foreground">
2267
+ {count}
2268
+ </span>
2269
+ </button>
2270
+ );
2271
+ })}
2272
+ </div>
2273
+ </DialogHeader>
2274
+ <div className="min-h-0 overflow-y-auto px-5 py-4 sm:px-6">
2275
+ {libraryAssets.length === 0 ? (
2276
+ <div className="flex min-h-48 flex-col items-center justify-center rounded-lg bg-muted/20 px-6 text-center">
2277
+ <Search className="size-4 text-muted-foreground" />
2278
+ <p className="mt-3 text-sm font-medium text-foreground">No uploads found</p>
2279
+ <p className="mt-1 text-xs text-muted-foreground">
2280
+ {librarySearch
2281
+ ? `No ${SUB_TABS.find(
2282
+ (tab) => tab.id === libraryTab,
2283
+ )?.label.toLowerCase()} match “${librarySearch}”.`
2284
+ : `There are no ${SUB_TABS.find(
2285
+ (tab) => tab.id === libraryTab,
2286
+ )?.label.toLowerCase()} uploads yet.`}
2287
+ </p>
2288
+ </div>
2289
+ ) : libraryTab === 'images' ? (
2290
+ <ImageGallery
2291
+ assets={paginatedLibraryAssets}
2292
+ onDelete={(id) =>
2293
+ setAssetToRemove(assets.find((asset) => asset.id === id) ?? null)
2294
+ }
2295
+ serverId={serverId}
2296
+ />
2297
+ ) : (
2298
+ <FileList
2299
+ assets={paginatedLibraryAssets}
2300
+ mediaType={TAB_TO_TYPE[libraryTab] as 'video' | 'audio'}
2301
+ onDelete={(id) =>
2302
+ setAssetToRemove(assets.find((asset) => asset.id === id) ?? null)
2303
+ }
2304
+ onProcess={handleRetry}
2305
+ />
2306
+ )}
2307
+ </div>
2308
+ {libraryAssets.length > 0 ? (
2309
+ <div className="flex items-center justify-between border-t px-5 py-3 text-xs text-muted-foreground sm:px-6">
2310
+ <span className="tabular-nums">
2311
+ {libraryStart}–{libraryEnd} of {libraryAssets.length}
2312
+ </span>
2313
+ <div className="flex items-center gap-1.5">
2314
+ <Button
2315
+ type="button"
2316
+ variant="ghost"
2317
+ size="icon-sm"
2318
+ aria-label="Previous uploads page"
2319
+ disabled={safeLibraryPage === 0}
2320
+ onClick={() => setLibraryPage((page) => Math.max(0, page - 1))}
2321
+ >
2322
+ <ChevronLeft className="size-4" />
2323
+ </Button>
2324
+ <span className="min-w-12 text-center tabular-nums">
2325
+ {safeLibraryPage + 1} / {libraryTotalPages}
2326
+ </span>
2327
+ <Button
2328
+ type="button"
2329
+ variant="ghost"
2330
+ size="icon-sm"
2331
+ aria-label="Next uploads page"
2332
+ disabled={safeLibraryPage >= libraryTotalPages - 1}
2333
+ onClick={() =>
2334
+ setLibraryPage((page) => Math.min(libraryTotalPages - 1, page + 1))
2335
+ }
2336
+ >
2337
+ <ChevronRight className="size-4" />
2338
+ </Button>
2339
+ </div>
2340
+ </div>
2341
+ ) : null}
2342
+ </DialogContent>
2343
+ </Dialog>
2344
+ </div>
2345
+ </div>
2346
+ ) : staged.length === 0 ? (
2347
+ <div className="py-10 text-center">
2348
+ <p className="text-[13px] text-muted-foreground">No media files yet</p>
2349
+ </div>
2350
+ ) : null}
2351
+
2352
+ <AlertDialog
2353
+ open={Boolean(assetToRemove)}
2354
+ onOpenChange={(open) => !open && setAssetToRemove(null)}
2355
+ >
2356
+ <AlertDialogContent>
2357
+ <AlertDialogHeader>
2358
+ <AlertDialogTitle>Remove this {assetToRemove?.type ?? 'media'} file?</AlertDialogTitle>
2359
+ <AlertDialogDescription>
2360
+ {assetToRemove && isActiveJob(assetToRemove)
2361
+ ? 'This will stop its indexing job and permanently remove any progress already made.'
2362
+ : 'This permanently removes the file and its searchable data from your knowledge base.'}
2363
+ </AlertDialogDescription>
2364
+ </AlertDialogHeader>
2365
+ <AlertDialogFooter>
2366
+ <AlertDialogCancel>Keep file</AlertDialogCancel>
2367
+ <AlertDialogAction
2368
+ className="bg-destructive text-white hover:bg-destructive/90"
2369
+ onClick={() => {
2370
+ if (assetToRemove) void handleDelete(assetToRemove.id);
2371
+ setAssetToRemove(null);
2372
+ }}
2373
+ >
2374
+ Remove file
2375
+ </AlertDialogAction>
2376
+ </AlertDialogFooter>
2377
+ </AlertDialogContent>
2378
+ </AlertDialog>
2379
+
2380
+ {/* Storage warning */}
2381
+ {/* {storageUsedBytes > 1024 * 1024 * 1024 && (
2382
+ <div className="flex items-start gap-2.5 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-3">
2383
+ <AlertCircle className="size-4 mt-0.5 shrink-0 text-amber-600" />
2384
+ <div>
2385
+ <p className="text-[12px] font-medium text-amber-800">
2386
+ Storage usage: {formatSize(storageUsedBytes)}
2387
+ </p>
2388
+ <p className="mt-0.5 text-[11px] text-amber-600">
2389
+ Media files are stored locally. Cloud storage support coming soon.
2390
+ </p>
2391
+ </div>
2392
+ </div>
2393
+ )} */}
2394
+ </div>
2395
+ );
2396
+ }
2397
+
2398
+ function ImageGallery({
2399
+ assets,
2400
+ onDelete,
2401
+ serverId,
2402
+ }: {
2403
+ assets: MediaAsset[];
2404
+ onDelete: (id: string) => void;
2405
+ serverId?: string;
2406
+ }) {
2407
+ const [expandedAsset, setExpandedAsset] = useState<MediaAsset | null>(null);
2408
+
2409
+ return (
2410
+ <div>
2411
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
2412
+ {assets.map((asset) => (
2413
+ <div
2414
+ key={asset.id}
2415
+ onClick={() => setExpandedAsset(asset)}
2416
+ className="group relative aspect-2/1 sm:aspect-21/9 rounded-xl overflow-hidden bg-muted/30 border border-border/50 cursor-pointer"
2417
+ >
2418
+ <img
2419
+ src={mediaAssetUrl(asset.id, serverId, true)}
2420
+ alt={asset.fileName}
2421
+ className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
2422
+ loading="lazy"
2423
+ onError={(e) => {
2424
+ (e.target as HTMLImageElement).src = mediaAssetUrl(asset.id, serverId);
2425
+ }}
2426
+ />
2427
+
2428
+ {/* Processing status overlay */}
2429
+ {asset.processingStatus === 'processing' && (
2430
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50 backdrop-blur-sm p-4 text-center">
2431
+ <Loader2 className="size-5 animate-spin text-white mb-2" />
2432
+ <span className="text-[10px] text-white/90 line-clamp-2 leading-tight">
2433
+ {asset.processingMessage || 'Processing...'}
2434
+ </span>
2435
+ <Progress
2436
+ value={asset.processingProgress ?? 0}
2437
+ className={cn('mt-2 h-1.5 w-4/5 bg-white/20', INDEXING_PROGRESS_CLASS)}
2438
+ />
2439
+ <span className="mt-1 text-[9px] tabular-nums text-white/75">
2440
+ {Math.round(asset.processingProgress ?? 0)}%
2441
+ </span>
2442
+ </div>
2443
+ )}
2444
+ {asset.processingStatus === 'failed' && (
2445
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50 backdrop-blur-sm p-4 text-center">
2446
+ <AlertCircle className="size-5 text-red-400 mb-2" />
2447
+ <span className="text-[10px] text-red-200 line-clamp-2 leading-tight">
2448
+ {asset.processingError || 'Failed'}
2449
+ </span>
2450
+ </div>
2451
+ )}
2452
+ {asset.processingStatus === 'pending' && (
2453
+ <div className="absolute bottom-2 right-2 bg-black/50 backdrop-blur-sm px-2 py-1 rounded-sm flex items-center gap-1.5 max-w-[80%]">
2454
+ <Clock className="size-3 text-white/80 shrink-0" />
2455
+ <span className="text-[9px] text-white/90 truncate">
2456
+ {asset.processingMessage || 'Pending...'}
2457
+ </span>
2458
+ </div>
2459
+ )}
2460
+ {asset.processingStatus === 'completed' && asset.activeVideoKnowledgeRevisionId ? (
2461
+ <div className="absolute bottom-2 left-2 rounded-sm bg-emerald-600/85 px-2 py-1 text-[9px] text-white">
2462
+ Evidence revision active
2463
+ </div>
2464
+ ) : null}
2465
+
2466
+ {/* Hover actions */}
2467
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none">
2468
+ {!isActiveJob(asset) ? (
2469
+ <button
2470
+ type="button"
2471
+ aria-label={`Delete ${asset.fileName}`}
2472
+ onClick={(e) => {
2473
+ e.stopPropagation();
2474
+ onDelete(asset.id);
2475
+ }}
2476
+ className="absolute right-2 top-2 rounded-full bg-black/50 p-1.5 text-white opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto hover:bg-black/70"
2477
+ >
2478
+ <Trash2 className="size-3.5" />
2479
+ </button>
2480
+ ) : null}
2481
+ </div>
2482
+ </div>
2483
+ ))}
2484
+ </div>
2485
+
2486
+ {/* Lightbox Overlay */}
2487
+ {expandedAsset && (
2488
+ <div
2489
+ className="fixed inset-0 z-9999 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 md:p-8 animate-in fade-in duration-200"
2490
+ onClick={() => setExpandedAsset(null)}
2491
+ >
2492
+ <div className="relative max-w-full max-h-full">
2493
+ <img
2494
+ src={mediaAssetUrl(expandedAsset.id, serverId)}
2495
+ alt={expandedAsset.fileName}
2496
+ className="max-w-full max-h-[90vh] rounded-lg object-contain"
2497
+ onClick={(e) => e.stopPropagation()} // Prevent click from closing immediately
2498
+ />
2499
+ <button
2500
+ onClick={() => setExpandedAsset(null)}
2501
+ className="absolute -top-3 -right-3 md:-top-5 md:-right-5 rounded-full bg-black/50 border border-white/20 p-2 text-white hover:bg-black/80 transition-colors"
2502
+ >
2503
+ <X className="size-5" />
2504
+ </button>
2505
+ <div className="absolute bottom-0 inset-x-0 p-4 bg-linear-to-t from-black/80 to-transparent text-white rounded-b-lg opacity-0 hover:opacity-100 transition-opacity flex justify-between items-end">
2506
+ <div>
2507
+ <p className="text-sm font-medium">{expandedAsset.fileName}</p>
2508
+ <p className="text-xs text-white/70 mt-1">{formatSize(expandedAsset.fileSize)}</p>
2509
+ </div>
2510
+ </div>
2511
+ </div>
2512
+ </div>
2513
+ )}
2514
+ </div>
2515
+ );
2516
+ }
2517
+
2518
+ function FileList({
2519
+ assets,
2520
+ mediaType,
2521
+ onDelete,
2522
+ onProcess,
2523
+ }: {
2524
+ assets: MediaAsset[];
2525
+ mediaType: 'video' | 'audio';
2526
+ onDelete: (id: string) => void;
2527
+ onProcess: (asset: MediaAsset) => void;
2528
+ }) {
2529
+ const Icon = mediaType === 'video' ? Video : AudioLines;
2530
+
2531
+ return (
2532
+ <div className="flex flex-col gap-1.5">
2533
+ {assets.map((asset) => (
2534
+ <div
2535
+ key={asset.id}
2536
+ className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5 group"
2537
+ >
2538
+ <div className="flex size-9 items-center justify-center rounded-md bg-muted shrink-0">
2539
+ <Icon className="size-4 text-muted-foreground" />
2540
+ </div>
2541
+ <div className="min-w-0 flex-1">
2542
+ <p className="truncate text-[12px] font-medium text-foreground">{asset.fileName}</p>
2543
+ <div className="flex items-center gap-2 mt-0.5">
2544
+ <span className="text-[10px] text-muted-foreground">
2545
+ {formatSize(asset.fileSize)}
2546
+ </span>
2547
+ {asset.durationSecs ? (
2548
+ <span className="text-[10px] text-muted-foreground">
2549
+ · {Math.floor(asset.durationSecs / 60)}:
2550
+ {String(Math.floor(asset.durationSecs % 60)).padStart(2, '0')}
2551
+ </span>
2552
+ ) : null}
2553
+ <StatusBadge asset={asset} />
2554
+ {mediaType === 'video' ? <IndexingTime asset={asset} /> : null}
2555
+ </div>
2556
+ {isActiveJob(asset) ? (
2557
+ <Progress
2558
+ value={asset.processingProgress ?? 0}
2559
+ aria-label="Overall indexing progress"
2560
+ className={cn('mt-2 h-1', INDEXING_PROGRESS_CLASS)}
2561
+ />
2562
+ ) : null}
2563
+ </div>
2564
+ <div className="flex items-center gap-1">
2565
+ {asset.processingStatus === 'failed' ? (
2566
+ <Button
2567
+ type="button"
2568
+ variant="secondary"
2569
+ size="sm"
2570
+ className="h-7 px-2 text-[11px]"
2571
+ onClick={() => onProcess(asset)}
2572
+ >
2573
+ Retry
2574
+ </Button>
2575
+ ) : null}
2576
+ {asset.processingStatus === 'completed' ? (
2577
+ <Button
2578
+ type="button"
2579
+ variant="ghost"
2580
+ size="sm"
2581
+ className="h-7 px-2 text-[11px] text-muted-foreground"
2582
+ onClick={() => onProcess(asset)}
2583
+ >
2584
+ Re-index
2585
+ </Button>
2586
+ ) : null}
2587
+ {!isActiveJob(asset) ? (
2588
+ <button
2589
+ type="button"
2590
+ aria-label={`Delete ${asset.fileName}`}
2591
+ onClick={() => onDelete(asset.id)}
2592
+ className="text-red-500 p-1.5 rounded cursor-pointer hover:text-red-600 transition-all"
2593
+ >
2594
+ <Trash2 className="size-3.5" />
2595
+ </button>
2596
+ ) : null}
2597
+ </div>
2598
+ </div>
2599
+ ))}
2600
+ </div>
2601
+ );
2602
+ }
2603
+
2604
+ function IndexingTime({ asset }: { asset: MediaAsset }) {
2605
+ const seconds = completedMediaProcessingSeconds(asset);
2606
+ if (seconds === null) return null;
2607
+
2608
+ const minutes = Math.floor(seconds / 60);
2609
+ const remainder = seconds % 60;
2610
+ const duration = minutes > 0 ? `${minutes}m ${remainder}s` : `${remainder}s`;
2611
+ return (
2612
+ <span
2613
+ className="flex items-center gap-0.5 text-[10px] text-muted-foreground"
2614
+ title="Time taken to index this video"
2615
+ >
2616
+ · <Clock className="size-2.5" /> Indexed in {duration}
2617
+ </span>
2618
+ );
2619
+ }
2620
+
2621
+ function StatusBadge({ asset }: { asset: MediaAsset }) {
2622
+ const status = asset.processingStatus;
2623
+
2624
+ if (status === 'completed') {
2625
+ return (
2626
+ <span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
2627
+ <Check className="size-2.5" />
2628
+ Indexed
2629
+ </span>
2630
+ );
2631
+ }
2632
+ if (status === 'processing') {
2633
+ return (
2634
+ <span className="flex items-center gap-1 text-[10px] text-blue-600 bg-blue-50/50 px-1.5 py-0.5 rounded-sm">
2635
+ <Loader2 className="size-2.5 animate-spin shrink-0" />
2636
+ <span
2637
+ className="truncate max-w-30 sm:max-w-50"
2638
+ title={asset.processingMessage || 'Processing...'}
2639
+ >
2640
+ {asset.processingMessage || 'Processing...'}
2641
+ </span>
2642
+ <span className="tabular-nums">
2643
+ {formatMediaProgressPercent(asset.processingProgress ?? 0)}% overall
2644
+ </span>
2645
+ </span>
2646
+ );
2647
+ }
2648
+ if (status === 'pending' && asset.processingMessage) {
2649
+ return (
2650
+ <span className="flex items-center gap-1 rounded-sm bg-blue-50/50 px-1.5 py-0.5 text-[10px] text-blue-600">
2651
+ <Loader2 className="size-2.5 shrink-0 animate-spin" />
2652
+ <span className="max-w-30 truncate sm:max-w-50" title={asset.processingMessage}>
2653
+ {asset.processingMessage}
2654
+ </span>
2655
+ <span className="tabular-nums">
2656
+ {formatMediaProgressPercent(asset.processingProgress ?? 0)}%
2657
+ </span>
2658
+ </span>
2659
+ );
2660
+ }
2661
+ if (status === 'failed') {
2662
+ return (
2663
+ <span
2664
+ className="flex items-center gap-0.5 text-[10px] text-red-500"
2665
+ title={asset.processingError}
2666
+ >
2667
+ <AlertCircle className="size-2.5 shrink-0" />
2668
+ <span className="truncate max-w-30">{asset.processingError || 'Failed'}</span>
2669
+ </span>
2670
+ );
2671
+ }
2672
+ return (
2673
+ <span className="flex items-center gap-1 text-[10px] text-muted-foreground/60">
2674
+ <Clock className="size-2.5 shrink-0" />
2675
+ <span className="truncate max-w-30" title={asset.processingMessage || 'Pending...'}>
2676
+ {asset.processingMessage || 'Pending...'}
2677
+ </span>
2678
+ </span>
2679
+ );
2680
+ }
2681
+
2682
+ function ActiveIndexingList({
2683
+ assets,
2684
+ onPause,
2685
+ onRemove,
2686
+ }: {
2687
+ assets: MediaAsset[];
2688
+ onPause: (asset: MediaAsset) => void;
2689
+ onRemove: (asset: MediaAsset) => void;
2690
+ }) {
2691
+ return (
2692
+ <section
2693
+ aria-live="polite"
2694
+ aria-label="Active media indexing jobs"
2695
+ className="space-y-3 rounded-xl border border-emerald-500 bg-emerald-50/20 p-3 dark:border-emerald-700/60 dark:bg-emerald-950/20"
2696
+ >
2697
+ <div className="flex items-center gap-2">
2698
+ <Loader2 className="size-3.5 animate-spin text-emerald-600 dark:text-emerald-400" />
2699
+ <div className="min-w-0 flex-1">
2700
+ <p className="text-[12px] font-medium text-foreground">
2701
+ Indexing {assets.length} {assets.length === 1 ? 'file' : 'files'}
2702
+ </p>
2703
+ </div>
2704
+ </div>
2705
+
2706
+ <div className="space-y-2">
2707
+ {assets.map((asset) => (
2708
+ <ActiveIndexingJob key={asset.id} asset={asset} onPause={onPause} onRemove={onRemove} />
2709
+ ))}
2710
+ </div>
2711
+ </section>
2712
+ );
2713
+ }
2714
+
2715
+ function ActiveIndexingJob({
2716
+ asset,
2717
+ onPause,
2718
+ onRemove,
2719
+ }: {
2720
+ asset: MediaAsset;
2721
+ onPause: (asset: MediaAsset) => void;
2722
+ onRemove: (asset: MediaAsset) => void;
2723
+ }) {
2724
+ const progress = useLiveMediaProgress(asset);
2725
+ const activeStep = primaryRunningMediaStep(asset.processingSteps);
2726
+ const isFinalizing = isFinalizingMediaStep(activeStep);
2727
+ return (
2728
+ <div className="group relative space-y-2 rounded-lg border border-transparent bg-background/40 p-2.5">
2729
+ <div className="flex items-start justify-between gap-3">
2730
+ <div className="min-w-0 pr-6">
2731
+ <p className="truncate text-[11px] font-medium text-foreground">{asset.fileName}</p>
2732
+ </div>
2733
+ <div className="flex items-center gap-2">
2734
+ <span className="shrink-0 text-[10px] font-medium tabular-nums text-emerald-600 dark:text-emerald-400">
2735
+ {isFinalizing ? 'Finalizing' : `${formatMediaProgressPercent(progress)}%`}
2736
+ </span>
2737
+ {asset.type !== 'image' ? (
2738
+ <button
2739
+ type="button"
2740
+ title={asset.processingPaused ? 'Resume Indexing' : 'Pause Indexing'}
2741
+ aria-label={
2742
+ asset.processingPaused ? `Resume ${asset.fileName}` : `Pause ${asset.fileName}`
2743
+ }
2744
+ onClick={() => onPause(asset)}
2745
+ className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
2746
+ >
2747
+ {asset.processingPaused ? <Play className="size-3" /> : <Pause className="size-3" />}
2748
+ </button>
2749
+ ) : null}
2750
+ <button
2751
+ type="button"
2752
+ title="Remove file"
2753
+ aria-label={`Remove ${asset.fileName}`}
2754
+ onClick={() => onRemove(asset)}
2755
+ className="-mr-1 rounded-full p-1 text-muted-foreground transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30"
2756
+ >
2757
+ <X className="size-3" />
2758
+ </button>
2759
+ </div>
2760
+ </div>
2761
+ <ActiveIndexingDescription asset={asset} />
2762
+ {isFinalizing ? (
2763
+ <IndeterminateIndexingProgress />
2764
+ ) : (
2765
+ <LiveMediaProgress value={progress} step={activeStep} paused={asset.processingPaused} />
2766
+ )}
2767
+ {asset.activeVideoKnowledgeJobId ? (
2768
+ <p
2769
+ className="truncate text-[9px] text-muted-foreground"
2770
+ title={asset.activeVideoKnowledgeJobId}
2771
+ >
2772
+ Knowledge job {asset.activeVideoKnowledgeJobId.slice(0, 8)}
2773
+ </p>
2774
+ ) : null}
2775
+ </div>
2776
+ );
2777
+ }
2778
+
2779
+ function IndeterminateIndexingProgress() {
2780
+ return (
2781
+ <div
2782
+ role="progressbar"
2783
+ aria-label="Finalizing video indexing"
2784
+ aria-valuetext="Finalizing verified results"
2785
+ className="relative h-2 min-h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-950/60"
2786
+ >
2787
+ <motion.div
2788
+ className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-emerald-500 dark:bg-emerald-400"
2789
+ initial={{ x: '-120%' }}
2790
+ animate={{ x: ['-120%', '270%'] }}
2791
+ transition={{ duration: 1.15, ease: 'easeInOut', repeat: Infinity }}
2792
+ />
2793
+ </div>
2794
+ );
2795
+ }
2796
+
2797
+ function ActiveIndexingDescription({ asset }: { asset: MediaAsset }) {
2798
+ const step = primaryRunningMediaStep(asset.processingSteps);
2799
+ const stepDescription = describeActiveMediaStep(step);
2800
+
2801
+ return (
2802
+ <div className="min-w-0 space-y-1">
2803
+ <div className="flex min-w-0 items-center gap-1.5">
2804
+ <ElapsedTime startTime={mediaProcessingStartedAt(asset)} />
2805
+ <p className="truncate text-[10px] text-muted-foreground animate-pulse">
2806
+ {asset.processingPaused
2807
+ ? 'Paused — resume when you are ready.'
2808
+ : asset.processingMessage || 'Preparing media indexing...'}
2809
+ </p>
2810
+ </div>
2811
+ {!asset.processingPaused && stepDescription ? (
2812
+ <LiveIndexingStepDescription step={step} description={stepDescription} />
2813
+ ) : null}
2814
+ </div>
2815
+ );
2816
+ }
2817
+
2818
+ function LiveIndexingStepDescription({
2819
+ step,
2820
+ description,
2821
+ }: {
2822
+ step: MediaProcessingStep | undefined;
2823
+ description: string;
2824
+ }) {
2825
+ const [now, setNow] = useState(() => Date.now());
2826
+ useEffect(() => {
2827
+ const interval = setInterval(() => setNow(Date.now()), 5_000);
2828
+ return () => clearInterval(interval);
2829
+ }, []);
2830
+ const stale = isMediaStepTelemetryStale(step, now);
2831
+
2832
+ return (
2833
+ <div
2834
+ className="flex min-w-0 items-center gap-1.5 text-[9px] text-muted-foreground/80"
2835
+ aria-live="polite"
2836
+ >
2837
+ <span className="truncate">
2838
+ {stale ? 'Waiting for a fresh worker update…' : `Worker active · ${description}`}
2839
+ </span>
2840
+ {!stale ? <EstimatedStepTime step={step} /> : null}
2841
+ </div>
2842
+ );
2843
+ }
2844
+
2845
+ function EstimatedStepTime({ step }: { step: MediaProcessingStep | undefined }) {
2846
+ const [now, setNow] = useState(() => Date.now());
2847
+ useEffect(() => {
2848
+ const interval = setInterval(() => setNow(Date.now()), 15_000);
2849
+ return () => clearInterval(interval);
2850
+ }, []);
2851
+
2852
+ const remainingSeconds = estimateMediaStepRemainingSeconds(step, now);
2853
+ if (remainingSeconds === null) return null;
2854
+ return (
2855
+ <span className="shrink-0 whitespace-nowrap text-muted-foreground/70">
2856
+ ~{formatShortDuration(remainingSeconds)} left
2857
+ </span>
2858
+ );
2859
+ }
2860
+
2861
+ function formatShortDuration(seconds: number): string {
2862
+ const minutes = Math.round(seconds / 60);
2863
+ if (minutes < 1) return '<1 min';
2864
+ if (minutes < 60) return `${minutes} min`;
2865
+ const hours = Math.floor(minutes / 60);
2866
+ return `${hours}h ${minutes % 60}m`;
2867
+ }
2868
+
2869
+ function ElapsedTime({ startTime }: { startTime: string }) {
2870
+ const [elapsed, setElapsed] = useState(0);
2871
+ useEffect(() => {
2872
+ const start = new Date(startTime).getTime();
2873
+ const update = () => setElapsed(Math.max(0, Math.floor((Date.now() - start) / 1000)));
2874
+ update();
2875
+ const interval = setInterval(update, 1000);
2876
+ return () => clearInterval(interval);
2877
+ }, [startTime]);
2878
+ const mins = Math.floor(elapsed / 60);
2879
+ const secs = elapsed % 60;
2880
+ return (
2881
+ <span className="font-mono tabular-nums text-[10px] text-muted-foreground/60">
2882
+ {mins}:{secs.toString().padStart(2, '0')}
2883
+ </span>
2884
+ );
2885
+ }
2886
+
2887
+ function EstimateMetric({ label, value }: { label: string; value: string }) {
2888
+ return (
2889
+ <div className="min-w-0">
2890
+ <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
2891
+ {label}
2892
+ </p>
2893
+ <p className="mt-0.5 truncate text-[11px] text-foreground" title={value}>
2894
+ {value}
2895
+ </p>
2896
+ </div>
2897
+ );
2898
+ }
2899
+
2900
+ function formatDuration(seconds: number): string {
2901
+ const hours = Math.floor(seconds / 3600);
2902
+ const minutes = Math.floor((seconds % 3600) / 60);
2903
+ const secs = Math.floor(seconds % 60);
2904
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}:${String(secs).padStart(2, '0')}`;
2905
+ }