@larkup/workspace-app 0.0.0-stage → 0.1.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.
- package/LICENSE +176 -0
- package/README.md +4 -2
- package/assets/github.svg +3 -0
- package/assets/icons/Confluence.png +0 -0
- package/assets/icons/Instagram.png +0 -0
- package/assets/icons/Northflank.png +0 -0
- package/assets/icons/Salesforce.png +0 -0
- package/assets/icons/agentskills.png +0 -0
- package/assets/icons/airtable.png +0 -0
- package/assets/icons/asana.png +0 -0
- package/assets/icons/ashby.png +0 -0
- package/assets/icons/audio.png +0 -0
- package/assets/icons/aws-kendra.png +0 -0
- package/assets/icons/aws.svg +38 -0
- package/assets/icons/azure-ai-search.png +0 -0
- package/assets/icons/azure.svg +1 -0
- package/assets/icons/bigquery.png +0 -0
- package/assets/icons/bing.png +0 -0
- package/assets/icons/bitbucket.png +0 -0
- package/assets/icons/brave.png +0 -0
- package/assets/icons/browserbase.png +0 -0
- package/assets/icons/calendly.png +0 -0
- package/assets/icons/chroma.png +0 -0
- package/assets/icons/cloudflare.png +0 -0
- package/assets/icons/cohere.svg +30 -0
- package/assets/icons/databricks.png +0 -0
- package/assets/icons/daytonaio.png +0 -0
- package/assets/icons/daytonaio2.png +0 -0
- package/assets/icons/deepgram.png +0 -0
- package/assets/icons/deepl.png +0 -0
- package/assets/icons/digital-ocean.webp +0 -0
- package/assets/icons/discord.png +0 -0
- package/assets/icons/e2b-2.png +0 -0
- package/assets/icons/e2b.png +0 -0
- package/assets/icons/elevenlabs.png +0 -0
- package/assets/icons/email.png +0 -0
- package/assets/icons/exa.png +0 -0
- package/assets/icons/excel.png +0 -0
- package/assets/icons/fal.png +0 -0
- package/assets/icons/firecrawl.png +0 -0
- package/assets/icons/firecrawl2.png +0 -0
- package/assets/icons/flyio.jpeg +0 -0
- package/assets/icons/gcp.svg +1 -0
- package/assets/icons/gemini.svg +1 -0
- package/assets/icons/gmail.png +0 -0
- package/assets/icons/google-analytics.png +0 -0
- package/assets/icons/google-calender.png +0 -0
- package/assets/icons/google-docs.png +0 -0
- package/assets/icons/google-drive.png +0 -0
- package/assets/icons/google-maps.png +0 -0
- package/assets/icons/google-meet.png +0 -0
- package/assets/icons/google-sheets.png +0 -0
- package/assets/icons/google-slides.png +0 -0
- package/assets/icons/google-tasks.png +0 -0
- package/assets/icons/google.png +0 -0
- package/assets/icons/grok.png +0 -0
- package/assets/icons/hubspot.png +0 -0
- package/assets/icons/image.png +0 -0
- package/assets/icons/jina.svg +1 -0
- package/assets/icons/jira.png +0 -0
- package/assets/icons/lancedb2.png +0 -0
- package/assets/icons/linear.png +0 -0
- package/assets/icons/linux.png +0 -0
- package/assets/icons/looker.png +0 -0
- package/assets/icons/luma.png +0 -0
- package/assets/icons/mac.png +0 -0
- package/assets/icons/markdown.png +0 -0
- package/assets/icons/mcp.png +0 -0
- package/assets/icons/mcp.svg +1 -0
- package/assets/icons/messenger.svg +1 -0
- package/assets/icons/microsoft-intra.png +0 -0
- package/assets/icons/microsoft-onenote.png +0 -0
- package/assets/icons/microsoft-planner.png +0 -0
- package/assets/icons/microsoft-teams.png +0 -0
- package/assets/icons/miro.png +0 -0
- package/assets/icons/mistral.svg +469 -0
- package/assets/icons/modal.png +0 -0
- package/assets/icons/ngrok.png +0 -0
- package/assets/icons/nomic.png +0 -0
- package/assets/icons/onedrive.png +0 -0
- package/assets/icons/openai.svg +1 -0
- package/assets/icons/outlook-calendar.png +0 -0
- package/assets/icons/outlook-email.png +0 -0
- package/assets/icons/outlook.png +0 -0
- package/assets/icons/pdf.png +0 -0
- package/assets/icons/personio.png +0 -0
- package/assets/icons/pgvector2.png +0 -0
- package/assets/icons/phone.png +0 -0
- package/assets/icons/pinecone.png +0 -0
- package/assets/icons/pipedrive.png +0 -0
- package/assets/icons/power-pi.png +0 -0
- package/assets/icons/pylon.png +0 -0
- package/assets/icons/python.png +0 -0
- package/assets/icons/qdrant.svg +35 -0
- package/assets/icons/revai.png +0 -0
- package/assets/icons/revai.svg +1 -0
- package/assets/icons/sap.png +0 -0
- package/assets/icons/serpapi.svg +34 -0
- package/assets/icons/serper.png +0 -0
- package/assets/icons/servicenow.png +0 -0
- package/assets/icons/sharepoint.png +0 -0
- package/assets/icons/slack.png +0 -0
- package/assets/icons/snowflake.png +0 -0
- package/assets/icons/statistia.png +0 -0
- package/assets/icons/stripe.png +0 -0
- package/assets/icons/supabase.png +0 -0
- package/assets/icons/tableau.png +0 -0
- package/assets/icons/tavily.png +0 -0
- package/assets/icons/telegram.png +0 -0
- package/assets/icons/typescript.png +0 -0
- package/assets/icons/vercel copy.svg +1 -0
- package/assets/icons/vercel.svg +1 -0
- package/assets/icons/vertex-ai.png +0 -0
- package/assets/icons/video.png +0 -0
- package/assets/icons/voyage-light.png +0 -0
- package/assets/icons/weaviate.webp +0 -0
- package/assets/icons/webcontainers.svg +11 -0
- package/assets/icons/webhook.png +0 -0
- package/assets/icons/whatsapp copy.png +0 -0
- package/assets/icons/whatsapp.png +0 -0
- package/assets/icons/windows.png +0 -0
- package/assets/icons/word.png +0 -0
- package/assets/icons/zendesk.png +0 -0
- package/assets/icons/zoom.png +0 -0
- package/assets/notion-white.png +0 -0
- package/assets/notion.png +0 -0
- package/dist-types/asset-server.d.ts +3 -0
- package/dist-types/asset-server.d.ts.map +1 -0
- package/dist-types/components/alerts/generic-alert.d.ts +15 -0
- package/dist-types/components/alerts/generic-alert.d.ts.map +1 -0
- package/dist-types/components/chat/canvas/doc-editor-provider.d.ts +62 -0
- package/dist-types/components/chat/canvas/doc-editor-provider.d.ts.map +1 -0
- package/dist-types/components/chat/canvas/document-canvas.d.ts +7 -0
- package/dist-types/components/chat/canvas/document-canvas.d.ts.map +1 -0
- package/dist-types/components/chat/canvas/document-field.d.ts +13 -0
- package/dist-types/components/chat/canvas/document-field.d.ts.map +1 -0
- package/dist-types/components/chat/canvas/document-viewer.d.ts +2 -0
- package/dist-types/components/chat/canvas/document-viewer.d.ts.map +1 -0
- package/dist-types/components/chat/canvas/file-attachment-button.d.ts +9 -0
- package/dist-types/components/chat/canvas/file-attachment-button.d.ts.map +1 -0
- package/dist-types/components/chat/chat-workspace.d.ts +4 -0
- package/dist-types/components/chat/chat-workspace.d.ts.map +1 -0
- package/dist-types/components/chat/message-actions.d.ts +11 -0
- package/dist-types/components/chat/message-actions.d.ts.map +1 -0
- package/dist-types/components/chat/message-item.d.ts +20 -0
- package/dist-types/components/chat/message-item.d.ts.map +1 -0
- package/dist-types/components/chat/message-queue.d.ts +15 -0
- package/dist-types/components/chat/message-queue.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-chart.d.ts +6 -0
- package/dist-types/components/chat/tools/chat-chart.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-citations.d.ts +32 -0
- package/dist-types/components/chat/tools/chat-citations.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-data-table.d.ts +14 -0
- package/dist-types/components/chat/tools/chat-data-table.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-file-export.d.ts +5 -0
- package/dist-types/components/chat/tools/chat-file-export.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-media-preview.d.ts +35 -0
- package/dist-types/components/chat/tools/chat-media-preview.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-sandbox-result.d.ts +17 -0
- package/dist-types/components/chat/tools/chat-sandbox-result.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-signature-request.d.ts +13 -0
- package/dist-types/components/chat/tools/chat-signature-request.d.ts.map +1 -0
- package/dist-types/components/chat/tools/chat-tabs.d.ts +17 -0
- package/dist-types/components/chat/tools/chat-tabs.d.ts.map +1 -0
- package/dist-types/components/chat/tools/corpus-data-result.d.ts +36 -0
- package/dist-types/components/chat/tools/corpus-data-result.d.ts.map +1 -0
- package/dist-types/components/chat/tools/knowledge-base-result.d.ts +14 -0
- package/dist-types/components/chat/tools/knowledge-base-result.d.ts.map +1 -0
- package/dist-types/components/chat/tools/visual-placement-modal.d.ts +23 -0
- package/dist-types/components/chat/tools/visual-placement-modal.d.ts.map +1 -0
- package/dist-types/components/configure/custom-model-modal.d.ts +13 -0
- package/dist-types/components/configure/custom-model-modal.d.ts.map +1 -0
- package/dist-types/components/configure/store-fields.d.ts +11 -0
- package/dist-types/components/configure/store-fields.d.ts.map +1 -0
- package/dist-types/components/data/corpus-panel.d.ts +12 -0
- package/dist-types/components/data/corpus-panel.d.ts.map +1 -0
- package/dist-types/components/data/data-entry-mode-switch.d.ts +13 -0
- package/dist-types/components/data/data-entry-mode-switch.d.ts.map +1 -0
- package/dist-types/components/data/data-primary-action.d.ts +7 -0
- package/dist-types/components/data/data-primary-action.d.ts.map +1 -0
- package/dist-types/components/data/data-workspace.d.ts +6 -0
- package/dist-types/components/data/data-workspace.d.ts.map +1 -0
- package/dist-types/components/data/groups-panel.d.ts +5 -0
- package/dist-types/components/data/groups-panel.d.ts.map +1 -0
- package/dist-types/components/data/integration-resources-panel.d.ts +9 -0
- package/dist-types/components/data/integration-resources-panel.d.ts.map +1 -0
- package/dist-types/components/data/integrations-panel.d.ts +5 -0
- package/dist-types/components/data/integrations-panel.d.ts.map +1 -0
- package/dist-types/components/data/jobs-panel.d.ts +6 -0
- package/dist-types/components/data/jobs-panel.d.ts.map +1 -0
- package/dist-types/components/data/jobs-tracking-button.d.ts +2 -0
- package/dist-types/components/data/jobs-tracking-button.d.ts.map +1 -0
- package/dist-types/components/data/live-media-progress.d.ts +9 -0
- package/dist-types/components/data/live-media-progress.d.ts.map +1 -0
- package/dist-types/components/data/media-indexing-jobs-panel.d.ts +34 -0
- package/dist-types/components/data/media-indexing-jobs-panel.d.ts.map +1 -0
- package/dist-types/components/data/media-panel.d.ts +58 -0
- package/dist-types/components/data/media-panel.d.ts.map +1 -0
- package/dist-types/components/data/notion-panel.d.ts +6 -0
- package/dist-types/components/data/notion-panel.d.ts.map +1 -0
- package/dist-types/components/data/paste-panel.d.ts +7 -0
- package/dist-types/components/data/paste-panel.d.ts.map +1 -0
- package/dist-types/components/data/project-data-page.d.ts +3 -0
- package/dist-types/components/data/project-data-page.d.ts.map +1 -0
- package/dist-types/components/data/scrape-panel.d.ts +9 -0
- package/dist-types/components/data/scrape-panel.d.ts.map +1 -0
- package/dist-types/components/data/tabular-preview.d.ts +10 -0
- package/dist-types/components/data/tabular-preview.d.ts.map +1 -0
- package/dist-types/components/data/tool-indexing-dialog.d.ts +54 -0
- package/dist-types/components/data/tool-indexing-dialog.d.ts.map +1 -0
- package/dist-types/components/data/upload-panel.d.ts +7 -0
- package/dist-types/components/data/upload-panel.d.ts.map +1 -0
- package/dist-types/components/data/web-crawler-status.d.ts +3 -0
- package/dist-types/components/data/web-crawler-status.d.ts.map +1 -0
- package/dist-types/components/projects/project-form-dialog.d.ts +10 -0
- package/dist-types/components/projects/project-form-dialog.d.ts.map +1 -0
- package/dist-types/components/projects/project-provider.d.ts +34 -0
- package/dist-types/components/projects/project-provider.d.ts.map +1 -0
- package/dist-types/components/settings/general/agent-customization-section.d.ts +2 -0
- package/dist-types/components/settings/general/agent-customization-section.d.ts.map +1 -0
- package/dist-types/components/settings/general/mcp-connections-section.d.ts +2 -0
- package/dist-types/components/settings/general/mcp-connections-section.d.ts.map +1 -0
- package/dist-types/components/settings/general/model-combobox.d.ts +24 -0
- package/dist-types/components/settings/general/model-combobox.d.ts.map +1 -0
- package/dist-types/components/settings/general/models-section.d.ts +2 -0
- package/dist-types/components/settings/general/models-section.d.ts.map +1 -0
- package/dist-types/components/settings/general/sandbox-section.d.ts +7 -0
- package/dist-types/components/settings/general/sandbox-section.d.ts.map +1 -0
- package/dist-types/components/settings/general/search-scraping-section.d.ts +2 -0
- package/dist-types/components/settings/general/search-scraping-section.d.ts.map +1 -0
- package/dist-types/components/settings/general/storage-indexing-section.d.ts +2 -0
- package/dist-types/components/settings/general/storage-indexing-section.d.ts.map +1 -0
- package/dist-types/components/ui/alert-dialog.d.ts +19 -0
- package/dist-types/components/ui/alert-dialog.d.ts.map +1 -0
- package/dist-types/components/ui/alert.d.ts +11 -0
- package/dist-types/components/ui/alert.d.ts.map +1 -0
- package/dist-types/components/ui/badge.d.ts +8 -0
- package/dist-types/components/ui/badge.d.ts.map +1 -0
- package/dist-types/components/ui/card.d.ts +2 -0
- package/dist-types/components/ui/card.d.ts.map +1 -0
- package/dist-types/components/ui/checkbox.d.ts +4 -0
- package/dist-types/components/ui/checkbox.d.ts.map +1 -0
- package/dist-types/components/ui/collapsible.d.ts +6 -0
- package/dist-types/components/ui/collapsible.d.ts.map +1 -0
- package/dist-types/components/ui/command.d.ts +20 -0
- package/dist-types/components/ui/command.d.ts.map +1 -0
- package/dist-types/components/ui/dialog.d.ts +18 -0
- package/dist-types/components/ui/dialog.d.ts.map +1 -0
- package/dist-types/components/ui/dropdown-menu.d.ts +30 -0
- package/dist-types/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist-types/components/ui/input-group.d.ts +19 -0
- package/dist-types/components/ui/input-group.d.ts.map +1 -0
- package/dist-types/components/ui/input.d.ts +4 -0
- package/dist-types/components/ui/input.d.ts.map +1 -0
- package/dist-types/components/ui/line-tabs.d.ts +15 -0
- package/dist-types/components/ui/line-tabs.d.ts.map +1 -0
- package/dist-types/components/ui/popover.d.ts +10 -0
- package/dist-types/components/ui/popover.d.ts.map +1 -0
- package/dist-types/components/ui/progress.d.ts +8 -0
- package/dist-types/components/ui/progress.d.ts.map +1 -0
- package/dist-types/components/ui/scroll-area.d.ts +6 -0
- package/dist-types/components/ui/scroll-area.d.ts.map +1 -0
- package/dist-types/components/ui/sheet.d.ts +15 -0
- package/dist-types/components/ui/sheet.d.ts.map +1 -0
- package/dist-types/components/ui/slider.d.ts +4 -0
- package/dist-types/components/ui/slider.d.ts.map +1 -0
- package/dist-types/components/ui/switch.d.ts +6 -0
- package/dist-types/components/ui/switch.d.ts.map +1 -0
- package/dist-types/components/ui/table.d.ts +2 -0
- package/dist-types/components/ui/table.d.ts.map +1 -0
- package/dist-types/components/ui/tabs.d.ts +11 -0
- package/dist-types/components/ui/tabs.d.ts.map +1 -0
- package/dist-types/components/ui/textarea.d.ts +2 -0
- package/dist-types/components/ui/textarea.d.ts.map +1 -0
- package/dist-types/components/ui/tooltip.d.ts +7 -0
- package/dist-types/components/ui/tooltip.d.ts.map +1 -0
- package/dist-types/hooks/use-copy-to-clipboard.d.ts +5 -0
- package/dist-types/hooks/use-copy-to-clipboard.d.ts.map +1 -0
- package/dist-types/hooks/use-integration-auth.d.ts +10 -0
- package/dist-types/hooks/use-integration-auth.d.ts.map +1 -0
- package/dist-types/hooks/use-live-media-progress.d.ts +14 -0
- package/dist-types/hooks/use-live-media-progress.d.ts.map +1 -0
- package/dist-types/hooks/use-notion-auth.d.ts +9 -0
- package/dist-types/hooks/use-notion-auth.d.ts.map +1 -0
- package/dist-types/host.d.ts +20 -0
- package/dist-types/host.d.ts.map +1 -0
- package/dist-types/lib/browser-cache.d.ts +7 -0
- package/dist-types/lib/browser-cache.d.ts.map +1 -0
- package/dist-types/lib/chat/chart-config.d.ts +2 -0
- package/dist-types/lib/chat/chart-config.d.ts.map +1 -0
- package/dist-types/lib/chat/data-export.d.ts +2 -0
- package/dist-types/lib/chat/data-export.d.ts.map +1 -0
- package/dist-types/lib/chat/live-tool-progress.d.ts +44 -0
- package/dist-types/lib/chat/live-tool-progress.d.ts.map +1 -0
- package/dist-types/lib/chat/message-feedback.d.ts +2 -0
- package/dist-types/lib/chat/message-feedback.d.ts.map +1 -0
- package/dist-types/lib/chat/table-presentation.d.ts +2 -0
- package/dist-types/lib/chat/table-presentation.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-output.d.ts +3 -0
- package/dist-types/lib/chat/tool-output.d.ts.map +1 -0
- package/dist-types/lib/chat-supporting-clip.d.ts +11 -0
- package/dist-types/lib/chat-supporting-clip.d.ts.map +1 -0
- package/dist-types/lib/constants/stores.d.ts +6 -0
- package/dist-types/lib/constants/stores.d.ts.map +1 -0
- package/dist-types/lib/constants/tools.d.ts +27 -0
- package/dist-types/lib/constants/tools.d.ts.map +1 -0
- package/dist-types/lib/error-formatter.d.ts +2 -0
- package/dist-types/lib/error-formatter.d.ts.map +1 -0
- package/dist-types/lib/jobs-client.d.ts +7 -0
- package/dist-types/lib/jobs-client.d.ts.map +1 -0
- package/dist-types/lib/media/pdf.d.ts +6 -0
- package/dist-types/lib/media/pdf.d.ts.map +1 -0
- package/dist-types/lib/media/progress.d.ts +61 -0
- package/dist-types/lib/media/progress.d.ts.map +1 -0
- package/dist-types/lib/media/source-selection.d.ts +10 -0
- package/dist-types/lib/media/source-selection.d.ts.map +1 -0
- package/dist-types/lib/media/tool-indexing-estimate.d.ts +13 -0
- package/dist-types/lib/media/tool-indexing-estimate.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-capacity.d.ts +2 -0
- package/dist-types/lib/media/video-intelligence-capacity.d.ts.map +1 -0
- package/dist-types/lib/media/video-runtime-scope.d.ts +2 -0
- package/dist-types/lib/media/video-runtime-scope.d.ts.map +1 -0
- package/dist-types/lib/shared/error-formatter.d.ts +2 -0
- package/dist-types/lib/shared/error-formatter.d.ts.map +1 -0
- package/dist-types/lib/utils.d.ts +3 -0
- package/dist-types/lib/utils.d.ts.map +1 -0
- package/dist-types/storage.d.ts +5 -0
- package/dist-types/storage.d.ts.map +1 -0
- package/dist-types/store/chat-store.d.ts +19 -0
- package/dist-types/store/chat-store.d.ts.map +1 -0
- package/dist-types/store/scrape-store.d.ts +42 -0
- package/dist-types/store/scrape-store.d.ts.map +1 -0
- package/package.json +579 -3
- package/scripts/build-assets.mjs +12 -0
- package/src/asset-data.json +1 -0
- package/src/asset-server.ts +19 -0
- package/src/components/alerts/generic-alert.tsx +76 -0
- package/src/components/chat/canvas/doc-editor-provider.tsx +338 -0
- package/src/components/chat/canvas/document-canvas.tsx +98 -0
- package/src/components/chat/canvas/document-field.tsx +167 -0
- package/src/components/chat/canvas/document-viewer.tsx +446 -0
- package/src/components/chat/canvas/file-attachment-button.tsx +88 -0
- package/src/components/chat/chat-workspace.tsx +1552 -0
- package/src/components/chat/message-actions.tsx +96 -0
- package/src/components/chat/message-item.tsx +1398 -0
- package/src/components/chat/message-queue.tsx +86 -0
- package/src/components/chat/tools/chat-chart.tsx +558 -0
- package/src/components/chat/tools/chat-citations.tsx +152 -0
- package/src/components/chat/tools/chat-data-table.tsx +373 -0
- package/src/components/chat/tools/chat-file-export.tsx +49 -0
- package/src/components/chat/tools/chat-media-preview.tsx +466 -0
- package/src/components/chat/tools/chat-sandbox-result.tsx +130 -0
- package/src/components/chat/tools/chat-signature-request.tsx +810 -0
- package/src/components/chat/tools/chat-tabs.tsx +59 -0
- package/src/components/chat/tools/corpus-data-result.tsx +194 -0
- package/src/components/chat/tools/knowledge-base-result.tsx +345 -0
- package/src/components/chat/tools/visual-placement-modal.tsx +308 -0
- package/src/components/configure/custom-model-modal.tsx +209 -0
- package/src/components/configure/store-fields.tsx +129 -0
- package/src/components/data/corpus-panel.tsx +1223 -0
- package/src/components/data/data-entry-mode-switch.tsx +53 -0
- package/src/components/data/data-primary-action.ts +6 -0
- package/src/components/data/data-workspace.tsx +623 -0
- package/src/components/data/groups-panel.tsx +530 -0
- package/src/components/data/integration-resources-panel.tsx +282 -0
- package/src/components/data/integrations-panel.tsx +392 -0
- package/src/components/data/jobs-panel.tsx +490 -0
- package/src/components/data/jobs-tracking-button.tsx +224 -0
- package/src/components/data/live-media-progress.tsx +79 -0
- package/src/components/data/media-indexing-jobs-panel.tsx +190 -0
- package/src/components/data/media-panel.tsx +2905 -0
- package/src/components/data/notion-panel.tsx +408 -0
- package/src/components/data/paste-panel.tsx +82 -0
- package/src/components/data/project-data-page.tsx +55 -0
- package/src/components/data/scrape-panel.tsx +1290 -0
- package/src/components/data/tabular-preview.tsx +205 -0
- package/src/components/data/tool-indexing-dialog.tsx +210 -0
- package/src/components/data/upload-panel.tsx +1313 -0
- package/src/components/data/web-crawler-status.tsx +132 -0
- package/src/components/projects/project-form-dialog.tsx +97 -0
- package/src/components/projects/project-provider.tsx +143 -0
- package/src/components/settings/general/agent-customization-section.tsx +620 -0
- package/src/components/settings/general/mcp-connections-section.tsx +907 -0
- package/src/components/settings/general/model-combobox.tsx +235 -0
- package/src/components/settings/general/models-section.tsx +909 -0
- package/src/components/settings/general/sandbox-section.tsx +394 -0
- package/src/components/settings/general/search-scraping-section.tsx +757 -0
- package/src/components/settings/general/storage-indexing-section.tsx +394 -0
- package/src/components/ui/alert-dialog.tsx +162 -0
- package/src/components/ui/alert.tsx +69 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/card.tsx +9 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/collapsible.tsx +17 -0
- package/src/components/ui/command.tsx +175 -0
- package/src/components/ui/dialog.tsx +138 -0
- package/src/components/ui/dropdown-menu.tsx +267 -0
- package/src/components/ui/input-group.tsx +146 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/line-tabs.tsx +49 -0
- package/src/components/ui/popover.tsx +77 -0
- package/src/components/ui/progress.tsx +66 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/sheet.tsx +125 -0
- package/src/components/ui/slider.tsx +52 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +10 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +1 -0
- package/src/components/ui/tooltip.tsx +54 -0
- package/src/hooks/use-copy-to-clipboard.ts +25 -0
- package/src/hooks/use-integration-auth.ts +39 -0
- package/src/hooks/use-live-media-progress.ts +71 -0
- package/src/hooks/use-notion-auth.ts +66 -0
- package/src/host.ts +55 -0
- package/src/lib/browser-cache.ts +17 -0
- package/src/lib/chat/chart-config.ts +1 -0
- package/src/lib/chat/data-export.ts +1 -0
- package/src/lib/chat/live-tool-progress.ts +69 -0
- package/src/lib/chat/message-feedback.ts +1 -0
- package/src/lib/chat/table-presentation.ts +1 -0
- package/src/lib/chat/tool-output.ts +14 -0
- package/src/lib/chat-supporting-clip.ts +16 -0
- package/src/lib/constants/stores.ts +12 -0
- package/src/lib/constants/tools.ts +178 -0
- package/src/lib/error-formatter.ts +30 -0
- package/src/lib/jobs-client.ts +27 -0
- package/src/lib/media/pdf.ts +57 -0
- package/src/lib/media/progress.ts +209 -0
- package/src/lib/media/source-selection.ts +47 -0
- package/src/lib/media/tool-indexing-estimate.ts +39 -0
- package/src/lib/media/video-intelligence-capacity.ts +1 -0
- package/src/lib/media/video-runtime-scope.ts +1 -0
- package/src/lib/shared/error-formatter.ts +1 -0
- package/src/lib/utils.ts +6 -0
- package/src/source.css +1 -0
- package/src/storage.ts +16 -0
- package/src/store/chat-store.ts +19 -0
- package/src/store/scrape-store.ts +76 -0
- 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
|
+
}
|