From f4ec4df77f650fa650535c6b934d4a1775cf491a Mon Sep 17 00:00:00 2001 From: Mckay Wrigley Date: Mon, 8 Jan 2024 17:11:48 -0700 Subject: [PATCH] Initial commit --- .env.local.example | 9 + .eslintrc.json | 25 + .github/funding.yaml | 3 + .gitignore | 40 + .husky/pre-commit | 4 + README.md | 82 + app/api/chat/anthropic/route.ts | 47 + app/api/chat/azure/route.ts | 74 + app/api/chat/google/route.ts | 72 + app/api/chat/mistral/route.ts | 71 + app/api/chat/openai/route.ts | 46 + app/api/chat/perplexity/route.ts | 68 + app/api/command/route.ts | 54 + app/api/localhost/ollama/route.ts | 39 + app/api/retrieval/process/route.ts | 150 + app/api/retrieval/retrieve/route.ts | 108 + app/api/username/available/route.ts | 37 + app/api/username/get/route.ts | 38 + app/auth/callback/route.ts | 16 + app/chat/[chatid]/page.tsx | 7 + app/chat/layout.tsx | 34 + app/chat/page.tsx | 55 + app/globals.css | 104 + app/help/page.tsx | 7 + app/layout.tsx | 44 + app/loading.tsx | 9 + app/login/page.tsx | 130 + app/page.tsx | 57 + app/setup/page.tsx | 294 + app/share/assistant/[id]/page.tsx | 65 + app/share/chat/[id]/page.tsx | 73 + app/share/collection/[id]/page.tsx | 75 + app/share/file/[id]/page.tsx | 72 + app/share/preset/[id]/page.tsx | 22 + app/share/prompt/[id]/page.tsx | 22 + components.json | 16 + components/chat/chat-command-input.tsx | 43 + components/chat/chat-files-display.tsx | 193 + components/chat/chat-help.tsx | 178 + components/chat/chat-helpers/index.ts | 472 + .../chat/chat-hooks/use-chat-handler.tsx | 249 + .../chat-hooks/use-prompt-and-command.tsx | 70 + components/chat/chat-hooks/use-scroll.tsx | 79 + .../chat-hooks/use-select-file-handler.tsx | 160 + components/chat/chat-input.tsx | 171 + components/chat/chat-messages.tsx | 34 + components/chat/chat-scroll-buttons.tsx | 41 + components/chat/chat-secondary-buttons.tsx | 35 + components/chat/chat-settings.tsx | 73 + components/chat/chat-ui.tsx | 207 + components/chat/file-picker.tsx | 125 + components/chat/prompt-picker.tsx | 193 + components/chat/quick-setting-option.tsx | 63 + components/chat/quick-settings.tsx | 233 + components/icons/anthropic-svg.tsx | 44 + components/icons/chatbotui-svg.tsx | 37 + components/icons/google-svg.tsx | 42 + components/icons/openai-svg.tsx | 31 + components/messages/message-actions.tsx | 117 + components/messages/message-codeblock.tsx | 135 + .../messages/message-markdown-memoized.tsx | 9 + components/messages/message-markdown.tsx | 62 + components/messages/message-replies.tsx | 51 + components/messages/message.tsx | 345 + components/models/model-icon.tsx | 146 + components/models/model-option.tsx | 50 + components/models/model-select.tsx | 190 + components/setup/api-step.tsx | 197 + components/setup/finish-step.tsx | 15 + components/setup/profile-step.tsx | 198 + components/setup/step-container.tsx | 89 + components/setup/workspace-step.tsx | 52 + components/sharing/add-to-workspace.tsx | 209 + components/sharing/share-assistant.tsx | 23 + components/sharing/share-chat.tsx | 149 + components/sharing/share-collection.tsx | 23 + components/sharing/share-file.tsx | 44 + components/sharing/share-header.tsx | 25 + components/sharing/share-item.tsx | 196 + components/sharing/share-message.tsx | 42 + components/sharing/share-page.tsx | 71 + components/sharing/share-preset.tsx | 73 + components/sharing/share-prompt.tsx | 26 + .../sidebar/items/all/sidebar-create-item.tsx | 198 + .../sidebar/items/all/sidebar-delete-item.tsx | 129 + .../items/all/sidebar-display-item.tsx | 122 + .../sidebar/items/all/sidebar-update-item.tsx | 382 + .../items/assistants/assistant-item.tsx | 137 + .../items/assistants/create-assistant.tsx | 133 + components/sidebar/items/chat/chat-item.tsx | 96 + components/sidebar/items/chat/delete-chat.tsx | 80 + components/sidebar/items/chat/update-chat.tsx | 76 + .../collections/collection-file-picker.tsx | 155 + .../items/collections/collection-item.tsx | 113 + .../items/collections/create-collection.tsx | 115 + .../sidebar/items/files/create-file.tsx | 92 + components/sidebar/items/files/file-item.tsx | 93 + .../sidebar/items/folders/delete-folder.tsx | 79 + .../sidebar/items/folders/folder-item.tsx | 111 + .../sidebar/items/folders/update-folder.tsx | 76 + .../sidebar/items/presets/create-preset.tsx | 88 + .../sidebar/items/presets/preset-item.tsx | 77 + .../sidebar/items/prompts/create-prompt.tsx | 67 + .../sidebar/items/prompts/prompt-item.tsx | 52 + components/sidebar/sidebar-content.tsx | 47 + components/sidebar/sidebar-create-buttons.tsx | 128 + components/sidebar/sidebar-data-list.tsx | 241 + components/sidebar/sidebar-search.tsx | 23 + components/sidebar/sidebar-switch-item.tsx | 33 + components/sidebar/sidebar-switcher.tsx | 79 + components/sidebar/sidebar.tsx | 94 + components/ui/accordion.tsx | 58 + components/ui/advanced-settings.tsx | 40 + components/ui/alert-dialog.tsx | 141 + components/ui/alert.tsx | 59 + components/ui/aspect-ratio.tsx | 7 + components/ui/avatar.tsx | 50 + components/ui/badge.tsx | 36 + components/ui/brand.tsx | 26 + components/ui/button.tsx | 56 + components/ui/calendar.tsx | 66 + components/ui/card.tsx | 79 + components/ui/chat-settings-form.tsx | 239 + components/ui/checkbox.tsx | 30 + components/ui/collapsible.tsx | 11 + components/ui/command.tsx | 155 + components/ui/context-menu.tsx | 200 + components/ui/dashboard.tsx | 131 + components/ui/dialog.tsx | 121 + components/ui/dropdown-menu.tsx | 200 + components/ui/file-icon.tsx | 39 + components/ui/file-preview.tsx | 65 + components/ui/form.tsx | 176 + components/ui/hover-card.tsx | 29 + components/ui/image-picker.tsx | 200 + components/ui/input.tsx | 25 + components/ui/label.tsx | 26 + components/ui/limit-display.tsx | 14 + components/ui/menubar.tsx | 236 + components/ui/navigation-menu.tsx | 128 + components/ui/popover.tsx | 31 + components/ui/progress.tsx | 28 + components/ui/radio-group.tsx | 44 + components/ui/screen-loader.tsx | 12 + components/ui/scroll-area.tsx | 48 + components/ui/select.tsx | 160 + components/ui/separator.tsx | 31 + components/ui/sheet.tsx | 139 + components/ui/skeleton.tsx | 15 + components/ui/slider.tsx | 28 + components/ui/sonner.tsx | 31 + components/ui/switch.tsx | 29 + components/ui/table.tsx | 117 + components/ui/tabs.tsx | 55 + components/ui/textarea-autosize.tsx | 44 + components/ui/textarea.tsx | 24 + components/ui/toast.tsx | 127 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 61 + components/ui/toggle.tsx | 45 + components/ui/tooltip.tsx | 30 + components/ui/use-toast.ts | 189 + components/ui/with-tooltip.tsx | 33 + components/utility/alerts.tsx | 30 + components/utility/announcements.tsx | 177 + components/utility/command-k.tsx | 87 + components/utility/global-state.tsx | 350 + components/utility/import.tsx | 283 + components/utility/profile-settings.tsx | 501 + components/utility/providers.tsx | 14 + components/utility/share-menu.tsx | 148 + components/utility/theme-switcher.tsx | 32 + components/utility/workspace-switcher.tsx | 152 + components/workspace/assign-workspaces.tsx | 159 + components/workspace/delete-workspace.tsx | 100 + components/workspace/workspace-settings.tsx | 223 + context/context.tsx | 184 + db/assistants.ts | 184 + db/chat-files.ts | 50 + db/chats.ts | 81 + db/collection-files.ts | 54 + db/collections.ts | 184 + db/files.ts | 208 + db/folders.ts | 57 + db/index.ts | 10 + db/limits.ts | 35 + db/message-file-items.ts | 36 + db/messages.ts | 104 + db/presets.ts | 177 + db/profile.ts | 71 + db/prompts.ts | 177 + db/storage/assistant-images.ts | 45 + db/storage/files.ts | 52 + db/storage/message-images.ts | 33 + db/storage/profile-images.ts | 33 + db/workspaces.ts | 77 + lib/blob-to-b64.ts | 8 + lib/build-prompt.ts | 267 + lib/canvas-preview.ts | 58 + lib/chat-setting-limits.ts | 94 + lib/consume-stream.ts | 32 + lib/effects/use-debounce.ts | 18 + lib/generate-local-embedding.ts | 17 + lib/hooks/use-copy-to-clipboard.tsx | 31 + lib/hooks/use-hotkey.tsx | 20 + lib/is-model-locked.ts | 24 + lib/models/llm/anthropic-llm-list.ts | 28 + lib/models/llm/google-llm-list.ts | 27 + lib/models/llm/llm-list.ts | 15 + lib/models/llm/mistral-llm-list.ts | 37 + lib/models/llm/openai-llm-list.ts | 37 + lib/models/llm/perplexity-llm-list.ts | 31 + lib/retrieval/processing/csv.ts | 32 + lib/retrieval/processing/doc.ts | 35 + lib/retrieval/processing/docx.ts | 35 + lib/retrieval/processing/html.ts | 35 + lib/retrieval/processing/index.ts | 1 + lib/retrieval/processing/json.ts | 35 + lib/retrieval/processing/md.ts | 37 + lib/retrieval/processing/pdf.ts | 50 + lib/retrieval/processing/txt.ts | 34 + lib/server-chat-helpers.ts | 41 + lib/supabase/browser-client.ts | 7 + lib/supabase/client.ts | 7 + lib/supabase/middleware.ts | 61 + lib/supabase/server.ts | 34 + lib/utils.ts | 15 + license | 21 + middleware.ts | 18 + next.config.js | 27 + package-lock.json | 11318 ++++++++++++++++ package.json | 114 + postcss.config.js | 6 + prettier.config.cjs | 36 + public/DARK_BRAND_LOGO.png | Bin 0 -> 7831 bytes public/LIGHT_BRAND_LOGO.png | Bin 0 -> 7831 bytes public/providers/meta.png | Bin 0 -> 16022 bytes public/providers/mistral.png | Bin 0 -> 64929 bytes public/providers/perplexity.png | Bin 0 -> 24298 bytes public/readme/screenshot.png | Bin 0 -> 151168 bytes supabase/.gitignore | 3 + supabase/config.toml | 117 + supabase/migrations/20240108234540_setup.sql | 81 + .../20240108234541_add_profiles.sql | 171 + .../20240108234542_add_workspaces.sql | 91 + .../migrations/20240108234543_add_folders.sql | 42 + .../migrations/20240108234544_add_files.sql | 157 + .../20240108234545_add_file_items.sql | 117 + .../migrations/20240108234546_add_presets.sql | 96 + .../20240108234547_add_assistants.sql | 161 + .../migrations/20240108234548_add_chats.sql | 96 + .../20240108234549_add_messages.sql | 173 + .../migrations/20240108234550_add_prompts.sql | 89 + .../20240108234551_add_collections.sql | 145 + supabase/types.ts | 1446 ++ tailwind.config.ts | 76 + tsconfig.json | 27 + types/announcement.ts | 8 + types/assistant-image.ts | 6 + types/chat-file.tsx | 6 + types/chat-message.ts | 6 + types/chat.ts | 25 + types/collection-file.ts | 5 + types/content-type.ts | 7 + types/file-item-chunk.ts | 4 + types/index.ts | 12 + types/llms.ts | 44 + types/message-image.ts | 7 + types/models.ts | 8 + types/sharing.ts | 1 + types/sidebar-data.ts | 17 + 271 files changed, 34589 insertions(+) create mode 100644 .env.local.example create mode 100644 .eslintrc.json create mode 100644 .github/funding.yaml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 README.md create mode 100644 app/api/chat/anthropic/route.ts create mode 100644 app/api/chat/azure/route.ts create mode 100644 app/api/chat/google/route.ts create mode 100644 app/api/chat/mistral/route.ts create mode 100644 app/api/chat/openai/route.ts create mode 100644 app/api/chat/perplexity/route.ts create mode 100644 app/api/command/route.ts create mode 100644 app/api/localhost/ollama/route.ts create mode 100644 app/api/retrieval/process/route.ts create mode 100644 app/api/retrieval/retrieve/route.ts create mode 100644 app/api/username/available/route.ts create mode 100644 app/api/username/get/route.ts create mode 100644 app/auth/callback/route.ts create mode 100644 app/chat/[chatid]/page.tsx create mode 100644 app/chat/layout.tsx create mode 100644 app/chat/page.tsx create mode 100644 app/globals.css create mode 100644 app/help/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/loading.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/setup/page.tsx create mode 100644 app/share/assistant/[id]/page.tsx create mode 100644 app/share/chat/[id]/page.tsx create mode 100644 app/share/collection/[id]/page.tsx create mode 100644 app/share/file/[id]/page.tsx create mode 100644 app/share/preset/[id]/page.tsx create mode 100644 app/share/prompt/[id]/page.tsx create mode 100644 components.json create mode 100644 components/chat/chat-command-input.tsx create mode 100644 components/chat/chat-files-display.tsx create mode 100644 components/chat/chat-help.tsx create mode 100644 components/chat/chat-helpers/index.ts create mode 100644 components/chat/chat-hooks/use-chat-handler.tsx create mode 100644 components/chat/chat-hooks/use-prompt-and-command.tsx create mode 100644 components/chat/chat-hooks/use-scroll.tsx create mode 100644 components/chat/chat-hooks/use-select-file-handler.tsx create mode 100644 components/chat/chat-input.tsx create mode 100644 components/chat/chat-messages.tsx create mode 100644 components/chat/chat-scroll-buttons.tsx create mode 100644 components/chat/chat-secondary-buttons.tsx create mode 100644 components/chat/chat-settings.tsx create mode 100644 components/chat/chat-ui.tsx create mode 100644 components/chat/file-picker.tsx create mode 100644 components/chat/prompt-picker.tsx create mode 100644 components/chat/quick-setting-option.tsx create mode 100644 components/chat/quick-settings.tsx create mode 100644 components/icons/anthropic-svg.tsx create mode 100644 components/icons/chatbotui-svg.tsx create mode 100644 components/icons/google-svg.tsx create mode 100644 components/icons/openai-svg.tsx create mode 100644 components/messages/message-actions.tsx create mode 100644 components/messages/message-codeblock.tsx create mode 100644 components/messages/message-markdown-memoized.tsx create mode 100644 components/messages/message-markdown.tsx create mode 100644 components/messages/message-replies.tsx create mode 100644 components/messages/message.tsx create mode 100644 components/models/model-icon.tsx create mode 100644 components/models/model-option.tsx create mode 100644 components/models/model-select.tsx create mode 100644 components/setup/api-step.tsx create mode 100644 components/setup/finish-step.tsx create mode 100644 components/setup/profile-step.tsx create mode 100644 components/setup/step-container.tsx create mode 100644 components/setup/workspace-step.tsx create mode 100644 components/sharing/add-to-workspace.tsx create mode 100644 components/sharing/share-assistant.tsx create mode 100644 components/sharing/share-chat.tsx create mode 100644 components/sharing/share-collection.tsx create mode 100644 components/sharing/share-file.tsx create mode 100644 components/sharing/share-header.tsx create mode 100644 components/sharing/share-item.tsx create mode 100644 components/sharing/share-message.tsx create mode 100644 components/sharing/share-page.tsx create mode 100644 components/sharing/share-preset.tsx create mode 100644 components/sharing/share-prompt.tsx create mode 100644 components/sidebar/items/all/sidebar-create-item.tsx create mode 100644 components/sidebar/items/all/sidebar-delete-item.tsx create mode 100644 components/sidebar/items/all/sidebar-display-item.tsx create mode 100644 components/sidebar/items/all/sidebar-update-item.tsx create mode 100644 components/sidebar/items/assistants/assistant-item.tsx create mode 100644 components/sidebar/items/assistants/create-assistant.tsx create mode 100644 components/sidebar/items/chat/chat-item.tsx create mode 100644 components/sidebar/items/chat/delete-chat.tsx create mode 100644 components/sidebar/items/chat/update-chat.tsx create mode 100644 components/sidebar/items/collections/collection-file-picker.tsx create mode 100644 components/sidebar/items/collections/collection-item.tsx create mode 100644 components/sidebar/items/collections/create-collection.tsx create mode 100644 components/sidebar/items/files/create-file.tsx create mode 100644 components/sidebar/items/files/file-item.tsx create mode 100644 components/sidebar/items/folders/delete-folder.tsx create mode 100644 components/sidebar/items/folders/folder-item.tsx create mode 100644 components/sidebar/items/folders/update-folder.tsx create mode 100644 components/sidebar/items/presets/create-preset.tsx create mode 100644 components/sidebar/items/presets/preset-item.tsx create mode 100644 components/sidebar/items/prompts/create-prompt.tsx create mode 100644 components/sidebar/items/prompts/prompt-item.tsx create mode 100644 components/sidebar/sidebar-content.tsx create mode 100644 components/sidebar/sidebar-create-buttons.tsx create mode 100644 components/sidebar/sidebar-data-list.tsx create mode 100644 components/sidebar/sidebar-search.tsx create mode 100644 components/sidebar/sidebar-switch-item.tsx create mode 100644 components/sidebar/sidebar-switcher.tsx create mode 100644 components/sidebar/sidebar.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/advanced-settings.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/brand.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/chat-settings-form.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dashboard.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/file-icon.tsx create mode 100644 components/ui/file-preview.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/image-picker.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/limit-display.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/screen-loader.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea-autosize.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 components/ui/with-tooltip.tsx create mode 100644 components/utility/alerts.tsx create mode 100644 components/utility/announcements.tsx create mode 100644 components/utility/command-k.tsx create mode 100644 components/utility/global-state.tsx create mode 100644 components/utility/import.tsx create mode 100644 components/utility/profile-settings.tsx create mode 100644 components/utility/providers.tsx create mode 100644 components/utility/share-menu.tsx create mode 100644 components/utility/theme-switcher.tsx create mode 100644 components/utility/workspace-switcher.tsx create mode 100644 components/workspace/assign-workspaces.tsx create mode 100644 components/workspace/delete-workspace.tsx create mode 100644 components/workspace/workspace-settings.tsx create mode 100644 context/context.tsx create mode 100644 db/assistants.ts create mode 100644 db/chat-files.ts create mode 100644 db/chats.ts create mode 100644 db/collection-files.ts create mode 100644 db/collections.ts create mode 100644 db/files.ts create mode 100644 db/folders.ts create mode 100644 db/index.ts create mode 100644 db/limits.ts create mode 100644 db/message-file-items.ts create mode 100644 db/messages.ts create mode 100644 db/presets.ts create mode 100644 db/profile.ts create mode 100644 db/prompts.ts create mode 100644 db/storage/assistant-images.ts create mode 100644 db/storage/files.ts create mode 100644 db/storage/message-images.ts create mode 100644 db/storage/profile-images.ts create mode 100644 db/workspaces.ts create mode 100644 lib/blob-to-b64.ts create mode 100644 lib/build-prompt.ts create mode 100644 lib/canvas-preview.ts create mode 100644 lib/chat-setting-limits.ts create mode 100644 lib/consume-stream.ts create mode 100644 lib/effects/use-debounce.ts create mode 100644 lib/generate-local-embedding.ts create mode 100644 lib/hooks/use-copy-to-clipboard.tsx create mode 100644 lib/hooks/use-hotkey.tsx create mode 100644 lib/is-model-locked.ts create mode 100644 lib/models/llm/anthropic-llm-list.ts create mode 100644 lib/models/llm/google-llm-list.ts create mode 100644 lib/models/llm/llm-list.ts create mode 100644 lib/models/llm/mistral-llm-list.ts create mode 100644 lib/models/llm/openai-llm-list.ts create mode 100644 lib/models/llm/perplexity-llm-list.ts create mode 100644 lib/retrieval/processing/csv.ts create mode 100644 lib/retrieval/processing/doc.ts create mode 100644 lib/retrieval/processing/docx.ts create mode 100644 lib/retrieval/processing/html.ts create mode 100644 lib/retrieval/processing/index.ts create mode 100644 lib/retrieval/processing/json.ts create mode 100644 lib/retrieval/processing/md.ts create mode 100644 lib/retrieval/processing/pdf.ts create mode 100644 lib/retrieval/processing/txt.ts create mode 100644 lib/server-chat-helpers.ts create mode 100644 lib/supabase/browser-client.ts create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/middleware.ts create mode 100644 lib/supabase/server.ts create mode 100644 lib/utils.ts create mode 100644 license create mode 100644 middleware.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prettier.config.cjs create mode 100644 public/DARK_BRAND_LOGO.png create mode 100644 public/LIGHT_BRAND_LOGO.png create mode 100644 public/providers/meta.png create mode 100644 public/providers/mistral.png create mode 100644 public/providers/perplexity.png create mode 100644 public/readme/screenshot.png create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20240108234540_setup.sql create mode 100644 supabase/migrations/20240108234541_add_profiles.sql create mode 100644 supabase/migrations/20240108234542_add_workspaces.sql create mode 100644 supabase/migrations/20240108234543_add_folders.sql create mode 100644 supabase/migrations/20240108234544_add_files.sql create mode 100644 supabase/migrations/20240108234545_add_file_items.sql create mode 100644 supabase/migrations/20240108234546_add_presets.sql create mode 100644 supabase/migrations/20240108234547_add_assistants.sql create mode 100644 supabase/migrations/20240108234548_add_chats.sql create mode 100644 supabase/migrations/20240108234549_add_messages.sql create mode 100644 supabase/migrations/20240108234550_add_prompts.sql create mode 100644 supabase/migrations/20240108234551_add_collections.sql create mode 100644 supabase/types.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/announcement.ts create mode 100644 types/assistant-image.ts create mode 100644 types/chat-file.tsx create mode 100644 types/chat-message.ts create mode 100644 types/chat.ts create mode 100644 types/collection-file.ts create mode 100644 types/content-type.ts create mode 100644 types/file-item-chunk.ts create mode 100644 types/index.ts create mode 100644 types/llms.ts create mode 100644 types/message-image.ts create mode 100644 types/models.ts create mode 100644 types/sharing.ts create mode 100644 types/sidebar-data.ts diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000000..375977fc85 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,9 @@ +# Supabase Public +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Supabase Private +SUPABASE_SERVICE_ROLE_KEY= + +# Ollama +NEXT_PUBLIC_OLLAMA_URL=http://localhost:11434 # Default diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..6ec5479fec --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc", + "root": true, + "extends": [ + "next/core-web-vitals", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "tailwindcss/no-custom-classname": "off" + }, + "settings": { + "tailwindcss": { + "callees": ["cn", "cva"], + "config": "tailwind.config.js" + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/.github/funding.yaml b/.github/funding.yaml new file mode 100644 index 0000000000..d6494ef5b2 --- /dev/null +++ b/.github/funding.yaml @@ -0,0 +1,3 @@ +# If you find my open-source work helpful, please consider sponsoring me! + +github: mckaywrigley diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..ebcb6f2436 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +seed.sql +.VSCodeCounter \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..d2eb6755b1 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run lint:fix && npm run format:write && git add . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000..65c16f5318 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Chatbot UI + +The open-source AI chat app for everyone. + +![Chatbot UI](./public/readme/screenshot.png) + +## Demo + +View the latest demo [here](https://twitter.com/mckaywrigley). + +## Support + +If you find Chatbot UI useful, please consider [sponsoring](https://github.com/mckaywrigley?tab=sponsoring) me :) + +## Quickstart + +### 1. Clone the repo + +```bash +git clone https://github.com/mckaywrigley/chatbot-ui.git +``` + +### 2. Install dependencies + +```bash +npm install +``` + +### 3. Install Supabase & run locally + +1. Install Supabase CLI + +```bash +brew install supabase/tap/supabase +``` + +2. Start Supabase + +```bash +supabase start +``` + +### 4. Install Ollama (for local models) + +Follow the instructions [here](https://github.com/jmorganca/ollama#macos) + +### 5. Fill in secrets + +1. .env + +```bash +cp .env.local.example .env.local +``` + +Get the required values by running: + +```bash +supabase status +``` + +2. sql + +In the 1st migration file `20240108234540_setup.sql` you will need to replace 2 values: + +- `project_url` (line 53): This can remain unchanged if you don't change your `config.toml` file. +- `service_role_key` (line 54): You got this value from running `supabase status` in step 5.1. + +You will also need to to fill in the values for project_url + +### 6. Run app locally + +```bash +npm run chat +``` + +## Contributing + +We are working on a guide for contributing. + +## Contact + +Message Mckay on [Twitter/X](https://twitter.com/mckaywrigley) diff --git a/app/api/chat/anthropic/route.ts b/app/api/chat/anthropic/route.ts new file mode 100644 index 0000000000..ded55d0cf7 --- /dev/null +++ b/app/api/chat/anthropic/route.ts @@ -0,0 +1,47 @@ +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { ChatSettings } from "@/types" +import Anthropic from "@anthropic-ai/sdk" +import { AnthropicStream, StreamingTextResponse } from "ai" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { chatSettings, messages } = json as { + chatSettings: ChatSettings + messages: any[] + } + + try { + const profile = await getServerProfile() + + checkApiKey(profile.anthropic_api_key, "Anthropic") + + let ANTHROPIC_FORMATTED_MESSAGES: any = messages.slice(1) + + const anthropic = new Anthropic({ + apiKey: profile.anthropic_api_key || "" + }) + + const response = await anthropic.beta.messages.create({ + model: chatSettings.model, + messages: ANTHROPIC_FORMATTED_MESSAGES, + temperature: chatSettings.temperature, + system: messages[0].content, + max_tokens: + CHAT_SETTING_LIMITS[chatSettings.model].MAX_TOKEN_OUTPUT_LENGTH, + stream: true + }) + + const stream = AnthropicStream(response) + + return new StreamingTextResponse(stream) + } catch (error: any) { + const errorMessage = error.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/chat/azure/route.ts b/app/api/chat/azure/route.ts new file mode 100644 index 0000000000..1200335232 --- /dev/null +++ b/app/api/chat/azure/route.ts @@ -0,0 +1,74 @@ +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { ChatAPIPayload } from "@/types" +import { OpenAIStream, StreamingTextResponse } from "ai" +import OpenAI from "openai" +import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { chatSettings, messages } = json as ChatAPIPayload + + try { + const profile = await getServerProfile() + + checkApiKey(profile.azure_openai_api_key, "Azure") + + const ENDPOINT = profile.azure_openai_endpoint + const KEY = profile.azure_openai_api_key + + let DEPLOYMENT_ID = "" + switch (chatSettings.model) { + case "gpt-3.5-turbo-1106": + DEPLOYMENT_ID = profile.azure_openai_35_turbo_id || "" + break + case "gpt-4-1106-preview": + DEPLOYMENT_ID = profile.azure_openai_45_turbo_id || "" + break + case "gpt-4-vision-preview": + DEPLOYMENT_ID = profile.azure_openai_45_vision_id || "" + break + default: + return new Response(JSON.stringify({ message: "Model not found" }), { + status: 400 + }) + } + + if (!ENDPOINT || !KEY || !DEPLOYMENT_ID) { + return new Response( + JSON.stringify({ message: "Azure resources not found" }), + { + status: 400 + } + ) + } + + const azureOpenai = new OpenAI({ + apiKey: KEY, + baseURL: `${ENDPOINT}/openai/deployments/${DEPLOYMENT_ID}`, + defaultQuery: { "api-version": "2023-07-01-preview" }, + defaultHeaders: { "api-key": KEY } + }) + + const response = await azureOpenai.chat.completions.create({ + model: DEPLOYMENT_ID as ChatCompletionCreateParamsBase["model"], + messages: messages as ChatCompletionCreateParamsBase["messages"], + temperature: chatSettings.temperature, + max_tokens: + CHAT_SETTING_LIMITS[chatSettings.model].MAX_TOKEN_OUTPUT_LENGTH, + stream: true + }) + + const stream = OpenAIStream(response) + + return new StreamingTextResponse(stream) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/chat/google/route.ts b/app/api/chat/google/route.ts new file mode 100644 index 0000000000..225e2aca87 --- /dev/null +++ b/app/api/chat/google/route.ts @@ -0,0 +1,72 @@ +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { ChatSettings } from "@/types" +import { GoogleGenerativeAI } from "@google/generative-ai" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { chatSettings, messages } = json as { + chatSettings: ChatSettings + messages: any[] + } + + try { + const profile = await getServerProfile() + + checkApiKey(profile.google_gemini_api_key, "Google") + + const genAI = new GoogleGenerativeAI(profile.google_gemini_api_key || "") + const googleModel = genAI.getGenerativeModel({ model: chatSettings.model }) + + if (chatSettings.model === "gemini-pro") { + const lastMessage = messages.pop() + + const chat = googleModel.startChat({ + history: messages, + generationConfig: { + temperature: chatSettings.temperature + } + }) + + const response = await chat.sendMessageStream(lastMessage.parts) + + const encoder = new TextEncoder() + const readableStream = new ReadableStream({ + async start(controller) { + for await (const chunk of response.stream) { + const chunkText = chunk.text() + controller.enqueue(encoder.encode(chunkText)) + } + controller.close() + } + }) + + return new Response(readableStream, { + headers: { "Content-Type": "text/plain" } + }) + } else if (chatSettings.model === "gemini-pro-vision") { + // FIX: Hacky until chat messages are supported + const HACKY_MESSAGE = messages[messages.length - 1] + + const result = await googleModel.generateContent([ + HACKY_MESSAGE.prompt, + HACKY_MESSAGE.imageParts + ]) + + const response = result.response + + const text = response.text() + + return new Response(text, { + headers: { "Content-Type": "text/plain" } + }) + } + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/chat/mistral/route.ts b/app/api/chat/mistral/route.ts new file mode 100644 index 0000000000..76ad728ddc --- /dev/null +++ b/app/api/chat/mistral/route.ts @@ -0,0 +1,71 @@ +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { ChatSettings } from "@/types" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { chatSettings, messages } = json as { + chatSettings: ChatSettings + messages: any[] + } + + try { + const profile = await getServerProfile() + + checkApiKey(profile.mistral_api_key, "Mistral") + + const response = await fetch("https://api.mistral.ai/v1/chat/completions", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${profile.mistral_api_key}` + }, + body: JSON.stringify({ + model: chatSettings.model, + messages: messages, + temperature: chatSettings.temperature, + stream: true + }) + }) + + const readableStream = new ReadableStream({ + async start(controller) { + if (!response.body) { + throw new Error("No response body!") + } + + const reader = response.body.getReader() + let isFirstChunk = true + while (true) { + const { done, value } = await reader.read() + if (done) { + controller.close() + break + } + const chunk = new TextDecoder("utf-8").decode(value) + const dataParts = chunk.split("data: ") + const data = + isFirstChunk && dataParts[2] ? dataParts[2] : dataParts[1] + if (data) { + const parsedData = JSON.parse(data) + const messageContent = parsedData.choices[0].delta.content + controller.enqueue(new TextEncoder().encode(messageContent)) + } + isFirstChunk = false + } + } + }) + + return new Response(readableStream, { + headers: { "Content-Type": "text/plain" } + }) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/chat/openai/route.ts b/app/api/chat/openai/route.ts new file mode 100644 index 0000000000..a2d5cf9ed2 --- /dev/null +++ b/app/api/chat/openai/route.ts @@ -0,0 +1,46 @@ +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { ChatSettings } from "@/types" +import { OpenAIStream, StreamingTextResponse } from "ai" +import OpenAI from "openai" +import { ChatCompletionCreateParamsBase } from "openai/resources/chat/completions.mjs" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { chatSettings, messages } = json as { + chatSettings: ChatSettings + messages: any[] + } + + try { + const profile = await getServerProfile() + + checkApiKey(profile.openai_api_key, "OpenAI") + + const openai = new OpenAI({ + apiKey: profile.openai_api_key || "", + organization: profile.openai_organization_id + }) + + const response = await openai.chat.completions.create({ + model: chatSettings.model as ChatCompletionCreateParamsBase["model"], + messages: messages as ChatCompletionCreateParamsBase["messages"], + temperature: chatSettings.temperature, + max_tokens: + CHAT_SETTING_LIMITS[chatSettings.model].MAX_TOKEN_OUTPUT_LENGTH, + stream: true + }) + + const stream = OpenAIStream(response) + + return new StreamingTextResponse(stream) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/chat/perplexity/route.ts b/app/api/chat/perplexity/route.ts new file mode 100644 index 0000000000..6f29d03b65 --- /dev/null +++ b/app/api/chat/perplexity/route.ts @@ -0,0 +1,68 @@ +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { ChatSettings } from "@/types" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { chatSettings, messages } = json as { + chatSettings: ChatSettings + messages: any[] + } + + try { + const profile = await getServerProfile() + + checkApiKey(profile.anthropic_api_key, "Anthropic") + + const response = await fetch("https://api.perplexity.ai/chat/completions", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${profile.perplexity_api_key}` + }, + body: JSON.stringify({ + model: chatSettings.model, + messages: messages, + temperature: chatSettings.temperature, + stream: true + }) + }) + + const readableStream = new ReadableStream({ + async start(controller) { + if (!response.body) { + throw new Error("No response body!") + } + + const reader = response.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + controller.close() + break + } + const chunk = new TextDecoder("utf-8").decode(value) + + const data = chunk.split("data: ")[1] + if (data) { + const parsedData = JSON.parse(data) + const messageContent = parsedData.choices[0].delta.content + controller.enqueue(new TextEncoder().encode(messageContent)) + } + } + } + }) + + return new Response(readableStream, { + headers: { "Content-Type": "text/plain" } + }) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/command/route.ts b/app/api/command/route.ts new file mode 100644 index 0000000000..d317467d93 --- /dev/null +++ b/app/api/command/route.ts @@ -0,0 +1,54 @@ +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import OpenAI from "openai" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { input } = json as { + input: string + } + + try { + const profile = await getServerProfile() + + checkApiKey(profile.openai_api_key, "OpenAI") + + const openai = new OpenAI({ + apiKey: profile.openai_api_key || "", + organization: profile.openai_organization_id + }) + + const response = await openai.chat.completions.create({ + model: "gpt-4-1106-preview", + messages: [ + { + role: "system", + content: "Respond to the user." + }, + { + role: "user", + content: input + } + ], + temperature: 0, + max_tokens: + CHAT_SETTING_LIMITS["gpt-4-1106-preview"].MAX_TOKEN_OUTPUT_LENGTH + // response_format: { type: "json_object" } + // stream: true + }) + + const content = response.choices[0].message.content + + return new Response(JSON.stringify({ content }), { + status: 200 + }) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/localhost/ollama/route.ts b/app/api/localhost/ollama/route.ts new file mode 100644 index 0000000000..9d7899e625 --- /dev/null +++ b/app/api/localhost/ollama/route.ts @@ -0,0 +1,39 @@ +import { LLM, LLMID } from "@/types" +import { exec } from "child_process" + +export function GET(): Promise { + return new Promise((resolve, reject) => { + exec("ollama serve", (error: any, stdout: any, stderr: any) => {}) + + exec("ollama list", (error: any, stdout: any, stderr: any) => { + if (error) { + reject(error) + return + } + + if (stderr) { + reject(new Error(stderr)) + return + } + + const rows = stdout.split("\n") + const dataRows = rows.slice(1, -1) + const modelNames = dataRows.map((row: string) => row.split(/\s+/)[0]) + + const localModels = modelNames.map((modelName: string) => { + const model: LLM = { + modelId: modelName as LLMID, + modelName: modelName, + provider: "ollama", + hostedId: modelName, + platformLink: "https://ollama.ai/library", + imageInput: false + } + + return model + }) + + resolve(new Response(JSON.stringify({ localModels }))) + }) + }) +} diff --git a/app/api/retrieval/process/route.ts b/app/api/retrieval/process/route.ts new file mode 100644 index 0000000000..5f09d04d74 --- /dev/null +++ b/app/api/retrieval/process/route.ts @@ -0,0 +1,150 @@ +import { generateLocalEmbedding } from "@/lib/generate-local-embedding" +import { processCSV } from "@/lib/retrieval/processing/csv" +import { processDoc } from "@/lib/retrieval/processing/doc" +import { processDocX } from "@/lib/retrieval/processing/docx" +import { processHTML } from "@/lib/retrieval/processing/html" +import { processJSON } from "@/lib/retrieval/processing/json" +import { processMarkdown } from "@/lib/retrieval/processing/md" +import { processPdf } from "@/lib/retrieval/processing/pdf" +import { processTxt } from "@/lib/retrieval/processing/txt" +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { Database } from "@/supabase/types" +import { FileItemChunk } from "@/types" +import { createClient } from "@supabase/supabase-js" +import { NextResponse } from "next/server" +import OpenAI from "openai" + +export async function POST(req: Request) { + try { + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + const profile = await getServerProfile() + + checkApiKey(profile.openai_api_key, "OpenAI") + + const formData = await req.formData() + + const file = formData.get("file") as File + const file_id = formData.get("file_id") as string + const embeddingsProvider = formData.get("embeddingsProvider") as string + + const fileBuffer = Buffer.from(await file.arrayBuffer()) + const blob = new Blob([fileBuffer]) + const fileExtension = file.name.split(".").pop()?.toLowerCase() + + let chunks: FileItemChunk[] = [] + + switch (fileExtension) { + case "csv": + chunks = await processCSV(blob) + break + case "doc": + chunks = await processDoc(blob) + break + case "docx": + console.log("docx") + chunks = await processDocX(blob) + break + case "html": + chunks = await processHTML(blob) + break + case "json": + chunks = await processJSON(blob) + break + case "md": + chunks = await processMarkdown(blob) + break + case "pdf": + console.log("pdf") + chunks = await processPdf(blob) + break + case "txt": + chunks = await processTxt(blob) + break + default: + return new NextResponse("Unsupported file type", { + status: 400 + }) + } + + let embeddings: any = [] + + if (embeddingsProvider === "openai") { + const openai = new OpenAI({ + apiKey: profile.openai_api_key || "", + organization: profile.openai_organization_id + }) + + const response = await openai.embeddings.create({ + model: "text-embedding-ada-002", + input: chunks.map(chunk => chunk.content) + }) + + embeddings = response.data.map((item: any) => { + return item.embedding + }) + } else if (embeddingsProvider === "local") { + const embeddingPromises = chunks.map(async chunk => { + try { + return await generateLocalEmbedding(chunk.content) + } catch (error) { + console.error(`Error generating embedding for chunk: ${chunk}`, error) + return null + } + }) + + embeddings = await Promise.all(embeddingPromises) + } + + console.log( + embeddings[0], + "embeddings", + embeddingsProvider, + embeddings.length + ) + + const file_items = chunks.map((chunk, index) => ({ + file_id, + user_id: profile.user_id, + content: chunk.content, + tokens: chunk.tokens, + openai_embedding: + embeddingsProvider === "openai" + ? ((embeddings[index] || null) as any) + : null, + local_embedding: + embeddingsProvider === "local" + ? ((embeddings[index] || null) as any) + : null + })) + + const { data: fileItemData, error: fileItemError } = await supabaseAdmin + .from("file_items") + .upsert(file_items) + + if (fileItemError) { + console.log("fileItemError", fileItemError) + } + + const totalTokens = file_items.reduce((acc, item) => acc + item.tokens, 0) + + await supabaseAdmin + .from("files") + .update({ tokens: totalTokens }) + .eq("id", file_id) + + return new NextResponse("Embed Successful", { + status: 200 + }) + } catch (error: any) { + console.error(error) + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/retrieval/retrieve/route.ts b/app/api/retrieval/retrieve/route.ts new file mode 100644 index 0000000000..c101c971db --- /dev/null +++ b/app/api/retrieval/retrieve/route.ts @@ -0,0 +1,108 @@ +import { generateLocalEmbedding } from "@/lib/generate-local-embedding" +import { checkApiKey, getServerProfile } from "@/lib/server-chat-helpers" +import { Database } from "@/supabase/types" +import { createClient } from "@supabase/supabase-js" +import OpenAI from "openai" + +export async function POST(request: Request) { + const json = await request.json() + const { userInput, fileIds, embeddingsProvider } = json as { + userInput: string + fileIds: string[] + embeddingsProvider: "openai" | "local" + } + + try { + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + const profile = await getServerProfile() + + checkApiKey(profile.openai_api_key, "OpenAI") + + console.log("userInput", userInput) + console.log("fileIds", fileIds) + + let chunks: any[] = [] + + const MATCH_COUNT = 100 + + if (embeddingsProvider === "openai") { + console.log("openai") + + const openai = new OpenAI({ + apiKey: profile.openai_api_key || "", + organization: profile.openai_organization_id + }) + + const response = await openai.embeddings.create({ + model: "text-embedding-ada-002", + input: userInput + }) + + const openaiEmbedding = response.data.map(item => item.embedding)[0] + console.log("openaiEmbedding", openaiEmbedding.length) + + const { data: openaiFileItems, error: openaiError } = + await supabaseAdmin.rpc("match_file_items_openai", { + query_embedding: openaiEmbedding as any, + match_count: MATCH_COUNT, + file_ids: fileIds + }) + + if (openaiError) { + throw openaiError + } + + chunks = openaiFileItems + } else if (embeddingsProvider === "local") { + console.log("local") + + const localEmbedding = await generateLocalEmbedding(userInput) + + const { data: localFileItems, error: localError } = + await supabaseAdmin.rpc("match_file_items_local", { + query_embedding: localEmbedding as any, + match_count: MATCH_COUNT, + file_ids: fileIds + }) + + if (localError) { + throw localError + } + + chunks = localFileItems + } + + const totalTokenCount = chunks?.reduce( + (total, chunk) => total + chunk.tokens, + 0 + ) + + const topThreeSimilar = chunks + ?.sort((a, b) => b.similarity - a.similarity) + .slice(0, 3) + console.log(topThreeSimilar, "Top 3 most similar:") + + const tokenCountTopThree = topThreeSimilar?.reduce( + (total, chunk) => total + chunk.tokens, + 0 + ) + + console.log("Total token count of all 100 combined:", totalTokenCount) + + console.log("Token count of top 3 most similar:", tokenCountTopThree) + + return new Response(JSON.stringify({ results: topThreeSimilar }), { + status: 200 + }) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/username/available/route.ts b/app/api/username/available/route.ts new file mode 100644 index 0000000000..bf00ee07f6 --- /dev/null +++ b/app/api/username/available/route.ts @@ -0,0 +1,37 @@ +import { Database } from "@/supabase/types" +import { createClient } from "@supabase/supabase-js" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { username } = json as { + username: string + } + + try { + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + const { data: usernames, error } = await supabaseAdmin + .from("profiles") + .select("username") + .eq("username", username) + + if (!usernames) { + throw new Error(error.message) + } + + return new Response(JSON.stringify({ isAvailable: !usernames.length }), { + status: 200 + }) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/api/username/get/route.ts b/app/api/username/get/route.ts new file mode 100644 index 0000000000..d3cd158021 --- /dev/null +++ b/app/api/username/get/route.ts @@ -0,0 +1,38 @@ +import { Database } from "@/supabase/types" +import { createClient } from "@supabase/supabase-js" + +export const runtime = "edge" + +export async function POST(request: Request) { + const json = await request.json() + const { userId } = json as { + userId: string + } + + try { + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + const { data, error } = await supabaseAdmin + .from("profiles") + .select("username") + .eq("user_id", userId) + .single() + + if (!data) { + throw new Error(error.message) + } + + return new Response(JSON.stringify({ username: data.username }), { + status: 200 + }) + } catch (error: any) { + const errorMessage = error.error?.message || "An unexpected error occurred" + const errorCode = error.status || 500 + return new Response(JSON.stringify({ message: errorMessage }), { + status: errorCode + }) + } +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000000..ece526325e --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,16 @@ +import { createClient } from "@/lib/supabase/server" +import { cookies } from "next/headers" +import { NextResponse } from "next/server" + +export async function GET(request: Request) { + const requestUrl = new URL(request.url) + const code = requestUrl.searchParams.get("code") + + if (code) { + const cookieStore = cookies() + const supabase = createClient(cookieStore) + await supabase.auth.exchangeCodeForSession(code) + } + + return NextResponse.redirect(requestUrl.origin) +} diff --git a/app/chat/[chatid]/page.tsx b/app/chat/[chatid]/page.tsx new file mode 100644 index 0000000000..30d082e82d --- /dev/null +++ b/app/chat/[chatid]/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import { ChatUI } from "@/components/chat/chat-ui" + +export default function ChatIDPage() { + return +} diff --git a/app/chat/layout.tsx b/app/chat/layout.tsx new file mode 100644 index 0000000000..96931c73d8 --- /dev/null +++ b/app/chat/layout.tsx @@ -0,0 +1,34 @@ +"use client" + +import { Dashboard } from "@/components/ui/dashboard" +import { supabase } from "@/lib/supabase/browser-client" +import { useRouter } from "next/navigation" +import { ReactNode, useEffect, useState } from "react" + +interface ChatLayoutProps { + children: ReactNode +} + +export default function ChatLayout({ children }: ChatLayoutProps) { + const [loading, setLoading] = useState(true) + + const router = useRouter() + + useEffect(() => { + ;(async () => { + const session = (await supabase.auth.getSession()).data.session + + if (!session) { + router.push("/login") + } else { + setLoading(false) + } + })() + }, []) + + if (loading) { + return null + } + + return {children} +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 0000000000..ef0b1c2165 --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,55 @@ +"use client" + +import { ChatHelp } from "@/components/chat/chat-help" +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatInput } from "@/components/chat/chat-input" +import { ChatSettings } from "@/components/chat/chat-settings" +import { ChatUI } from "@/components/chat/chat-ui" +import { QuickSettings } from "@/components/chat/quick-settings" +import { Brand } from "@/components/ui/brand" +import { ChatbotUIContext } from "@/context/context" +import useHotkey from "@/lib/hooks/use-hotkey" +import { useTheme } from "next-themes" +import { useContext } from "react" + +export default function ChatPage() { + useHotkey("o", () => handleNewChat()) + + const { chatMessages } = useContext(ChatbotUIContext) + + const { handleNewChat } = useChatHandler() + + const { theme } = useTheme() + + return ( + <> + {chatMessages.length === 0 ? ( +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ) : ( + + )} + + ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000..c0d1efcb5a --- /dev/null +++ b/app/globals.css @@ -0,0 +1,104 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +::-webkit-scrollbar-track { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: #ccc; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #aaa; +} + +::-webkit-scrollbar-track:hover { + background-color: #f2f2f2; +} + +::-webkit-scrollbar-corner { + background-color: transparent; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --ring: 0 0% 63.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + + --ring: 0 0% 14.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/help/page.tsx b/app/help/page.tsx new file mode 100644 index 0000000000..26a283e7b3 --- /dev/null +++ b/app/help/page.tsx @@ -0,0 +1,7 @@ +export default function HelpPage() { + return ( +
+
Help under construction.
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000..1ac5b1c53e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import { Toaster } from "@/components/ui/sonner" +import { GlobalState } from "@/components/utility/global-state" +import { Providers } from "@/components/utility/providers" +import { Database } from "@/supabase/types" +import { createServerClient } from "@supabase/ssr" +import { Inter } from "next/font/google" +import { cookies } from "next/headers" +import { ReactNode } from "react" +import "./globals.css" + +const inter = Inter({ subsets: ["latin"] }) + +interface RootLayoutProps { + children: ReactNode +} + +export default async function RootLayout({ children }: RootLayoutProps) { + const cookieStore = cookies() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value + } + } + } + ) + const session = (await supabase.auth.getSession()).data.session + + return ( + + + + +
+ {session ? {children} : children} +
+
+ + + ) +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000000..e88ca3e47c --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,9 @@ +import { IconLoader2 } from "@tabler/icons-react" + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000000..3b3ca3e3a9 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,130 @@ +import { Brand } from "@/components/ui/brand" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { createClient } from "@/lib/supabase/server" +import { Database } from "@/supabase/types" +import { createServerClient } from "@supabase/ssr" +import { cookies, headers } from "next/headers" +import { redirect } from "next/navigation" + +export default async function Login({ + searchParams +}: { + searchParams: { message: string } +}) { + const cookieStore = cookies() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value + } + } + } + ) + const session = (await supabase.auth.getSession()).data.session + + if (session) { + return redirect("/chat") + } + + const signIn = async (formData: FormData) => { + "use server" + + const email = formData.get("email") as string + const password = formData.get("password") as string + const cookieStore = cookies() + const supabase = createClient(cookieStore) + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password + }) + + if (error) { + return redirect("/login?message=Could not authenticate user") + } + + return redirect("/chat") + } + + const signUp = async (formData: FormData) => { + "use server" + + const origin = headers().get("origin") + const email = formData.get("email") as string + const password = formData.get("password") as string + const cookieStore = cookies() + const supabase = createClient(cookieStore) + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + // TODO: USE IF YOU WANT TO SEND EMAIL VERIFICATION, ALSO CHANGE TOML FILE + // emailRedirectTo: `${origin}/auth/callback` + } + }) + + if (error) { + return redirect("/login?message=Could not authenticate user") + } + + return redirect("/setup") + + // TODO: USE IF YOU WANT TO SEND EMAIL VERIFICATION, ALSO CHANGE TOML FILE + // return redirect("/login?message=Check email to continue sign in process") + } + + return ( +
+
+ + + + + + + + + + + + + {searchParams?.message && ( +

+ {searchParams.message} +

+ )} + +
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000000..c06795b886 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,57 @@ +"use client" + +import { ChatbotUISVG } from "@/components/icons/chatbotui-svg" +import { IconArrowRight } from "@tabler/icons-react" +import { useTheme } from "next-themes" +import Link from "next/link" +import { useEffect, useState } from "react" + +export default function HomePage() { + const { theme } = useTheme() + + const [stars, setStars] = useState(19000) + + useEffect(() => { + getGitHubRepoStars() + }, []) + + const getGitHubRepoStars = async () => { + const url = `https://api.github.com/repos/mckaywrigley/chatbot-ui` + + try { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github.v3+json" + } + }) + + if (!response.ok) { + throw new Error(`Error: ${response.status}`) + } + + const data = await response.json() + setStars(data.stargazers_count) + } catch (error) { + console.error("Failed to fetch GitHub stars:", error) + return 0 + } + } + + return ( +
+
+ +
+ +
Chatbot UI
+ + + Start Chatting + + +
+ ) +} diff --git a/app/setup/page.tsx b/app/setup/page.tsx new file mode 100644 index 0000000000..607b0931ba --- /dev/null +++ b/app/setup/page.tsx @@ -0,0 +1,294 @@ +"use client" + +import { ChatbotUIContext } from "@/context/context" +import { getProfileByUserId, updateProfile } from "@/db/profile" +import { uploadImage } from "@/db/storage/profile-images" +import { getWorkspacesByUserId, updateWorkspace } from "@/db/workspaces" +import { supabase } from "@/lib/supabase/browser-client" +import { TablesUpdate } from "@/supabase/types" +import { ChatSettings } from "@/types" +import { useRouter } from "next/navigation" +import { useContext, useEffect, useState } from "react" +import { APIStep } from "../../components/setup/api-step" +import { FinishStep } from "../../components/setup/finish-step" +import { ProfileStep } from "../../components/setup/profile-step" +import { + SETUP_STEP_COUNT, + StepContainer +} from "../../components/setup/step-container" +import { WorkspaceStep } from "../../components/setup/workspace-step" + +export default function SetupPage() { + const { profile, setProfile, setSelectedWorkspace, setWorkspaces } = + useContext(ChatbotUIContext) + + const router = useRouter() + + const [loading, setLoading] = useState(true) + + const [currentStep, setCurrentStep] = useState(1) + + // Profile Step + const [profileContext, setProfileContext] = useState("") + const [displayName, setDisplayName] = useState("") + const [username, setUsername] = useState(profile?.username || "") + const [usernameAvailable, setUsernameAvailable] = useState(true) + const [profileImageSrc, setProfileImageSrc] = useState("") + const [profileImage, setProfileImage] = useState(null) + + // API Step + const [useAzureOpenai, setUseAzureOpenai] = useState(false) + const [openaiAPIKey, setOpenaiAPIKey] = useState("") + const [openaiOrgID, setOpenaiOrgID] = useState("") + const [azureOpenaiAPIKey, setAzureOpenaiAPIKey] = useState("") + const [azureOpenaiEndpoint, setAzureOpenaiEndpoint] = useState("") + const [azureOpenai35TurboID, setAzureOpenai35TurboID] = useState("") + const [azureOpenai45TurboID, setAzureOpenai45TurboID] = useState("") + const [azureOpenai45VisionID, setAzureOpenai45VisionID] = useState("") + const [anthropicAPIKey, setAnthropicAPIKey] = useState("") + const [googleGeminiAPIKey, setGoogleGeminiAPIKey] = useState("") + const [mistralAPIKey, setMistralAPIKey] = useState("") + const [perplexityAPIKey, setPerplexityAPIKey] = useState("") + + // Workspace Step + const [workspaceInstructions, setWorkspaceInstructions] = useState("") + const [defaultChatSettings, setDefaultChatSettings] = useState({ + model: "gpt-4-1106-preview", + prompt: "You are a friendly, helpful AI assistant.", + temperature: 0.5, + contextLength: 4096, + includeProfileContext: true, + includeWorkspaceInstructions: true, + embeddingsProvider: "openai" + }) + + const handleShouldProceed = (proceed: boolean) => { + if (proceed) { + if (currentStep === SETUP_STEP_COUNT) { + handleSaveSetupSetting() + } else { + setCurrentStep(currentStep + 1) + } + } else { + setCurrentStep(currentStep - 1) + } + } + + const handleSaveSetupSetting = async () => { + if (!profile) return + + let profileImageUrl = "" + let profileImagePath = "" + + if (profileImage) { + const { path, url } = await uploadImage(profile, profileImage) + profileImageUrl = url + profileImagePath = path + } + + const updateProfilePayload: TablesUpdate<"profiles"> = { + ...profile, + has_onboarded: true, + display_name: displayName, + username, + profile_context: profileContext, + image_url: profileImageUrl, + image_path: profileImageUrl, + openai_api_key: openaiAPIKey, + openai_organization_id: openaiOrgID, + anthropic_api_key: anthropicAPIKey, + google_gemini_api_key: googleGeminiAPIKey, + mistral_api_key: mistralAPIKey, + perplexity_api_key: perplexityAPIKey, + use_azure_openai: useAzureOpenai, + azure_openai_api_key: azureOpenaiAPIKey, + azure_openai_endpoint: azureOpenaiEndpoint, + azure_openai_35_turbo_id: azureOpenai35TurboID, + azure_openai_45_turbo_id: azureOpenai45TurboID, + azure_openai_45_vision_id: azureOpenai45VisionID + } + + const updatedProfile = await updateProfile(profile.id, updateProfilePayload) + + setProfile(updatedProfile) + + const updateHomeWorkspacePayload: TablesUpdate<"workspaces"> = { + default_context_length: defaultChatSettings.contextLength, + default_model: defaultChatSettings.model, + default_prompt: defaultChatSettings.prompt, + default_temperature: defaultChatSettings.temperature, + include_profile_context: defaultChatSettings.includeProfileContext, + include_workspace_instructions: + defaultChatSettings.includeWorkspaceInstructions, + instructions: workspaceInstructions, + embeddings_provider: defaultChatSettings.embeddingsProvider + } + + const workspaces = await getWorkspacesByUserId(profile.user_id) + const homeWorkspace = workspaces.find(w => w.is_home) + + // There will always be a home workspace + const updatedWorkspace = await updateWorkspace( + homeWorkspace!.id, + updateHomeWorkspacePayload + ) + + setSelectedWorkspace(updatedWorkspace) + setWorkspaces( + workspaces.map(workspace => + workspace.id === updatedWorkspace.id ? updatedWorkspace : workspace + ) + ) + + router.push("/chat") + } + + const renderStep = (stepNum: number) => { + switch (stepNum) { + // Profile Step + case 1: + return ( + + + + ) + + // API Step + case 2: + return ( + + + + ) + + // Workspace Step + case 3: + return ( + + + + ) + + // Finish Step + case 4: + return ( + + + + ) + default: + return null + } + } + + useEffect(() => { + ;(async () => { + const session = (await supabase.auth.getSession()).data.session + + if (!session) { + router.push("/login") + } else { + const user = session.user + + const profile = await getProfileByUserId(user.id) + setProfile(profile) + + if (!profile.has_onboarded) { + setLoading(false) + } else { + router.push("/chat") + } + } + })() + }, []) + + if (loading) { + return null + } + + return ( +
+ {renderStep(currentStep)} +
+ ) +} diff --git a/app/share/assistant/[id]/page.tsx b/app/share/assistant/[id]/page.tsx new file mode 100644 index 0000000000..c8c0064bee --- /dev/null +++ b/app/share/assistant/[id]/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import { ShareAssistant } from "@/components/sharing/share-assistant" +import { ShareHeader } from "@/components/sharing/share-header" +import { ScreenLoader } from "@/components/ui/screen-loader" +import { getAssistantById } from "@/db/assistants" +import { supabase } from "@/lib/supabase/browser-client" +import { Tables } from "@/supabase/types" +import { useEffect, useState } from "react" + +interface ShareAssistantPageProps { + params: { + id: string + } +} + +export default function ShareAssistantPage({ + params +}: ShareAssistantPageProps) { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [assistant, setAssistant] = useState | null>(null) + + const onLoad = async () => { + const session = (await supabase.auth.getSession()).data.session + + setSession(session) + + const fetchedAssistant = await getAssistantById(params.id) + setAssistant(fetchedAssistant) + + if (!fetchedAssistant) { + setLoading(false) + return + } + + setLoading(false) + } + + useEffect(() => { + onLoad() + }, []) + + if (loading) { + return + } + + if (!assistant) { + return ( +
+ Assistant not found. +
+ ) + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/app/share/chat/[id]/page.tsx b/app/share/chat/[id]/page.tsx new file mode 100644 index 0000000000..613044e0cc --- /dev/null +++ b/app/share/chat/[id]/page.tsx @@ -0,0 +1,73 @@ +"use client" + +import { ShareChat } from "@/components/sharing/share-chat" +import { ShareHeader } from "@/components/sharing/share-header" +import { ScreenLoader } from "@/components/ui/screen-loader" +import { getChatById } from "@/db/chats" +import { getMessagesByChatId } from "@/db/messages" +import { supabase } from "@/lib/supabase/browser-client" +import { Tables } from "@/supabase/types" +import { useEffect, useState } from "react" + +interface ShareChatPageProps { + params: { + id: string + } +} + +export default function ShareChatPage({ params }: ShareChatPageProps) { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [chat, setChat] = useState | null>(null) + const [messages, setMessages] = useState[]>([]) + + const onLoad = async () => { + const session = (await supabase.auth.getSession()).data.session + + setSession(session) + + const fetchedChat = await getChatById(params.id) + setChat(fetchedChat) + + if (!fetchedChat) { + setLoading(false) + return + } + + const fetchedMessages = await getMessagesByChatId(fetchedChat.id) + setMessages(fetchedMessages) + + setLoading(false) + } + + useEffect(() => { + onLoad() + }, []) + + if (loading) { + return + } + + if (!chat) { + return ( +
+ Chat not found. +
+ ) + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/app/share/collection/[id]/page.tsx b/app/share/collection/[id]/page.tsx new file mode 100644 index 0000000000..af4fc50ff9 --- /dev/null +++ b/app/share/collection/[id]/page.tsx @@ -0,0 +1,75 @@ +"use client" + +import { ShareCollection } from "@/components/sharing/share-collection" +import { ShareHeader } from "@/components/sharing/share-header" +import { ScreenLoader } from "@/components/ui/screen-loader" +import { getCollectionFilesByCollectionId } from "@/db/collection-files" +import { getCollectionById } from "@/db/collections" +import { supabase } from "@/lib/supabase/browser-client" +import { Tables } from "@/supabase/types" +import { CollectionFile } from "@/types" +import { useEffect, useState } from "react" + +interface ShareCollectionPageProps { + params: { + id: string + } +} + +export default function ShareCollectionPage({ + params +}: ShareCollectionPageProps) { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [collection, setCollection] = useState | null>( + null + ) + const [collectionFiles, setCollectionFiles] = useState([]) + + const onLoad = async () => { + const session = (await supabase.auth.getSession()).data.session + + setSession(session) + + const fetchedCollection = await getCollectionById(params.id) + setCollection(fetchedCollection) + + if (!fetchedCollection) { + setLoading(false) + return + } + + const fetchedCollectionFiles = await getCollectionFilesByCollectionId( + fetchedCollection.id + ) + setCollectionFiles(fetchedCollectionFiles.files) + + setLoading(false) + } + + useEffect(() => { + onLoad() + }, []) + + if (loading) { + return + } + + if (!collection) { + return ( +
+ Collection not found. +
+ ) + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/app/share/file/[id]/page.tsx b/app/share/file/[id]/page.tsx new file mode 100644 index 0000000000..34dd8e49e2 --- /dev/null +++ b/app/share/file/[id]/page.tsx @@ -0,0 +1,72 @@ +"use client" + +import { ShareFile } from "@/components/sharing/share-file" +import { ShareHeader } from "@/components/sharing/share-header" +import { ScreenLoader } from "@/components/ui/screen-loader" +import { getFileById } from "@/db/files" +import { getFileFromStorage } from "@/db/storage/files" +import { supabase } from "@/lib/supabase/browser-client" +import { Tables } from "@/supabase/types" +import { useEffect, useState } from "react" + +interface ShareFilePageProps { + params: { + id: string + } +} + +export default function ShareFilePage({ params }: ShareFilePageProps) { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [file, setFile] = useState | null>(null) + const [link, setLink] = useState("") + + const onLoad = async () => { + try { + const session = (await supabase.auth.getSession()).data.session + + setSession(session) + + const fetchedFile = await getFileById(params.id) + setFile(fetchedFile) + + if (!fetchedFile) { + setLoading(false) + return + } + + const fileLink = await getFileFromStorage(fetchedFile.file_path) + setLink(fileLink) + + setLoading(false) + } catch (error) { + setLoading(false) + } + } + + useEffect(() => { + onLoad() + }, []) + + if (loading) { + return + } + + if (!file) { + return ( +
+ File not found. +
+ ) + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/app/share/preset/[id]/page.tsx b/app/share/preset/[id]/page.tsx new file mode 100644 index 0000000000..5767ae1ce7 --- /dev/null +++ b/app/share/preset/[id]/page.tsx @@ -0,0 +1,22 @@ +"use client" + +import SharePage from "@/components/sharing/share-page" +import { SharePreset } from "@/components/sharing/share-preset" +import { getPresetById } from "@/db/presets" + +interface SharePresetPageProps { + params: { + id: string + } +} + +export default function SharePresetPage({ params }: SharePresetPageProps) { + return ( + + ) +} diff --git a/app/share/prompt/[id]/page.tsx b/app/share/prompt/[id]/page.tsx new file mode 100644 index 0000000000..fecfa5c153 --- /dev/null +++ b/app/share/prompt/[id]/page.tsx @@ -0,0 +1,22 @@ +"use client" + +import SharePage from "@/components/sharing/share-page" +import { SharePrompt } from "@/components/sharing/share-prompt" +import { getPromptById } from "@/db/prompts" + +interface SharePromptPageProps { + params: { + id: string + } +} + +export default function SharePromptPage({ params }: SharePromptPageProps) { + return ( + + ) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000000..433a5ad510 --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "gray", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/components/chat/chat-command-input.tsx b/components/chat/chat-command-input.tsx new file mode 100644 index 0000000000..a2e498398a --- /dev/null +++ b/components/chat/chat-command-input.tsx @@ -0,0 +1,43 @@ +import { ChatbotUIContext } from "@/context/context" +import { FC, useContext } from "react" +import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command" +import { FilePicker } from "./file-picker" +import { PromptPicker } from "./prompt-picker" + +interface ChatCommandInputProps {} + +export const ChatCommandInput: FC = ({}) => { + const { + chatFiles, + slashCommand, + isAtPickerOpen, + setIsAtPickerOpen, + atCommand, + + focusPrompt, + focusFile + } = useContext(ChatbotUIContext) + + const { handleSelectPrompt, handleSelectUserFile } = usePromptAndCommand() + + return ( + <> + + + file.id)} + selectedCollectionIds={[]} + onSelectFile={handleSelectUserFile} + onSelectCollection={() => {}} + isFocused={focusFile} + /> + + ) +} diff --git a/components/chat/chat-files-display.tsx b/components/chat/chat-files-display.tsx new file mode 100644 index 0000000000..8a999a96f0 --- /dev/null +++ b/components/chat/chat-files-display.tsx @@ -0,0 +1,193 @@ +import { ChatbotUIContext } from "@/context/context" +import { getFileFromStorage } from "@/db/storage/files" +import { ChatFile, MessageImage } from "@/types" +import { + IconFileFilled, + IconFileTypePdf, + IconLoader2, + IconX +} from "@tabler/icons-react" +import Image from "next/image" +import { FC, useContext, useState } from "react" +import { Button } from "../ui/button" +import { FilePreview } from "../ui/file-preview" + +interface ChatFilesDisplayProps {} + +export const ChatFilesDisplay: FC = ({}) => { + const { + files, + newMessageImages, + setNewMessageImages, + newMessageFiles, + setNewMessageFiles, + setShowFilesDisplay, + showFilesDisplay + } = useContext(ChatbotUIContext) + + const [selectedFile, setSelectedFile] = useState(null) + const [selectedImage, setSelectedImage] = useState(null) + const [showPreview, setShowPreview] = useState(false) + + const combinedNewMessageFiles = [...newMessageFiles, ...newMessageImages] + + const getLinkAndView = async (file: ChatFile) => { + const fileRecord = files.find(f => f.id === file.id) + + if (!fileRecord) return + + const link = await getFileFromStorage(fileRecord.file_path) + window.open(link, "_blank") + } + + return showFilesDisplay && combinedNewMessageFiles.length > 0 ? ( + <> + {showPreview && selectedImage && ( + { + setShowPreview(isOpen) + setSelectedImage(null) + }} + /> + )} + + {showPreview && selectedFile && ( + { + setShowPreview(isOpen) + setSelectedFile(null) + }} + /> + )} + +
+
+ +
+ +
+
+ {newMessageImages.map(image => ( +
+ File image { + setSelectedImage(image) + setShowPreview(true) + }} + /> + + { + e.stopPropagation() + setNewMessageImages( + newMessageImages.filter( + f => f.messageId !== image.messageId + ) + ) + }} + /> +
+ ))} + + {newMessageFiles.map(file => + file.id === "loading" ? ( +
+
+ +
+ +
+
{file.name}
+
{file.type}
+
+
+ ) : ( +
getLinkAndView(file)} + > +
+ {(() => { + let fileExtension = file.type.includes("/") + ? file.type.split("/")[1] + : file.type + + switch (fileExtension) { + case "pdf": + return + default: + return + } + })()} +
+ +
+
{file.name}
+
+ {file.type.includes("/") + ? file.type.split("/")[1].toUpperCase() + : file.type.toUpperCase()} +
+
+ + { + e.stopPropagation() + setNewMessageFiles( + newMessageFiles.filter(f => f.id !== file.id) + ) + }} + /> +
+ ) + )} +
+
+
+ + ) : ( + combinedNewMessageFiles.length > 0 && ( +
+ +
+ ) + ) +} diff --git a/components/chat/chat-help.tsx b/components/chat/chat-help.tsx new file mode 100644 index 0000000000..377d48d0e6 --- /dev/null +++ b/components/chat/chat-help.tsx @@ -0,0 +1,178 @@ +import useHotkey from "@/lib/hooks/use-hotkey" +import { + IconBrandGithub, + IconBrandX, + IconHelpCircle, + IconQuestionMark +} from "@tabler/icons-react" +import Link from "next/link" +import { FC, useState } from "react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "../ui/dropdown-menu" +import { Announcements } from "../utility/announcements" + +interface ChatHelpProps {} + +export const ChatHelp: FC = ({}) => { + useHotkey("/", () => setIsOpen(prevState => !prevState)) + + const [isOpen, setIsOpen] = useState(false) + + return ( + + + + + + + +
+ + + + + + + +
+ +
+ + + + + +
+
+ + + + +
Show Help
+
+
+ ⌘ +
+
+ Shift +
+
+ / +
+
+
+ + +
Show Workspaces
+
+
+ ⌘ +
+
+ Shift +
+
+ ; +
+
+
+ + +
New Chat
+
+
+ ⌘ +
+
+ Shift +
+
+ O +
+
+
+ + +
Focus Chat
+
+
+ ⌘ +
+
+ Shift +
+
+ L +
+
+
+ + +
Open Settings
+
+
+ ⌘ +
+
+ Shift +
+
+ I +
+
+
+ + +
Open Presets
+
+
+ ⌘ +
+
+ Shift +
+
+ P +
+
+
+ + +
Toggle Sidebar
+
+
+ ⌘ +
+
+ Shift +
+
+ S +
+
+
+
+
+ ) +} diff --git a/components/chat/chat-helpers/index.ts b/components/chat/chat-helpers/index.ts new file mode 100644 index 0000000000..3ad15648c0 --- /dev/null +++ b/components/chat/chat-helpers/index.ts @@ -0,0 +1,472 @@ +// Only used in use-chat-handler.tsx to keep it clean + +import { createChatFiles } from "@/db/chat-files" +import { createChat } from "@/db/chats" +import { createMessageFileItems } from "@/db/message-file-items" +import { createMessages, updateMessage } from "@/db/messages" +import { uploadMessageImage } from "@/db/storage/message-images" +import { + buildFinalMessages, + buildGoogleGeminiFinalMessages +} from "@/lib/build-prompt" +import { consumeReadableStream } from "@/lib/consume-stream" +import { Tables, TablesInsert } from "@/supabase/types" +import { + ChatFile, + ChatMessage, + ChatPayload, + ChatSettings, + LLM, + MessageImage +} from "@/types" +import React from "react" +import { toast } from "sonner" +import { v4 as uuidv4 } from "uuid" + +export const validateChatSettings = ( + chatSettings: ChatSettings | null, + modelData: LLM | undefined, + profile: Tables<"profiles"> | null, + selectedWorkspace: Tables<"workspaces"> | null, + messageContent: string +) => { + if (!chatSettings) { + throw new Error("Chat settings not found") + } + + if (!modelData) { + throw new Error("Model not found") + } + + if (!profile) { + throw new Error("Profile not found") + } + + if (!selectedWorkspace) { + throw new Error("Workspace not found") + } + + if (!messageContent) { + throw new Error("Message content not found") + } +} + +export const handleRetrieval = async ( + userInput: string, + newMessageFiles: ChatFile[], + chatFiles: ChatFile[], + embeddingsProvider: "openai" | "local" +) => { + const response = await fetch("/api/retrieval/retrieve", { + method: "POST", + body: JSON.stringify({ + userInput, + fileIds: [...newMessageFiles, ...chatFiles].map(file => file.id), + embeddingsProvider + }) + }) + + if (!response.ok) { + console.error("Error retrieving:", response) + } + + const { results } = (await response.json()) as { + results: Tables<"file_items">[] + } + + return results +} + +export const createTempMessages = ( + messageContent: string, + chatMessages: ChatMessage[], + chatSettings: ChatSettings, + b64Images: string[], + isRegeneration: boolean, + setChatMessages: React.Dispatch> +) => { + let tempUserChatMessage: ChatMessage = { + message: { + chat_id: "", + content: messageContent, + created_at: "", + id: uuidv4(), + image_paths: b64Images, + model: chatSettings.model, + role: "user", + sequence_number: chatMessages.length, + updated_at: "", + user_id: "" + }, + fileItems: [] + } + + let tempAssistantChatMessage: ChatMessage = { + message: { + chat_id: "", + content: "", + created_at: "", + id: uuidv4(), + image_paths: [], + model: chatSettings.model, + role: "assistant", + sequence_number: chatMessages.length + 1, + updated_at: "", + user_id: "" + }, + fileItems: [] + } + + let newMessages = [] + + if (isRegeneration) { + const lastMessageIndex = chatMessages.length - 1 + chatMessages[lastMessageIndex].message.content = "" + newMessages = [...chatMessages] + } else { + newMessages = [ + ...chatMessages, + tempUserChatMessage, + tempAssistantChatMessage + ] + } + + setChatMessages(newMessages) + + return { + tempUserChatMessage, + tempAssistantChatMessage + } +} + +export const handleLocalChat = async ( + payload: ChatPayload, + profile: Tables<"profiles">, + chatSettings: ChatSettings, + tempAssistantMessage: ChatMessage, + isRegeneration: boolean, + newAbortController: AbortController, + setIsGenerating: React.Dispatch>, + setFirstTokenReceived: React.Dispatch>, + setChatMessages: React.Dispatch>, + setToolInUse: React.Dispatch> +) => { + const formattedMessages = await buildFinalMessages(payload, profile, []) + + // Ollama API: https://github.com/jmorganca/ollama/blob/main/docs/api.md + const response = await fetchChatResponse( + process.env.NEXT_PUBLIC_OLLAMA_URL + "/api/chat", + { + model: chatSettings.model, + messages: formattedMessages, + options: { + temperature: payload.chatSettings.temperature + } + }, + false, + newAbortController, + setIsGenerating, + setChatMessages + ) + + return await processResponse( + response, + isRegeneration + ? payload.chatMessages[payload.chatMessages.length - 1] + : tempAssistantMessage, + false, + newAbortController, + setFirstTokenReceived, + setChatMessages, + setToolInUse + ) +} + +export const handleHostedChat = async ( + payload: ChatPayload, + profile: Tables<"profiles">, + modelData: LLM, + tempAssistantChatMessage: ChatMessage, + isRegeneration: boolean, + newAbortController: AbortController, + newMessageImages: MessageImage[], + chatImages: MessageImage[], + setIsGenerating: React.Dispatch>, + setFirstTokenReceived: React.Dispatch>, + setChatMessages: React.Dispatch>, + setToolInUse: React.Dispatch> +) => { + const provider = + modelData.provider === "openai" && profile.use_azure_openai + ? "azure" + : modelData.provider + + let formattedMessages = [] + + if (provider === "google") { + formattedMessages = await buildGoogleGeminiFinalMessages( + payload, + profile, + newMessageImages + ) + } else { + formattedMessages = await buildFinalMessages(payload, profile, chatImages) + } + + const response = await fetchChatResponse( + `/api/chat/${provider}`, + { + chatSettings: payload.chatSettings, + messages: formattedMessages + }, + true, + newAbortController, + setIsGenerating, + setChatMessages + ) + + return await processResponse( + response, + isRegeneration + ? payload.chatMessages[payload.chatMessages.length - 1] + : tempAssistantChatMessage, + true, + newAbortController, + setFirstTokenReceived, + setChatMessages, + setToolInUse + ) +} + +export const fetchChatResponse = async ( + url: string, + body: object, + isHosted: boolean, + controller: AbortController, + setIsGenerating: React.Dispatch>, + setChatMessages: React.Dispatch> +) => { + const response = await fetch(url, { + method: "POST", + body: JSON.stringify(body), + signal: controller.signal + }) + + if (!response.ok) { + if (response.status === 404 && !isHosted) { + toast.error( + "Model not found. Make sure you have it downloaded via Ollama." + ) + } + + const errorData = await response.json() + + toast.error(errorData.message) + + setIsGenerating(false) + setChatMessages(prevMessages => prevMessages.slice(0, -2)) + } + + return response +} + +export const processResponse = async ( + response: Response, + lastChatMessage: ChatMessage, + isHosted: boolean, + controller: AbortController, + setFirstTokenReceived: React.Dispatch>, + setChatMessages: React.Dispatch>, + setToolInUse: React.Dispatch> +) => { + let fullText = "" + let contentToAdd = "" + + await consumeReadableStream( + response.body, + (chunk: any) => { + setFirstTokenReceived(true) + setToolInUse("none") + + try { + contentToAdd = isHosted ? chunk : JSON.parse(chunk).message.content + fullText += contentToAdd + } catch (error) { + console.error("Error parsing JSON:", error) + } + + setChatMessages(prev => + prev.map(chatMessage => { + if (chatMessage.message.id === lastChatMessage.message.id) { + const updatedChatMessage: ChatMessage = { + message: { + ...chatMessage.message, + content: chatMessage.message.content + contentToAdd + }, + fileItems: chatMessage.fileItems + } + + return updatedChatMessage + } + + return chatMessage + }) + ) + }, + controller.signal + ) + + return fullText +} + +export const handleCreateChat = async ( + chatSettings: ChatSettings, + profile: Tables<"profiles">, + selectedWorkspace: Tables<"workspaces">, + messageContent: string, + selectedAssistant: Tables<"assistants">, + newMessageFiles: ChatFile[], + setSelectedChat: React.Dispatch | null>>, + setChats: React.Dispatch[]>>, + setChatFiles: React.Dispatch> +) => { + const createdChat = await createChat({ + user_id: profile.user_id, + workspace_id: selectedWorkspace.id, + assistant_id: selectedAssistant?.id || null, + context_length: chatSettings.contextLength, + include_profile_context: chatSettings.includeProfileContext, + include_workspace_instructions: chatSettings.includeWorkspaceInstructions, + model: chatSettings.model, + name: messageContent.substring(0, 100), + prompt: chatSettings.prompt, + temperature: chatSettings.temperature, + embeddings_provider: chatSettings.embeddingsProvider + }) + + setSelectedChat(createdChat) + setChats(chats => [createdChat, ...chats]) + + await createChatFiles( + newMessageFiles.map(file => ({ + user_id: profile.user_id, + chat_id: createdChat.id, + file_id: file.id + })) + ) + + setChatFiles(prev => [...prev, ...newMessageFiles]) + + return createdChat +} + +export const handleCreateMessages = async ( + chatMessages: ChatMessage[], + currentChat: Tables<"chats">, + profile: Tables<"profiles">, + modelData: LLM, + messageContent: string, + generatedText: string, + newMessageImages: MessageImage[], + isRegeneration: boolean, + retrievedFileItems: Tables<"file_items">[], + setChatMessages: React.Dispatch>, + setChatFileItems: React.Dispatch[]>> +) => { + const finalUserMessage: TablesInsert<"messages"> = { + chat_id: currentChat.id, + user_id: profile.user_id, + content: messageContent, + model: modelData.modelId, + role: "user", + sequence_number: chatMessages.length, + image_paths: [] + } + + const finalAssistantMessage: TablesInsert<"messages"> = { + chat_id: currentChat.id, + user_id: profile.user_id, + content: generatedText, + model: modelData.modelId, + role: "assistant", + sequence_number: chatMessages.length + 1, + image_paths: [] + } + + let finalChatMessages: ChatMessage[] = [] + + if (isRegeneration) { + const lastStartingMessage = chatMessages[chatMessages.length - 1].message + + const updatedMessage = await updateMessage(lastStartingMessage.id, { + ...lastStartingMessage, + content: generatedText + }) + + chatMessages[chatMessages.length - 1].message = updatedMessage + + finalChatMessages = [...chatMessages] + + setChatMessages(finalChatMessages) + } else { + const createdMessages = await createMessages([ + finalUserMessage, + finalAssistantMessage + ]) + + // Upload each image (stored in newMessageImages) for the user message to message_images bucket + const uploadPromises = newMessageImages + .filter(obj => obj.file !== null) + .map(obj => { + let filePath = `${profile.user_id}/${currentChat.id}/${ + createdMessages[0].id + }/${uuidv4()}` + + return uploadMessageImage(filePath, obj.file as File).catch(error => { + console.error(`Failed to upload image at ${filePath}:`, error) + return null + }) + }) + + const paths = (await Promise.all(uploadPromises)).filter( + Boolean + ) as string[] + + updateMessage(createdMessages[0].id, { + ...createdMessages[0], + image_paths: paths + }) + + const createdMessageFileItems = await createMessageFileItems( + retrievedFileItems.map(fileItem => { + return { + user_id: profile.user_id, + message_id: createdMessages[1].id, + file_item_id: fileItem.id + } + }) + ) + + finalChatMessages = [ + ...chatMessages, + { + message: createdMessages[0], + fileItems: [] + }, + { + message: createdMessages[1], + fileItems: retrievedFileItems.map(fileItem => fileItem.id) + } + ] + + setChatFileItems(prevFileItems => { + const newFileItems = retrievedFileItems.filter( + fileItem => !prevFileItems.some(prevItem => prevItem.id === fileItem.id) + ) + + return [...prevFileItems, ...newFileItems] + }) + + setChatMessages(finalChatMessages) + } +} diff --git a/components/chat/chat-hooks/use-chat-handler.tsx b/components/chat/chat-hooks/use-chat-handler.tsx new file mode 100644 index 0000000000..8b0783e55b --- /dev/null +++ b/components/chat/chat-hooks/use-chat-handler.tsx @@ -0,0 +1,249 @@ +import { ChatbotUIContext } from "@/context/context" +import { deleteMessagesIncludingAndAfter } from "@/db/messages" +import { Tables } from "@/supabase/types" +import { ChatMessage, ChatPayload } from "@/types" +import { useRouter } from "next/navigation" +import { useContext, useRef } from "react" +import { LLM_LIST } from "../../../lib/models/llm/llm-list" +import { + createTempMessages, + handleCreateChat, + handleCreateMessages, + handleHostedChat, + handleLocalChat, + handleRetrieval, + validateChatSettings +} from "../chat-helpers" + +export const useChatHandler = () => { + const { + userInput, + chatFiles, + setUserInput, + setNewMessageImages, + profile, + setIsGenerating, + setChatMessages, + setFirstTokenReceived, + selectedChat, + selectedWorkspace, + setSelectedChat, + setChats, + availableLocalModels, + abortController, + setAbortController, + chatSettings, + newMessageImages, + selectedAssistant, + chatMessages, + chatImages, + setChatImages, + setChatFiles, + setNewMessageFiles, + setShowFilesDisplay, + newMessageFiles, + chatFileItems, + setChatFileItems, + setToolInUse + } = useContext(ChatbotUIContext) + + const router = useRouter() + + const chatInputRef = useRef(null) + + const handleNewChat = () => { + setUserInput("") + setChatMessages([]) + setSelectedChat(null) + setChatFileItems([]) + + setIsGenerating(false) + setFirstTokenReceived(false) + + setChatFiles([]) + setChatImages([]) + setNewMessageFiles([]) + setNewMessageImages([]) + setShowFilesDisplay(false) + + router.push("/chat") + } + + const handleFocusChatInput = () => { + chatInputRef.current?.focus() + } + + const handleStopMessage = () => { + if (abortController) { + abortController.abort() + } + } + + const handleSendMessage = async ( + messageContent: string, + chatMessages: ChatMessage[], + isRegeneration: boolean + ) => { + setIsGenerating(true) + + const newAbortController = new AbortController() + setAbortController(newAbortController) + + const modelData = [...LLM_LIST, ...availableLocalModels].find( + llm => llm.modelId === chatSettings?.model + ) + + try { + validateChatSettings( + chatSettings, + modelData, + profile, + selectedWorkspace, + messageContent + ) + } catch (error) { + setIsGenerating(false) + return + } + + let currentChat = selectedChat ? { ...selectedChat } : null + + const b64Images = newMessageImages.map(image => image.base64) + + let retrievedFileItems: Tables<"file_items">[] = [] + + if (newMessageFiles.length > 0 || chatFiles.length > 0) { + setToolInUse("retrieval") + + retrievedFileItems = await handleRetrieval( + userInput, + newMessageFiles, + chatFiles, + chatSettings!.embeddingsProvider + ) + + console.log("retrievedFileItems", retrievedFileItems) + } + + const { tempUserChatMessage, tempAssistantChatMessage } = + createTempMessages( + messageContent, + chatMessages, + chatSettings!, + b64Images, + isRegeneration, + setChatMessages + ) + + const payload: ChatPayload = { + chatSettings: chatSettings!, + workspaceInstructions: selectedWorkspace!.instructions || "", + chatMessages: isRegeneration + ? [...chatMessages] + : [...chatMessages, tempUserChatMessage], + assistant: selectedChat?.assistant_id ? selectedAssistant : null, + messageFileItems: retrievedFileItems + } + + let generatedText = "" + + if (modelData!.provider === "ollama") { + generatedText = await handleLocalChat( + payload, + profile!, + chatSettings!, + tempAssistantChatMessage, + isRegeneration, + newAbortController, + setIsGenerating, + setFirstTokenReceived, + setChatMessages, + setToolInUse + ) + } else { + generatedText = await handleHostedChat( + payload, + profile!, + modelData!, + tempAssistantChatMessage, + isRegeneration, + newAbortController, + newMessageImages, + chatImages, + setIsGenerating, + setFirstTokenReceived, + setChatMessages, + setToolInUse + ) + } + + if (!currentChat) { + currentChat = await handleCreateChat( + chatSettings!, + profile!, + selectedWorkspace!, + messageContent, + selectedAssistant!, + newMessageFiles, + setSelectedChat, + setChats, + setChatFiles + ) + } + + if (!currentChat) { + throw new Error("Chat not found") + } + + await handleCreateMessages( + chatMessages, + currentChat, + profile!, + modelData!, + messageContent, + generatedText, + newMessageImages, + isRegeneration, + retrievedFileItems, + setChatMessages, + setChatFileItems + ) + + setIsGenerating(false) + setFirstTokenReceived(false) + setUserInput("") + setNewMessageImages([]) + setNewMessageFiles([]) + } + + const handleSendEdit = async ( + editedContent: string, + sequenceNumber: number + ) => { + if (!selectedChat) return + + await deleteMessagesIncludingAndAfter( + selectedChat.user_id, + selectedChat.id, + sequenceNumber + ) + + const filteredMessages = chatMessages.filter( + chatMessage => chatMessage.message.sequence_number < sequenceNumber + ) + + setChatMessages(filteredMessages) + + handleSendMessage(editedContent, filteredMessages, false) + } + + return { + chatInputRef, + prompt, + handleNewChat, + handleSendMessage, + handleFocusChatInput, + handleStopMessage, + handleSendEdit + } +} diff --git a/components/chat/chat-hooks/use-prompt-and-command.tsx b/components/chat/chat-hooks/use-prompt-and-command.tsx new file mode 100644 index 0000000000..9b9c008812 --- /dev/null +++ b/components/chat/chat-hooks/use-prompt-and-command.tsx @@ -0,0 +1,70 @@ +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { useContext } from "react" + +export const usePromptAndCommand = () => { + const { + selectedChat, + chatFiles, + setChatFiles, + setNewMessageFiles, + userInput, + setUserInput, + setShowFilesDisplay, + setIsPromptPickerOpen, + setIsAtPickerOpen, + setSlashCommand, + setAtCommand + } = useContext(ChatbotUIContext) + + const handleInputChange = (value: string) => { + const slashTextRegex = /\/([^ ]*)$/ + const atTextRegex = /@([^ ]*)$/ + const slashMatch = value.match(slashTextRegex) + const atMatch = value.match(atTextRegex) + + if (slashMatch) { + setIsPromptPickerOpen(true) + setSlashCommand(slashMatch[1]) + } else if (atMatch) { + setIsAtPickerOpen(true) + setAtCommand(atMatch[1]) + } else { + setIsPromptPickerOpen(false) + setIsAtPickerOpen(false) + setSlashCommand("") + setAtCommand("") + } + + setUserInput(value) + } + + const handleSelectPrompt = (prompt: Tables<"prompts">) => { + setIsPromptPickerOpen(false) + setUserInput(userInput.replace(/\/[^ ]*$/, "") + prompt.content) + } + + const handleSelectUserFile = async (file: Tables<"files">) => { + setShowFilesDisplay(true) + setIsAtPickerOpen(false) + + setNewMessageFiles(prev => [ + ...prev, + ...chatFiles, + { + id: file.id, + name: file.name, + type: file.type, + file: null + } + ]) + + setUserInput(userInput.replace(/@[^ ]*$/, "")) + } + + return { + handleInputChange, + handleSelectPrompt, + handleSelectUserFile + } +} diff --git a/components/chat/chat-hooks/use-scroll.tsx b/components/chat/chat-hooks/use-scroll.tsx new file mode 100644 index 0000000000..becb4c9218 --- /dev/null +++ b/components/chat/chat-hooks/use-scroll.tsx @@ -0,0 +1,79 @@ +import { ChatbotUIContext } from "@/context/context" +import { useCallback, useContext, useEffect, useRef, useState } from "react" + +export const useScroll = () => { + const { isGenerating, chatMessages } = useContext(ChatbotUIContext) + + const messagesStartRef = useRef(null) + const messagesEndRef = useRef(null) + const isAutoScrolling = useRef(false) + + const [isAtTop, setIsAtTop] = useState(false) + const [isAtBottom, setIsAtBottom] = useState(true) + const [userScrolled, setUserScrolled] = useState(false) + const [isOverflowing, setIsOverflowing] = useState(false) + + useEffect(() => { + setUserScrolled(false) + + if (!isGenerating && userScrolled) { + setUserScrolled(false) + } + }, [isGenerating]) + + useEffect(() => { + if (isGenerating && !userScrolled) { + scrollToBottom() + } + }, [chatMessages]) + + const handleScroll = useCallback((e: any) => { + const bottom = + Math.round(e.target.scrollHeight) - Math.round(e.target.scrollTop) === + Math.round(e.target.clientHeight) + setIsAtBottom(bottom) + + const top = e.target.scrollTop === 0 + setIsAtTop(top) + + if (!bottom && !isAutoScrolling.current) { + setUserScrolled(true) + } else { + setUserScrolled(false) + } + + const isOverflow = e.target.scrollHeight > e.target.clientHeight + setIsOverflowing(isOverflow) + }, []) + + const scrollToTop = useCallback(() => { + if (messagesStartRef.current) { + messagesStartRef.current.scrollIntoView({ behavior: "instant" }) + } + }, []) + + const scrollToBottom = useCallback(() => { + isAutoScrolling.current = true + + setTimeout(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "instant" }) + } + + isAutoScrolling.current = false + }, 0) + }, []) + + return { + messagesStartRef, + messagesEndRef, + isAtTop, + isAtBottom, + userScrolled, + isOverflowing, + handleScroll, + scrollToTop, + scrollToBottom, + setIsAtBottom + } +} diff --git a/components/chat/chat-hooks/use-select-file-handler.tsx b/components/chat/chat-hooks/use-select-file-handler.tsx new file mode 100644 index 0000000000..86cb87b26a --- /dev/null +++ b/components/chat/chat-hooks/use-select-file-handler.tsx @@ -0,0 +1,160 @@ +import { ChatbotUIContext } from "@/context/context" +import { createFile } from "@/db/files" +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { useContext, useEffect, useState } from "react" +import { toast } from "sonner" + +export const ACCEPTED_FILE_TYPES = [ + // "text/csv", // TODO: Add support for CSVs + // "application/msword", // TODO: Add support for DOCs + // "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // TODO: Add support for DOCXs + // "text/html", // TODO: Add support for HTML + // "application/json", // TODO: Add support for JSON + // "text/markdown", // TODO: Add support for Markdown + "application/pdf" + // "text/plain" // TODO: Add support for TXT +].join(",") + +export const useSelectFileHandler = () => { + const { + selectedWorkspace, + profile, + chatSettings, + setNewMessageImages, + setNewMessageFiles, + setShowFilesDisplay, + setFiles + } = useContext(ChatbotUIContext) + + const [filesToAccept, setFilesToAccept] = useState(ACCEPTED_FILE_TYPES) + + useEffect(() => { + handleFilesToAccept() + }, [chatSettings?.model]) + + const handleFilesToAccept = () => { + const model = chatSettings?.model + const FULL_MODEL = LLM_LIST.find(llm => llm.modelId === model) + + if (!FULL_MODEL) return + + setFilesToAccept( + FULL_MODEL.imageInput + ? `${ACCEPTED_FILE_TYPES},image/*` + : ACCEPTED_FILE_TYPES + ) + } + + const handleSelectDeviceFile = (file: File) => { + setShowFilesDisplay(true) + + if (file) { + let reader = new FileReader() + + if (file.type.includes("image")) { + reader.readAsDataURL(file) + } else if (`${file.type}`.includes("pdf")) { + reader.readAsArrayBuffer(file) + } else if ( + ACCEPTED_FILE_TYPES.split(",").includes(file.type) || + file.type.includes("pdf") + ) { + // Use readAsArrayBuffer for PDFs and readAsText for other types + file.type.includes("pdf") + ? reader.readAsArrayBuffer(file) + : reader.readAsText(file) + } else { + throw new Error("Unsupported file type") + } + + reader.onloadend = async function () { + try { + if (file.type.includes("image")) { + // Create a temp url for the image file + const imageUrl = URL.createObjectURL(file) + + // This is a temporary image for display purposes in the chat input + setNewMessageImages(prev => [ + ...prev, + { + messageId: "temp", + path: "", + base64: reader.result, // base64 image + url: imageUrl, + file + } + ]) + } else { + if (!profile || !selectedWorkspace || !chatSettings) return + + let simplifiedFileType = file.type.split("/")[1] + if ( + simplifiedFileType === + "vnd.openxmlformats-officedocument.wordprocessingml.document" + ) { + simplifiedFileType = "docx" + } else if (simplifiedFileType === "msword") { + simplifiedFileType = "doc" + } else if (simplifiedFileType === "vnd.adobe.pdf") { + simplifiedFileType = "pdf" + } + + setNewMessageFiles(prev => [ + ...prev, + { + id: "loading", + name: file.name, + type: simplifiedFileType, + file: file + } + ]) + + const createdFile = await createFile( + file, + { + user_id: profile.user_id, + description: "", + file_path: "", + name: file.name, + size: file.size, + tokens: 0, + type: simplifiedFileType + }, + selectedWorkspace.id, + chatSettings.embeddingsProvider + ) + console.log("createdFile", createdFile) + + setFiles(prev => [...prev, createdFile]) + + setNewMessageFiles(prev => + prev.map(item => + item.id === "loading" + ? { + id: createdFile.id, + name: createdFile.name, + type: createdFile.type, + file: file + } + : item + ) + ) + } + } catch (error) { + console.error(error) + toast.error("Failed to upload.") + + setNewMessageImages(prev => + prev.filter(img => img.messageId !== "temp") + ) + setNewMessageFiles(prev => prev.filter(file => file.id !== "loading")) + } + } + } + } + + return { + handleSelectDeviceFile, + filesToAccept + } +} diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx new file mode 100644 index 0000000000..d496bd9442 --- /dev/null +++ b/components/chat/chat-input.tsx @@ -0,0 +1,171 @@ +import { ChatbotUIContext } from "@/context/context" +import { getChatFilesByChatId } from "@/db/chat-files" +import useHotkey from "@/lib/hooks/use-hotkey" +import { cn } from "@/lib/utils" +import { + IconCirclePlus, + IconPlayerStopFilled, + IconSend +} from "@tabler/icons-react" +import { FC, useContext, useEffect, useRef } from "react" +import { Input } from "../ui/input" +import { TextareaAutosize } from "../ui/textarea-autosize" +import { ChatCommandInput } from "./chat-command-input" +import { ChatFilesDisplay } from "./chat-files-display" +import { useChatHandler } from "./chat-hooks/use-chat-handler" +import { usePromptAndCommand } from "./chat-hooks/use-prompt-and-command" +import { useSelectFileHandler } from "./chat-hooks/use-select-file-handler" + +interface ChatInputProps {} + +export const ChatInput: FC = ({}) => { + useHotkey("l", () => { + handleFocusChatInput() + }) + + const { + selectedChat, + userInput, + setChatFiles, + chatMessages, + isGenerating, + selectedPreset, + selectedAssistant, + focusPrompt, + setFocusPrompt, + focusFile, + isPromptPickerOpen, + setIsPromptPickerOpen, + isAtPickerOpen, + setFocusFile + } = useContext(ChatbotUIContext) + + const { + chatInputRef, + handleSendMessage, + handleStopMessage, + handleFocusChatInput + } = useChatHandler() + + const { handleInputChange } = usePromptAndCommand() + + const { filesToAccept, handleSelectDeviceFile } = useSelectFileHandler() + + const fileInputRef = useRef(null) + + useEffect(() => { + const fetchData = async () => { + await fetchChatFiles() + } + + fetchData() + }, [selectedChat]) + + useEffect(() => { + setTimeout(() => { + handleFocusChatInput() + }, 200) // FIX: hacky + }, [selectedPreset, selectedAssistant]) + + const fetchChatFiles = async () => { + if (!selectedChat) return + + const chatFiles = await getChatFilesByChatId(selectedChat.id) + + setChatFiles( + chatFiles.files.map(file => { + return { + id: file.id, + name: file.name, + type: file.type, + data: "", + file: null + } + }) + ) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + setIsPromptPickerOpen(false) + handleSendMessage(userInput, chatMessages, false) + } + + if (event.key === "Tab" && isPromptPickerOpen) { + event.preventDefault() + setFocusPrompt(!focusPrompt) + } + + if (event.key === "Tab" && isAtPickerOpen) { + event.preventDefault() + setFocusFile(!focusFile) + } + } + + return ( + <> + + +
+
+ +
+ + <> + fileInputRef.current?.click()} + /> + + {/* Hidden input to select files from device */} + { + if (!e.target.files) return + handleSelectDeviceFile(e.target.files[0]) + }} + accept={filesToAccept} + /> + + + + +
+ {isGenerating ? ( + + ) : ( + { + if (!userInput) return + + handleSendMessage(userInput, chatMessages, false) + }} + size={30} + /> + )} +
+
+ + ) +} diff --git a/components/chat/chat-messages.tsx b/components/chat/chat-messages.tsx new file mode 100644 index 0000000000..b11c610b03 --- /dev/null +++ b/components/chat/chat-messages.tsx @@ -0,0 +1,34 @@ +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { FC, useContext, useState } from "react" +import { Message } from "../messages/message" + +interface ChatMessagesProps {} + +export const ChatMessages: FC = ({}) => { + const { chatMessages, chatFileItems } = useContext(ChatbotUIContext) + + const { handleSendEdit } = useChatHandler() + + const [editingMessage, setEditingMessage] = useState>() + + return chatMessages + .sort((a, b) => a.message.sequence_number - b.message.sequence_number) + .map((chatMessage, index, array) => { + return ( + + chatMessage.fileItems.includes(chatFileItem.id) + )} + isEditing={editingMessage?.id === chatMessage.message.id} + isLast={index === array.length - 1} + onStartEdit={setEditingMessage} + onCancelEdit={() => setEditingMessage(undefined)} + onSubmitEdit={handleSendEdit} + /> + ) + }) +} diff --git a/components/chat/chat-scroll-buttons.tsx b/components/chat/chat-scroll-buttons.tsx new file mode 100644 index 0000000000..3eb6f2d643 --- /dev/null +++ b/components/chat/chat-scroll-buttons.tsx @@ -0,0 +1,41 @@ +import { + IconCircleArrowDownFilled, + IconCircleArrowUpFilled +} from "@tabler/icons-react" +import { FC } from "react" + +interface ChatScrollButtonsProps { + isAtTop: boolean + isAtBottom: boolean + isOverflowing: boolean + scrollToTop: () => void + scrollToBottom: () => void +} + +export const ChatScrollButtons: FC = ({ + isAtTop, + isAtBottom, + isOverflowing, + scrollToTop, + scrollToBottom +}) => { + return ( + <> + {!isAtTop && isOverflowing && ( + + )} + + {!isAtBottom && isOverflowing && ( + + )} + + ) +} diff --git a/components/chat/chat-secondary-buttons.tsx b/components/chat/chat-secondary-buttons.tsx new file mode 100644 index 0000000000..d5ab40ea12 --- /dev/null +++ b/components/chat/chat-secondary-buttons.tsx @@ -0,0 +1,35 @@ +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatbotUIContext } from "@/context/context" +import { IconMessagePlus } from "@tabler/icons-react" +import { FC, useContext } from "react" +import { WithTooltip } from "../ui/with-tooltip" + +interface ChatSecondaryButtonsProps {} + +export const ChatSecondaryButtons: FC = ({}) => { + const { selectedChat } = useContext(ChatbotUIContext) + + const { handleNewChat } = useChatHandler() + + return ( + <> + {selectedChat && ( + Start a new chat
} + trigger={ +
+ +
+ } + /> + )} + + {/* TODO */} + {/* */} + + ) +} diff --git a/components/chat/chat-settings.tsx b/components/chat/chat-settings.tsx new file mode 100644 index 0000000000..72c2496458 --- /dev/null +++ b/components/chat/chat-settings.tsx @@ -0,0 +1,73 @@ +import { ChatbotUIContext } from "@/context/context" +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import useHotkey from "@/lib/hooks/use-hotkey" +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { IconAdjustmentsHorizontal } from "@tabler/icons-react" +import { FC, useContext, useEffect, useRef } from "react" +import { Button } from "../ui/button" +import { ChatSettingsForm } from "../ui/chat-settings-form" +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover" + +interface ChatSettingsProps {} + +export const ChatSettings: FC = ({}) => { + useHotkey("i", () => handleClick()) + + const { chatSettings, setChatSettings } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const handleClick = () => { + if (buttonRef.current) { + buttonRef.current.click() + } + } + + useEffect(() => { + if (!chatSettings) return + + setChatSettings({ + ...chatSettings, + temperature: Math.min( + chatSettings.temperature, + CHAT_SETTING_LIMITS[chatSettings.model]?.MAX_TEMPERATURE || 1 + ), + contextLength: Math.min( + chatSettings.contextLength, + CHAT_SETTING_LIMITS[chatSettings.model]?.MAX_CONTEXT_LENGTH || 4096 + ) + }) + }, [chatSettings?.model]) + + if (!chatSettings) return null + + const fullModel = LLM_LIST.find(llm => llm.modelId === chatSettings.model) + + return ( + + + + + + + + + + ) +} diff --git a/components/chat/chat-ui.tsx b/components/chat/chat-ui.tsx new file mode 100644 index 0000000000..2cfd90b011 --- /dev/null +++ b/components/chat/chat-ui.tsx @@ -0,0 +1,207 @@ +import Loading from "@/app/loading" +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatbotUIContext } from "@/context/context" +import { getChatById } from "@/db/chats" +import { getMessageFileItemsByMessageId } from "@/db/message-file-items" +import { getMessagesByChatId } from "@/db/messages" +import { getMessageImageFromStorage } from "@/db/storage/message-images" +import { convertBlobToBase64 } from "@/lib/blob-to-b64" +import useHotkey from "@/lib/hooks/use-hotkey" +import { LLMID, MessageImage } from "@/types" +import { useParams } from "next/navigation" +import { FC, useContext, useEffect, useState } from "react" +import { ChatHelp } from "./chat-help" +import { useScroll } from "./chat-hooks/use-scroll" +import { ChatInput } from "./chat-input" +import { ChatMessages } from "./chat-messages" +import { ChatScrollButtons } from "./chat-scroll-buttons" +import { ChatSecondaryButtons } from "./chat-secondary-buttons" + +interface ChatUIProps {} + +export const ChatUI: FC = ({}) => { + useHotkey("o", () => handleNewChat()) + + const params = useParams() + + const { + setChatMessages, + selectedChat, + setSelectedChat, + setChatSettings, + setChatImages, + assistants, + setSelectedAssistant, + setChatFileItems + } = useContext(ChatbotUIContext) + + const { handleNewChat, handleFocusChatInput } = useChatHandler() + + const { + messagesStartRef, + messagesEndRef, + handleScroll, + scrollToBottom, + setIsAtBottom, + isAtTop, + isAtBottom, + isOverflowing, + scrollToTop + } = useScroll() + + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchData = async () => { + await fetchMessages() + await fetchChat() + + scrollToBottom() + setIsAtBottom(true) + } + + if (params.chatid) { + fetchData().then(() => { + handleFocusChatInput() + setLoading(false) + }) + } else { + setLoading(false) + } + }, []) + + const fetchMessages = async () => { + const fetchedMessages = await getMessagesByChatId(params.chatid as string) + + const imagePromises: Promise[] = fetchedMessages.flatMap( + message => + message.image_paths + ? message.image_paths.map(async imagePath => { + const url = await getMessageImageFromStorage(imagePath) + + if (url) { + const response = await fetch(url) + const blob = await response.blob() + const base64 = await convertBlobToBase64(blob) + + return { + messageId: message.id, + path: imagePath, + base64, + url, + file: null + } + } + + return { + messageId: message.id, + path: imagePath, + base64: "", + url, + file: null + } + }) + : [] + ) + + const images: MessageImage[] = await Promise.all(imagePromises.flat()) + setChatImages(images) + + const messageFileItemPromises = fetchedMessages.map( + async message => await getMessageFileItemsByMessageId(message.id) + ) + + const messageFileItems = await Promise.all(messageFileItemPromises) + + const uniqueFileItems = messageFileItems.flatMap(item => item.file_items) + console.log("uniqueFileItems", uniqueFileItems) + setChatFileItems(uniqueFileItems) + + const fetchedChatMessages = fetchedMessages.map(message => { + return { + message, + fileItems: messageFileItems + .filter(messageFileItem => messageFileItem.id === message.id) + .flatMap(messageFileItem => + messageFileItem.file_items.map(fileItem => fileItem.id) + ) + } + }) + console.log("fetchedChatMessages", fetchedChatMessages) + + setChatMessages(fetchedChatMessages) + } + + const fetchChat = async () => { + const chat = await getChatById(params.chatid as string) + if (!chat) return + + if (chat.assistant_id) { + const assistant = assistants.find( + assistant => assistant.id === chat.assistant_id + ) + + if (assistant) { + setSelectedAssistant(assistant) + } + } + + setSelectedChat(chat) + setChatSettings({ + model: chat.model as LLMID, + prompt: chat.prompt, + temperature: chat.temperature, + contextLength: chat.context_length, + includeProfileContext: chat.include_profile_context, + includeWorkspaceInstructions: chat.include_workspace_instructions, + embeddingsProvider: chat.embeddings_provider as "openai" | "local" + }) + } + + if (loading) { + return + } + + return ( +
+
+ +
+ +
+ +
+ +
+
+ {selectedChat?.name || "Chat"} +
+
+ +
+
+ + + +
+
+ +
+ +
+ +
+ +
+
+ ) +} diff --git a/components/chat/file-picker.tsx b/components/chat/file-picker.tsx new file mode 100644 index 0000000000..81467f34b1 --- /dev/null +++ b/components/chat/file-picker.tsx @@ -0,0 +1,125 @@ +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { IconBooks } from "@tabler/icons-react" +import { FC, useContext, useEffect, useRef } from "react" +import { FileIcon } from "../ui/file-icon" + +interface FilePickerProps { + isOpen: boolean + searchQuery: string + onOpenChange: (isOpen: boolean) => void + selectedFileIds: string[] + selectedCollectionIds: string[] + onSelectFile: (file: Tables<"files">) => void + onSelectCollection: (collection: Tables<"collections">) => void + isFocused: boolean +} + +export const FilePicker: FC = ({ + isOpen, + searchQuery, + onOpenChange, + selectedFileIds, + selectedCollectionIds, + onSelectFile, + onSelectCollection, + isFocused +}) => { + const { files, collections } = useContext(ChatbotUIContext) + + useEffect(() => { + firstFileRef.current?.focus() + }, [isFocused]) + + const firstFileRef = useRef(null) + + const filteredFiles = files.filter( + file => + file.name.toLowerCase().includes(searchQuery.toLowerCase()) && + !selectedFileIds.includes(file.id) + ) + + const filteredCollections = collections.filter( + collection => + collection.name.toLowerCase().includes(searchQuery.toLowerCase()) && + !selectedCollectionIds.includes(collection.id) + ) + + const handleOpenChange = (isOpen: boolean) => { + onOpenChange(isOpen) + } + + const handleSelectFile = (file: Tables<"files">) => { + onSelectFile(file) + handleOpenChange(false) + } + + const handleSelectCollection = (collection: Tables<"collections">) => { + onSelectCollection(collection) + handleOpenChange(false) + } + + const getKeyDownHandler = + (index: number) => (e: React.KeyboardEvent) => { + if (e.key === "Backspace") { + e.preventDefault() + } else if (e.key === "Enter") { + e.preventDefault() + handleSelectFile(filteredFiles[index]) + } else if ( + e.key === "Tab" && + !e.shiftKey && + index === filteredFiles.length + ) { + e.preventDefault() + firstFileRef.current?.focus() + } + } + + return ( + <> + {isOpen && ( +
+ {filteredFiles.length === 0 && filteredCollections.length === 0 ? ( +
+ No matching files. +
+ ) : ( + <> + {[...filteredFiles, ...filteredCollections].map((item, index) => ( +
{ + if ("type" in item) { + handleSelectFile(item as Tables<"files">) + } else { + handleSelectCollection(item) + } + }} + onKeyDown={getKeyDownHandler(index)} + > + {"type" in item ? ( + ).type} size={32} /> + ) : ( + + )} + +
+
{item.name}
+ +
+ {item.description || "No description."} +
+
+
+ ))} + + )} +
+ )} + + ) +} diff --git a/components/chat/prompt-picker.tsx b/components/chat/prompt-picker.tsx new file mode 100644 index 0000000000..b4a6d7ab96 --- /dev/null +++ b/components/chat/prompt-picker.tsx @@ -0,0 +1,193 @@ +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { Button } from "../ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog" +import { Label } from "../ui/label" +import { TextareaAutosize } from "../ui/textarea-autosize" + +interface PromptPickerProps { + searchQuery: string + onSelect: (prompt: Tables<"prompts">) => void + isFocused: boolean +} + +export const PromptPicker: FC = ({ + searchQuery, + onSelect, + isFocused +}) => { + const { prompts, isPromptPickerOpen, setIsPromptPickerOpen } = + useContext(ChatbotUIContext) + + const firstPromptRef = useRef(null) + + const [promptVariables, setPromptVariables] = useState< + { + promptId: string + name: string + value: string + }[] + >([]) + const [showPromptVariables, setShowPromptVariables] = useState(false) + + const filteredPrompts = prompts.filter(prompt => + prompt.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const handleOpenChange = (isOpen: boolean) => { + setIsPromptPickerOpen(isOpen) + } + + const handleSelectPrompt = (prompt: Tables<"prompts">) => { + const regex = /\{\{.*?\}\}/g + const matches = prompt.content.match(regex) + + if (matches) { + const newPromptVariables = matches.map(match => ({ + promptId: prompt.id, + name: match.replace(/\{\{|\}\}/g, ""), + value: "" + })) + + setPromptVariables(newPromptVariables) + setShowPromptVariables(true) + } else { + onSelect(prompt) + handleOpenChange(false) + } + } + + const getKeyDownHandler = + (index: number) => (e: React.KeyboardEvent) => { + if (e.key === "Backspace") { + e.preventDefault() + handleOpenChange(false) + } else if (e.key === "Enter") { + e.preventDefault() + handleSelectPrompt(filteredPrompts[index]) + } else if ( + e.key === "Tab" && + !e.shiftKey && + index === filteredPrompts.length - 1 + ) { + e.preventDefault() + firstPromptRef.current?.focus() + } + } + + const handleSubmitPromptVariables = () => { + const newPromptContent = promptVariables.reduce( + (prevContent, variable) => + prevContent.replace( + new RegExp(`\\{\\{${variable.name}\\}\\}`, "g"), + variable.value + ), + prompts.find(prompt => prompt.id === promptVariables[0].promptId) + ?.content || "" + ) + + const newPrompt: any = { + ...prompts.find(prompt => prompt.id === promptVariables[0].promptId), + content: newPromptContent + } + + onSelect(newPrompt) + handleOpenChange(false) + setShowPromptVariables(false) + setPromptVariables([]) + } + + const handleCancelPromptVariables = () => { + setShowPromptVariables(false) + setPromptVariables([]) + } + + const handleKeydownPromptVariables = ( + e: React.KeyboardEvent + ) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSubmitPromptVariables() + } + } + + useEffect(() => { + firstPromptRef.current?.focus() + }, [isFocused]) + + return ( + <> + {isPromptPickerOpen && ( +
+ {showPromptVariables ? ( + + + + Enter Prompt Variables + + +
+ {promptVariables.map((variable, index) => ( +
+ + + { + const newPromptVariables = [...promptVariables] + newPromptVariables[index].value = value + setPromptVariables(newPromptVariables) + }} + minRows={3} + maxRows={5} + /> +
+ ))} +
+ +
+ + + +
+
+
+ ) : filteredPrompts.length === 0 ? ( +
+ No matching prompts. +
+ ) : ( + filteredPrompts.map((prompt, index) => ( +
handleSelectPrompt(prompt)} + onKeyDown={getKeyDownHandler(index)} + > +
{prompt.name}
+
+ {prompt.content} +
+
+ )) + )} +
+ )} + + ) +} diff --git a/components/chat/quick-setting-option.tsx b/components/chat/quick-setting-option.tsx new file mode 100644 index 0000000000..61cc8ac644 --- /dev/null +++ b/components/chat/quick-setting-option.tsx @@ -0,0 +1,63 @@ +import { Tables } from "@/supabase/types" +import { IconCircleCheckFilled, IconRobotFace } from "@tabler/icons-react" +import Image from "next/image" +import { FC } from "react" +import { ModelIcon } from "../models/model-icon" +import { DropdownMenuItem } from "../ui/dropdown-menu" + +interface QuickSettingOptionProps { + contentType: "presets" | "assistants" + isSelected: boolean + item: Tables<"presets"> | Tables<"assistants"> + onSelect: () => void + image: string +} + +export const QuickSettingOption: FC = ({ + contentType, + isSelected, + item, + onSelect, + image +}) => { + return ( + +
+ {contentType === "presets" ? ( + + ) : image ? ( + Assistant + ) : ( + + )} +
+ +
+
{item.name}
+ +
+ {item.description || "No description."} +
+
+ +
+ {isSelected ? ( + + ) : null} +
+
+ ) +} diff --git a/components/chat/quick-settings.tsx b/components/chat/quick-settings.tsx new file mode 100644 index 0000000000..89fc499769 --- /dev/null +++ b/components/chat/quick-settings.tsx @@ -0,0 +1,233 @@ +import { ChatbotUIContext } from "@/context/context" +import useHotkey from "@/lib/hooks/use-hotkey" +import { Tables } from "@/supabase/types" +import { LLMID } from "@/types" +import { IconChevronDown, IconRobotFace } from "@tabler/icons-react" +import Image from "next/image" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { ModelIcon } from "../models/model-icon" +import { Button } from "../ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "../ui/dropdown-menu" +import { Input } from "../ui/input" +import { QuickSettingOption } from "./quick-setting-option" + +interface QuickSettingsProps {} + +export const QuickSettings: FC = ({}) => { + useHotkey("p", () => setIsOpen(prevState => !prevState)) + + const { + presets, + assistants, + selectedAssistant, + selectedPreset, + chatSettings, + setSelectedPreset, + setSelectedAssistant, + setChatSettings, + assistantImages + } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") + + useEffect(() => { + if (isOpen) { + setTimeout(() => { + inputRef.current?.focus() + }, 100) // FIX: hacky + } + }, [isOpen]) + + const handleSelectQuickSetting = ( + item: Tables<"presets"> | Tables<"assistants">, + contentType: "presets" | "assistants" + ) => { + if (contentType === "assistants") { + setSelectedAssistant(item as Tables<"assistants">) + setSelectedPreset(null) + } else if (contentType === "presets") { + setSelectedPreset(item as Tables<"presets">) + setSelectedAssistant(null) + } + + setChatSettings({ + model: item.model as LLMID, + prompt: item.prompt, + temperature: item.temperature, + contextLength: item.context_length, + includeProfileContext: item.include_profile_context, + includeWorkspaceInstructions: item.include_workspace_instructions, + embeddingsProvider: item.embeddings_provider as "openai" | "local" + }) + } + + const checkIfModified = () => { + if (!chatSettings) return false + + if (selectedPreset) { + if ( + selectedPreset.include_profile_context !== + chatSettings.includeProfileContext || + selectedPreset.include_workspace_instructions !== + chatSettings.includeWorkspaceInstructions || + selectedPreset.context_length !== chatSettings.contextLength || + selectedPreset.model !== chatSettings.model || + selectedPreset.prompt !== chatSettings.prompt || + selectedPreset.temperature !== chatSettings.temperature + ) { + return true + } + } else if (selectedAssistant) { + if ( + selectedAssistant.include_profile_context !== + chatSettings.includeProfileContext || + selectedAssistant.include_workspace_instructions !== + chatSettings.includeWorkspaceInstructions || + selectedAssistant.context_length !== chatSettings.contextLength || + selectedAssistant.model !== chatSettings.model || + selectedAssistant.prompt !== chatSettings.prompt || + selectedAssistant.temperature !== chatSettings.temperature + ) { + return true + } + } + + return false + } + + const isModified = checkIfModified() + + const items = [ + ...presets.map(preset => ({ ...preset, contentType: "presets" })), + ...assistants.map(assistant => ({ + ...assistant, + contentType: "assistants" + })) + ] + + const selectedAssistantImage = selectedPreset + ? "" + : assistantImages.find( + image => image.path === selectedAssistant?.image_path + )?.base64 || "" + + return ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + {presets.length === 0 && assistants.length === 0 ? ( +
No items found.
+ ) : ( + <> + setSearch(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + {!!(selectedPreset || selectedAssistant) && ( + + | Tables<"assistants">) + } + onSelect={() => { + setSelectedPreset(null) + setSelectedAssistant(null) + }} + image={selectedPreset ? "" : selectedAssistantImage} + /> + )} + + {items + .filter( + item => + item.name.toLowerCase().includes(search.toLowerCase()) && + item.id !== selectedPreset?.id && + item.id !== selectedAssistant?.id + ) + .map(({ contentType, ...item }) => ( + + handleSelectQuickSetting( + item, + contentType as "presets" | "assistants" + ) + } + image={ + contentType === "assistants" + ? assistantImages.find( + image => + image.path === + (item as Tables<"assistants">).image_path + )?.base64 || "" + : "" + } + /> + ))} + + )} +
+
+ ) +} diff --git a/components/icons/anthropic-svg.tsx b/components/icons/anthropic-svg.tsx new file mode 100644 index 0000000000..27b2cd18f4 --- /dev/null +++ b/components/icons/anthropic-svg.tsx @@ -0,0 +1,44 @@ +import { FC } from "react" + +interface AnthropicSVGProps { + height?: number + width?: number + className?: string +} + +export const AnthropicSVG: FC = ({ + height = 40, + width = 40, + className +}) => { + return ( + + + + + + + + + ) +} diff --git a/components/icons/chatbotui-svg.tsx b/components/icons/chatbotui-svg.tsx new file mode 100644 index 0000000000..4c29cf66f6 --- /dev/null +++ b/components/icons/chatbotui-svg.tsx @@ -0,0 +1,37 @@ +import { FC } from "react" + +interface ChatbotUISVGProps { + theme: "dark" | "light" + scale?: number +} + +export const ChatbotUISVG: FC = ({ theme, scale = 1 }) => { + return ( + + + + + + ) +} diff --git a/components/icons/google-svg.tsx b/components/icons/google-svg.tsx new file mode 100644 index 0000000000..8a86709ce7 --- /dev/null +++ b/components/icons/google-svg.tsx @@ -0,0 +1,42 @@ +import { FC } from "react" + +interface GoogleSVGProps { + height?: number + width?: number + className?: string +} + +export const GoogleSVG: FC = ({ + height = 40, + width = 40, + className +}) => { + return ( + + + + + + + ) +} diff --git a/components/icons/openai-svg.tsx b/components/icons/openai-svg.tsx new file mode 100644 index 0000000000..670c613598 --- /dev/null +++ b/components/icons/openai-svg.tsx @@ -0,0 +1,31 @@ +import { FC } from "react" + +interface OpenAISVGProps { + height?: number + width?: number + className?: string +} + +export const OpenAISVG: FC = ({ + height = 40, + width = 40, + className +}) => { + return ( + + + + ) +} diff --git a/components/messages/message-actions.tsx b/components/messages/message-actions.tsx new file mode 100644 index 0000000000..0e1c8c78ea --- /dev/null +++ b/components/messages/message-actions.tsx @@ -0,0 +1,117 @@ +import { ChatbotUIContext } from "@/context/context" +import { IconCheck, IconCopy, IconEdit, IconRepeat } from "@tabler/icons-react" +import { FC, useContext, useEffect, useState } from "react" +import { WithTooltip } from "../ui/with-tooltip" + +export const MESSAGE_ICON_SIZE = 18 + +interface MessageActionsProps { + isAssistant: boolean + isLast: boolean + isEditing: boolean + isHovering: boolean + onCopy: () => void + onEdit: () => void + onRegenerate: () => void +} + +export const MessageActions: FC = ({ + isAssistant, + isLast, + isEditing, + isHovering, + onCopy, + onEdit, + onRegenerate +}) => { + const { isGenerating } = useContext(ChatbotUIContext) + + const [showCheckmark, setShowCheckmark] = useState(false) + + const handleCopy = () => { + onCopy() + setShowCheckmark(true) + } + + const handleForkChat = async () => {} + + useEffect(() => { + if (showCheckmark) { + const timer = setTimeout(() => { + setShowCheckmark(false) + }, 2000) + + return () => clearTimeout(timer) + } + }, [showCheckmark]) + + return (isLast && isGenerating) || isEditing ? null : ( +
+ {/* {((isAssistant && isHovering) || isLast) && ( + Fork Chat
} + trigger={ + + } + /> + )} */} + + {!isAssistant && isHovering && ( + Edit
} + trigger={ + + } + /> + )} + + {(isHovering || isLast) && ( + Copy
} + trigger={ + showCheckmark ? ( + + ) : ( + + ) + } + /> + )} + + {isLast && ( + Regenerate} + trigger={ + + } + /> + )} + + {/* {1 > 0 && isAssistant && } */} + + ) +} diff --git a/components/messages/message-codeblock.tsx b/components/messages/message-codeblock.tsx new file mode 100644 index 0000000000..2b8d79552d --- /dev/null +++ b/components/messages/message-codeblock.tsx @@ -0,0 +1,135 @@ +import { Button } from "@/components/ui/button" +import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard" +import { IconCheck, IconCopy, IconDownload } from "@tabler/icons-react" +import { FC, memo } from "react" +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" +import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism" + +interface MessageCodeBlockProps { + language: string + value: string +} + +interface languageMap { + [key: string]: string | undefined +} + +export const programmingLanguages: languageMap = { + javascript: ".js", + python: ".py", + java: ".java", + c: ".c", + cpp: ".cpp", + "c++": ".cpp", + "c#": ".cs", + ruby: ".rb", + php: ".php", + swift: ".swift", + "objective-c": ".m", + kotlin: ".kt", + typescript: ".ts", + go: ".go", + perl: ".pl", + rust: ".rs", + scala: ".scala", + haskell: ".hs", + lua: ".lua", + shell: ".sh", + sql: ".sql", + html: ".html", + css: ".css" +} + +export const generateRandomString = (length: number, lowercase = false) => { + const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789" // excluding similar looking characters like Z, 2, I, 1, O, 0 + let result = "" + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return lowercase ? result.toLowerCase() : result +} + +export const MessageCodeBlock: FC = memo( + ({ language, value }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) + + const downloadAsFile = () => { + if (typeof window === "undefined") { + return + } + const fileExtension = programmingLanguages[language] || ".file" + const suggestedFileName = `file-${generateRandomString( + 3, + true + )}${fileExtension}` + const fileName = window.prompt("Enter file name" || "", suggestedFileName) + + if (!fileName) { + return + } + + const blob = new Blob([value], { type: "text/plain" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.download = fileName + link.href = url + link.style.display = "none" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const onCopy = () => { + if (isCopied) return + copyToClipboard(value) + } + + return ( +
+
+ {language} +
+ + + +
+
+ + {value} + +
+ ) + } +) + +MessageCodeBlock.displayName = "MessageCodeBlock" diff --git a/components/messages/message-markdown-memoized.tsx b/components/messages/message-markdown-memoized.tsx new file mode 100644 index 0000000000..2fc2106500 --- /dev/null +++ b/components/messages/message-markdown-memoized.tsx @@ -0,0 +1,9 @@ +import { FC, memo } from "react" +import ReactMarkdown, { Options } from "react-markdown" + +export const MessageMarkdownMemoized: FC = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className +) diff --git a/components/messages/message-markdown.tsx b/components/messages/message-markdown.tsx new file mode 100644 index 0000000000..c00be87ee1 --- /dev/null +++ b/components/messages/message-markdown.tsx @@ -0,0 +1,62 @@ +import React, { FC } from "react" +import remarkGfm from "remark-gfm" +import remarkMath from "remark-math" +import { MessageCodeBlock } from "./message-codeblock" +import { MessageMarkdownMemoized } from "./message-markdown-memoized" + +interface MessageMarkdownProps { + content: string +} + +export const MessageMarkdown: FC = ({ content }) => { + return ( + {children}

+ }, + code({ node, className, children, ...props }) { + const childArray = React.Children.toArray(children) + const firstChild = childArray[0] as React.ReactElement + const firstChildAsString = React.isValidElement(firstChild) + ? (firstChild as React.ReactElement).props.children + : firstChild + + if (firstChildAsString === "▍") { + return + } + + if (typeof firstChildAsString === "string") { + childArray[0] = firstChildAsString.replace("`▍`", "▍") + } + + const match = /language-(\w+)/.exec(className || "") + + if ( + typeof firstChildAsString === "string" && + !firstChildAsString.includes("\n") + ) { + return ( + + {childArray} + + ) + } + + return ( + + ) + } + }} + > + {content} +
+ ) +} diff --git a/components/messages/message-replies.tsx b/components/messages/message-replies.tsx new file mode 100644 index 0000000000..7daf2a6b1a --- /dev/null +++ b/components/messages/message-replies.tsx @@ -0,0 +1,51 @@ +import { IconMessage } from "@tabler/icons-react" +import { FC, useState } from "react" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger +} from "../ui/sheet" +import { WithTooltip } from "../ui/with-tooltip" +import { MESSAGE_ICON_SIZE } from "./message-actions" + +interface MessageRepliesProps {} + +export const MessageReplies: FC = ({}) => { + const [isOpen, setIsOpen] = useState(false) + + return ( + + + View Replies} + trigger={ +
setIsOpen(true)} + > + +
+ {1} +
+
+ } + /> +
+ + + + Are you sure absolutely sure? + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + +
+ ) +} diff --git a/components/messages/message.tsx b/components/messages/message.tsx new file mode 100644 index 0000000000..722668caf9 --- /dev/null +++ b/components/messages/message.tsx @@ -0,0 +1,345 @@ +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatbotUIContext } from "@/context/context" +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { LLMID, MessageImage } from "@/types" +import { + IconCircleFilled, + IconFileFilled, + IconFileText, + IconFileTypePdf, + IconMoodSmile, + IconPencil, + IconRobotFace +} from "@tabler/icons-react" +import Image from "next/image" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { ModelIcon } from "../models/model-icon" +import { Avatar, AvatarImage } from "../ui/avatar" +import { Button } from "../ui/button" +import { FilePreview } from "../ui/file-preview" +import { TextareaAutosize } from "../ui/textarea-autosize" +import { MessageActions } from "./message-actions" +import { MessageMarkdown } from "./message-markdown" + +const ICON_SIZE = 28 + +interface MessageProps { + message: Tables<"messages"> + fileItems: Tables<"file_items">[] + isEditing: boolean + isLast: boolean + onStartEdit: (message: Tables<"messages">) => void + onCancelEdit: () => void + onSubmitEdit: (value: string, sequenceNumber: number) => void +} + +export const Message: FC = ({ + message, + fileItems, + isEditing, + isLast, + onStartEdit, + onCancelEdit, + onSubmitEdit +}) => { + const { + profile, + isGenerating, + setIsGenerating, + firstTokenReceived, + availableLocalModels, + chatMessages, + selectedAssistant, + chatImages, + assistantImages, + toolInUse, + files + } = useContext(ChatbotUIContext) + + const { handleSendMessage } = useChatHandler() + + const editInputRef = useRef(null) + + const [isHovering, setIsHovering] = useState(false) + const [editedMessage, setEditedMessage] = useState(message.content) + + const [showImagePreview, setShowImagePreview] = useState(false) + const [selectedImage, setSelectedImage] = useState(null) + + const [showFileItemPreview, setShowFileItemPreview] = useState(false) + const [selectedFileItem, setSelectedFileItem] = + useState | null>(null) + + const handleCopy = () => { + navigator.clipboard.writeText(message.content) + } + + const handleSendEdit = () => { + onSubmitEdit(editedMessage, message.sequence_number) + onCancelEdit() + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (isEditing && event.key === "Enter" && event.metaKey) { + handleSendEdit() + } + } + + const handleRegenerate = async () => { + setIsGenerating(true) + await handleSendMessage(editedMessage, chatMessages, true) + } + + const handleStartEdit = () => { + onStartEdit(message) + } + + useEffect(() => { + setEditedMessage(message.content) + + if (isEditing && editInputRef.current) { + const input = editInputRef.current + input.focus() + input.setSelectionRange(input.value.length, input.value.length) + } + }, [isEditing]) + + const MODEL_DATA = [...LLM_LIST, ...availableLocalModels].find( + llm => llm.modelId === message.model + ) + + const selectedAssistantImage = assistantImages.find( + image => image.path === selectedAssistant?.image_path + )?.base64 + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onKeyDown={handleKeyDown} + > +
+
+ +
+
+ {message.role === "system" ? ( +
+ + +
Prompt
+
+ ) : ( +
+ {message.role === "assistant" ? ( + selectedAssistant ? ( + selectedAssistantImage ? ( + assistant image + ) : ( + + ) + ) : ( + + ) + ) : profile?.image_url ? ( + + + + ) : ( + + )} + +
+ {message.role === "assistant" + ? selectedAssistant + ? selectedAssistant?.name + : MODEL_DATA?.modelName + : profile?.display_name ?? profile?.username} +
+
+ )} + {!firstTokenReceived && + isGenerating && + isLast && + message.role === "assistant" ? ( + <> + {(() => { + switch (toolInUse) { + case "none": + return ( + + ) + case "retrieval": + return ( +
+ + +
Reading documents...
+
+ ) + default: + return null + } + })()} + + ) : isEditing ? ( + + ) : ( + + )} +
+ + {fileItems.length > 0 && ( +
+
Sources
+ +
+ {fileItems.map(fileItem => { + const parentFile = files.find( + file => file.id === fileItem.file_id + ) + + return ( +
{ + setSelectedFileItem(fileItem) + setShowFileItemPreview(true) + }} + > +
+ {(() => { + let fileExtension = parentFile?.type.includes("/") + ? parentFile.type.split("/")[1] + : parentFile?.type + + switch (fileExtension) { + case "pdf": + return + default: + return + } + })()} +
+ +
+
{parentFile?.name}
+ +
+ {fileItem.content.substring(0, 70)}... +
+
+
+ ) + })} +
+
+ )} + +
+ {message.image_paths.map((path, index) => { + const item = chatImages.find(image => image.path === path) + + return ( + message image { + setSelectedImage({ + messageId: message.id, + path, + base64: path.startsWith("data") ? path : item?.base64 || "", + url: path.startsWith("data") ? "" : item?.url || "", + file: null + }) + + setShowImagePreview(true) + }} + loading="lazy" + /> + ) + })} +
+ {isEditing && ( +
+ + + +
+ )} +
+ + {showImagePreview && selectedImage && ( + { + setShowImagePreview(isOpen) + setSelectedImage(null) + }} + /> + )} + + {showFileItemPreview && selectedFileItem && ( + { + setShowFileItemPreview(isOpen) + setSelectedFileItem(null) + }} + /> + )} +
+ ) +} diff --git a/components/models/model-icon.tsx b/components/models/model-icon.tsx new file mode 100644 index 0000000000..464fd49c40 --- /dev/null +++ b/components/models/model-icon.tsx @@ -0,0 +1,146 @@ +import { cn } from "@/lib/utils" +import meta from "@/public/providers/meta.png" +import mistral from "@/public/providers/mistral.png" +import perplexity from "@/public/providers/perplexity.png" +import { LLMID } from "@/types" +import { IconSparkles } from "@tabler/icons-react" +import { useTheme } from "next-themes" +import Image from "next/image" +import { FC, HTMLAttributes } from "react" +import { AnthropicSVG } from "../icons/anthropic-svg" +import { GoogleSVG } from "../icons/google-svg" +import { OpenAISVG } from "../icons/openai-svg" + +interface ModelIconProps extends HTMLAttributes { + modelId: LLMID | string + height: number + width: number +} + +export const ModelIcon: FC = ({ + modelId, + height, + width, + ...props +}) => { + const { theme } = useTheme() + + switch (modelId as string) { + case "gpt-4-1106-preview": + case "gpt-4-vision-preview": + case "gpt-3.5-turbo-1106": + return ( + + ) + case "llama2-7b": + case "llama2-70b": + return ( + Mistral + ) + case "mistral-tiny": + case "mistral-small": + case "mistral-medium": + return ( + Mistral + ) + case "claude-2.1": + case "claude-instant-1.2": + return ( + + ) + case "gemini-pro": + case "gemini-pro-vision": + return ( + + ) + case "pplx-7b-online": + case "pplx-70b-online": + return ( + Mistral + ) + default: + if (!modelId) { + return + } else if (modelId.includes("llama")) { + return ( + Mistral + ) + } else if (modelId.includes("mistral") || modelId.includes("mixtral")) { + return ( + Mistral + ) + } else { + return + } + } +} diff --git a/components/models/model-option.tsx b/components/models/model-option.tsx new file mode 100644 index 0000000000..8a1572c14a --- /dev/null +++ b/components/models/model-option.tsx @@ -0,0 +1,50 @@ +import { ChatbotUIContext } from "@/context/context" +import { isModelLocked } from "@/lib/is-model-locked" +import { LLM, LLMID } from "@/types" +import { IconLock } from "@tabler/icons-react" +import { FC, useContext } from "react" +import { WithTooltip } from "../ui/with-tooltip" +import { ModelIcon } from "./model-icon" + +interface ModelOptionProps { + model: LLM + onSelect: () => void +} + +export const ModelOption: FC = ({ model, onSelect }) => { + const { profile } = useContext(ChatbotUIContext) + + if (!profile) return null + + const isLocked = isModelLocked(model.provider, profile) + + const handleSelectModel = () => { + if (isLocked) return + + onSelect() + } + + return ( +
+
+ {isLocked ? ( + + Save {model.provider} API key in profile settings to unlock. +
+ } + trigger={} + /> + ) : ( + + )} + +
{model.modelName}
+
+ + ) +} diff --git a/components/models/model-select.tsx b/components/models/model-select.tsx new file mode 100644 index 0000000000..0bc4dbba3e --- /dev/null +++ b/components/models/model-select.tsx @@ -0,0 +1,190 @@ +import { ChatbotUIContext } from "@/context/context" +import { isModelLocked } from "@/lib/is-model-locked" +import { LLM, LLMID } from "@/types" +import { IconCheck, IconChevronDown, IconLock } from "@tabler/icons-react" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { Button } from "../ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "../ui/dropdown-menu" +import { Input } from "../ui/input" +import { Tabs, TabsList, TabsTrigger } from "../ui/tabs" +import { WithTooltip } from "../ui/with-tooltip" +import { ModelIcon } from "./model-icon" +import { ModelOption } from "./model-option" + +interface ModelSelectProps { + hostedModelOptions: LLM[] + localModelOptions: LLM[] + selectedModelId: string + onSelectModel: (modelId: LLMID) => void +} + +export const ModelSelect: FC = ({ + hostedModelOptions, + localModelOptions, + selectedModelId, + onSelectModel +}) => { + const { profile } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + const triggerRef = useRef(null) + + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") + const [tab, setTab] = useState<"hosted" | "local">("hosted") + + useEffect(() => { + if (isOpen) { + setTimeout(() => { + inputRef.current?.focus() + }, 100) // FIX: hacky + } + }, [isOpen]) + + const handleSelectModel = (modelId: LLMID) => { + onSelectModel(modelId) + setIsOpen(false) + } + + const ALL_MODELS = [...hostedModelOptions, ...localModelOptions] + + const groupedModels = ALL_MODELS.reduce>( + (groups, model) => { + const key = model.provider + if (!groups[key]) { + groups[key] = [] + } + groups[key].push(model) + return groups + }, + {} + ) + + const SELECTED_MODEL = ALL_MODELS.find( + model => model.modelId === selectedModelId + ) + + if (!SELECTED_MODEL) return null + if (!profile) return null + + const isLocked = isModelLocked(SELECTED_MODEL.provider, profile) + + return ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + setTab(value)}> + + Hosted + Local + + + + setSearch(e.target.value)} + /> + +
+ {Object.entries(groupedModels).map(([provider, models]) => { + const filteredModels = models + .filter(model => { + if (tab === "hosted") return model.provider !== "ollama" + if (tab === "local") return model.provider === "ollama" + }) + .filter(model => + model.modelName.toLowerCase().includes(search.toLowerCase()) + ) + .sort((a, b) => a.provider.localeCompare(b.provider)) + + if (filteredModels.length === 0) return null + + return ( +
+
+ {provider === "openai" && profile.use_azure_openai + ? "AZURE OPENAI" + : provider.toLocaleUpperCase()} +
+ +
+ {filteredModels.map(model => { + return ( +
+ {selectedModelId === model.modelId && ( + + )} + + handleSelectModel(model.modelId)} + /> +
+ ) + })} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/components/setup/api-step.tsx b/components/setup/api-step.tsx new file mode 100644 index 0000000000..57ca0e5a64 --- /dev/null +++ b/components/setup/api-step.tsx @@ -0,0 +1,197 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FC } from "react" +import { Button } from "../ui/button" + +interface APIStepProps { + openaiAPIKey: string + openaiOrgID: string + azureOpenaiAPIKey: string + azureOpenaiEndpoint: string + azureOpenai35TurboID: string + azureOpenai45TurboID: string + azureOpenai45VisionID: string + anthropicAPIKey: string + googleGeminiAPIKey: string + mistralAPIKey: string + perplexityAPIKey: string + useAzureOpenai: boolean + onOpenaiAPIKeyChange: (value: string) => void + onOpenaiOrgIDChange: (value: string) => void + onAzureOpenaiAPIKeyChange: (value: string) => void + onAzureOpenaiEndpointChange: (value: string) => void + onAzureOpenai35TurboIDChange: (value: string) => void + onAzureOpenai45TurboIDChange: (value: string) => void + onAzureOpenai45VisionIDChange: (value: string) => void + onAnthropicAPIKeyChange: (value: string) => void + onGoogleGeminiAPIKeyChange: (value: string) => void + onMistralAPIKeyChange: (value: string) => void + onPerplexityAPIKeyChange: (value: string) => void + onUseAzureOpenaiChange: (value: boolean) => void +} + +export const APIStep: FC = ({ + openaiAPIKey, + openaiOrgID, + azureOpenaiAPIKey, + azureOpenaiEndpoint, + azureOpenai35TurboID, + azureOpenai45TurboID, + azureOpenai45VisionID, + anthropicAPIKey, + googleGeminiAPIKey, + mistralAPIKey, + perplexityAPIKey, + useAzureOpenai, + onOpenaiAPIKeyChange, + onOpenaiOrgIDChange, + onAzureOpenaiAPIKeyChange, + onAzureOpenaiEndpointChange, + onAzureOpenai35TurboIDChange, + onAzureOpenai45TurboIDChange, + onAzureOpenai45VisionIDChange, + onAnthropicAPIKeyChange, + onGoogleGeminiAPIKeyChange, + onMistralAPIKeyChange, + onPerplexityAPIKeyChange, + onUseAzureOpenaiChange +}) => { + return ( + <> +
+ + + + useAzureOpenai + ? onAzureOpenaiAPIKeyChange(e.target.value) + : onOpenaiAPIKeyChange(e.target.value) + } + /> +
+ +
+ {useAzureOpenai ? ( + <> +
+ + + onAzureOpenaiEndpointChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenai35TurboIDChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenai45TurboIDChange(e.target.value)} + /> +
+ +
+ + + onAzureOpenai45VisionIDChange(e.target.value)} + /> +
+ + ) : ( + <> +
+ + + onOpenaiOrgIDChange(e.target.value)} + /> +
+ + )} +
+ +
+ + + onAnthropicAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onGoogleGeminiAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onMistralAPIKeyChange(e.target.value)} + /> +
+ +
+ + + onPerplexityAPIKeyChange(e.target.value)} + /> +
+ + ) +} diff --git a/components/setup/finish-step.tsx b/components/setup/finish-step.tsx new file mode 100644 index 0000000000..74994e1914 --- /dev/null +++ b/components/setup/finish-step.tsx @@ -0,0 +1,15 @@ +import { FC } from "react" + +interface FinishStepProps { + displayName: string +} + +export const FinishStep: FC = ({ displayName }) => { + return ( +
+
Welcome to Chatbot UI, {displayName.split(" ")[0]}!
+ +
Click next to start chatting.
+
+ ) +} diff --git a/components/setup/profile-step.tsx b/components/setup/profile-step.tsx new file mode 100644 index 0000000000..ad750e042e --- /dev/null +++ b/components/setup/profile-step.tsx @@ -0,0 +1,198 @@ +import ImagePicker from "@/components/ui/image-picker" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + PROFILE_CONTEXT_MAX, + PROFILE_DISPLAY_NAME_MAX, + PROFILE_USERNAME_MAX, + PROFILE_USERNAME_MIN +} from "@/db/limits" +import { + IconCircleCheckFilled, + IconCircleXFilled, + IconLoader2 +} from "@tabler/icons-react" +import { FC, useCallback, useState } from "react" +import ReactTextareaAutosize from "react-textarea-autosize" +import { LimitDisplay } from "../ui/limit-display" + +interface ProfileStepProps { + profileContext: string + profileImage: File | null + profileImageSrc: string + username: string + usernameAvailable: boolean + displayName: string + onProfileContextChange: (profileContext: string) => void + onProfileImageChange: (image: File) => void + onProfileImageChangeSrc: (image: string) => void + onUsernameAvailableChange: (isAvailable: boolean) => void + onUsernameChange: (username: string) => void + onDisplayNameChange: (name: string) => void +} + +export const ProfileStep: FC = ({ + profileContext, + profileImage, + profileImageSrc, + username, + usernameAvailable, + displayName, + onProfileContextChange, + onProfileImageChange, + onProfileImageChangeSrc, + onUsernameAvailableChange, + onUsernameChange, + onDisplayNameChange +}) => { + const [loading, setLoading] = useState(false) + + const debounce = (func: (...args: any[]) => void, wait: number) => { + let timeout: NodeJS.Timeout | null + + return (...args: any[]) => { + const later = () => { + if (timeout) clearTimeout(timeout) + func(...args) + } + + if (timeout) clearTimeout(timeout) + timeout = setTimeout(later, wait) + } + } + + const checkUsernameAvailability = useCallback( + debounce(async (username: string) => { + if (!username) return + + if (username.length < PROFILE_USERNAME_MIN) { + onUsernameAvailableChange(false) + return + } + + if (username.length > PROFILE_USERNAME_MAX) { + onUsernameAvailableChange(false) + return + } + + const usernameRegex = /^[a-zA-Z0-9_]+$/ + if (!usernameRegex.test(username)) { + onUsernameAvailableChange(false) + alert( + "Username must be letters, numbers, or underscores only - no other characters or spacing allowed." + ) + return + } + + setLoading(true) + + const response = await fetch(`/api/username/available`, { + method: "POST", + body: JSON.stringify({ username }) + }) + + const data = await response.json() + const isAvailable = data.isAvailable + + onUsernameAvailableChange(isAvailable) + + setLoading(false) + }, 500), + [] + ) + + return ( + <> +
+
+ + +
+ {usernameAvailable ? ( +
AVAILABLE
+ ) : ( +
UNAVAILABLE
+ )} +
+
+ +
+ { + onUsernameChange(e.target.value) + checkUsernameAvailability(e.target.value) + }} + minLength={PROFILE_USERNAME_MIN} + maxLength={PROFILE_USERNAME_MAX} + /> + +
+ {loading ? ( + + ) : usernameAvailable ? ( + + ) : ( + + )} +
+
+ + +
+ +
+ + + +
+ +
+ + + onDisplayNameChange(e.target.value)} + maxLength={PROFILE_DISPLAY_NAME_MAX} + /> + + +
+ +
+ + + onProfileContextChange(e.target.value)} + /> + + +
+ + ) +} diff --git a/components/setup/step-container.tsx b/components/setup/step-container.tsx new file mode 100644 index 0000000000..4e0ef30be6 --- /dev/null +++ b/components/setup/step-container.tsx @@ -0,0 +1,89 @@ +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from "@/components/ui/card" +import { FC, useRef } from "react" + +export const SETUP_STEP_COUNT = 4 + +interface StepContainerProps { + stepDescription: string + stepNum: number + stepTitle: string + onShouldProceed: (shouldProceed: boolean) => void + children?: React.ReactNode + showBackButton?: boolean + showNextButton?: boolean +} + +export const StepContainer: FC = ({ + stepDescription, + stepNum, + stepTitle, + onShouldProceed, + children, + showBackButton = false, + showNextButton = true +}) => { + const buttonRef = useRef(null) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + if (buttonRef.current) { + buttonRef.current.click() + } + } + } + + return ( + + + +
{stepTitle}
+ +
+ {stepNum} / {SETUP_STEP_COUNT} +
+
+ + {stepDescription} +
+ + {children} + + +
+ {showBackButton && ( + + )} +
+ +
+ {showNextButton && ( + + )} +
+
+
+ ) +} diff --git a/components/setup/workspace-step.tsx b/components/setup/workspace-step.tsx new file mode 100644 index 0000000000..8931eb177a --- /dev/null +++ b/components/setup/workspace-step.tsx @@ -0,0 +1,52 @@ +import { Label } from "@/components/ui/label" +import { WORKSPACE_INSTRUCTIONS_MAX } from "@/db/limits" +import { ChatSettings } from "@/types" +import { FC } from "react" +import ReactTextareaAutosize from "react-textarea-autosize" +import { ChatSettingsForm } from "../ui/chat-settings-form" +import { LimitDisplay } from "../ui/limit-display" + +interface WorkspaceStepProps { + chatSettings: ChatSettings + workspaceInstructions: string + onChatSettingsChange: (chatSettings: ChatSettings) => void + onWorkspaceInstructionsChange: (workspaceInstructions: string) => void +} + +export const WorkspaceStep: FC = ({ + chatSettings: defaultChatSettings, + workspaceInstructions, + onChatSettingsChange, + onWorkspaceInstructionsChange +}) => { + return ( + <> +
+ + + onWorkspaceInstructionsChange(e.target.value)} + maxLength={WORKSPACE_INSTRUCTIONS_MAX} + /> + + +
+ + + + ) +} diff --git a/components/sharing/add-to-workspace.tsx b/components/sharing/add-to-workspace.tsx new file mode 100644 index 0000000000..547036b212 --- /dev/null +++ b/components/sharing/add-to-workspace.tsx @@ -0,0 +1,209 @@ +import { ChatbotUIContext } from "@/context/context" +import { createAssistant } from "@/db/assistants" +import { createPreset } from "@/db/presets" +import { createPrompt } from "@/db/prompts" +import { getWorkspaceById } from "@/db/workspaces" +import { Tables } from "@/supabase/types" +import { ContentType, DataItemType } from "@/types" +import { useRouter } from "next/navigation" +import { FC, useContext } from "react" +import { toast } from "sonner" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "../ui/command" + +interface AddToWorkspaceProps { + contentType: ContentType + item: DataItemType +} + +export const AddToWorkspace: FC = ({ + contentType, + item +}) => { + const { + profile, + setSelectedWorkspace, + workspaces, + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants + } = useContext(ChatbotUIContext) + + const router = useRouter() + + const createFunctions = { + chats: async ( + item: Tables<"chats">, + workspaceId: string, + userId: string + ) => { + // TODO + // const createdChat = await createChat( + // { + // user_id: userId, + // folder_id: null, + // description: item.description, + // name: item.name, + // prompt: item.prompt, + // temperature: item.temperature + // }, + // workspaceId + // ) + // return createdChat + }, + presets: async ( + item: Tables<"presets">, + workspaceId: string, + userId: string + ) => { + const createdPreset = await createPreset( + { + user_id: userId, + folder_id: null, + context_length: item.context_length, + description: item.description, + include_profile_context: item.include_profile_context, + include_workspace_instructions: item.include_workspace_instructions, + model: item.model, + name: item.name, + prompt: item.prompt, + temperature: item.temperature, + embeddings_provider: item.embeddings_provider + }, + workspaceId + ) + + return createdPreset + }, + prompts: async ( + item: Tables<"prompts">, + workspaceId: string, + userId: string + ) => { + const createdPrompt = await createPrompt( + { + user_id: userId, + folder_id: null, + content: item.content, + name: item.name + }, + workspaceId + ) + + return createdPrompt + }, + files: async ( + item: Tables<"files">, + workspaceId: string, + userId: string + ) => { + // TODO also need file items to duplicate + // const createdFile = await createFile( + // { + // user_id: userId, + // folder_id: null, + // name: item.name + // }, + // workspaceId + // ) + // return createdFile + }, + collections: async ( + item: Tables<"collections">, + workspaceId: string, + userId: string + ) => { + // TODO also need to duplicate each file item in the collection and file items + }, + assistants: async ( + item: Tables<"assistants">, + workspaceId: string, + userId: string + ) => { + const createdAssistant = await createAssistant( + { + user_id: userId, + folder_id: null, + context_length: item.context_length, + description: item.description, + include_profile_context: item.include_profile_context, + include_workspace_instructions: item.include_workspace_instructions, + model: item.model, + name: item.name, + // TODO need to duplicate image + image_path: item.image_path, + prompt: item.prompt, + temperature: item.temperature, + embeddings_provider: item.embeddings_provider + }, + workspaceId + ) + + return createdAssistant + } + } + + const stateUpdateFunctions = { + chats: setChats, + presets: setPresets, + prompts: setPrompts, + files: setFiles, + collections: setCollections, + assistants: setAssistants + } + + const handleAddToWorkspace = async (workspaceId: string) => { + if (!profile) return + + const createFunction = createFunctions[contentType] + const setStateFunction = stateUpdateFunctions[contentType] + + if (!createFunction || !setStateFunction) return + + const newItem = await createFunction( + item as any, + workspaceId, + profile.user_id + ) + + setStateFunction((prevItems: any) => [...prevItems, newItem]) + + toast.success(`Added ${item.name} to workspace.`) + + const workspace = await getWorkspaceById(workspaceId) + setSelectedWorkspace(workspace) + + router.push(`/chat/?tab=${contentType}`) + } + + return ( + + + + + No workspaces found. + + + {workspaces.map(workspace => ( + handleAddToWorkspace(workspace.id)} + > +
Add to {workspace.name}
+
+ ))} +
+
+
+ ) +} diff --git a/components/sharing/share-assistant.tsx b/components/sharing/share-assistant.tsx new file mode 100644 index 0000000000..02e272a279 --- /dev/null +++ b/components/sharing/share-assistant.tsx @@ -0,0 +1,23 @@ +import { Tables } from "@/supabase/types" +import { User } from "@supabase/supabase-js" +import { FC } from "react" +import { ShareItem } from "./share-item" + +interface ShareAssistantProps { + user: User | null + assistant: Tables<"assistants"> +} + +export const ShareAssistant: FC = ({ + user, + assistant +}) => { + return ( +
{assistant.name}
} + /> + ) +} diff --git a/components/sharing/share-chat.tsx b/components/sharing/share-chat.tsx new file mode 100644 index 0000000000..66127500ba --- /dev/null +++ b/components/sharing/share-chat.tsx @@ -0,0 +1,149 @@ +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { User } from "@supabase/supabase-js" +import { IconDownload, IconLock } from "@tabler/icons-react" +import { FC, useContext, useState } from "react" +import { Button } from "../ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from "../ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "../ui/dropdown-menu" +import { WithTooltip } from "../ui/with-tooltip" +import { AddToWorkspace } from "./add-to-workspace" +import { ShareMessage } from "./share-message" + +interface ShareChatProps { + user: User | null + chat: Tables<"chats"> + messages: Tables<"messages">[] + username: string +} + +export const ShareChat: FC = ({ + user, + chat, + messages, + username +}) => { + const { profile } = useContext(ChatbotUIContext) + + const [showWorkspaceMenu, setShowWorkspaceMenu] = useState(false) + + const handleDownload = () => { + let chatData: any = { + contentType: "chats", + context_length: chat.context_length, + include_profile_context: chat.include_profile_context, + include_workspace_instructions: chat.include_workspace_instructions, + model: chat.model, + name: chat.name, + prompt: chat.prompt, + temperature: chat.temperature + } + + let messagesData: any = messages.map(message => ({ + id: message.id, + content: message.content, + model: message.model, + role: message.role, + sequence_number: message.sequence_number + })) + + let data: any = { + contentType: "chats", + chat: chatData, + messages: messagesData + } + + const element = document.createElement("a") + const file = new Blob([JSON.stringify(data)], { + type: "text/plain" + }) + element.href = URL.createObjectURL(file) + element.download = `${data.name}.json` + document.body.appendChild(element) + element.click() + document.body.removeChild(element) + } + + return ( + + + + {user?.id === profile?.user_id && user + ? `You created this chat` + : `Chat created by ${username || "Anonymous"}`} + + + {chat.name} + + + + {messages.map(message => ( + + ))} + + + + {(user?.id !== profile?.user_id || !user) && ( + setShowWorkspaceMenu(open)} + > + setShowWorkspaceMenu(!showWorkspaceMenu)} + disabled={!user?.id} + > + + {!user?.id + ? `Sign up for Chatbot UI to continue this chat.` + : "Continue this chat in a workspace."} + + } + trigger={ + + } + /> + + + + + + + )} + +
+ Export
} + trigger={ + + } + /> + +
+
+ ) +} diff --git a/components/sharing/share-collection.tsx b/components/sharing/share-collection.tsx new file mode 100644 index 0000000000..42277af35b --- /dev/null +++ b/components/sharing/share-collection.tsx @@ -0,0 +1,23 @@ +import { Tables } from "@/supabase/types" +import { User } from "@supabase/supabase-js" +import { FC } from "react" +import { ShareItem } from "./share-item" + +interface ShareCollectionProps { + user: User | null + collection: Tables<"collections"> +} + +export const ShareCollection: FC = ({ + user, + collection +}) => { + return ( +
{collection.name}
} + /> + ) +} diff --git a/components/sharing/share-file.tsx b/components/sharing/share-file.tsx new file mode 100644 index 0000000000..1ae04402e7 --- /dev/null +++ b/components/sharing/share-file.tsx @@ -0,0 +1,44 @@ +import { Tables } from "@/supabase/types" +import { User } from "@supabase/supabase-js" +import Link from "next/link" +import { FC } from "react" +import { formatFileSize } from "../sidebar/items/files/file-item" +import { ShareItem } from "./share-item" + +interface ShareFileProps { + user: User | null + file: Tables<"files"> + link: string +} + +export const ShareFile: FC = ({ user, file, link }) => { + return ( + ( + <> +
{file.name}
+ + + View + + +
+
{file.type}
+ +
{formatFileSize(file.size)}
+ +
{file.tokens.toLocaleString()} tokens
+
+ + )} + /> + ) +} diff --git a/components/sharing/share-header.tsx b/components/sharing/share-header.tsx new file mode 100644 index 0000000000..dc28d480c2 --- /dev/null +++ b/components/sharing/share-header.tsx @@ -0,0 +1,25 @@ +import { Session } from "@supabase/supabase-js" +import Link from "next/link" +import { FC } from "react" +import { Button } from "../ui/button" + +interface ShareHeaderProps { + session: Session +} + +export const ShareHeader: FC = ({ session }) => { + return ( +
+ + Chatbot UI + + + + + +
+ ) +} diff --git a/components/sharing/share-item.tsx b/components/sharing/share-item.tsx new file mode 100644 index 0000000000..16a6c9b57b --- /dev/null +++ b/components/sharing/share-item.tsx @@ -0,0 +1,196 @@ +import Loading from "@/app/loading" +import { ContentType } from "@/types" +import { User } from "@supabase/supabase-js" +import { IconDownload, IconLock } from "@tabler/icons-react" +import { FC, ReactNode, useEffect, useState } from "react" +import { Button } from "../ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader +} from "../ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "../ui/dropdown-menu" +import { WithTooltip } from "../ui/with-tooltip" +import { AddToWorkspace } from "./add-to-workspace" + +interface ShareItemProps { + user: User | null + item: any // DataItemType + contentType: ContentType + renderContent: () => ReactNode +} + +export const ShareItem: FC = ({ + user, + item, + contentType, + renderContent +}) => { + const [showWorkspaceMenu, setShowWorkspaceMenu] = useState(false) + const [username, setUsername] = useState("") + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchData = async () => { + setLoading(true) + + const response = await fetch("/api/username/get", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ userId: item.user_id }) + }) + + const data = await response.json() + setUsername(data.username) + + setLoading(false) + } + + fetchData() + }, []) + + const handleExport = () => { + let data: any = { + contentType: contentType + } + + switch (contentType) { + case "presets": + data = { + ...data, + context_length: item.context_length, + description: item.description, + include_profile_context: item.include_profile_context, + include_workspace_instructions: item.include_workspace_instructions, + model: item.model, + name: item.name, + prompt: item.prompt, + temperature: item.temperature + } + break + + case "prompts": + data = { + ...data, + content: item.content, + name: item.name + } + break + + case "files": + data = { + ...data, + name: item.name + } + break + + case "collections": + data = { + ...data, + name: item.name + } + break + + case "assistants": + data = { + ...data, + name: item.name + } + break + + default: + break + } + + const element = document.createElement("a") + const file = new Blob([JSON.stringify(data)], { + type: "text/plain" + }) + element.href = URL.createObjectURL(file) + element.download = `${data.name}.json` + document.body.appendChild(element) + element.click() + document.body.removeChild(element) + } + + if (loading) { + return + } + + return ( + + + + {user?.id === item?.user_id && user + ? `You created this ${contentType.slice(0, -1)}` + : `${ + contentType.charAt(0).toUpperCase() + contentType.slice(1, -1) + } created by ${username || "Anonymous"}`} + + + + + {renderContent()} + + + + {(user?.id !== item?.user_id || !user) && ( + setShowWorkspaceMenu(open)} + > + setShowWorkspaceMenu(!showWorkspaceMenu)} + disabled={!user?.id} + > + + {!user?.id + ? `Sign up for Chatbot UI to continue this ${contentType.slice( + 0, + -1 + )}.` + : `Use this ${contentType.slice(0, -1)} in a workspace.`} + + } + trigger={ + + } + /> + + + + + + + )} + +
+ Export
} + trigger={ + + } + /> + +
+
+ ) +} diff --git a/components/sharing/share-message.tsx b/components/sharing/share-message.tsx new file mode 100644 index 0000000000..4c1a36ac8f --- /dev/null +++ b/components/sharing/share-message.tsx @@ -0,0 +1,42 @@ +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { Tables } from "@/supabase/types" +import { IconMoodSmile } from "@tabler/icons-react" +import { FC } from "react" +import { MessageMarkdown } from "../messages/message-markdown" +import { ModelIcon } from "../models/model-icon" + +interface MessageProps { + username: string + message: Tables<"messages"> +} + +export const ShareMessage: FC = ({ username, message }) => { + const modelData = LLM_LIST.find( + llmModel => llmModel.modelId === message.model + ) + + const isUser = message.role === "user" + const ICON_SIZE = 28 + const icon = isUser ? ( + + ) : ( + + ) + const label = isUser ? "User" : modelData?.modelName + const bgClass = isUser ? "bg-muted/50" : "bg-muted" + + return ( +
+
+ {icon} + +
{label}
+
+ + +
+ ) +} diff --git a/components/sharing/share-page.tsx b/components/sharing/share-page.tsx new file mode 100644 index 0000000000..7a58aefb66 --- /dev/null +++ b/components/sharing/share-page.tsx @@ -0,0 +1,71 @@ +"use client" + +import { ShareHeader } from "@/components/sharing/share-header" +import { ScreenLoader } from "@/components/ui/screen-loader" +import { supabase } from "@/lib/supabase/browser-client" +import { ContentType, DataItemType } from "@/types" +import { FC, useEffect, useState } from "react" + +interface SharePageProps { + contentType: ContentType + id: string + fetchById: (id: string) => Promise + ViewComponent: FC +} + +export default function SharePage({ + contentType, + id, + fetchById, + ViewComponent +}: SharePageProps) { + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [item, setItem] = useState(null) + + const onLoad = async () => { + const session = (await supabase.auth.getSession()).data.session + + setSession(session) + + const fetchedItem = await fetchById(id) + setItem(fetchedItem) + + if (!fetchedItem) { + setLoading(false) + return + } + + setLoading(false) + } + + useEffect(() => { + onLoad() + }, []) + + const itemProps = { [contentType.slice(0, -1)]: item } + + if (loading) { + return + } + + if (!item) { + return ( +
+ {contentType.slice(0, -1).charAt(0).toUpperCase() + + contentType.slice(1, -1)}{" "} + not found. +
+ ) + } + + return ( +
+ {} + +
+ +
+
+ ) +} diff --git a/components/sharing/share-preset.tsx b/components/sharing/share-preset.tsx new file mode 100644 index 0000000000..599f587077 --- /dev/null +++ b/components/sharing/share-preset.tsx @@ -0,0 +1,73 @@ +import { Tables } from "@/supabase/types" +import { User } from "@supabase/supabase-js" +import { FC } from "react" +import { ModelIcon } from "../models/model-icon" +import { Checkbox } from "../ui/checkbox" +import { Label } from "../ui/label" +import { ShareItem } from "./share-item" + +interface SharePresetProps { + user: User | null + preset: Tables<"presets"> +} + +export const SharePreset: FC = ({ user, preset }) => { + return ( + ( +
+
{preset.name}
+ +
{preset.description}
+ +
+ + +
+
+ +
+ +
{preset.model}
+
+
+ +
+ + +
{preset.prompt}
+
+ +
+ + +
{preset.temperature}
+
+ +
+ + +
+ {preset.context_length.toLocaleString()} +
+
+ +
+ + +
Includes Profile Context
+
+ +
+ + +
Includes Workspace Instructions
+
+
+ )} + /> + ) +} diff --git a/components/sharing/share-prompt.tsx b/components/sharing/share-prompt.tsx new file mode 100644 index 0000000000..c4b8a1192d --- /dev/null +++ b/components/sharing/share-prompt.tsx @@ -0,0 +1,26 @@ +import { Tables } from "@/supabase/types" +import { User } from "@supabase/supabase-js" +import { FC } from "react" +import { ShareItem } from "./share-item" + +interface SharePromptProps { + user: User | null + prompt: Tables<"prompts"> +} + +export const SharePrompt: FC = ({ user, prompt }) => { + return ( + ( +
+
{prompt.name}
+ +
{prompt.content}
+
+ )} + /> + ) +} diff --git a/components/sidebar/items/all/sidebar-create-item.tsx b/components/sidebar/items/all/sidebar-create-item.tsx new file mode 100644 index 0000000000..21ca66efba --- /dev/null +++ b/components/sidebar/items/all/sidebar-create-item.tsx @@ -0,0 +1,198 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog" +import { ChatbotUIContext } from "@/context/context" +import { createAssistant, updateAssistant } from "@/db/assistants" +import { createChat } from "@/db/chats" +import { createCollectionFiles } from "@/db/collection-files" +import { createCollection } from "@/db/collections" +import { createFile } from "@/db/files" +import { createPreset } from "@/db/presets" +import { createPrompt } from "@/db/prompts" +import { + getAssistantImageFromStorage, + uploadAssistantImage +} from "@/db/storage/assistant-images" +import { convertBlobToBase64 } from "@/lib/blob-to-b64" +import { Tables, TablesInsert } from "@/supabase/types" +import { ContentType } from "@/types" +import { FC, useContext, useRef, useState } from "react" + +interface SidebarCreateItemProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void + contentType: ContentType + renderInputs: () => JSX.Element + createState: any +} + +export const SidebarCreateItem: FC = ({ + isOpen, + onOpenChange, + contentType, + renderInputs, + createState +}) => { + const { + selectedWorkspace, + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants, + setAssistantImages + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [creating, setCreating] = useState(false) + + const createFunctions = { + chats: createChat, + presets: createPreset, + prompts: createPrompt, + files: async ( + createState: { file: File } & TablesInsert<"files">, + workspaceId: string + ) => { + if (!selectedWorkspace) return + + const { file, ...rest } = createState + + const createdFile = await createFile( + file, + rest, + workspaceId, + selectedWorkspace.embeddings_provider as "openai" | "local" + ) + + return createdFile + }, + collections: async ( + createState: { + image: File + collectionFiles: TablesInsert<"collection_files">[] + } & Tables<"collections">, + workspaceId: string + ) => { + const { collectionFiles, image, ...rest } = createState + + const createdCollection = await createCollection(rest, workspaceId) + + const finalCollectionFiles = collectionFiles.map(collectionFile => ({ + ...collectionFile, + collection_id: createdCollection.id + })) + + await createCollectionFiles(finalCollectionFiles) + + return createdCollection + }, + assistants: async ( + createState: { + image: File + } & Tables<"assistants">, + workspaceId: string + ) => { + const { image, ...rest } = createState + + const createdAssistant = await createAssistant(rest, workspaceId) + + const filePath = await uploadAssistantImage(createdAssistant, image) + + const updatedAssistant = await updateAssistant(createdAssistant.id, { + image_path: filePath + }) + + const url = (await getAssistantImageFromStorage(filePath)) || "" + + if (url) { + const response = await fetch(url) + const blob = await response.blob() + const base64 = await convertBlobToBase64(blob) + + setAssistantImages(prev => [ + ...prev, + { + assistantId: updatedAssistant.id, + path: filePath, + base64, + url + } + ]) + } + + return updatedAssistant + } + } + + const stateUpdateFunctions = { + chats: setChats, + presets: setPresets, + prompts: setPrompts, + files: setFiles, + collections: setCollections, + assistants: setAssistants + } + + const handleCreate = async () => { + if (!selectedWorkspace) return + + const createFunction = createFunctions[contentType] + const setStateFunction = stateUpdateFunctions[contentType] + + if (!createFunction || !setStateFunction) return + + setCreating(true) + + const newItem = await createFunction(createState, selectedWorkspace.id) + + setStateFunction((prevItems: any) => [...prevItems, newItem]) + + onOpenChange(false) + setCreating(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + buttonRef.current?.click() + } + } + + return ( + + + + + Create {contentType.slice(0, -1)} + + + +
{renderInputs()}
+ + +
+ + + +
+
+
+
+ ) +} diff --git a/components/sidebar/items/all/sidebar-delete-item.tsx b/components/sidebar/items/all/sidebar-delete-item.tsx new file mode 100644 index 0000000000..b56f8af7ff --- /dev/null +++ b/components/sidebar/items/all/sidebar-delete-item.tsx @@ -0,0 +1,129 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { ChatbotUIContext } from "@/context/context" +import { deleteAssistant } from "@/db/assistants" +import { deleteChat } from "@/db/chats" +import { deleteCollection } from "@/db/collections" +import { deleteFile } from "@/db/files" +import { deletePreset } from "@/db/presets" +import { deletePrompt } from "@/db/prompts" +import { deleteFileFromStorage } from "@/db/storage/files" +import { Tables } from "@/supabase/types" +import { ContentType, DataItemType } from "@/types" +import { FC, useContext, useRef, useState } from "react" + +interface SidebarDeleteItemProps { + item: DataItemType + contentType: ContentType +} + +export const SidebarDeleteItem: FC = ({ + item, + contentType +}) => { + const { + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [showDialog, setShowDialog] = useState(false) + + const deleteFunctions = { + chats: async (chat: Tables<"chats">) => { + await deleteChat(chat.id) + }, + presets: async (preset: Tables<"presets">) => { + await deletePreset(preset.id) + }, + prompts: async (prompt: Tables<"prompts">) => { + await deletePrompt(prompt.id) + }, + files: async (file: Tables<"files">) => { + await deleteFileFromStorage(file.file_path) + await deleteFile(file.id) + }, + collections: async (collection: Tables<"collections">) => { + await deleteCollection(collection.id) + }, + assistants: async (assistant: Tables<"assistants">) => { + await deleteAssistant(assistant.id) + setChats(prevState => + prevState.filter(chat => chat.assistant_id !== assistant.id) + ) + } + } + + const stateUpdateFunctions = { + chats: setChats, + presets: setPresets, + prompts: setPrompts, + files: setFiles, + collections: setCollections, + assistants: setAssistants + } + + const handleDelete = async () => { + const deleteFunction = deleteFunctions[contentType] + const setStateFunction = stateUpdateFunctions[contentType] + + if (!deleteFunction || !setStateFunction) return + + await deleteFunction(item as any) + + setStateFunction((prevItems: any) => + prevItems.filter((prevItem: any) => prevItem.id !== item.id) + ) + + setShowDialog(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Delete {contentType} + + + Are you sure you want to delete this {contentType.slice(0, -1)}? + + + + + + + + + + + ) +} diff --git a/components/sidebar/items/all/sidebar-display-item.tsx b/components/sidebar/items/all/sidebar-display-item.tsx new file mode 100644 index 0000000000..7312ae820b --- /dev/null +++ b/components/sidebar/items/all/sidebar-display-item.tsx @@ -0,0 +1,122 @@ +import { ChatbotUIContext } from "@/context/context" +import { createChat } from "@/db/chats" +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { ContentType, DataItemType } from "@/types" +import { useRouter } from "next/navigation" +import { FC, useContext, useRef, useState } from "react" +import { SidebarUpdateItem } from "./sidebar-update-item" + +interface SidebarItemProps { + item: DataItemType + contentType: ContentType + icon: React.ReactNode + updateState: any + renderInputs: () => JSX.Element +} + +export const SidebarItem: FC = ({ + item, + contentType, + updateState, + renderInputs, + icon +}) => { + const { selectedWorkspace, setChats, setSelectedAssistant } = + useContext(ChatbotUIContext) + + const router = useRouter() + + const itemRef = useRef(null) + + const [isHovering, setIsHovering] = useState(false) + + const actionMap = { + chats: async (item: any) => {}, + presets: async (item: any) => {}, + prompts: async (item: any) => {}, + files: async (item: any) => {}, + collections: async (item: any) => {}, + assistants: async (assistant: Tables<"assistants">) => { + if (!selectedWorkspace) return + + const createdChat = await createChat({ + user_id: assistant.user_id, + workspace_id: selectedWorkspace.id, + assistant_id: assistant.id, + context_length: assistant.context_length, + include_profile_context: assistant.include_profile_context, + include_workspace_instructions: + assistant.include_workspace_instructions, + model: assistant.model, + name: `Chat with ${assistant.name}`, + prompt: assistant.prompt, + temperature: assistant.temperature, + embeddings_provider: assistant.embeddings_provider + }) + + setChats(prevState => [createdChat, ...prevState]) + setSelectedAssistant(assistant) + + router.push(`/chat/${createdChat.id}`) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.stopPropagation() + itemRef.current?.click() + } + } + + // const handleClickAction = async ( + // e: React.MouseEvent + // ) => { + // e.stopPropagation() + + // const action = actionMap[contentType] + + // await action(item as any) + // } + + return ( + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > + {icon} + +
+ {item.name} +
+ + {/* TODO */} + {/* {isHovering && ( + Start chat with {contentType.slice(0, -1)}
} + trigger={ + + } + /> + )} */} + +
+ ) +} diff --git a/components/sidebar/items/all/sidebar-update-item.tsx b/components/sidebar/items/all/sidebar-update-item.tsx new file mode 100644 index 0000000000..0cc6e11e57 --- /dev/null +++ b/components/sidebar/items/all/sidebar-update-item.tsx @@ -0,0 +1,382 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { AssignWorkspaces } from "@/components/workspace/assign-workspaces" +import { ChatbotUIContext } from "@/context/context" +import { + createAssistantWorkspaces, + deleteAssistantWorkspace, + getAssistantWorkspacesByAssistantId, + updateAssistant +} from "@/db/assistants" +import { updateChat } from "@/db/chats" +import { + createCollectionWorkspaces, + deleteCollectionWorkspace, + getCollectionWorkspacesByCollectionId, + updateCollection +} from "@/db/collections" +import { + createFileWorkspaces, + deleteFileWorkspace, + getFileWorkspacesByFileId, + updateFile +} from "@/db/files" +import { + createPresetWorkspaces, + deletePresetWorkspace, + getPresetWorkspacesByPresetId, + updatePreset +} from "@/db/presets" +import { + createPromptWorkspaces, + deletePromptWorkspace, + getPromptWorkspacesByPromptId, + updatePrompt +} from "@/db/prompts" +import { uploadAssistantImage } from "@/db/storage/assistant-images" +import { Tables, TablesUpdate } from "@/supabase/types" +import { ContentType, DataItemType } from "@/types" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { SidebarDeleteItem } from "./sidebar-delete-item" + +interface SidebarUpdateItemProps { + item: DataItemType + contentType: ContentType + children: React.ReactNode + renderInputs: () => JSX.Element + updateState: any +} + +export const SidebarUpdateItem: FC = ({ + item, + contentType, + children, + renderInputs, + updateState +}) => { + const { + workspaces, + selectedWorkspace, + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants + } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [isOpen, setIsOpen] = useState(false) + const [startingWorkspaces, setStartingWorkspaces] = useState< + Tables<"workspaces">[] + >([]) + const [selectedWorkspaces, setSelectedWorkspaces] = useState< + Tables<"workspaces">[] + >([]) + + useEffect(() => { + if (isOpen) { + const fetchData = async () => { + const workspaces = await fetchSelectedWorkspaces() + setStartingWorkspaces(workspaces) + setSelectedWorkspaces(workspaces) + } + + fetchData() + } + }, [isOpen]) + + const fetchWorkpaceFunctions = { + chats: null, + presets: async (presetId: string) => { + const item = await getPresetWorkspacesByPresetId(presetId) + return item.workspaces + }, + prompts: async (promptId: string) => { + const item = await getPromptWorkspacesByPromptId(promptId) + return item.workspaces + }, + files: async (fileId: string) => { + const item = await getFileWorkspacesByFileId(fileId) + return item.workspaces + }, + collections: async (collectionId: string) => { + const item = await getCollectionWorkspacesByCollectionId(collectionId) + return item.workspaces + }, + assistants: async (assistantId: string) => { + const item = await getAssistantWorkspacesByAssistantId(assistantId) + return item.workspaces + } + } + + const fetchSelectedWorkspaces = async () => { + const fetchFunction = fetchWorkpaceFunctions[contentType] + + if (!fetchFunction) return [] + + const workspaces = await fetchFunction(item.id) + + return workspaces + } + + const handleWorkspaceUpdates = async ( + startingWorkspaces: Tables<"workspaces">[], + selectedWorkspaces: Tables<"workspaces">[], + itemId: string, + deleteWorkspaceFn: ( + itemId: string, + workspaceId: string + ) => Promise, + createWorkspaceFn: ( + workspaces: { user_id: string; item_id: string; workspace_id: string }[] + ) => Promise, + itemIdKey: string + ) => { + if (!selectedWorkspace) return + + const deleteList = startingWorkspaces.filter( + startingWorkspace => + !selectedWorkspaces.some( + selectedWorkspace => selectedWorkspace.id === startingWorkspace.id + ) + ) + + for (const workspace of deleteList) { + await deleteWorkspaceFn(itemId, workspace.id) + } + + if (deleteList.map(w => w.id).includes(selectedWorkspace.id)) { + const setStateFunction = stateUpdateFunctions[contentType] + + if (setStateFunction) { + setStateFunction((prevItems: any) => + prevItems.filter((prevItem: any) => prevItem.id !== item.id) + ) + } + } + + const createList = selectedWorkspaces.filter( + selectedWorkspace => + !startingWorkspaces.some( + startingWorkspace => startingWorkspace.id === selectedWorkspace.id + ) + ) + + await createWorkspaceFn( + createList.map(workspace => { + return { + user_id: workspace.user_id, + [itemIdKey]: itemId, + workspace_id: workspace.id + } as any + }) + ) + } + + const updateFunctions = { + chats: updateChat, + presets: async (presetId: string, updateState: TablesUpdate<"presets">) => { + const updatedPreset = await updatePreset(presetId, updateState) + + await handleWorkspaceUpdates( + startingWorkspaces, + selectedWorkspaces, + presetId, + deletePresetWorkspace, + createPresetWorkspaces as any, + "preset_id" + ) + + return updatedPreset + }, + prompts: async (promptId: string, updateState: TablesUpdate<"prompts">) => { + const updatedPrompt = await updatePrompt(promptId, updateState) + + await handleWorkspaceUpdates( + startingWorkspaces, + selectedWorkspaces, + promptId, + deletePromptWorkspace, + createPromptWorkspaces as any, + "prompt_id" + ) + + return updatedPrompt + }, + files: async (fileId: string, updateState: TablesUpdate<"files">) => { + const updatedFile = await updateFile(fileId, updateState) + + await handleWorkspaceUpdates( + startingWorkspaces, + selectedWorkspaces, + fileId, + deleteFileWorkspace, + createFileWorkspaces as any, + "file_id" + ) + + return updatedFile + }, + collections: async ( + collectionId: string, + updateState: { + image: File + collectionFilesToAdd: string[] + collectionFilesToRemove: string[] + } & TablesUpdate<"assistants"> + ) => { + const { image, collectionFilesToAdd, collectionFilesToRemove, ...rest } = + updateState + + // add files + // remove files + + // TODO deletes image + const updatedCollection = await updateCollection(collectionId, rest) + + await handleWorkspaceUpdates( + startingWorkspaces, + selectedWorkspaces, + collectionId, + deleteCollectionWorkspace, + createCollectionWorkspaces as any, + "collection_id" + ) + + return updatedCollection + }, + assistants: async ( + assistantId: string, + updateState: { + image: File + } & TablesUpdate<"assistants"> + ) => { + const { image, ...rest } = updateState + + const updatedAssistant = await updateAssistant(assistantId, rest) + + // TODO deletes + if (image) { + await uploadAssistantImage(updatedAssistant, image) + } + + await handleWorkspaceUpdates( + startingWorkspaces, + selectedWorkspaces, + assistantId, + deleteAssistantWorkspace, + createAssistantWorkspaces as any, + "assistant_id" + ) + + return updatedAssistant + } + } + + const stateUpdateFunctions = { + chats: setChats, + presets: setPresets, + prompts: setPrompts, + files: setFiles, + collections: setCollections, + assistants: setAssistants + } + + const handleUpdate = async () => { + const updateFunction = updateFunctions[contentType] + const setStateFunction = stateUpdateFunctions[contentType] + + if (!updateFunction || !setStateFunction) return + + const updatedItem = await updateFunction(item.id, updateState) + + setStateFunction((prevItems: any) => + prevItems.map((prevItem: any) => + prevItem.id === item.id ? updatedItem : prevItem + ) + ) + + setIsOpen(false) + } + + const handleSelectWorkspace = (workspace: Tables<"workspaces">) => { + setSelectedWorkspaces(prevState => { + const isWorkspaceAlreadySelected = prevState.find( + selectedWorkspace => selectedWorkspace.id === workspace.id + ) + + if (isWorkspaceAlreadySelected) { + return prevState.filter( + selectedWorkspace => selectedWorkspace.id !== workspace.id + ) + } else { + return [...prevState, workspace] + } + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + buttonRef.current?.click() + } + } + + return ( + + {children} + + + + + Edit {contentType.slice(0, -1)} + + + + {/* TODO */} + {/*
+ +
*/} + +
+ {workspaces.length > 1 && ( +
+ + + +
+ )} + + {renderInputs()} +
+ + + + +
+ + + +
+
+
+
+ ) +} diff --git a/components/sidebar/items/assistants/assistant-item.tsx b/components/sidebar/items/assistants/assistant-item.tsx new file mode 100644 index 0000000000..2587958dd2 --- /dev/null +++ b/components/sidebar/items/assistants/assistant-item.tsx @@ -0,0 +1,137 @@ +import { ChatSettingsForm } from "@/components/ui/chat-settings-form" +import ImagePicker from "@/components/ui/image-picker" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { ASSISTANT_DESCRIPTION_MAX, ASSISTANT_NAME_MAX } from "@/db/limits" +import { Tables } from "@/supabase/types" +import { IconRobotFace } from "@tabler/icons-react" +import Image from "next/image" +import { FC, useContext, useEffect, useState } from "react" +import profile from "react-syntax-highlighter/dist/esm/languages/hljs/profile" +import { SidebarItem } from "../all/sidebar-display-item" + +interface AssistantItemProps { + assistant: Tables<"assistants"> +} + +export const AssistantItem: FC = ({ assistant }) => { + const { selectedWorkspace, assistantImages } = useContext(ChatbotUIContext) + + const [name, setName] = useState(assistant.name) + const [description, setDescription] = useState(assistant.description) + const [assistantChatSettings, setAssistantChatSettings] = useState({ + model: assistant.model, + prompt: assistant.prompt, + temperature: assistant.temperature, + contextLength: assistant.context_length, + includeProfileContext: assistant.include_profile_context, + includeWorkspaceInstructions: assistant.include_workspace_instructions + }) + const [selectedImage, setSelectedImage] = useState(null) + const [imageLink, setImageLink] = useState("") + + useEffect(() => { + const assistantImage = + assistantImages.find(image => image.path === assistant.image_path) + ?.base64 || "" + + setImageLink(assistantImage) + }, [assistantImages]) + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + ) : ( + + ) + } + updateState={{ + image: selectedImage, + user_id: assistant.user_id, + name, + description, + include_profile_context: assistantChatSettings.includeProfileContext, + include_workspace_instructions: + assistantChatSettings.includeWorkspaceInstructions, + context_length: assistantChatSettings.contextLength, + model: assistantChatSettings.model, + image_path: assistant.image_path, + prompt: assistantChatSettings.prompt, + temperature: assistantChatSettings.temperature + }} + renderInputs={() => ( + <> +
+ + + +
+ +
+ + + setName(e.target.value)} + maxLength={ASSISTANT_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={ASSISTANT_DESCRIPTION_MAX} + /> +
+ +
+ + +
Coming soon...
+
+ +
+ + +
Coming soon...
+
+ + + + )} + /> + ) +} diff --git a/components/sidebar/items/assistants/create-assistant.tsx b/components/sidebar/items/assistants/create-assistant.tsx new file mode 100644 index 0000000000..96473b579b --- /dev/null +++ b/components/sidebar/items/assistants/create-assistant.tsx @@ -0,0 +1,133 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { ChatSettingsForm } from "@/components/ui/chat-settings-form" +import ImagePicker from "@/components/ui/image-picker" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { ASSISTANT_DESCRIPTION_MAX, ASSISTANT_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useEffect, useState } from "react" + +interface CreateAssistantProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreateAssistant: FC = ({ + isOpen, + onOpenChange +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [assistantChatSettings, setAssistantChatSettings] = useState({ + model: selectedWorkspace?.default_model, + prompt: selectedWorkspace?.default_prompt, + temperature: selectedWorkspace?.default_temperature, + contextLength: selectedWorkspace?.default_context_length, + includeProfileContext: selectedWorkspace?.include_profile_context, + includeWorkspaceInstructions: false, + embeddingsProvider: selectedWorkspace?.embeddings_provider + }) + const [selectedImage, setSelectedImage] = useState(null) + const [imageLink, setImageLink] = useState("") + + useEffect(() => { + setAssistantChatSettings(prevSettings => { + const previousPrompt = prevSettings.prompt || "" + const previousPromptParts = previousPrompt.split(". ") + + previousPromptParts[0] = name ? `You are ${name}` : "" + + return { + ...prevSettings, + prompt: previousPromptParts.join(". ") + } + }) + }, [name]) + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + } + isOpen={isOpen} + renderInputs={() => ( + <> +
+ + + +
+ +
+ + + setName(e.target.value)} + maxLength={ASSISTANT_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={ASSISTANT_DESCRIPTION_MAX} + /> +
+ +
+ + +
Coming soon...
+
+ +
+ + +
Coming soon...
+
+ + + + )} + onOpenChange={onOpenChange} + /> + ) +} diff --git a/components/sidebar/items/chat/chat-item.tsx b/components/sidebar/items/chat/chat-item.tsx new file mode 100644 index 0000000000..c72243f402 --- /dev/null +++ b/components/sidebar/items/chat/chat-item.tsx @@ -0,0 +1,96 @@ +import { ModelIcon } from "@/components/models/model-icon" +import { ChatbotUIContext } from "@/context/context" +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { LLM } from "@/types" +import { IconRobotFace } from "@tabler/icons-react" +import Image from "next/image" +import { useParams, useRouter } from "next/navigation" +import { FC, useContext, useRef } from "react" +import { DeleteChat } from "./delete-chat" +import { UpdateChat } from "./update-chat" + +interface ChatItemProps { + chat: Tables<"chats"> +} + +export const ChatItem: FC = ({ chat }) => { + const { selectedChat, availableLocalModels, assistantImages } = + useContext(ChatbotUIContext) + + const router = useRouter() + const params = useParams() + const isActive = params.chatid === chat.id || selectedChat?.id === chat.id + + const itemRef = useRef(null) + + const handleClick = () => { + router.push(`/chat/${chat.id}`) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.stopPropagation() + itemRef.current?.click() + } + } + + const MODEL_DATA = [...LLM_LIST, ...availableLocalModels].find( + llm => llm.modelId === chat.model + ) as LLM + + const assistantImage = assistantImages.find( + image => image.assistantId === chat.assistant_id + )?.base64 + + return ( +
+ {chat.assistant_id ? ( + assistantImage ? ( + Assistant image + ) : ( + + ) + ) : ( + + )} + +
+ {chat.name} +
+ + {isActive && ( +
{ + e.stopPropagation() + e.preventDefault() + }} + className="ml-2 flex space-x-2" + > + + + +
+ )} +
+ ) +} diff --git a/components/sidebar/items/chat/delete-chat.tsx b/components/sidebar/items/chat/delete-chat.tsx new file mode 100644 index 0000000000..868e38a34d --- /dev/null +++ b/components/sidebar/items/chat/delete-chat.tsx @@ -0,0 +1,80 @@ +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { ChatbotUIContext } from "@/context/context" +import { deleteChat } from "@/db/chats" +import useHotkey from "@/lib/hooks/use-hotkey" +import { Tables } from "@/supabase/types" +import { IconTrash } from "@tabler/icons-react" +import { FC, useContext, useRef, useState } from "react" + +interface DeleteChatProps { + chat: Tables<"chats"> +} + +export const DeleteChat: FC = ({ chat }) => { + useHotkey("Backspace", () => setShowChatDialog(true)) + + const { setChats } = useContext(ChatbotUIContext) + const { handleNewChat } = useChatHandler() + + const buttonRef = useRef(null) + + const [showChatDialog, setShowChatDialog] = useState(false) + + const handleDeleteChat = async () => { + await deleteChat(chat.id) + + setChats(prevState => prevState.filter(c => c.id !== chat.id)) + + setShowChatDialog(false) + + handleNewChat() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Delete {chat.name} + + + Are you sure you want to delete this chat? + + + + + + + + + + + ) +} diff --git a/components/sidebar/items/chat/update-chat.tsx b/components/sidebar/items/chat/update-chat.tsx new file mode 100644 index 0000000000..e5893009a2 --- /dev/null +++ b/components/sidebar/items/chat/update-chat.tsx @@ -0,0 +1,76 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { updateChat } from "@/db/chats" +import { Tables } from "@/supabase/types" +import { IconEdit } from "@tabler/icons-react" +import { FC, useContext, useRef, useState } from "react" + +interface UpdateChatProps { + chat: Tables<"chats"> +} + +export const UpdateChat: FC = ({ chat }) => { + const { setChats } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [showChatDialog, setShowChatDialog] = useState(false) + const [name, setName] = useState(chat.name) + + const handleUpdateChat = async (e: React.MouseEvent) => { + const updatedChat = await updateChat(chat.id, { + name + }) + setChats(prevState => + prevState.map(c => (c.id === chat.id ? updatedChat : c)) + ) + + setShowChatDialog(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Edit Chat + + +
+ + + setName(e.target.value)} /> +
+ + + + + + +
+
+ ) +} diff --git a/components/sidebar/items/collections/collection-file-picker.tsx b/components/sidebar/items/collections/collection-file-picker.tsx new file mode 100644 index 0000000000..7ed97f267b --- /dev/null +++ b/components/sidebar/items/collections/collection-file-picker.tsx @@ -0,0 +1,155 @@ +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { FileIcon } from "@/components/ui/file-icon" +import { Input } from "@/components/ui/input" +import { ChatbotUIContext } from "@/context/context" +import { CollectionFile } from "@/types" +import { IconChevronDown, IconCircleCheckFilled } from "@tabler/icons-react" +import { FC, useContext, useEffect, useRef, useState } from "react" + +interface CollectionFilePickerProps { + selectedCollectionFiles: CollectionFile[] + onCollectionFileSelect: (file: CollectionFile) => void +} + +export const CollectionFilePicker: FC = ({ + selectedCollectionFiles, + onCollectionFileSelect +}) => { + const { files } = useContext(ChatbotUIContext) + + const inputRef = useRef(null) + const triggerRef = useRef(null) + + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState("") + + useEffect(() => { + if (isOpen) { + setTimeout(() => { + inputRef.current?.focus() + }, 100) // FIX: hacky + } + }, [isOpen]) + + const handleFileSelect = (file: CollectionFile) => { + onCollectionFileSelect(file) + } + + if (!files) return null + + return ( + { + setIsOpen(isOpen) + setSearch("") + }} + > + + + + + + setSearch(e.target.value)} + onKeyDown={e => e.stopPropagation()} + /> + + {selectedCollectionFiles + .filter(file => + file.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(file => ( + selectedCollectionFile.id === file.id + )} + onSelect={handleFileSelect} + /> + ))} + + {files + .filter( + file => + !selectedCollectionFiles.some( + selectedCollectionFile => selectedCollectionFile.id === file.id + ) && file.name.toLowerCase().includes(search.toLowerCase()) + ) + .map(file => ( + selectedCollectionFile.id === file.id + )} + onSelect={handleFileSelect} + /> + ))} + + + ) +} + +interface CollectionFileItemProps { + file: CollectionFile + selected: boolean + onSelect: (file: CollectionFile) => void +} + +const CollectionFileItem: FC = ({ + file, + selected, + onSelect +}) => { + const handleSelect = () => { + onSelect(file) + } + + return ( +
+
+
+ +
+ +
{file.name}
+
+ + {selected && ( + + )} +
+ ) +} diff --git a/components/sidebar/items/collections/collection-item.tsx b/components/sidebar/items/collections/collection-item.tsx new file mode 100644 index 0000000000..562268280c --- /dev/null +++ b/components/sidebar/items/collections/collection-item.tsx @@ -0,0 +1,113 @@ +import ImagePicker from "@/components/ui/image-picker" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { getCollectionFilesByCollectionId } from "@/db/collection-files" +import { COLLECTION_DESCRIPTION_MAX, COLLECTION_NAME_MAX } from "@/db/limits" +import { Tables } from "@/supabase/types" +import { CollectionFile } from "@/types" +import { IconBooks } from "@tabler/icons-react" +import { FC, useEffect, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" +import { CollectionFilePicker } from "./collection-file-picker" + +interface CollectionItemProps { + collection: Tables<"collections"> +} + +export const CollectionItem: FC = ({ collection }) => { + const [name, setName] = useState(collection.name) + const [description, setDescription] = useState(collection.description) + const [selectedCollectionFiles, setSelectedCollectionFiles] = useState< + CollectionFile[] + >([]) + const [selectedImage, setSelectedImage] = useState(null) + const [imageLink, setImageLink] = useState("") + + useEffect(() => { + const fetchData = async () => { + const collectionFiles = await getCollectionFilesByCollectionId( + collection.id + ) + console.log("collectionFiles", collectionFiles) + const files = collectionFiles.files + setSelectedCollectionFiles(files) + } + + fetchData() + }, []) + + const handleFileSelect = (file: CollectionFile) => { + setSelectedCollectionFiles(prevState => { + const isFileAlreadySelected = prevState.find( + selectedFile => selectedFile.id === file.id + ) + if (isFileAlreadySelected) { + return prevState.filter(selectedFile => selectedFile.id !== file.id) + } else { + return [...prevState, file] + } + }) + } + + return ( + } + updateState={{ + image: selectedImage, + collectionFilesToAdd: [], + collectionFilesToRemove: [], + name, + description + }} + renderInputs={() => ( + <> +
+ + + +
+ +
+ + + +
+ +
+ + + setName(e.target.value)} + maxLength={COLLECTION_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={COLLECTION_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} diff --git a/components/sidebar/items/collections/create-collection.tsx b/components/sidebar/items/collections/create-collection.tsx new file mode 100644 index 0000000000..a465966b85 --- /dev/null +++ b/components/sidebar/items/collections/create-collection.tsx @@ -0,0 +1,115 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import ImagePicker from "@/components/ui/image-picker" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { COLLECTION_DESCRIPTION_MAX, COLLECTION_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { CollectionFile } from "@/types" +import { FC, useContext, useState } from "react" +import { CollectionFilePicker } from "./collection-file-picker" + +interface CreateCollectionProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreateCollection: FC = ({ + isOpen, + onOpenChange +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [selectedCollectionFiles, setSelectedCollectionFiles] = useState< + CollectionFile[] + >([]) + const [selectedImage, setSelectedImage] = useState(null) + const [imageLink, setImageLink] = useState("") + + const handleFileSelect = (file: CollectionFile) => { + setSelectedCollectionFiles(prevState => { + const isFileAlreadySelected = prevState.find( + selectedFile => selectedFile.id === file.id + ) + if (isFileAlreadySelected) { + return prevState.filter(selectedFile => selectedFile.id !== file.id) + } else { + return [...prevState, file] + } + }) + } + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + ({ + user_id: profile.user_id, + collection_id: "", + file_id: file.id + })), + user_id: profile.user_id, + image_path: "", + name, + description + } as TablesInsert<"collections"> + } + isOpen={isOpen} + onOpenChange={onOpenChange} + renderInputs={() => ( + <> +
+ + + +
+ +
+ + + +
+ +
+ + + setName(e.target.value)} + maxLength={COLLECTION_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={COLLECTION_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} diff --git a/components/sidebar/items/files/create-file.tsx b/components/sidebar/items/files/create-file.tsx new file mode 100644 index 0000000000..c68d5348ae --- /dev/null +++ b/components/sidebar/items/files/create-file.tsx @@ -0,0 +1,92 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { FILE_DESCRIPTION_MAX, FILE_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreateFileProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreateFile: FC = ({ isOpen, onOpenChange }) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + + const ALLOWED_FILES = ["pdf"] + + const handleSelectedFile = async (e: React.ChangeEvent) => { + if (!e.target.files) return + + const file = e.target.files[0] + + if (!file) return + + setSelectedFile(file) + const fileNameWithoutExtension = file.name.split(".").slice(0, -1).join(".") + setName(fileNameWithoutExtension) + } + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + } + isOpen={isOpen} + onOpenChange={onOpenChange} + renderInputs={() => ( + <> +
+ + + `.${file}`).join(",")} + /> +
+ +
+ + + setName(e.target.value)} + maxLength={FILE_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={FILE_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} diff --git a/components/sidebar/items/files/file-item.tsx b/components/sidebar/items/files/file-item.tsx new file mode 100644 index 0000000000..c1b18a95f0 --- /dev/null +++ b/components/sidebar/items/files/file-item.tsx @@ -0,0 +1,93 @@ +import { FileIcon } from "@/components/ui/file-icon" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FILE_DESCRIPTION_MAX, FILE_NAME_MAX } from "@/db/limits" +import { getFileFromStorage } from "@/db/storage/files" +import { Tables } from "@/supabase/types" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface FileItemProps { + file: Tables<"files"> +} + +export const FileItem: FC = ({ file }) => { + const [name, setName] = useState(file.name) + const [description, setDescription] = useState(file.description) + + const getLinkAndView = async () => { + const link = await getFileFromStorage(file.file_path) + window.open(link, "_blank") + } + + return ( + } + updateState={{ name }} + renderInputs={() => ( + <> +
+ View {file.name} +
+ +
+
{file.type}
+ +
{formatFileSize(file.size)}
+ +
{file.tokens.toLocaleString()} tokens
+
+ +
+ + + setName(e.target.value)} + maxLength={FILE_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={FILE_DESCRIPTION_MAX} + /> +
+ + )} + /> + ) +} + +export const formatFileSize = (sizeInBytes: number): string => { + let size = sizeInBytes + let unit = "bytes" + + if (size >= 1024) { + size /= 1024 + unit = "KB" + } + + if (size >= 1024) { + size /= 1024 + unit = "MB" + } + + if (size >= 1024) { + size /= 1024 + unit = "GB" + } + + return `${size.toFixed(2)} ${unit}` +} diff --git a/components/sidebar/items/folders/delete-folder.tsx b/components/sidebar/items/folders/delete-folder.tsx new file mode 100644 index 0000000000..b2d17d1e17 --- /dev/null +++ b/components/sidebar/items/folders/delete-folder.tsx @@ -0,0 +1,79 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { ChatbotUIContext } from "@/context/context" +import { deleteFolder } from "@/db/folders" +import { Tables } from "@/supabase/types" +import { IconTrash } from "@tabler/icons-react" +import { FC, useContext, useRef, useState } from "react" + +interface DeleteFolderProps { + folder: Tables<"folders"> +} + +export const DeleteFolder: FC = ({ folder }) => { + const { setFolders, setChats } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [showFolderDialog, setShowFolderDialog] = useState(false) + + const handleDeleteFolder = async () => { + await deleteFolder(folder.id) + + setFolders(prevState => prevState.filter(c => c.id !== folder.id)) + + setShowFolderDialog(false) + + setChats(prevState => + prevState.map(c => + c.folder_id === folder.id ? { ...c, folder_id: null } : c + ) + ) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Delete {folder.name} + + + Are you sure you want to delete this folder? + + + + + + + + + + + ) +} diff --git a/components/sidebar/items/folders/folder-item.tsx b/components/sidebar/items/folders/folder-item.tsx new file mode 100644 index 0000000000..3100850663 --- /dev/null +++ b/components/sidebar/items/folders/folder-item.tsx @@ -0,0 +1,111 @@ +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react" +import { FC, useRef, useState } from "react" +import { DeleteFolder } from "./delete-folder" +import { UpdateFolder } from "./update-folder" + +interface FolderProps { + folder: Tables<"folders"> + children: React.ReactNode + onUpdateFolder: (itemId: string, folderId: string | null) => void +} + +export const Folder: FC = ({ + folder, + children, + onUpdateFolder +}) => { + const itemRef = useRef(null) + + const [isDragOver, setIsDragOver] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [isHovering, setIsHovering] = useState(false) + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + + setIsDragOver(false) + const itemId = e.dataTransfer.getData("text/plain") + onUpdateFolder(itemId, folder.id) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.stopPropagation() + itemRef.current?.click() + } + } + + const handleClick = (e: React.MouseEvent) => { + setIsExpanded(!isExpanded) + } + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + > +
+
+
+ {isExpanded ? ( + + ) : ( + + )} + +
{folder.name}
+
+ + {isHovering && ( +
{ + e.stopPropagation() + e.preventDefault() + }} + className="ml-2 flex space-x-2" + > + + + +
+ )} +
+
+ + {isExpanded && ( +
{children}
+ )} +
+ ) +} diff --git a/components/sidebar/items/folders/update-folder.tsx b/components/sidebar/items/folders/update-folder.tsx new file mode 100644 index 0000000000..d98833e2f7 --- /dev/null +++ b/components/sidebar/items/folders/update-folder.tsx @@ -0,0 +1,76 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { updateFolder } from "@/db/folders" +import { Tables } from "@/supabase/types" +import { IconEdit } from "@tabler/icons-react" +import { FC, useContext, useRef, useState } from "react" + +interface UpdateFolderProps { + folder: Tables<"folders"> +} + +export const UpdateFolder: FC = ({ folder }) => { + const { setFolders } = useContext(ChatbotUIContext) + + const buttonRef = useRef(null) + + const [showFolderDialog, setShowFolderDialog] = useState(false) + const [name, setName] = useState(folder.name) + + const handleUpdateFolder = async (e: React.MouseEvent) => { + const updatedFolder = await updateFolder(folder.id, { + name + }) + setFolders(prevState => + prevState.map(c => (c.id === folder.id ? updatedFolder : c)) + ) + + setShowFolderDialog(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + buttonRef.current?.click() + } + } + + return ( + + + + + + + + Edit Folder + + +
+ + + setName(e.target.value)} /> +
+ + + + + + +
+
+ ) +} diff --git a/components/sidebar/items/presets/create-preset.tsx b/components/sidebar/items/presets/create-preset.tsx new file mode 100644 index 0000000000..33bf436b27 --- /dev/null +++ b/components/sidebar/items/presets/create-preset.tsx @@ -0,0 +1,88 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { ChatSettingsForm } from "@/components/ui/chat-settings-form" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { ChatbotUIContext } from "@/context/context" +import { PRESET_DESCRIPTION_MAX, PRESET_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreatePresetProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreatePreset: FC = ({ + isOpen, + onOpenChange +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [presetChatSettings, setPresetChatSettings] = useState({ + model: selectedWorkspace?.default_model, + prompt: selectedWorkspace?.default_prompt, + temperature: selectedWorkspace?.default_temperature, + contextLength: selectedWorkspace?.default_context_length, + includeProfileContext: selectedWorkspace?.include_profile_context, + includeWorkspaceInstructions: + selectedWorkspace?.include_workspace_instructions + }) + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + } + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PRESET_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={PRESET_DESCRIPTION_MAX} + /> +
+ + + + )} + /> + ) +} diff --git a/components/sidebar/items/presets/preset-item.tsx b/components/sidebar/items/presets/preset-item.tsx new file mode 100644 index 0000000000..79350fed7a --- /dev/null +++ b/components/sidebar/items/presets/preset-item.tsx @@ -0,0 +1,77 @@ +import { ModelIcon } from "@/components/models/model-icon" +import { ChatSettingsForm } from "@/components/ui/chat-settings-form" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { PRESET_DESCRIPTION_MAX, PRESET_NAME_MAX } from "@/db/limits" +import { Tables } from "@/supabase/types" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface PresetItemProps { + preset: Tables<"presets"> +} + +export const PresetItem: FC = ({ preset }) => { + const [name, setName] = useState(preset.name) + const [description, setDescription] = useState(preset.description) + const [presetChatSettings, setPresetChatSettings] = useState({ + model: preset.model, + prompt: preset.prompt, + temperature: preset.temperature, + contextLength: preset.context_length, + includeProfileContext: preset.include_profile_context, + includeWorkspaceInstructions: preset.include_workspace_instructions + }) + + return ( + + } + updateState={{ + name, + description, + include_profile_context: presetChatSettings.includeProfileContext, + include_workspace_instructions: + presetChatSettings.includeWorkspaceInstructions, + context_length: presetChatSettings.contextLength, + model: presetChatSettings.model, + prompt: presetChatSettings.prompt, + temperature: presetChatSettings.temperature + }} + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PRESET_NAME_MAX} + /> +
+ +
+ + + setDescription(e.target.value)} + maxLength={PRESET_DESCRIPTION_MAX} + /> +
+ + + + )} + /> + ) +} diff --git a/components/sidebar/items/prompts/create-prompt.tsx b/components/sidebar/items/prompts/create-prompt.tsx new file mode 100644 index 0000000000..58015664f9 --- /dev/null +++ b/components/sidebar/items/prompts/create-prompt.tsx @@ -0,0 +1,67 @@ +import { SidebarCreateItem } from "@/components/sidebar/items/all/sidebar-create-item" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { TextareaAutosize } from "@/components/ui/textarea-autosize" +import { ChatbotUIContext } from "@/context/context" +import { PROMPT_NAME_MAX } from "@/db/limits" +import { TablesInsert } from "@/supabase/types" +import { FC, useContext, useState } from "react" + +interface CreatePromptProps { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const CreatePrompt: FC = ({ + isOpen, + onOpenChange +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const [name, setName] = useState("") + const [content, setContent] = useState("") + + if (!profile) return null + if (!selectedWorkspace) return null + + return ( + + } + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PROMPT_NAME_MAX} + /> +
+ +
+ + + +
+ + )} + /> + ) +} diff --git a/components/sidebar/items/prompts/prompt-item.tsx b/components/sidebar/items/prompts/prompt-item.tsx new file mode 100644 index 0000000000..bf74f746d7 --- /dev/null +++ b/components/sidebar/items/prompts/prompt-item.tsx @@ -0,0 +1,52 @@ +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { TextareaAutosize } from "@/components/ui/textarea-autosize" +import { PROMPT_NAME_MAX } from "@/db/limits" +import { Tables } from "@/supabase/types" +import { IconPencil } from "@tabler/icons-react" +import { FC, useState } from "react" +import { SidebarItem } from "../all/sidebar-display-item" + +interface PromptItemProps { + prompt: Tables<"prompts"> +} + +export const PromptItem: FC = ({ prompt }) => { + const [name, setName] = useState(prompt.name) + const [content, setContent] = useState(prompt.content) + + return ( + } + updateState={{ name, content }} + renderInputs={() => ( + <> +
+ + + setName(e.target.value)} + maxLength={PROMPT_NAME_MAX} + /> +
+ +
+ + + +
+ + )} + /> + ) +} diff --git a/components/sidebar/sidebar-content.tsx b/components/sidebar/sidebar-content.tsx new file mode 100644 index 0000000000..1e826557c2 --- /dev/null +++ b/components/sidebar/sidebar-content.tsx @@ -0,0 +1,47 @@ +import { Tables } from "@/supabase/types" +import { ContentType, DataListType } from "@/types" +import { FC, useState } from "react" +import { SidebarCreateButtons } from "./sidebar-create-buttons" +import { SidebarDataList } from "./sidebar-data-list" +import { SidebarSearch } from "./sidebar-search" + +interface SidebarContentProps { + contentType: ContentType + data: DataListType + folders: Tables<"folders">[] +} + +export const SidebarContent: FC = ({ + contentType, + data, + folders +}) => { + const [searchTerm, setSearchTerm] = useState("") + + const filteredData: any = data.filter(item => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + return ( + // Subtract 50px for the height of the workspace settings +
+
+ +
+ +
+ +
+ + +
+ ) +} diff --git a/components/sidebar/sidebar-create-buttons.tsx b/components/sidebar/sidebar-create-buttons.tsx new file mode 100644 index 0000000000..45b22d7776 --- /dev/null +++ b/components/sidebar/sidebar-create-buttons.tsx @@ -0,0 +1,128 @@ +import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler" +import { ChatbotUIContext } from "@/context/context" +import { createFolder } from "@/db/folders" +import { ContentType } from "@/types" +import { IconFolderPlus, IconPlus } from "@tabler/icons-react" +import { FC, useContext, useState } from "react" +import { Button } from "../ui/button" +import { CreateAssistant } from "./items/assistants/create-assistant" +import { CreateCollection } from "./items/collections/create-collection" +import { CreateFile } from "./items/files/create-file" +import { CreatePreset } from "./items/presets/create-preset" +import { CreatePrompt } from "./items/prompts/create-prompt" + +interface SidebarCreateButtonsProps { + contentType: ContentType +} + +export const SidebarCreateButtons: FC = ({ + contentType +}) => { + const { profile, selectedWorkspace, folders, setFolders } = + useContext(ChatbotUIContext) + const { handleNewChat } = useChatHandler() + + const [isCreatingPrompt, setIsCreatingPrompt] = useState(false) + const [isCreatingPreset, setIsCreatingPreset] = useState(false) + const [isCreatingFile, setIsCreatingFile] = useState(false) + const [isCreatingCollection, setIsCreatingCollection] = useState(false) + const [isCreatingAssistant, setIsCreatingAssistant] = useState(false) + + const handleCreateFolder = async () => { + if (!profile) return + if (!selectedWorkspace) return + + const createdFolder = await createFolder({ + user_id: profile.user_id, + workspace_id: selectedWorkspace.id, + name: "New Folder", + description: "", + type: contentType + }) + setFolders([...folders, createdFolder]) + } + + const getCreateFunction = () => { + switch (contentType) { + case "chats": + return async () => { + handleNewChat() + } + + case "presets": + return async () => { + setIsCreatingPreset(true) + } + + case "prompts": + return async () => { + setIsCreatingPrompt(true) + } + + case "files": + return async () => { + setIsCreatingFile(true) + } + + case "collections": + return async () => { + setIsCreatingCollection(true) + } + + case "assistants": + return async () => { + setIsCreatingAssistant(true) + } + + default: + break + } + } + + return ( +
+ + + + + {isCreatingPrompt && ( + + )} + + {isCreatingPreset && ( + + )} + + {isCreatingFile && ( + + )} + + {isCreatingCollection && ( + + )} + + {isCreatingAssistant && ( + + )} +
+ ) +} diff --git a/components/sidebar/sidebar-data-list.tsx b/components/sidebar/sidebar-data-list.tsx new file mode 100644 index 0000000000..a6e9922380 --- /dev/null +++ b/components/sidebar/sidebar-data-list.tsx @@ -0,0 +1,241 @@ +import { ChatbotUIContext } from "@/context/context" +import { updateAssistant } from "@/db/assistants" +import { updateChat } from "@/db/chats" +import { updateCollection } from "@/db/collections" +import { updateFile } from "@/db/files" +import { updatePreset } from "@/db/presets" +import { updatePrompt } from "@/db/prompts" +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { ContentType, DataItemType, DataListType } from "@/types" +import { FC, useContext, useEffect, useRef, useState } from "react" +import { Separator } from "../ui/separator" +import { AssistantItem } from "./items/assistants/assistant-item" +import { ChatItem } from "./items/chat/chat-item" +import { CollectionItem } from "./items/collections/collection-item" +import { FileItem } from "./items/files/file-item" +import { Folder } from "./items/folders/folder-item" +import { PresetItem } from "./items/presets/preset-item" +import { PromptItem } from "./items/prompts/prompt-item" + +interface SidebarDataListProps { + contentType: ContentType + data: DataListType + folders: Tables<"folders">[] +} + +export const SidebarDataList: FC = ({ + contentType, + data, + folders +}) => { + const { + setChats, + setPresets, + setPrompts, + setFiles, + setCollections, + setAssistants + } = useContext(ChatbotUIContext) + + const divRef = useRef(null) + + const [isOverflowing, setIsOverflowing] = useState(false) + const [isDragOver, setIsDragOver] = useState(false) + + const getDataListComponent = ( + contentType: ContentType, + item: DataItemType + ) => { + switch (contentType) { + case "chats": + return } /> + + case "presets": + return } /> + + case "prompts": + return } /> + + case "files": + return } /> + + case "collections": + return ( + } + /> + ) + + case "assistants": + return ( + } + /> + ) + + default: + return null + } + } + + const updateFunctions = { + chats: updateChat, + presets: updatePreset, + prompts: updatePrompt, + files: updateFile, + collections: updateCollection, + assistants: updateAssistant + } + + const stateUpdateFunctions = { + chats: setChats, + presets: setPresets, + prompts: setPrompts, + files: setFiles, + collections: setCollections, + assistants: setAssistants + } + + const updateFolder = async (itemId: string, folderId: string | null) => { + const item: any = data.find(item => item.id === itemId) + + if (!item) return null + + const updateFunction = updateFunctions[contentType] + const setStateFunction = stateUpdateFunctions[contentType] + + if (!updateFunction || !setStateFunction) return + + const updatedItem = await updateFunction(item.id, { + folder_id: folderId + }) + + setStateFunction((items: any) => + items.map((item: any) => + item.id === updatedItem.id ? updatedItem : item + ) + ) + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + } + + const handleDragStart = (e: React.DragEvent, id: string) => { + e.dataTransfer.setData("text/plain", id) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + + const target = e.target as Element + + if (!target.closest("#folder")) { + const itemId = e.dataTransfer.getData("text/plain") + updateFolder(itemId, null) + } + + setIsDragOver(false) + } + + useEffect(() => { + if (divRef.current) { + setIsOverflowing( + divRef.current.scrollHeight > divRef.current.clientHeight + ) + } + }, [data]) + + const dataWithFolders = data.filter(item => item.folder_id) + + const dataWithoutFolders = data.filter(item => item.folder_id === null) + + return ( + <> +
+ {data.length === 0 && ( +
+
+ No {contentType}. +
+
+ )} + + {(dataWithFolders.length > 0 || dataWithoutFolders.length > 0) && ( +
+ {folders.map(folder => ( + + {dataWithFolders + .filter(item => item.folder_id === folder.id) + .map(item => ( +
handleDragStart(e, item.id)} + > + {getDataListComponent(contentType, item)} +
+ ))} +
+ ))} + + {folders.length > 0 && } + +
+ {dataWithoutFolders.map(item => { + return ( +
handleDragStart(e, item.id)} + > + {getDataListComponent(contentType, item)} +
+ ) + })} +
+
+ )} +
+ +
+ + ) +} diff --git a/components/sidebar/sidebar-search.tsx b/components/sidebar/sidebar-search.tsx new file mode 100644 index 0000000000..b451bc8442 --- /dev/null +++ b/components/sidebar/sidebar-search.tsx @@ -0,0 +1,23 @@ +import { ContentType } from "@/types" +import { FC } from "react" +import { Input } from "../ui/input" + +interface SidebarSearchProps { + contentType: ContentType + searchTerm: string + setSearchTerm: Function +} + +export const SidebarSearch: FC = ({ + contentType, + searchTerm, + setSearchTerm +}) => { + return ( + setSearchTerm(e.target.value)} + /> + ) +} diff --git a/components/sidebar/sidebar-switch-item.tsx b/components/sidebar/sidebar-switch-item.tsx new file mode 100644 index 0000000000..2ccc92f440 --- /dev/null +++ b/components/sidebar/sidebar-switch-item.tsx @@ -0,0 +1,33 @@ +import { ContentType } from "@/types" +import { FC } from "react" +import { TabsTrigger } from "../ui/tabs" +import { WithTooltip } from "../ui/with-tooltip" + +interface SidebarSwitchItemProps { + contentType: ContentType + icon: React.ReactNode + onContentTypeChange: (contentType: ContentType) => void +} + +export const SidebarSwitchItem: FC = ({ + contentType, + icon, + onContentTypeChange +}) => { + return ( + {contentType[0].toUpperCase() + contentType.substring(1)} + } + trigger={ + onContentTypeChange(contentType as ContentType)} + > + {icon} + + } + /> + ) +} diff --git a/components/sidebar/sidebar-switcher.tsx b/components/sidebar/sidebar-switcher.tsx new file mode 100644 index 0000000000..5947a46bc4 --- /dev/null +++ b/components/sidebar/sidebar-switcher.tsx @@ -0,0 +1,79 @@ +import { ContentType } from "@/types" +import { + IconAdjustmentsHorizontal, + IconBooks, + IconFile, + IconMessage, + IconPencil, + IconRobotFace +} from "@tabler/icons-react" +import { FC } from "react" +import { TabsList } from "../ui/tabs" +import { WithTooltip } from "../ui/with-tooltip" +import { ProfileSettings } from "../utility/profile-settings" +import { SidebarSwitchItem } from "./sidebar-switch-item" + +export const SIDEBAR_ICON_SIZE = 28 + +interface SidebarSwitcherProps { + onContentTypeChange: (contentType: ContentType) => void +} + +export const SidebarSwitcher: FC = ({ + onContentTypeChange +}) => { + return ( +
+ + } + contentType="chats" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="presets" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="prompts" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="files" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="collections" + onContentTypeChange={onContentTypeChange} + /> + + } + contentType="assistants" + onContentTypeChange={onContentTypeChange} + /> + + +
+ {/* TODO */} + {/* Import
} trigger={} /> */} + + {/* TODO */} + {/* */} + + Profile Settings
} + trigger={} + /> + + + ) +} diff --git a/components/sidebar/sidebar.tsx b/components/sidebar/sidebar.tsx new file mode 100644 index 0000000000..01aac5a9b8 --- /dev/null +++ b/components/sidebar/sidebar.tsx @@ -0,0 +1,94 @@ +import { ChatbotUIContext } from "@/context/context" +import { Tables } from "@/supabase/types" +import { ContentType } from "@/types" +import { FC, useContext } from "react" +import { SIDEBAR_WIDTH } from "../ui/dashboard" +import { TabsContent } from "../ui/tabs" +import { WorkspaceSwitcher } from "../utility/workspace-switcher" +import { WorkspaceSettings } from "../workspace/workspace-settings" +import { SidebarContent } from "./sidebar-content" + +interface SidebarProps { + contentType: ContentType + showSidebar: boolean +} + +export const Sidebar: FC = ({ contentType, showSidebar }) => { + const { folders, chats, presets, prompts, files, collections, assistants } = + useContext(ChatbotUIContext) + + const chatFolders = folders.filter(folder => folder.type === "chats") + const presetFolders = folders.filter(folder => folder.type === "presets") + const promptFolders = folders.filter(folder => folder.type === "prompts") + const filesFolders = folders.filter(folder => folder.type === "files") + const collectionFolders = folders.filter( + folder => folder.type === "collection" + ) + const assistantFolders = folders.filter( + folder => folder.type === "assistants" + ) + + const renderSidebarContent = ( + contentType: ContentType, + data: any[], + folders: Tables<"folders">[] + ) => { + return ( + + ) + } + + return ( + +
+
+ + + +
+ + {(() => { + switch (contentType) { + case "chats": + return renderSidebarContent("chats", chats, chatFolders) + + case "presets": + return renderSidebarContent("presets", presets, presetFolders) + + case "prompts": + return renderSidebarContent("prompts", prompts, promptFolders) + + case "files": + return renderSidebarContent("files", files, filesFolders) + + case "collections": + return renderSidebarContent( + "collections", + collections, + collectionFolders + ) + + case "assistants": + return renderSidebarContent( + "assistants", + assistants, + assistantFolders + ) + + default: + return null + } + })()} +
+
+ ) +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000000..d360b22470 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/advanced-settings.tsx b/components/ui/advanced-settings.tsx new file mode 100644 index 0000000000..0c2bf85960 --- /dev/null +++ b/components/ui/advanced-settings.tsx @@ -0,0 +1,40 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@/components/ui/collapsible" +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react" +import { FC, useState } from "react" + +interface AdvancedSettingsProps { + children: React.ReactNode +} + +export const AdvancedSettings: FC = ({ children }) => { + const [isOpen, setIsOpen] = useState( + false + // localStorage.getItem("advanced-settings-open") === "true" + ) + + const handleOpenChange = (isOpen: boolean) => { + setIsOpen(isOpen) + // localStorage.setItem("advanced-settings-open", String(isOpen)) + } + + return ( + + +
+
Advanced Settings
+ {isOpen ? ( + + ) : ( + + )} +
+
+ + {children} +
+ ) +} diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000..d468c39290 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000000..588ee66fe4 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive" + } + }, + defaultVariants: { + variant: "default" + } + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000000..d6a5226f5e --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000000..e6a93ce078 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000000..56f0ea4b42 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", + outline: "text-foreground" + } + }, + defaultVariants: { + variant: "default" + } + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/brand.tsx b/components/ui/brand.tsx new file mode 100644 index 0000000000..ae42e07372 --- /dev/null +++ b/components/ui/brand.tsx @@ -0,0 +1,26 @@ +"use client" + +import Link from "next/link" +import { FC } from "react" +import { ChatbotUISVG } from "../icons/chatbotui-svg" + +interface BrandProps { + theme?: "dark" | "light" +} + +export const Brand: FC = ({ theme = "dark" }) => { + return ( + +
+ +
+ +
Chatbot UI
+ + ) +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000000..0652e0347b --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors hover:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border-input bg-background hover:bg-accent hover:text-accent-foreground border", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline" + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx new file mode 100644 index 0000000000..65e98ce528 --- /dev/null +++ b/components/ui/calendar.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000000..a26fd5d7b7 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/chat-settings-form.tsx b/components/ui/chat-settings-form.tsx new file mode 100644 index 0000000000..2b0e4ef19e --- /dev/null +++ b/components/ui/chat-settings-form.tsx @@ -0,0 +1,239 @@ +"use client" + +import { ChatbotUIContext } from "@/context/context" +import { CHAT_SETTING_LIMITS } from "@/lib/chat-setting-limits" +import { LLM_LIST } from "@/lib/models/llm/llm-list" +import { ChatSettings } from "@/types" +import { IconInfoCircle } from "@tabler/icons-react" +import { FC, useContext } from "react" +import { ModelSelect } from "../models/model-select" +import { AdvancedSettings } from "./advanced-settings" +import { Checkbox } from "./checkbox" +import { Label } from "./label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "./select" +import { Slider } from "./slider" +import { TextareaAutosize } from "./textarea-autosize" +import { WithTooltip } from "./with-tooltip" + +interface ChatSettingsFormProps { + chatSettings: ChatSettings + onChangeChatSettings: (value: ChatSettings) => void + useAdvancedDropdown?: boolean + showTooltip?: boolean +} + +export const ChatSettingsForm: FC = ({ + chatSettings, + onChangeChatSettings, + useAdvancedDropdown = true, + showTooltip = true +}) => { + const { profile, availableLocalModels } = useContext(ChatbotUIContext) + + if (!profile) return null + + return ( +
+
+ + + { + onChangeChatSettings({ ...chatSettings, model }) + }} + /> +
+ +
+ + + { + onChangeChatSettings({ ...chatSettings, prompt }) + }} + value={chatSettings.prompt} + minRows={3} + maxRows={6} + /> +
+ + {useAdvancedDropdown ? ( + + + + ) : ( +
+ +
+ )} +
+ ) +} + +interface AdvancedContentProps { + chatSettings: ChatSettings + onChangeChatSettings: (value: ChatSettings) => void + showTooltip: boolean +} + +const AdvancedContent: FC = ({ + chatSettings, + onChangeChatSettings, + showTooltip +}) => { + const { profile, selectedWorkspace } = useContext(ChatbotUIContext) + + const MODEL_LIMITS = CHAT_SETTING_LIMITS[chatSettings.model] || { + MIN_TEMPERATURE: 0, + MAX_TEMPERATURE: 1, + MAX_CONTEXT_LENGTH: 4096 + } + + return ( +
+
+ + + { + onChangeChatSettings({ + ...chatSettings, + temperature: temperature[0] + }) + }} + min={MODEL_LIMITS.MIN_TEMPERATURE} + max={MODEL_LIMITS.MAX_TEMPERATURE} + step={0.01} + /> +
+ +
+ + + { + onChangeChatSettings({ + ...chatSettings, + contextLength: contextLength[0] + }) + }} + min={0} + max={MODEL_LIMITS.MAX_CONTEXT_LENGTH - 200} // 200 is a minimum buffer for token output + step={1} + /> +
+ +
+ + onChangeChatSettings({ + ...chatSettings, + includeProfileContext: value + }) + } + /> + + + + {showTooltip && ( + + {profile?.profile_context || "No profile context."} +
+ } + trigger={ + + } + /> + )} +
+ +
+ + onChangeChatSettings({ + ...chatSettings, + includeWorkspaceInstructions: value + }) + } + /> + + + + {showTooltip && ( + + {selectedWorkspace?.instructions || + "No workspace instructions."} +
+ } + trigger={ + + } + /> + )} +
+ +
+ + + +
+
+ ) +} diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000000..ff148f2d23 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000000..9fa48946af --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 0000000000..ea9f817bca --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator +} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 0000000000..d504aab074 --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup +} diff --git a/components/ui/dashboard.tsx b/components/ui/dashboard.tsx new file mode 100644 index 0000000000..faf2d8aea8 --- /dev/null +++ b/components/ui/dashboard.tsx @@ -0,0 +1,131 @@ +"use client" + +import { Sidebar } from "@/components/sidebar/sidebar" +import { SidebarSwitcher } from "@/components/sidebar/sidebar-switcher" +import { Button } from "@/components/ui/button" +import { Tabs } from "@/components/ui/tabs" +import useHotkey from "@/lib/hooks/use-hotkey" +import { cn } from "@/lib/utils" +import { ContentType } from "@/types" +import { IconChevronCompactRight } from "@tabler/icons-react" +import { usePathname, useRouter, useSearchParams } from "next/navigation" +import { FC, useState } from "react" +import { useSelectFileHandler } from "../chat/chat-hooks/use-select-file-handler" +import { CommandK } from "../utility/command-k" + +export const SIDEBAR_WIDTH = 350 + +interface DashboardProps { + children: React.ReactNode +} + +export const Dashboard: FC = ({ children }) => { + useHotkey("s", () => setShowSidebar(prevState => !prevState)) + + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + const tabValue = searchParams.get("tab") || "chats" + + const { handleSelectDeviceFile } = useSelectFileHandler() + + const [contentType, setContentType] = useState( + tabValue as ContentType + ) + const [showSidebar, setShowSidebar] = useState( + localStorage.getItem("showSidebar") === "true" + ) + const [isDragging, setIsDragging] = useState(false) + + const onFileDrop = (event: React.DragEvent) => { + event.preventDefault() + + const files = event.dataTransfer.files + const file = files[0] + + handleSelectDeviceFile(file) + + setIsDragging(false) + } + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(true) + } + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault() + setIsDragging(false) + } + + const onDragOver = (event: React.DragEvent) => { + event.preventDefault() + } + + const handleToggleSidebar = () => { + setShowSidebar(prevState => !prevState) + localStorage.setItem("showSidebar", String(!showSidebar)) + } + + return ( +
+ + + + +
+ {showSidebar && ( + { + setContentType(tabValue as ContentType) + router.replace(`${pathname}?tab=${tabValue}`) + }} + > + + + + + )} +
+ +
+ {isDragging ? ( +
+ drop file here +
+ ) : ( + children + )} +
+
+ ) +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000000..5c01896f87 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +"use client" + +import * as DialogPrimitive from "@radix-ui/react-dialog" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + {/* + + Close + */} + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..02297f2b6b --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup +} diff --git a/components/ui/file-icon.tsx b/components/ui/file-icon.tsx new file mode 100644 index 0000000000..94c36777e7 --- /dev/null +++ b/components/ui/file-icon.tsx @@ -0,0 +1,39 @@ +import { + IconFile, + IconFileText, + IconFileTypeCsv, + IconFileTypeDoc, + IconFileTypeDocx, + IconFileTypePdf, + IconJson, + IconMarkdown, + IconPhoto +} from "@tabler/icons-react" +import { FC } from "react" + +interface FileIconProps { + type: string + size?: number +} + +export const FileIcon: FC = ({ type, size = 32 }) => { + if (type.includes("image")) { + return + } else if (type.includes("pdf")) { + return + } else if (type.includes("csv")) { + return + } else if (type.includes("doc")) { + return + } else if (type.includes("docx")) { + return + } else if (type.includes("plain")) { + return + } else if (type.includes("json")) { + return + } else if (type.includes("markdown")) { + return + } else { + return + } +} diff --git a/components/ui/file-preview.tsx b/components/ui/file-preview.tsx new file mode 100644 index 0000000000..4fc37bf52f --- /dev/null +++ b/components/ui/file-preview.tsx @@ -0,0 +1,65 @@ +import { cn } from "@/lib/utils" +import { Tables } from "@/supabase/types" +import { ChatFile, MessageImage } from "@/types" +import { IconFileFilled } from "@tabler/icons-react" +import Image from "next/image" +import { FC } from "react" +import { Dialog, DialogContent } from "./dialog" + +interface FilePreviewProps { + type: "image" | "file" | "file_item" + item: ChatFile | MessageImage | Tables<"file_items"> + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const FilePreview: FC = ({ + type, + item, + isOpen, + onOpenChange +}) => { + return ( + + + {(() => { + if (type === "image") { + const imageItem = item as MessageImage + + return ( + File image + ) + } else if (type === "file_item") { + const fileItem = item as Tables<"file_items"> + return ( +
+
{fileItem.content}
+
+ ) + } else if (type === "file") { + return ( +
+ +
+ ) + } + })()} +
+
+ ) +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000000..38cb190f81 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +