From 3b6e5e17250f949d307d17a9b047e66c729aad1d Mon Sep 17 00:00:00 2001 From: Brian Hackett Date: Wed, 12 Feb 2025 14:39:37 -0800 Subject: [PATCH] merge --- .cursorrules | 157 + .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/epic.md | 2 +- .github/ISSUE_TEMPLATE/feature.md | 6 +- .github/workflows/docker.yaml | 4 +- .github/workflows/docs.yaml | 6 +- .github/workflows/pr-release-validation.yaml | 6 +- .github/workflows/semantic-pr.yaml | 2 +- .github/workflows/stale.yml | 22 +- .github/workflows/update-stable.yml | 11 +- .gitignore | 7 +- .husky/pre-commit | 12 +- CONTRIBUTING.md | 155 +- Dockerfile | 2 + FAQ.md | 24 +- PROJECT.md | 8 +- .../@settings/core/AvatarDropdown.tsx | 158 + .../@settings/core/ControlPanel.tsx | 534 + app/components/@settings/core/constants.ts | 88 + app/components/@settings/core/types.ts | 114 + app/components/@settings/index.ts | 14 + .../shared/components/DraggableTabList.tsx | 163 + .../shared/components/TabManagement.tsx | 270 + .../@settings/shared/components/TabTile.tsx | 135 + .../tabs/connections/ConnectionsTab.tsx | 615 + .../connections/components/ConnectionForm.tsx | 180 + .../components/CreateBranchDialog.tsx | 150 + .../components/PushToGitHubDialog.tsx | 528 + .../components/RepositorySelectionDialog.tsx | 693 + .../tabs/connections/types/GitHub.ts | 95 + .../@settings/tabs/data/DataTab.tsx | 452 + .../@settings/tabs/debug/DebugTab.tsx | 2020 ++ .../tabs/event-logs/EventLogsTab.tsx | 1013 + .../@settings/tabs/features/FeaturesTab.tsx | 285 + .../tabs/notifications/NotificationsTab.tsx | 300 + .../@settings/tabs/profile/ProfileTab.tsx | 174 + .../providers/cloud/CloudProvidersTab.tsx | 305 + .../providers/local/LocalProvidersTab.tsx | 711 + .../providers/local/OllamaModelInstaller.tsx | 597 + .../service-status/ServiceStatusTab.tsx | 135 + .../providers/service-status/base-provider.ts | 121 + .../service-status/provider-factory.ts | 154 + .../providers/amazon-bedrock.ts | 76 + .../service-status/providers/anthropic.ts | 80 + .../service-status/providers/cohere.ts | 91 + .../service-status/providers/deepseek.ts | 40 + .../service-status/providers/google.ts | 77 + .../service-status/providers/groq.ts | 72 + .../service-status/providers/huggingface.ts | 98 + .../service-status/providers/hyperbolic.ts | 40 + .../service-status/providers/mistral.ts | 76 + .../service-status/providers/openai.ts | 99 + .../service-status/providers/openrouter.ts | 91 + .../service-status/providers/perplexity.ts | 91 + .../service-status/providers/together.ts | 91 + .../providers/service-status/providers/xai.ts | 40 + .../tabs/providers/service-status/types.ts | 55 + .../providers/status/ServiceStatusTab.tsx | 886 + .../@settings/tabs/settings/SettingsTab.tsx | 310 + .../tabs/task-manager/TaskManagerTab.tsx | 1265 ++ .../@settings/tabs/update/UpdateTab.tsx | 628 + app/components/@settings/utils/animations.ts | 41 + app/components/@settings/utils/tab-helpers.ts | 89 + app/components/chat/AssistantMessage.tsx | 94 +- app/components/chat/BaseChat.tsx | 196 +- app/components/chat/Chat.client.tsx | 7 +- app/components/chat/GitCloneButton.tsx | 160 +- app/components/chat/ImportFolderButton.tsx | 21 +- app/components/chat/Markdown.tsx | 5 + app/components/chat/Messages.client.tsx | 158 +- app/components/chat/ProgressCompilation.tsx | 111 + app/components/chat/StarterTemplates.tsx | 13 +- app/components/chat/ThoughtBox.tsx | 43 + .../chatExportAndImport/ImportButtons.tsx | 28 +- app/components/git/GitUrlImport.client.tsx | 13 +- app/components/settings/Settings.module.scss | 63 - app/components/settings/SettingsWindow.tsx | 128 - .../settings/connections/ConnectionsTab.tsx | 151 - app/components/settings/data/DataTab.tsx | 388 - app/components/settings/debug/DebugTab.tsx | 639 - .../settings/event-logs/EventLogsTab.tsx | 219 - .../settings/features/FeaturesTab.tsx | 107 - .../settings/providers/ProvidersTab.tsx | 147 - app/components/sidebar/HistoryItem.tsx | 72 +- app/components/sidebar/Menu.client.tsx | 166 +- app/components/sidebar/date-binning.ts | 14 +- app/components/ui/Badge.tsx | 32 + app/components/ui/Button.tsx | 46 + app/components/ui/Card.tsx | 55 + app/components/ui/Collapsible.tsx | 9 + app/components/ui/Dialog.tsx | 135 +- app/components/ui/Dropdown.tsx | 63 + app/components/ui/Input.tsx | 22 + app/components/ui/Label.tsx | 20 + app/components/ui/Popover.tsx | 29 + app/components/ui/Progress.tsx | 22 + app/components/ui/ScrollArea.tsx | 41 + app/components/ui/Separator.tsx | 22 + app/components/ui/Tabs.tsx | 52 + app/components/ui/use-toast.ts | 40 + app/components/workbench/FileBreadcrumb.tsx | 4 +- app/components/workbench/Preview.tsx | 174 +- app/components/workbench/Workbench.client.tsx | 53 +- app/entry.server.tsx | 4 +- app/lib/.server/llm/constants.ts | 33 + app/lib/.server/llm/create-summary.ts | 197 + app/lib/.server/llm/select-context.ts | 234 + app/lib/.server/llm/stream-text.ts | 4 +- app/lib/.server/llm/utils.ts | 128 + app/lib/api/connection.ts | 63 + app/lib/api/cookies.ts | 33 + app/lib/api/debug.ts | 121 + app/lib/api/features.ts | 35 + app/lib/api/notifications.ts | 58 + app/lib/api/updates.ts | 108 + app/lib/common/prompts/optimized.ts | 64 +- app/lib/common/prompts/prompts.ts | 173 +- app/lib/hooks/index.ts | 5 + app/lib/hooks/useConnectionStatus.ts | 61 + app/lib/hooks/useDebugStatus.ts | 89 + app/lib/hooks/useFeatures.ts | 72 + app/lib/hooks/useGit.ts | 3 + app/lib/hooks/useLocalProviders.ts | 25 + app/lib/hooks/useNotifications.ts | 51 + app/lib/hooks/useSettings.ts | 231 + app/lib/hooks/useSettings.tsx | 229 - app/lib/hooks/useShortcuts.ts | 70 +- app/lib/hooks/useSnapScroll.ts | 175 +- app/lib/hooks/useUpdateCheck.ts | 58 + app/lib/modules/llm/base-provider.ts | 2 +- app/lib/modules/llm/manager.ts | 18 +- app/lib/modules/llm/providers/deepseek.ts | 10 +- app/lib/modules/llm/providers/github.ts | 53 + app/lib/modules/llm/providers/google.ts | 41 + app/lib/modules/llm/providers/groq.ts | 43 + app/lib/modules/llm/providers/lmstudio.ts | 9 +- app/lib/modules/llm/providers/ollama.ts | 6 +- app/lib/modules/llm/providers/openai.ts | 41 + app/lib/modules/llm/registry.ts | 2 + app/lib/persistence/db.ts | 26 +- app/lib/persistence/index.ts | 1 + app/lib/persistence/localStorage.ts | 28 + app/lib/persistence/useChatHistory.ts | 22 +- app/lib/runtime/action-runner.ts | 8 +- app/lib/runtime/message-parser.ts | 6 + app/lib/stores/files.ts | 4 +- app/lib/stores/logs.ts | 410 +- app/lib/stores/previews.ts | 260 + app/lib/stores/profile.ts | 28 + app/lib/stores/settings.ts | 293 +- app/lib/stores/tabConfigurationStore.ts | 32 + app/lib/stores/theme.ts | 22 +- app/lib/stores/workbench.ts | 23 +- app/root.tsx | 6 +- app/routes/_index.tsx | 11 + app/routes/api.enhancer.ts | 35 +- app/routes/api.health.ts | 18 + app/routes/api.llmcall.ts | 54 +- app/routes/api.models.$provider.ts | 2 + app/routes/api.models.ts | 90 +- app/routes/api.system.app-info.ts | 146 + app/routes/api.system.git-info.ts | 138 + app/routes/api.update.ts | 573 + app/routes/webcontainer.preview.$id.tsx | 92 + app/styles/variables.scss | 2 +- app/types/GitHub.ts | 133 + app/types/context.ts | 18 + app/utils/constants.ts | 35 +- app/utils/folderImport.ts | 5 + app/utils/formatSize.ts | 12 + app/utils/markdown.ts | 30 +- app/utils/os.ts | 4 + app/utils/path.ts | 19 + app/utils/projectCommands.ts | 63 +- app/utils/selectStarterTemplate.ts | 1 + bindings.sh | 35 +- changelog.md | 17 +- docker-compose.yaml | 31 +- docs/docs/CONTRIBUTING.md | 155 +- docs/docs/FAQ.md | 47 +- docs/docs/index.md | 154 +- docs/mkdocs.yml | 7 +- eslint.config.mjs | 28 +- package.json | 54 +- pnpm-lock.yaml | 16552 ++++++++-------- pre-start.cjs | 2 +- scripts/clean.js | 45 + scripts/update-imports.sh | 7 + scripts/update.sh | 52 + tsconfig.json | 7 +- uno.config.ts | 2 +- vite.config.ts | 84 +- worker-configuration.d.ts | 3 +- 193 files changed, 29168 insertions(+), 11841 deletions(-) create mode 100644 .cursorrules create mode 100644 app/components/@settings/core/AvatarDropdown.tsx create mode 100644 app/components/@settings/core/ControlPanel.tsx create mode 100644 app/components/@settings/core/constants.ts create mode 100644 app/components/@settings/core/types.ts create mode 100644 app/components/@settings/index.ts create mode 100644 app/components/@settings/shared/components/DraggableTabList.tsx create mode 100644 app/components/@settings/shared/components/TabManagement.tsx create mode 100644 app/components/@settings/shared/components/TabTile.tsx create mode 100644 app/components/@settings/tabs/connections/ConnectionsTab.tsx create mode 100644 app/components/@settings/tabs/connections/components/ConnectionForm.tsx create mode 100644 app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx create mode 100644 app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx create mode 100644 app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx create mode 100644 app/components/@settings/tabs/connections/types/GitHub.ts create mode 100644 app/components/@settings/tabs/data/DataTab.tsx create mode 100644 app/components/@settings/tabs/debug/DebugTab.tsx create mode 100644 app/components/@settings/tabs/event-logs/EventLogsTab.tsx create mode 100644 app/components/@settings/tabs/features/FeaturesTab.tsx create mode 100644 app/components/@settings/tabs/notifications/NotificationsTab.tsx create mode 100644 app/components/@settings/tabs/profile/ProfileTab.tsx create mode 100644 app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx create mode 100644 app/components/@settings/tabs/providers/local/LocalProvidersTab.tsx create mode 100644 app/components/@settings/tabs/providers/local/OllamaModelInstaller.tsx create mode 100644 app/components/@settings/tabs/providers/service-status/ServiceStatusTab.tsx create mode 100644 app/components/@settings/tabs/providers/service-status/base-provider.ts create mode 100644 app/components/@settings/tabs/providers/service-status/provider-factory.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/amazon-bedrock.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/anthropic.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/cohere.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/deepseek.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/google.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/groq.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/huggingface.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/hyperbolic.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/mistral.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/openai.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/openrouter.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/perplexity.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/together.ts create mode 100644 app/components/@settings/tabs/providers/service-status/providers/xai.ts create mode 100644 app/components/@settings/tabs/providers/service-status/types.ts create mode 100644 app/components/@settings/tabs/providers/status/ServiceStatusTab.tsx create mode 100644 app/components/@settings/tabs/settings/SettingsTab.tsx create mode 100644 app/components/@settings/tabs/task-manager/TaskManagerTab.tsx create mode 100644 app/components/@settings/tabs/update/UpdateTab.tsx create mode 100644 app/components/@settings/utils/animations.ts create mode 100644 app/components/@settings/utils/tab-helpers.ts create mode 100644 app/components/chat/ProgressCompilation.tsx create mode 100644 app/components/chat/ThoughtBox.tsx delete mode 100644 app/components/settings/Settings.module.scss delete mode 100644 app/components/settings/SettingsWindow.tsx delete mode 100644 app/components/settings/connections/ConnectionsTab.tsx delete mode 100644 app/components/settings/data/DataTab.tsx delete mode 100644 app/components/settings/debug/DebugTab.tsx delete mode 100644 app/components/settings/event-logs/EventLogsTab.tsx delete mode 100644 app/components/settings/features/FeaturesTab.tsx delete mode 100644 app/components/settings/providers/ProvidersTab.tsx create mode 100644 app/components/ui/Badge.tsx create mode 100644 app/components/ui/Button.tsx create mode 100644 app/components/ui/Card.tsx create mode 100644 app/components/ui/Collapsible.tsx create mode 100644 app/components/ui/Dropdown.tsx create mode 100644 app/components/ui/Input.tsx create mode 100644 app/components/ui/Label.tsx create mode 100644 app/components/ui/Popover.tsx create mode 100644 app/components/ui/Progress.tsx create mode 100644 app/components/ui/ScrollArea.tsx create mode 100644 app/components/ui/Separator.tsx create mode 100644 app/components/ui/Tabs.tsx create mode 100644 app/components/ui/use-toast.ts create mode 100644 app/lib/.server/llm/create-summary.ts create mode 100644 app/lib/.server/llm/select-context.ts create mode 100644 app/lib/.server/llm/utils.ts create mode 100644 app/lib/api/connection.ts create mode 100644 app/lib/api/cookies.ts create mode 100644 app/lib/api/debug.ts create mode 100644 app/lib/api/features.ts create mode 100644 app/lib/api/notifications.ts create mode 100644 app/lib/api/updates.ts create mode 100644 app/lib/hooks/useConnectionStatus.ts create mode 100644 app/lib/hooks/useDebugStatus.ts create mode 100644 app/lib/hooks/useFeatures.ts create mode 100644 app/lib/hooks/useLocalProviders.ts create mode 100644 app/lib/hooks/useNotifications.ts create mode 100644 app/lib/hooks/useSettings.ts delete mode 100644 app/lib/hooks/useSettings.tsx create mode 100644 app/lib/hooks/useUpdateCheck.ts create mode 100644 app/lib/modules/llm/providers/github.ts create mode 100644 app/lib/persistence/localStorage.ts create mode 100644 app/lib/stores/profile.ts create mode 100644 app/lib/stores/tabConfigurationStore.ts create mode 100644 app/routes/api.health.ts create mode 100644 app/routes/api.models.$provider.ts create mode 100644 app/routes/api.system.app-info.ts create mode 100644 app/routes/api.system.git-info.ts create mode 100644 app/routes/api.update.ts create mode 100644 app/routes/webcontainer.preview.$id.tsx create mode 100644 app/types/GitHub.ts create mode 100644 app/types/context.ts create mode 100644 app/utils/formatSize.ts create mode 100644 app/utils/os.ts create mode 100644 app/utils/path.ts create mode 100644 scripts/clean.js create mode 100755 scripts/update-imports.sh create mode 100755 scripts/update.sh diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 000000000..de6183f2b --- /dev/null +++ b/.cursorrules @@ -0,0 +1,157 @@ +# Project Overview + +bolt.diy (previously oTToDev) is an open-source AI-powered full-stack web development platform that allows users to choose different LLM providers for coding assistance. The project supports multiple AI providers including OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, and Groq. + +# Personality + +- Professional and technically precise +- Focus on best practices and clean code +- Provide clear explanations for code changes +- Maintain consistent code style with the existing codebase + +# Techstack + +- Framework: Remix +- Runtime: Node.js (>=18.18.0) +- Package Manager: pnpm +- UI: React with TypeScript +- Styling: UnoCSS +- Development Environment: Vite +- Testing: Vitest +- Deployment: Cloudflare Pages +- Containerization: Docker +- Code Quality: ESLint, Prettier, TypeScript + +# our .env file + +- Follow .env.example for required environment variables +- Keep API keys and sensitive data in .env.local +- Never commit .env files (they are gitignored) + +# Error Fixing Process + +1. Identify the root cause through error messages and logs +2. Check relevant components and dependencies +3. Verify type safety and TypeScript compliance +4. Test changes locally before committing +5. Follow existing error handling patterns + +# Our Codebase + +- Main application code in /app directory +- Components follow a modular structure +- Server-side code in app/lib/.server +- Client-side utilities in app/lib/ +- Type definitions in types/ directory +- Documentation in docs/ directory + +# Current File Structure + +- /app - Main application code +- /docs - Documentation +- /functions - Serverless functions +- /public - Static assets +- /scripts - Build and utility scripts +- /types - TypeScript definitions +- /icons - SVG icons and assets + +# github upload process + +1. Follow conventional commit messages +2. Run linting and tests before committing +3. Create feature branches for new work +4. Submit PRs with clear descriptions +5. Ensure CI/CD checks pass + +# Important + +- Keep dependencies updated +- Follow TypeScript strict mode +- Maintain backward compatibility +- Document API changes +- Test cross-browser compatibility + +# comments + +- Use JSDoc for function documentation +- Keep comments clear and concise +- Document complex logic and business rules +- Update comments when changing code +- Remove redundant comments +- Always write comments that are relevant to the code they describe +- Ensure comments explain the "why" not just the "what" + +# code review + +- Check for type safety +- Verify error handling +- Ensure code follows project patterns +- Look for performance implications +- Validate accessibility standards + +# code writing + +- Follow TypeScript best practices +- Use functional components for React +- Implement proper error boundaries +- Write testable code +- Follow the DRY principle + +# code refactoring + +- Maintain backward compatibility +- Update tests alongside changes +- Document breaking changes +- Follow the project's type system +- Keep components modular and reusable + +# Development Process + +- Write 3 reasoning paragraphs before implementing solutions +- Analyze the problem space thoroughly before jumping to conclusions +- Consider all edge cases and potential impacts +- Process tasks with a Senior Developer mindset +- Continue working until the solution is complete and verified +- Remember and consider the full commit/change history when working + +# Code Quality Guidelines + +- Fewer lines of code is better, but not at the expense of readability +- Preserve existing comments and documentation +- Add meaningful comments explaining complex logic or business rules +- Follow the principle of "Clean Code, Clear Intent" +- Balance between conciseness and maintainability +- Think twice, code once - avoid premature optimization +- Never add comments just for the sake of commenting - ensure they add value + +# Problem Solving Approach + +1. Understand the context fully before making changes +2. Document your reasoning and assumptions +3. Consider alternative approaches and their trade-offs +4. Validate your solution against existing patterns +5. Test thoroughly before considering work complete +6. Review impact on related components + +# UI GUIDELINES + +- Use consistent colors and typography +- Ensure UI is responsive and accessible +- Provide clear feedback for user actions +- Use meaningful icons and labels +- Keep UI clean and organized +- Use consistent spacing and alignment +- Use consistent naming conventions for components and variables +- Use consistent file and folder structure +- Use consistent naming conventions for components and variables +- Use consistent file and folder structure + +# Style Guide + +- Use consistent naming conventions for components and variables +- Use consistent file and folder structure +- Respect the Light/Dark mode +- Don't use white background for dark mode +- Don't use white text on white background for dark mode +- Match the style of the existing codebase +- Use consistent naming conventions for components and variables diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8b66eb1bc..5c8c6ad70 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,4 +1,4 @@ -name: "Bug report" +name: 'Bug report' description: Create a report to help us improve body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md index 2727594f4..e75eca011 100644 --- a/.github/ISSUE_TEMPLATE/epic.md +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -19,5 +19,5 @@ Usual values: Software Developers using the IDE | Contributors --> # Capabilities - diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md index 8df8c3217..3869b4d33 100644 --- a/.github/ISSUE_TEMPLATE/feature.md +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -13,13 +13,13 @@ assignees: '' # Scope - # Options - - diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index d3bd2f1b3..0b54001c2 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -8,7 +8,7 @@ on: - main tags: - v* - - "*" + - '*' permissions: packages: write @@ -57,7 +57,7 @@ jobs: with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} # ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }} + password: ${{ secrets.GITHUB_TOKEN }} # ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-action@v6 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 0691be2fd..c0f117b76 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -5,7 +5,7 @@ on: branches: - main paths: - - 'docs/**' # This will only trigger the workflow when files in docs directory change + - 'docs/**' # This will only trigger the workflow when files in docs directory change permissions: contents: write jobs: @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} @@ -32,4 +32,4 @@ jobs: mkdocs-material- - run: pip install mkdocs-material - - run: mkdocs gh-deploy --force \ No newline at end of file + - run: mkdocs gh-deploy --force diff --git a/.github/workflows/pr-release-validation.yaml b/.github/workflows/pr-release-validation.yaml index 99c570373..9c5787e2d 100644 --- a/.github/workflows/pr-release-validation.yaml +++ b/.github/workflows/pr-release-validation.yaml @@ -9,10 +9,10 @@ on: jobs: validate: runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v4 - + - name: Validate PR Labels run: | if [[ "${{ contains(github.event.pull_request.labels.*.name, 'stable-release') }}" == "true" ]]; then @@ -28,4 +28,4 @@ jobs: fi else echo "This PR doesn't have the stable-release label. No release will be created." - fi \ No newline at end of file + fi diff --git a/.github/workflows/semantic-pr.yaml b/.github/workflows/semantic-pr.yaml index b6d64c888..503b04552 100644 --- a/.github/workflows/semantic-pr.yaml +++ b/.github/workflows/semantic-pr.yaml @@ -29,4 +29,4 @@ jobs: docs refactor revert - test \ No newline at end of file + test diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c9eb890eb..4b6fc78cf 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -2,8 +2,8 @@ name: Mark Stale Issues and Pull Requests on: schedule: - - cron: '0 2 * * *' # Runs daily at 2:00 AM UTC - workflow_dispatch: # Allows manual triggering of the workflow + - cron: '0 2 * * *' # Runs daily at 2:00 AM UTC + workflow_dispatch: # Allows manual triggering of the workflow jobs: stale: @@ -14,12 +14,12 @@ jobs: uses: actions/stale@v8 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: "This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days." - stale-pr-message: "This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days." - days-before-stale: 10 # Number of days before marking an issue or PR as stale - days-before-close: 4 # Number of days after being marked stale before closing - stale-issue-label: "stale" # Label to apply to stale issues - stale-pr-label: "stale" # Label to apply to stale pull requests - exempt-issue-labels: "pinned,important" # Issues with these labels won't be marked stale - exempt-pr-labels: "pinned,important" # PRs with these labels won't be marked stale - operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits + stale-issue-message: 'This issue has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days.' + stale-pr-message: 'This pull request has been marked as stale due to inactivity. If no further activity occurs, it will be closed in 7 days.' + days-before-stale: 10 # Number of days before marking an issue or PR as stale + days-before-close: 4 # Number of days after being marked stale before closing + stale-issue-label: 'stale' # Label to apply to stale issues + stale-pr-label: 'stale' # Label to apply to stale pull requests + exempt-issue-labels: 'pinned,important' # Issues with these labels won't be marked stale + exempt-pr-labels: 'pinned,important' # PRs with these labels won't be marked stale + operations-per-run: 75 # Limits the number of actions per run to avoid API rate limits diff --git a/.github/workflows/update-stable.yml b/.github/workflows/update-stable.yml index f7341c432..a867a4c4c 100644 --- a/.github/workflows/update-stable.yml +++ b/.github/workflows/update-stable.yml @@ -7,12 +7,12 @@ on: permissions: contents: write - + jobs: prepare-release: if: contains(github.event.head_commit.message, '#release') runs-on: ubuntu-latest - + steps: - uses: actions/checkout@v4 with: @@ -80,7 +80,6 @@ jobs: NEW_VERSION=${{ steps.bump_version.outputs.new_version }} pnpm version $NEW_VERSION --no-git-tag-version --allow-same-version - - name: Prepare changelog script run: chmod +x .github/scripts/generate-changelog.sh @@ -89,14 +88,14 @@ jobs: env: NEW_VERSION: ${{ steps.bump_version.outputs.new_version }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + run: .github/scripts/generate-changelog.sh - name: Get the latest commit hash and version tag run: | echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV echo "NEW_VERSION=${{ steps.bump_version.outputs.new_version }}" >> $GITHUB_ENV - + - name: Commit and Tag Release run: | git pull @@ -123,4 +122,4 @@ jobs: gh release create "$VERSION" \ --title "Release $VERSION" \ --notes "${{ steps.changelog.outputs.content }}" \ - --target stable \ No newline at end of file + --target stable diff --git a/.gitignore b/.gitignore index 53eb0368f..909a59e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,9 @@ modelfiles site # commit file ignore -app/commit.json \ No newline at end of file +app/commit.json +changelogUI.md +docs/instructions/Roadmap.md +.cursorrules +.cursorrules +*.md diff --git a/.husky/pre-commit b/.husky/pre-commit index 184f18780..842a7144c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -14,12 +14,12 @@ if ! command -v pnpm >/dev/null 2>&1; then fi # Run typecheck -echo "Running typecheck..." -if ! pnpm typecheck; then - echo "❌ Type checking failed! Please review TypeScript types." - echo "Once you're done, don't forget to add your changes to the commit! πŸš€" - exit 1 -fi +#echo "Running typecheck..." +#if ! pnpm typecheck; then +# echo "❌ Type checking failed! Please review TypeScript types." +# echo "Once you're done, don't forget to add your changes to the commit! πŸš€" +# exit 1 +#fi # Run lint #echo "Running lint..." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a8d5be8f..400bb32aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,15 +6,15 @@ Welcome! This guide provides all the details you need to contribute effectively ## πŸ“‹ Table of Contents -1. [Code of Conduct](#code-of-conduct) -2. [How Can I Contribute?](#how-can-i-contribute) -3. [Pull Request Guidelines](#pull-request-guidelines) -4. [Coding Standards](#coding-standards) -5. [Development Setup](#development-setup) -6. [Testing](#testing) -7. [Deployment](#deployment) -8. [Docker Deployment](#docker-deployment) -9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration) +1. [Code of Conduct](#code-of-conduct) +2. [How Can I Contribute?](#how-can-i-contribute) +3. [Pull Request Guidelines](#pull-request-guidelines) +4. [Coding Standards](#coding-standards) +5. [Development Setup](#development-setup) +6. [Testing](#testing) +7. [Deployment](#deployment) +8. [Docker Deployment](#docker-deployment) +9. [VS Code Dev Containers Integration](#vs-code-dev-containers-integration) --- @@ -27,60 +27,67 @@ This project is governed by our **Code of Conduct**. By participating, you agree ## πŸ› οΈ How Can I Contribute? ### 1️⃣ Reporting Bugs or Feature Requests + - Check the [issue tracker](#) to avoid duplicates. -- Use issue templates (if available). +- Use issue templates (if available). - Provide detailed, relevant information and steps to reproduce bugs. ### 2️⃣ Code Contributions -1. Fork the repository. -2. Create a feature or fix branch. -3. Write and test your code. + +1. Fork the repository. +2. Create a feature or fix branch. +3. Write and test your code. 4. Submit a pull request (PR). -### 3️⃣ Join as a Core Contributor +### 3️⃣ Join as a Core Contributor + Interested in maintaining and growing the project? Fill out our [Contributor Application Form](https://forms.gle/TBSteXSDCtBDwr5m7). --- ## βœ… Pull Request Guidelines -### PR Checklist -- Branch from the **main** branch. -- Update documentation, if needed. -- Test all functionality manually. -- Focus on one feature/bug per PR. +### PR Checklist -### Review Process -1. Manual testing by reviewers. -2. At least one maintainer review required. -3. Address review comments. +- Branch from the **main** branch. +- Update documentation, if needed. +- Test all functionality manually. +- Focus on one feature/bug per PR. + +### Review Process + +1. Manual testing by reviewers. +2. At least one maintainer review required. +3. Address review comments. 4. Maintain a clean commit history. --- ## πŸ“ Coding Standards -### General Guidelines -- Follow existing code style. -- Comment complex logic. -- Keep functions small and focused. +### General Guidelines + +- Follow existing code style. +- Comment complex logic. +- Keep functions small and focused. - Use meaningful variable names. --- ## πŸ–₯️ Development Setup -### 1️⃣ Initial Setup -- Clone the repository: +### 1️⃣ Initial Setup + +- Clone the repository: ```bash git clone https://github.com/stackblitz-labs/bolt.diy.git ``` -- Install dependencies: +- Install dependencies: ```bash pnpm install ``` -- Set up environment variables: - 1. Rename `.env.example` to `.env.local`. +- Set up environment variables: + 1. Rename `.env.example` to `.env.local`. 2. Add your API keys: ```bash GROQ_API_KEY=XXX @@ -88,23 +95,26 @@ Interested in maintaining and growing the project? Fill out our [Contributor App OPENAI_API_KEY=XXX ... ``` - 3. Optionally set: - - Debug level: `VITE_LOG_LEVEL=debug` - - Context size: `DEFAULT_NUM_CTX=32768` + 3. Optionally set: + - Debug level: `VITE_LOG_LEVEL=debug` + - Context size: `DEFAULT_NUM_CTX=32768` **Note**: Never commit your `.env.local` file to version control. It’s already in `.gitignore`. -### 2️⃣ Run Development Server +### 2️⃣ Run Development Server + ```bash pnpm run dev ``` + **Tip**: Use **Google Chrome Canary** for local testing. --- ## πŸ§ͺ Testing -Run the test suite with: +Run the test suite with: + ```bash pnpm test ``` @@ -113,10 +123,12 @@ pnpm test ## πŸš€ Deployment -### Deploy to Cloudflare Pages +### Deploy to Cloudflare Pages + ```bash pnpm run deploy ``` + Ensure you have required permissions and that Wrangler is configured. --- @@ -127,67 +139,76 @@ This section outlines the methods for deploying the application using Docker. Th --- -### πŸ§‘β€πŸ’» Development Environment +### πŸ§‘β€πŸ’» Development Environment -#### Build Options +#### Build Options + +**Option 1: Helper Scripts** -**Option 1: Helper Scripts** ```bash # Development build npm run dockerbuild ``` -**Option 2: Direct Docker Build Command** +**Option 2: Direct Docker Build Command** + ```bash docker build . --target bolt-ai-development ``` -**Option 3: Docker Compose Profile** +**Option 3: Docker Compose Profile** + ```bash -docker-compose --profile development up +docker compose --profile development up ``` -#### Running the Development Container +#### Running the Development Container + ```bash docker run -p 5173:5173 --env-file .env.local bolt-ai:development ``` --- -### 🏭 Production Environment +### 🏭 Production Environment + +#### Build Options -#### Build Options +**Option 1: Helper Scripts** -**Option 1: Helper Scripts** ```bash # Production build npm run dockerbuild:prod ``` -**Option 2: Direct Docker Build Command** +**Option 2: Direct Docker Build Command** + ```bash docker build . --target bolt-ai-production ``` -**Option 3: Docker Compose Profile** +**Option 3: Docker Compose Profile** + ```bash -docker-compose --profile production up +docker compose --profile production up ``` -#### Running the Production Container +#### Running the Production Container + ```bash docker run -p 5173:5173 --env-file .env.local bolt-ai:production ``` --- -### Coolify Deployment +### Coolify Deployment + +For an easy deployment process, use [Coolify](https://github.com/coollabsio/coolify): -For an easy deployment process, use [Coolify](https://github.com/coollabsio/coolify): -1. Import your Git repository into Coolify. -2. Choose **Docker Compose** as the build pack. -3. Configure environment variables (e.g., API keys). -4. Set the start command: +1. Import your Git repository into Coolify. +2. Choose **Docker Compose** as the build pack. +3. Configure environment variables (e.g., API keys). +4. Set the start command: ```bash docker compose --profile production up ``` @@ -200,20 +221,22 @@ The `docker-compose.yaml` configuration is compatible with **VS Code Dev Contain ### Steps to Use Dev Containers -1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS). -2. Select **Dev Containers: Reopen in Container**. -3. Choose the **development** profile when prompted. +1. Open the command palette in VS Code (`Ctrl+Shift+P` or `Cmd+Shift+P` on macOS). +2. Select **Dev Containers: Reopen in Container**. +3. Choose the **development** profile when prompted. 4. VS Code will rebuild the container and open it with the pre-configured environment. --- ## πŸ”‘ Environment Variables -Ensure `.env.local` is configured correctly with: -- API keys. -- Context-specific configurations. +Ensure `.env.local` is configured correctly with: + +- API keys. +- Context-specific configurations. + +Example for the `DEFAULT_NUM_CTX` variable: -Example for the `DEFAULT_NUM_CTX` variable: ```bash DEFAULT_NUM_CTX=24576 # Uses 32GB VRAM -``` \ No newline at end of file +``` diff --git a/Dockerfile b/Dockerfile index d287d4076..99e6f1b57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ WORKDIR /app # Install dependencies (this step is cached as long as the dependencies don't change) COPY package.json pnpm-lock.yaml ./ +RUN npm install -g corepack@latest + RUN corepack enable pnpm && pnpm install # Copy the rest of your app's source code diff --git a/FAQ.md b/FAQ.md index a09fae885..cf00f5467 100644 --- a/FAQ.md +++ b/FAQ.md @@ -12,6 +12,7 @@ For the best experience with bolt.diy, we recommend using the following models: - **Qwen 2.5 Coder 32b**: Best model for self-hosting with reasonable hardware requirements **Note**: Models with less than 7b parameters typically lack the capability to properly interact with bolt! +
@@ -21,20 +22,21 @@ For the best experience with bolt.diy, we recommend using the following models: Mention the frameworks or libraries you want to use (e.g., Astro, Tailwind, ShadCN) in your initial prompt. This ensures that bolt.diy scaffolds the project according to your preferences. - **Use the enhance prompt icon**: - Before sending your prompt, click the *enhance* icon to let the AI refine your prompt. You can edit the suggested improvements before submitting. + Before sending your prompt, click the _enhance_ icon to let the AI refine your prompt. You can edit the suggested improvements before submitting. - **Scaffold the basics first, then add features**: Ensure the foundational structure of your application is in place before introducing advanced functionality. This helps bolt.diy establish a solid base to build on. - **Batch simple instructions**: - Combine simple tasks into a single prompt to save time and reduce API credit consumption. For example: - *"Change the color scheme, add mobile responsiveness, and restart the dev server."* + Combine simple tasks into a single prompt to save time and reduce API credit consumption. For example: + _"Change the color scheme, add mobile responsiveness, and restart the dev server."_
How do I contribute to bolt.diy? Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to get involved! +
@@ -42,48 +44,60 @@ Check out our [Contribution Guide](CONTRIBUTING.md) for more details on how to g Visit our [Roadmap](https://roadmap.sh/r/ottodev-roadmap-2ovzo) for the latest updates. New features and improvements are on the way! +
Why are there so many open issues/pull requests? -bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort! +bolt.diy began as a small showcase project on @ColeMedin's YouTube channel to explore editing open-source projects with local LLMs. However, it quickly grew into a massive community effort! We're forming a team of maintainers to manage demand and streamline issue resolution. The maintainers are rockstars, and we're also exploring partnerships to help the project thrive. +
How do local LLMs compare to larger models like Claude 3.5 Sonnet for bolt.diy? While local LLMs are improving rapidly, larger models like GPT-4o, Claude 3.5 Sonnet, and DeepSeek Coder V2 236b still offer the best results for complex applications. Our ongoing focus is to improve prompts, agents, and the platform to better support smaller local LLMs. +
Common Errors and Troubleshooting ### **"There was an error processing this request"** + This generic error message means something went wrong. Check both: + - The terminal (if you started the app with Docker or `pnpm`). -- The developer console in your browser (press `F12` or right-click > *Inspect*, then go to the *Console* tab). +- The developer console in your browser (press `F12` or right-click > _Inspect_, then go to the _Console_ tab). ### **"x-api-key header missing"** + This error is sometimes resolved by restarting the Docker container. If that doesn't work, try switching from Docker to `pnpm` or vice versa. We're actively investigating this issue. ### **Blank preview when running the app** + A blank preview often occurs due to hallucinated bad code or incorrect commands. To troubleshoot: + - Check the developer console for errors. - Remember, previews are core functionality, so the app isn't broken! We're working on making these errors more transparent. ### **"Everything works, but the results are bad"** + Local LLMs like Qwen-2.5-Coder are powerful for small applications but still experimental for larger projects. For better results, consider using larger models like GPT-4o, Claude 3.5 Sonnet, or DeepSeek Coder V2 236b. ### **"Received structured exception #0xc0000005: access violation"** + If you are getting this, you are probably on Windows. The fix is generally to update the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) ### **"Miniflare or Wrangler errors in Windows"** + You will need to make sure you have the latest version of Visual Studio C++ installed (14.40.33816), more information here https://github.com/stackblitz-labs/bolt.diy/issues/19. +
--- diff --git a/PROJECT.md b/PROJECT.md index 33e697ef6..58d470891 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -31,7 +31,7 @@ and this way communicate where the focus currently is. 2. Grouping of features -By linking features with epics, we can keep them together and document *why* we invest work into a particular thing. +By linking features with epics, we can keep them together and document _why_ we invest work into a particular thing. ## Features (mid-term) @@ -41,13 +41,13 @@ function, you name it). However, we intentionally describe features in a more vague manner. Why? Everybody loves crisp, well-defined acceptance-criteria, no? Well, every product owner loves it. because he knows what he’ll get once it’s done. -But: **here is no owner of this product**. Therefore, we grant *maximum flexibility to the developer contributing a feature* – so that he can bring in his ideas and have most fun implementing it. +But: **here is no owner of this product**. Therefore, we grant _maximum flexibility to the developer contributing a feature_ – so that he can bring in his ideas and have most fun implementing it. -The feature therefore tries to describe *what* should be improved but not in detail *how*. +The feature therefore tries to describe _what_ should be improved but not in detail _how_. ## PRs as materialized features (short-term) -Once a developer starts working on a feature, a draft-PR *can* be opened asap to share, describe and discuss, how the feature shall be implemented. But: this is not a must. It just helps to get early feedback and get other developers involved. Sometimes, the developer just wants to get started and then open a PR later. +Once a developer starts working on a feature, a draft-PR _can_ be opened asap to share, describe and discuss, how the feature shall be implemented. But: this is not a must. It just helps to get early feedback and get other developers involved. Sometimes, the developer just wants to get started and then open a PR later. In a loosely organized project, it may as well happen that multiple PRs are opened for the same feature. This is no real issue: Usually, peoply being passionate about a solution are willing to join forces and get it done together. And if a second developer was just faster getting the same feature realized: Be happy that it's been done, close the PR and look out for the next feature to implement πŸ€“ diff --git a/app/components/@settings/core/AvatarDropdown.tsx b/app/components/@settings/core/AvatarDropdown.tsx new file mode 100644 index 000000000..6adfd31d3 --- /dev/null +++ b/app/components/@settings/core/AvatarDropdown.tsx @@ -0,0 +1,158 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { motion } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import { profileStore } from '~/lib/stores/profile'; +import type { TabType, Profile } from './types'; + +const BetaLabel = () => ( + + BETA + +); + +interface AvatarDropdownProps { + onSelectTab: (tab: TabType) => void; +} + +export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { + const profile = useStore(profileStore) as Profile; + + return ( + + + + {profile?.avatar ? ( + {profile?.username + ) : ( +
+
+
+ )} + + + + + +
+
+ {profile?.avatar ? ( + {profile?.username + ) : ( +
+ ? +
+ )} +
+
+
+ {profile?.username || 'Guest User'} +
+ {profile?.bio &&
{profile.bio}
} +
+
+ + onSelectTab('profile')} + > +
+ Edit Profile + + + onSelectTab('settings')} + > +
+ Settings + + +
+ + onSelectTab('task-manager')} + > +
+ Task Manager + + + + onSelectTab('service-status')} + > +
+ Service Status + + + + + + ); +}; diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx new file mode 100644 index 000000000..c0e190350 --- /dev/null +++ b/app/components/@settings/core/ControlPanel.tsx @@ -0,0 +1,534 @@ +import { useState, useEffect, useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { Switch } from '@radix-ui/react-switch'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import { classNames } from '~/utils/classNames'; +import { TabManagement } from '~/components/@settings/shared/components/TabManagement'; +import { TabTile } from '~/components/@settings/shared/components/TabTile'; +import { useUpdateCheck } from '~/lib/hooks/useUpdateCheck'; +import { useFeatures } from '~/lib/hooks/useFeatures'; +import { useNotifications } from '~/lib/hooks/useNotifications'; +import { useConnectionStatus } from '~/lib/hooks/useConnectionStatus'; +import { useDebugStatus } from '~/lib/hooks/useDebugStatus'; +import { + tabConfigurationStore, + developerModeStore, + setDeveloperMode, + resetTabConfiguration, +} from '~/lib/stores/settings'; +import { profileStore } from '~/lib/stores/profile'; +import type { TabType, TabVisibilityConfig, Profile } from './types'; +import { TAB_LABELS, DEFAULT_TAB_CONFIG } from './constants'; +import { DialogTitle } from '~/components/ui/Dialog'; +import { AvatarDropdown } from './AvatarDropdown'; +import BackgroundRays from '~/components/ui/BackgroundRays'; + +// Import all tab components +import ProfileTab from '~/components/@settings/tabs/profile/ProfileTab'; +import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab'; +import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab'; +import FeaturesTab from '~/components/@settings/tabs/features/FeaturesTab'; +import DataTab from '~/components/@settings/tabs/data/DataTab'; +import DebugTab from '~/components/@settings/tabs/debug/DebugTab'; +import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; +import UpdateTab from '~/components/@settings/tabs/update/UpdateTab'; +import ConnectionsTab from '~/components/@settings/tabs/connections/ConnectionsTab'; +import CloudProvidersTab from '~/components/@settings/tabs/providers/cloud/CloudProvidersTab'; +import ServiceStatusTab from '~/components/@settings/tabs/providers/status/ServiceStatusTab'; +import LocalProvidersTab from '~/components/@settings/tabs/providers/local/LocalProvidersTab'; +import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab'; + +interface ControlPanelProps { + open: boolean; + onClose: () => void; +} + +interface TabWithDevType extends TabVisibilityConfig { + isExtraDevTab?: boolean; +} + +interface ExtendedTabConfig extends TabVisibilityConfig { + isExtraDevTab?: boolean; +} + +interface BaseTabConfig { + id: TabType; + visible: boolean; + window: 'user' | 'developer'; + order: number; +} + +interface AnimatedSwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + id: string; + label: string; +} + +const TAB_DESCRIPTIONS: Record = { + profile: 'Manage your profile and account settings', + settings: 'Configure application preferences', + notifications: 'View and manage your notifications', + features: 'Explore new and upcoming features', + data: 'Manage your data and storage', + 'cloud-providers': 'Configure cloud AI providers and models', + 'local-providers': 'Configure local AI providers and models', + 'service-status': 'Monitor cloud LLM service status', + connection: 'Check connection status and settings', + debug: 'Debug tools and system information', + 'event-logs': 'View system events and logs', + update: 'Check for updates and release notes', + 'task-manager': 'Monitor system resources and processes', + 'tab-management': 'Configure visible tabs and their order', +}; + +// Beta status for experimental features +const BETA_TABS = new Set(['task-manager', 'service-status', 'update', 'local-providers']); + +const BetaLabel = () => ( +
+ BETA +
+); + +const AnimatedSwitch = ({ checked, onCheckedChange, id, label }: AnimatedSwitchProps) => { + return ( +
+ + + + + Toggle {label} + +
+ +
+
+ ); +}; + +export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { + // State + const [activeTab, setActiveTab] = useState(null); + const [loadingTab, setLoadingTab] = useState(null); + const [showTabManagement, setShowTabManagement] = useState(false); + + // Store values + const tabConfiguration = useStore(tabConfigurationStore); + const developerMode = useStore(developerModeStore); + const profile = useStore(profileStore) as Profile; + + // Status hooks + const { hasUpdate, currentVersion, acknowledgeUpdate } = useUpdateCheck(); + const { hasNewFeatures, unviewedFeatures, acknowledgeAllFeatures } = useFeatures(); + const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications(); + const { hasConnectionIssues, currentIssue, acknowledgeIssue } = useConnectionStatus(); + const { hasActiveWarnings, activeIssues, acknowledgeAllIssues } = useDebugStatus(); + + // Memoize the base tab configurations to avoid recalculation + const baseTabConfig = useMemo(() => { + return new Map(DEFAULT_TAB_CONFIG.map((tab) => [tab.id, tab])); + }, []); + + // Add visibleTabs logic using useMemo with optimized calculations + const visibleTabs = useMemo(() => { + if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) { + console.warn('Invalid tab configuration, resetting to defaults'); + resetTabConfiguration(); + + return []; + } + + const notificationsDisabled = profile?.preferences?.notifications === false; + + // In developer mode, show ALL tabs without restrictions + if (developerMode) { + const seenTabs = new Set(); + const devTabs: ExtendedTabConfig[] = []; + + // Process tabs in order of priority: developer, user, default + const processTab = (tab: BaseTabConfig) => { + if (!seenTabs.has(tab.id)) { + seenTabs.add(tab.id); + devTabs.push({ + id: tab.id, + visible: true, + window: 'developer', + order: tab.order || devTabs.length, + }); + } + }; + + // Process tabs in priority order + tabConfiguration.developerTabs?.forEach((tab) => processTab(tab as BaseTabConfig)); + tabConfiguration.userTabs.forEach((tab) => processTab(tab as BaseTabConfig)); + DEFAULT_TAB_CONFIG.forEach((tab) => processTab(tab as BaseTabConfig)); + + // Add Tab Management tile + devTabs.push({ + id: 'tab-management' as TabType, + visible: true, + window: 'developer', + order: devTabs.length, + isExtraDevTab: true, + }); + + return devTabs.sort((a, b) => a.order - b.order); + } + + // Optimize user mode tab filtering + return tabConfiguration.userTabs + .filter((tab) => { + if (!tab?.id) { + return false; + } + + if (tab.id === 'notifications' && notificationsDisabled) { + return false; + } + + return tab.visible && tab.window === 'user'; + }) + .sort((a, b) => a.order - b.order); + }, [tabConfiguration, developerMode, profile?.preferences?.notifications, baseTabConfig]); + + // Optimize animation performance with layout animations + const gridLayoutVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, + }; + + const itemVariants = { + hidden: { opacity: 0, scale: 0.8 }, + visible: { + opacity: 1, + scale: 1, + transition: { + type: 'spring', + stiffness: 200, + damping: 20, + mass: 0.6, + }, + }, + }; + + // Handlers + const handleBack = () => { + if (showTabManagement) { + setShowTabManagement(false); + } else if (activeTab) { + setActiveTab(null); + } + }; + + const handleDeveloperModeChange = (checked: boolean) => { + console.log('Developer mode changed:', checked); + setDeveloperMode(checked); + }; + + // Add effect to log developer mode changes + useEffect(() => { + console.log('Current developer mode:', developerMode); + }, [developerMode]); + + const getTabComponent = (tabId: TabType | 'tab-management') => { + if (tabId === 'tab-management') { + return ; + } + + switch (tabId) { + case 'profile': + return ; + case 'settings': + return ; + case 'notifications': + return ; + case 'features': + return ; + case 'data': + return ; + case 'cloud-providers': + return ; + case 'local-providers': + return ; + case 'connection': + return ; + case 'debug': + return ; + case 'event-logs': + return ; + case 'update': + return ; + case 'task-manager': + return ; + case 'service-status': + return ; + default: + return null; + } + }; + + const getTabUpdateStatus = (tabId: TabType): boolean => { + switch (tabId) { + case 'update': + return hasUpdate; + case 'features': + return hasNewFeatures; + case 'notifications': + return hasUnreadNotifications; + case 'connection': + return hasConnectionIssues; + case 'debug': + return hasActiveWarnings; + default: + return false; + } + }; + + const getStatusMessage = (tabId: TabType): string => { + switch (tabId) { + case 'update': + return `New update available (v${currentVersion})`; + case 'features': + return `${unviewedFeatures.length} new feature${unviewedFeatures.length === 1 ? '' : 's'} to explore`; + case 'notifications': + return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`; + case 'connection': + return currentIssue === 'disconnected' + ? 'Connection lost' + : currentIssue === 'high-latency' + ? 'High latency detected' + : 'Connection issues detected'; + case 'debug': { + const warnings = activeIssues.filter((i) => i.type === 'warning').length; + const errors = activeIssues.filter((i) => i.type === 'error').length; + + return `${warnings} warning${warnings === 1 ? '' : 's'}, ${errors} error${errors === 1 ? '' : 's'}`; + } + default: + return ''; + } + }; + + const handleTabClick = (tabId: TabType) => { + setLoadingTab(tabId); + setActiveTab(tabId); + setShowTabManagement(false); + + // Acknowledge notifications based on tab + switch (tabId) { + case 'update': + acknowledgeUpdate(); + break; + case 'features': + acknowledgeAllFeatures(); + break; + case 'notifications': + markAllAsRead(); + break; + case 'connection': + acknowledgeIssue(); + break; + case 'debug': + acknowledgeAllIssues(); + break; + } + + // Clear loading state after a delay + setTimeout(() => setLoadingTab(null), 500); + }; + + return ( + + +
+ + + + + + +
+ +
+
+ {/* Header */} +
+
+ {(activeTab || showTabManagement) && ( + + )} + + {showTabManagement ? 'Tab Management' : activeTab ? TAB_LABELS[activeTab] : 'Control Panel'} + +
+ +
+ {/* Mode Toggle */} +
+ +
+ + {/* Avatar and Dropdown */} +
+ +
+ + {/* Close Button */} + +
+
+ + {/* Content */} +
+ + {showTabManagement ? ( + + ) : activeTab ? ( + getTabComponent(activeTab) + ) : ( + + + {(visibleTabs as TabWithDevType[]).map((tab: TabWithDevType) => ( + + handleTabClick(tab.id as TabType)} + isActive={activeTab === tab.id} + hasUpdate={getTabUpdateStatus(tab.id)} + statusMessage={getStatusMessage(tab.id)} + description={TAB_DESCRIPTIONS[tab.id]} + isLoading={loadingTab === tab.id} + className="h-full relative" + > + {BETA_TABS.has(tab.id) && } + + + ))} + + + )} + +
+
+
+
+
+
+
+ ); +}; diff --git a/app/components/@settings/core/constants.ts b/app/components/@settings/core/constants.ts new file mode 100644 index 000000000..ff72a2746 --- /dev/null +++ b/app/components/@settings/core/constants.ts @@ -0,0 +1,88 @@ +import type { TabType } from './types'; + +export const TAB_ICONS: Record = { + profile: 'i-ph:user-circle-fill', + settings: 'i-ph:gear-six-fill', + notifications: 'i-ph:bell-fill', + features: 'i-ph:star-fill', + data: 'i-ph:database-fill', + 'cloud-providers': 'i-ph:cloud-fill', + 'local-providers': 'i-ph:desktop-fill', + 'service-status': 'i-ph:activity-bold', + connection: 'i-ph:wifi-high-fill', + debug: 'i-ph:bug-fill', + 'event-logs': 'i-ph:list-bullets-fill', + update: 'i-ph:arrow-clockwise-fill', + 'task-manager': 'i-ph:chart-line-fill', + 'tab-management': 'i-ph:squares-four-fill', +}; + +export const TAB_LABELS: Record = { + profile: 'Profile', + settings: 'Settings', + notifications: 'Notifications', + features: 'Features', + data: 'Data Management', + 'cloud-providers': 'Cloud Providers', + 'local-providers': 'Local Providers', + 'service-status': 'Service Status', + connection: 'Connection', + debug: 'Debug', + 'event-logs': 'Event Logs', + update: 'Updates', + 'task-manager': 'Task Manager', + 'tab-management': 'Tab Management', +}; + +export const TAB_DESCRIPTIONS: Record = { + profile: 'Manage your profile and account settings', + settings: 'Configure application preferences', + notifications: 'View and manage your notifications', + features: 'Explore new and upcoming features', + data: 'Manage your data and storage', + 'cloud-providers': 'Configure cloud AI providers and models', + 'local-providers': 'Configure local AI providers and models', + 'service-status': 'Monitor cloud LLM service status', + connection: 'Check connection status and settings', + debug: 'Debug tools and system information', + 'event-logs': 'View system events and logs', + update: 'Check for updates and release notes', + 'task-manager': 'Monitor system resources and processes', + 'tab-management': 'Configure visible tabs and their order', +}; + +export const DEFAULT_TAB_CONFIG = [ + // User Window Tabs (Always visible by default) + { id: 'features', visible: true, window: 'user' as const, order: 0 }, + { id: 'data', visible: true, window: 'user' as const, order: 1 }, + { id: 'cloud-providers', visible: true, window: 'user' as const, order: 2 }, + { id: 'local-providers', visible: true, window: 'user' as const, order: 3 }, + { id: 'connection', visible: true, window: 'user' as const, order: 4 }, + { id: 'notifications', visible: true, window: 'user' as const, order: 5 }, + { id: 'event-logs', visible: true, window: 'user' as const, order: 6 }, + + // User Window Tabs (In dropdown, initially hidden) + { id: 'profile', visible: false, window: 'user' as const, order: 7 }, + { id: 'settings', visible: false, window: 'user' as const, order: 8 }, + { id: 'task-manager', visible: false, window: 'user' as const, order: 9 }, + { id: 'service-status', visible: false, window: 'user' as const, order: 10 }, + + // User Window Tabs (Hidden, controlled by TaskManagerTab) + { id: 'debug', visible: false, window: 'user' as const, order: 11 }, + { id: 'update', visible: false, window: 'user' as const, order: 12 }, + + // Developer Window Tabs (All visible by default) + { id: 'features', visible: true, window: 'developer' as const, order: 0 }, + { id: 'data', visible: true, window: 'developer' as const, order: 1 }, + { id: 'cloud-providers', visible: true, window: 'developer' as const, order: 2 }, + { id: 'local-providers', visible: true, window: 'developer' as const, order: 3 }, + { id: 'connection', visible: true, window: 'developer' as const, order: 4 }, + { id: 'notifications', visible: true, window: 'developer' as const, order: 5 }, + { id: 'event-logs', visible: true, window: 'developer' as const, order: 6 }, + { id: 'profile', visible: true, window: 'developer' as const, order: 7 }, + { id: 'settings', visible: true, window: 'developer' as const, order: 8 }, + { id: 'task-manager', visible: true, window: 'developer' as const, order: 9 }, + { id: 'service-status', visible: true, window: 'developer' as const, order: 10 }, + { id: 'debug', visible: true, window: 'developer' as const, order: 11 }, + { id: 'update', visible: true, window: 'developer' as const, order: 12 }, +]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts new file mode 100644 index 000000000..97d4d3606 --- /dev/null +++ b/app/components/@settings/core/types.ts @@ -0,0 +1,114 @@ +import type { ReactNode } from 'react'; + +export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences'; + +export type TabType = + | 'profile' + | 'settings' + | 'notifications' + | 'features' + | 'data' + | 'cloud-providers' + | 'local-providers' + | 'service-status' + | 'connection' + | 'debug' + | 'event-logs' + | 'update' + | 'task-manager' + | 'tab-management'; + +export type WindowType = 'user' | 'developer'; + +export interface UserProfile { + nickname: any; + name: string; + email: string; + avatar?: string; + theme: 'light' | 'dark' | 'system'; + notifications: boolean; + password?: string; + bio?: string; + language: string; + timezone: string; +} + +export interface SettingItem { + id: TabType; + label: string; + icon: string; + category: SettingCategory; + description?: string; + component: () => ReactNode; + badge?: string; + keywords?: string[]; +} + +export interface TabVisibilityConfig { + id: TabType; + visible: boolean; + window: WindowType; + order: number; + isExtraDevTab?: boolean; + locked?: boolean; +} + +export interface DevTabConfig extends TabVisibilityConfig { + window: 'developer'; +} + +export interface UserTabConfig extends TabVisibilityConfig { + window: 'user'; +} + +export interface TabWindowConfig { + userTabs: UserTabConfig[]; + developerTabs: DevTabConfig[]; +} + +export const TAB_LABELS: Record = { + profile: 'Profile', + settings: 'Settings', + notifications: 'Notifications', + features: 'Features', + data: 'Data Management', + 'cloud-providers': 'Cloud Providers', + 'local-providers': 'Local Providers', + 'service-status': 'Service Status', + connection: 'Connections', + debug: 'Debug', + 'event-logs': 'Event Logs', + update: 'Updates', + 'task-manager': 'Task Manager', + 'tab-management': 'Tab Management', +}; + +export const categoryLabels: Record = { + profile: 'Profile & Account', + file_sharing: 'File Sharing', + connectivity: 'Connectivity', + system: 'System', + services: 'Services', + preferences: 'Preferences', +}; + +export const categoryIcons: Record = { + profile: 'i-ph:user-circle', + file_sharing: 'i-ph:folder-simple', + connectivity: 'i-ph:wifi-high', + system: 'i-ph:gear', + services: 'i-ph:cube', + preferences: 'i-ph:sliders', +}; + +export interface Profile { + username?: string; + bio?: string; + avatar?: string; + preferences?: { + notifications?: boolean; + theme?: 'light' | 'dark' | 'system'; + language?: string; + timezone?: string; + }; +} diff --git a/app/components/@settings/index.ts b/app/components/@settings/index.ts new file mode 100644 index 000000000..862c33ef7 --- /dev/null +++ b/app/components/@settings/index.ts @@ -0,0 +1,14 @@ +// Core exports +export { ControlPanel } from './core/ControlPanel'; +export type { TabType, TabVisibilityConfig } from './core/types'; + +// Constants +export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constants'; + +// Shared components +export { TabTile } from './shared/components/TabTile'; +export { TabManagement } from './shared/components/TabManagement'; + +// Utils +export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers'; +export * from './utils/animations'; diff --git a/app/components/@settings/shared/components/DraggableTabList.tsx b/app/components/@settings/shared/components/DraggableTabList.tsx new file mode 100644 index 000000000..a8681835d --- /dev/null +++ b/app/components/@settings/shared/components/DraggableTabList.tsx @@ -0,0 +1,163 @@ +import { useDrag, useDrop } from 'react-dnd'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import type { TabVisibilityConfig } from '~/components/@settings/core/types'; +import { TAB_LABELS } from '~/components/@settings/core/types'; +import { Switch } from '~/components/ui/Switch'; + +interface DraggableTabListProps { + tabs: TabVisibilityConfig[]; + onReorder: (tabs: TabVisibilityConfig[]) => void; + onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void; + onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void; + showControls?: boolean; +} + +interface DraggableTabItemProps { + tab: TabVisibilityConfig; + index: number; + moveTab: (dragIndex: number, hoverIndex: number) => void; + showControls?: boolean; + onWindowChange?: (tab: TabVisibilityConfig, window: 'user' | 'developer') => void; + onVisibilityChange?: (tab: TabVisibilityConfig, visible: boolean) => void; +} + +interface DragItem { + type: string; + index: number; + id: string; +} + +const DraggableTabItem = ({ + tab, + index, + moveTab, + showControls, + onWindowChange, + onVisibilityChange, +}: DraggableTabItemProps) => { + const [{ isDragging }, dragRef] = useDrag({ + type: 'tab', + item: { type: 'tab', index, id: tab.id }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + const [, dropRef] = useDrop({ + accept: 'tab', + hover: (item: DragItem, monitor) => { + if (!monitor.isOver({ shallow: true })) { + return; + } + + if (item.index === index) { + return; + } + + if (item.id === tab.id) { + return; + } + + moveTab(item.index, index); + item.index = index; + }, + }); + + const ref = (node: HTMLDivElement | null) => { + dragRef(node); + dropRef(node); + }; + + return ( + +
+
+
+
+
+
{TAB_LABELS[tab.id]}
+ {showControls && ( +
+ Order: {tab.order}, Window: {tab.window} +
+ )} +
+
+ {showControls && !tab.locked && ( +
+
+ onVisibilityChange?.(tab, checked)} + className="data-[state=checked]:bg-purple-500" + aria-label={`Toggle ${TAB_LABELS[tab.id]} visibility`} + /> + +
+
+ + onWindowChange?.(tab, checked ? 'developer' : 'user')} + className="data-[state=checked]:bg-purple-500" + aria-label={`Toggle ${TAB_LABELS[tab.id]} window assignment`} + /> + +
+
+ )} + + ); +}; + +export const DraggableTabList = ({ + tabs, + onReorder, + onWindowChange, + onVisibilityChange, + showControls = false, +}: DraggableTabListProps) => { + const moveTab = (dragIndex: number, hoverIndex: number) => { + const items = Array.from(tabs); + const [reorderedItem] = items.splice(dragIndex, 1); + items.splice(hoverIndex, 0, reorderedItem); + + // Update order numbers based on position + const reorderedTabs = items.map((tab, index) => ({ + ...tab, + order: index + 1, + })); + + onReorder(reorderedTabs); + }; + + return ( +
+ {tabs.map((tab, index) => ( + + ))} +
+ ); +}; diff --git a/app/components/@settings/shared/components/TabManagement.tsx b/app/components/@settings/shared/components/TabManagement.tsx new file mode 100644 index 000000000..ec6aceceb --- /dev/null +++ b/app/components/@settings/shared/components/TabManagement.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { Switch } from '@radix-ui/react-switch'; +import { classNames } from '~/utils/classNames'; +import { tabConfigurationStore } from '~/lib/stores/settings'; +import { TAB_LABELS } from '~/components/@settings/core/constants'; +import type { TabType } from '~/components/@settings/core/types'; +import { toast } from 'react-toastify'; +import { TbLayoutGrid } from 'react-icons/tb'; + +// Define tab icons mapping +const TAB_ICONS: Record = { + profile: 'i-ph:user-circle-fill', + settings: 'i-ph:gear-six-fill', + notifications: 'i-ph:bell-fill', + features: 'i-ph:star-fill', + data: 'i-ph:database-fill', + 'cloud-providers': 'i-ph:cloud-fill', + 'local-providers': 'i-ph:desktop-fill', + 'service-status': 'i-ph:activity-fill', + connection: 'i-ph:wifi-high-fill', + debug: 'i-ph:bug-fill', + 'event-logs': 'i-ph:list-bullets-fill', + update: 'i-ph:arrow-clockwise-fill', + 'task-manager': 'i-ph:chart-line-fill', + 'tab-management': 'i-ph:squares-four-fill', +}; + +// Define which tabs are default in user mode +const DEFAULT_USER_TABS: TabType[] = [ + 'features', + 'data', + 'cloud-providers', + 'local-providers', + 'connection', + 'notifications', + 'event-logs', +]; + +// Define which tabs can be added to user mode +const OPTIONAL_USER_TABS: TabType[] = ['profile', 'settings', 'task-manager', 'service-status', 'debug', 'update']; + +// All available tabs for user mode +const ALL_USER_TABS = [...DEFAULT_USER_TABS, ...OPTIONAL_USER_TABS]; + +// Define which tabs are beta +const BETA_TABS = new Set(['task-manager', 'service-status', 'update', 'local-providers']); + +// Beta label component +const BetaLabel = () => ( + BETA +); + +export const TabManagement = () => { + const [searchQuery, setSearchQuery] = useState(''); + const tabConfiguration = useStore(tabConfigurationStore); + + const handleTabVisibilityChange = (tabId: TabType, checked: boolean) => { + // Get current tab configuration + const currentTab = tabConfiguration.userTabs.find((tab) => tab.id === tabId); + + // If tab doesn't exist in configuration, create it + if (!currentTab) { + const newTab = { + id: tabId, + visible: checked, + window: 'user' as const, + order: tabConfiguration.userTabs.length, + }; + + const updatedTabs = [...tabConfiguration.userTabs, newTab]; + + tabConfigurationStore.set({ + ...tabConfiguration, + userTabs: updatedTabs, + }); + + toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`); + + return; + } + + // Check if tab can be enabled in user mode + const canBeEnabled = DEFAULT_USER_TABS.includes(tabId) || OPTIONAL_USER_TABS.includes(tabId); + + if (!canBeEnabled && checked) { + toast.error('This tab cannot be enabled in user mode'); + return; + } + + // Update tab visibility + const updatedTabs = tabConfiguration.userTabs.map((tab) => { + if (tab.id === tabId) { + return { ...tab, visible: checked }; + } + + return tab; + }); + + // Update store + tabConfigurationStore.set({ + ...tabConfiguration, + userTabs: updatedTabs, + }); + + // Show success message + toast.success(`Tab ${checked ? 'enabled' : 'disabled'} successfully`); + }; + + // Create a map of existing tab configurations + const tabConfigMap = new Map(tabConfiguration.userTabs.map((tab) => [tab.id, tab])); + + // Generate the complete list of tabs, including those not in the configuration + const allTabs = ALL_USER_TABS.map((tabId) => { + return ( + tabConfigMap.get(tabId) || { + id: tabId, + visible: false, + window: 'user' as const, + order: -1, + } + ); + }); + + // Filter tabs based on search query + const filteredTabs = allTabs.filter((tab) => TAB_LABELS[tab.id].toLowerCase().includes(searchQuery.toLowerCase())); + + return ( +
+ + {/* Header */} +
+
+
+ +
+
+

Tab Management

+

Configure visible tabs and their order

+
+
+ + {/* Search */} +
+
+
+
+ setSearchQuery(e.target.value)} + placeholder="Search tabs..." + className={classNames( + 'w-full pl-10 pr-4 py-2 rounded-lg', + 'bg-bolt-elements-background-depth-2', + 'border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary', + 'placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + 'transition-all duration-200', + )} + /> +
+
+ + {/* Tab Grid */} +
+ {filteredTabs.map((tab, index) => ( + + {/* Status Badges */} +
+ {DEFAULT_USER_TABS.includes(tab.id) && ( + + Default + + )} + {OPTIONAL_USER_TABS.includes(tab.id) && ( + + Optional + + )} +
+ +
+ +
+
+
+ + +
+
+
+
+

+ {TAB_LABELS[tab.id]} +

+ {BETA_TABS.has(tab.id) && } +
+

+ {tab.visible ? 'Visible in user mode' : 'Hidden in user mode'} +

+
+ handleTabVisibilityChange(tab.id, checked)} + disabled={!DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id)} + className={classNames( + 'relative inline-flex h-5 w-9 items-center rounded-full', + 'transition-colors duration-200', + tab.visible ? 'bg-purple-500' : 'bg-bolt-elements-background-depth-4', + { + 'opacity-50 cursor-not-allowed': + !DEFAULT_USER_TABS.includes(tab.id) && !OPTIONAL_USER_TABS.includes(tab.id), + }, + )} + /> +
+
+
+ + + + ))} +
+
+
+ ); +}; diff --git a/app/components/@settings/shared/components/TabTile.tsx b/app/components/@settings/shared/components/TabTile.tsx new file mode 100644 index 000000000..ea409d690 --- /dev/null +++ b/app/components/@settings/shared/components/TabTile.tsx @@ -0,0 +1,135 @@ +import { motion } from 'framer-motion'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { classNames } from '~/utils/classNames'; +import type { TabVisibilityConfig } from '~/components/@settings/core/types'; +import { TAB_LABELS, TAB_ICONS } from '~/components/@settings/core/constants'; + +interface TabTileProps { + tab: TabVisibilityConfig; + onClick?: () => void; + isActive?: boolean; + hasUpdate?: boolean; + statusMessage?: string; + description?: string; + isLoading?: boolean; + className?: string; + children?: React.ReactNode; +} + +export const TabTile: React.FC = ({ + tab, + onClick, + isActive, + hasUpdate, + statusMessage, + description, + isLoading, + className, + children, +}: TabTileProps) => { + return ( + + + + + {/* Main Content */} +
+ {/* Icon */} + + + + + {/* Label and Description */} +
+

+ {TAB_LABELS[tab.id]} +

+ {description && ( +

+ {description} +

+ )} +
+
+ + {/* Update Indicator with Tooltip */} + {hasUpdate && ( + <> +
+ + + {statusMessage} + + + + + )} + + {/* Children (e.g. Beta Label) */} + {children} + + + + + ); +}; diff --git a/app/components/@settings/tabs/connections/ConnectionsTab.tsx b/app/components/@settings/tabs/connections/ConnectionsTab.tsx new file mode 100644 index 000000000..caaedce57 --- /dev/null +++ b/app/components/@settings/tabs/connections/ConnectionsTab.tsx @@ -0,0 +1,615 @@ +import React, { useState, useEffect } from 'react'; +import { logStore } from '~/lib/stores/logs'; +import { classNames } from '~/utils/classNames'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; + +interface GitHubUserResponse { + login: string; + avatar_url: string; + html_url: string; + name: string; + bio: string; + public_repos: number; + followers: number; + following: number; + created_at: string; + public_gists: number; +} + +interface GitHubRepoInfo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + languages_url: string; +} + +interface GitHubOrganization { + login: string; + avatar_url: string; + html_url: string; +} + +interface GitHubEvent { + id: string; + type: string; + repo: { + name: string; + }; + created_at: string; +} + +interface GitHubLanguageStats { + [language: string]: number; +} + +interface GitHubStats { + repos: GitHubRepoInfo[]; + totalStars: number; + totalForks: number; + organizations: GitHubOrganization[]; + recentActivity: GitHubEvent[]; + languages: GitHubLanguageStats; + totalGists: number; +} + +interface GitHubConnection { + user: GitHubUserResponse | null; + token: string; + tokenType: 'classic' | 'fine-grained'; + stats?: GitHubStats; +} + +export default function ConnectionsTab() { + const [connection, setConnection] = useState({ + user: null, + token: '', + tokenType: 'classic', + }); + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [isFetchingStats, setIsFetchingStats] = useState(false); + + // Load saved connection on mount + useEffect(() => { + const savedConnection = localStorage.getItem('github_connection'); + + if (savedConnection) { + const parsed = JSON.parse(savedConnection); + + // Ensure backward compatibility with existing connections + if (!parsed.tokenType) { + parsed.tokenType = 'classic'; + } + + setConnection(parsed); + + if (parsed.user && parsed.token) { + fetchGitHubStats(parsed.token); + } + } + + setIsLoading(false); + }, []); + + const fetchGitHubStats = async (token: string) => { + try { + setIsFetchingStats(true); + + // Fetch repositories - only owned by the authenticated user + const reposResponse = await fetch( + 'https://api.github.com/user/repos?sort=updated&per_page=10&affiliation=owner,organization_member,collaborator', + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!reposResponse.ok) { + throw new Error('Failed to fetch repositories'); + } + + const repos = (await reposResponse.json()) as GitHubRepoInfo[]; + + // Fetch organizations + const orgsResponse = await fetch('https://api.github.com/user/orgs', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!orgsResponse.ok) { + throw new Error('Failed to fetch organizations'); + } + + const organizations = (await orgsResponse.json()) as GitHubOrganization[]; + + // Fetch recent activity + const eventsResponse = await fetch('https://api.github.com/users/' + connection.user?.login + '/events/public', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!eventsResponse.ok) { + throw new Error('Failed to fetch events'); + } + + const recentActivity = ((await eventsResponse.json()) as GitHubEvent[]).slice(0, 5); + + // Fetch languages for each repository + const languagePromises = repos.map((repo) => + fetch(repo.languages_url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then((res) => res.json() as Promise>), + ); + + const repoLanguages = await Promise.all(languagePromises); + const languages: GitHubLanguageStats = {}; + + repoLanguages.forEach((repoLang) => { + Object.entries(repoLang).forEach(([lang, bytes]) => { + languages[lang] = (languages[lang] || 0) + bytes; + }); + }); + + // Calculate total stats + const totalStars = repos.reduce((acc, repo) => acc + repo.stargazers_count, 0); + const totalForks = repos.reduce((acc, repo) => acc + repo.forks_count, 0); + const totalGists = connection.user?.public_gists || 0; + + setConnection((prev) => ({ + ...prev, + stats: { + repos, + totalStars, + totalForks, + organizations, + recentActivity, + languages, + totalGists, + }, + })); + } catch (error) { + logStore.logError('Failed to fetch GitHub stats', { error }); + toast.error('Failed to fetch GitHub statistics'); + } finally { + setIsFetchingStats(false); + } + }; + + const fetchGithubUser = async (token: string) => { + try { + setIsConnecting(true); + + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Invalid token or unauthorized'); + } + + const data = (await response.json()) as GitHubUserResponse; + const newConnection: GitHubConnection = { + user: data, + token, + tokenType: connection.tokenType, + }; + + // Save connection + localStorage.setItem('github_connection', JSON.stringify(newConnection)); + setConnection(newConnection); + + // Fetch additional stats + await fetchGitHubStats(token); + + toast.success('Successfully connected to GitHub'); + } catch (error) { + logStore.logError('Failed to authenticate with GitHub', { error }); + toast.error('Failed to connect to GitHub'); + setConnection({ user: null, token: '', tokenType: 'classic' }); + } finally { + setIsConnecting(false); + } + }; + + const handleConnect = async (event: React.FormEvent) => { + event.preventDefault(); + await fetchGithubUser(connection.token); + }; + + const handleDisconnect = () => { + localStorage.removeItem('github_connection'); + setConnection({ user: null, token: '', tokenType: 'classic' }); + toast.success('Disconnected from GitHub'); + }; + + if (isLoading) { + return ; + } + + return ( +
+ {/* Header */} + +
+

Connection Settings

+ +

+ Manage your external service connections and integrations +

+ +
+ {/* GitHub Connection */} + +
+
+
+

GitHub Connection

+
+ +
+
+ + +
+ +
+ + setConnection((prev) => ({ ...prev, token: e.target.value }))} + disabled={isConnecting || !!connection.user} + placeholder={`Enter your GitHub ${connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token'}`} + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-[#F8F8F8] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#333333]', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-1 focus:ring-purple-500', + 'disabled:opacity-50', + )} + /> +
+ + Get your token +
+ + β€’ + + Required scopes:{' '} + {connection.tokenType === 'classic' + ? 'repo, read:org, read:user' + : 'Repository access, Organization access'} + +
+
+
+ +
+ {!connection.user ? ( + + ) : ( + + )} + + {connection.user && ( + +
+ Connected to GitHub + + )} +
+ + {connection.user && ( +
+
+ {connection.user.login} +
+

{connection.user.name}

+

@{connection.user.login}

+
+
+ + {isFetchingStats ? ( +
+
+ Fetching GitHub stats... +
+ ) : ( + connection.stats && ( +
+
+

Public Repos

+

+ {connection.user.public_repos} +

+
+
+

Total Stars

+

+ {connection.stats.totalStars} +

+
+
+

Total Forks

+

+ {connection.stats.totalForks} +

+
+
+ ) + )} +
+ )} + + {connection.user && connection.stats && ( +
+
+ {connection.user.login} +
+

+ {connection.user.name || connection.user.login} +

+ {connection.user.bio && ( +

{connection.user.bio}

+ )} +
+ +
+ {connection.user.followers} followers + + +
+ {connection.stats.totalStars} stars + + +
+ {connection.stats.totalForks} forks + +
+
+
+ + {/* Organizations Section */} + {connection.stats.organizations.length > 0 && ( +
+

Organizations

+
+ {connection.stats.organizations.map((org) => ( + + {org.login} + {org.login} + + ))} +
+
+ )} + + {/* Languages Section */} +
+

Top Languages

+
+ {Object.entries(connection.stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([language]) => ( + + {language} + + ))} +
+
+ + {/* Recent Activity Section */} +
+

Recent Activity

+
+ {connection.stats.recentActivity.map((event) => ( +
+
+
+ {event.type.replace('Event', '')} + on + + {event.repo.name} + +
+
+ {new Date(event.created_at).toLocaleDateString()} at{' '} + {new Date(event.created_at).toLocaleTimeString()} +
+
+ ))} +
+
+ + {/* Additional Stats */} +
+
+
Member Since
+
+ {new Date(connection.user.created_at).toLocaleDateString()} +
+
+
+
Public Gists
+
+ {connection.stats.totalGists} +
+
+
+
Organizations
+
+ {connection.stats.organizations.length} +
+
+
+
Languages
+
+ {Object.keys(connection.stats.languages).length} +
+
+
+ + {/* Existing repositories section */} +

Recent Repositories

+ +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ Loading... +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/components/ConnectionForm.tsx b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx new file mode 100644 index 000000000..04210e2b5 --- /dev/null +++ b/app/components/@settings/tabs/connections/components/ConnectionForm.tsx @@ -0,0 +1,180 @@ +import React, { useEffect } from 'react'; +import { classNames } from '~/utils/classNames'; +import type { GitHubAuthState } from '~/components/@settings/tabs/connections/types/GitHub'; +import Cookies from 'js-cookie'; +import { getLocalStorage } from '~/lib/persistence'; + +const GITHUB_TOKEN_KEY = 'github_token'; + +interface ConnectionFormProps { + authState: GitHubAuthState; + setAuthState: React.Dispatch>; + onSave: (e: React.FormEvent) => void; + onDisconnect: () => void; +} + +export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) { + // Check for saved token on mount + useEffect(() => { + const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY); + + if (savedToken && !authState.tokenInfo?.token) { + setAuthState((prev: GitHubAuthState) => ({ + ...prev, + tokenInfo: { + token: savedToken, + scope: [], + avatar_url: '', + name: null, + created_at: new Date().toISOString(), + followers: 0, + }, + })); + } + }, []); + + return ( +
+
+
+
+
+
+
+
+

Connection Settings

+

Configure your GitHub connection

+
+
+
+ +
+
+ + setAuthState((prev: GitHubAuthState) => ({ ...prev, username: e.target.value }))} + className={classNames( + 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base', + 'border-[#E5E5E5] dark:border-[#1A1A1A]', + 'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500', + 'transition-all duration-200', + )} + placeholder="e.g., octocat" + /> +
+ +
+
+ + + Generate new token +
+ +
+ + setAuthState((prev: GitHubAuthState) => ({ + ...prev, + tokenInfo: { + token: e.target.value, + scope: [], + avatar_url: '', + name: null, + created_at: new Date().toISOString(), + followers: 0, + }, + username: '', + isConnected: false, + isVerifying: false, + isLoadingRepos: false, + })) + } + className={classNames( + 'w-full px-4 py-2.5 bg-[#F5F5F5] dark:bg-[#1A1A1A] border rounded-lg', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary text-base', + 'border-[#E5E5E5] dark:border-[#1A1A1A]', + 'focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500', + 'transition-all duration-200', + )} + placeholder="ghp_xxxxxxxxxxxx" + /> +
+ +
+
+ {!authState.isConnected ? ( + + ) : ( + <> + + +
+ Connected + + + )} +
+ {authState.rateLimits && ( +
+
+ Rate limit resets at {authState.rateLimits.reset.toLocaleTimeString()} +
+ )} +
+ +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx new file mode 100644 index 000000000..3fd32ff27 --- /dev/null +++ b/app/components/@settings/tabs/connections/components/CreateBranchDialog.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { classNames } from '~/utils/classNames'; +import type { GitHubRepoInfo } from '~/components/@settings/tabs/connections/types/GitHub'; +import { GitBranch } from '@phosphor-icons/react'; + +interface GitHubBranch { + name: string; + default?: boolean; +} + +interface CreateBranchDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (branchName: string, sourceBranch: string) => void; + repository: GitHubRepoInfo; + branches?: GitHubBranch[]; +} + +export function CreateBranchDialog({ isOpen, onClose, onConfirm, repository, branches }: CreateBranchDialogProps) { + const [branchName, setBranchName] = useState(''); + const [sourceBranch, setSourceBranch] = useState(branches?.find((b) => b.default)?.name || 'main'); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onConfirm(branchName, sourceBranch); + setBranchName(''); + onClose(); + }; + + return ( + + + + + + Create New Branch + + +
+
+
+ + setBranchName(e.target.value)} + placeholder="feature/my-new-branch" + className={classNames( + 'w-full px-3 py-2 rounded-lg', + 'bg-[#F5F5F5] dark:bg-[#1A1A1A]', + 'border border-[#E5E5E5] dark:border-[#1A1A1A]', + 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/50', + )} + required + /> +
+ +
+ + +
+ +
+

Branch Overview

+
    +
  • + + Repository: {repository.name} +
  • + {branchName && ( +
  • +
    + New branch will be created as: {branchName} +
  • + )} +
  • +
    + Based on: {sourceBranch} +
  • +
+
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx b/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx new file mode 100644 index 000000000..350c60f0b --- /dev/null +++ b/app/components/@settings/tabs/connections/components/PushToGitHubDialog.tsx @@ -0,0 +1,528 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { useState, useEffect } from 'react'; +import { toast } from 'react-toastify'; +import { motion } from 'framer-motion'; +import { getLocalStorage } from '~/lib/persistence'; +import { classNames } from '~/utils/classNames'; +import type { GitHubUserResponse } from '~/types/GitHub'; +import { logStore } from '~/lib/stores/logs'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { extractRelativePath } from '~/utils/diff'; +import { formatSize } from '~/utils/formatSize'; +import type { FileMap, File } from '~/lib/stores/files'; +import { Octokit } from '@octokit/rest'; + +interface PushToGitHubDialogProps { + isOpen: boolean; + onClose: () => void; + onPush: (repoName: string, username?: string, token?: string, isPrivate?: boolean) => Promise; +} + +interface GitHubRepo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + language: string; + private: boolean; +} + +export function PushToGitHubDialog({ isOpen, onClose, onPush }: PushToGitHubDialogProps) { + const [repoName, setRepoName] = useState(''); + const [isPrivate, setIsPrivate] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [user, setUser] = useState(null); + const [recentRepos, setRecentRepos] = useState([]); + const [isFetchingRepos, setIsFetchingRepos] = useState(false); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [createdRepoUrl, setCreatedRepoUrl] = useState(''); + const [pushedFiles, setPushedFiles] = useState<{ path: string; size: number }[]>([]); + + // Load GitHub connection on mount + useEffect(() => { + if (isOpen) { + const connection = getLocalStorage('github_connection'); + + if (connection?.user && connection?.token) { + setUser(connection.user); + + // Only fetch if we have both user and token + if (connection.token.trim()) { + fetchRecentRepos(connection.token); + } + } + } + }, [isOpen]); + + const fetchRecentRepos = async (token: string) => { + if (!token) { + logStore.logError('No GitHub token available'); + toast.error('GitHub authentication required'); + + return; + } + + try { + setIsFetchingRepos(true); + + const response = await fetch( + 'https://api.github.com/user/repos?sort=updated&per_page=5&type=all&affiliation=owner,organization_member', + { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `Bearer ${token.trim()}`, + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + + if (response.status === 401) { + toast.error('GitHub token expired. Please reconnect your account.'); + + // Clear invalid token + const connection = getLocalStorage('github_connection'); + + if (connection) { + localStorage.removeItem('github_connection'); + setUser(null); + } + } else { + logStore.logError('Failed to fetch GitHub repositories', { + status: response.status, + statusText: response.statusText, + error: errorData, + }); + toast.error(`Failed to fetch repositories: ${response.statusText}`); + } + + return; + } + + const repos = (await response.json()) as GitHubRepo[]; + setRecentRepos(repos); + } catch (error) { + logStore.logError('Failed to fetch GitHub repositories', { error }); + toast.error('Failed to fetch recent repositories'); + } finally { + setIsFetchingRepos(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const connection = getLocalStorage('github_connection'); + + if (!connection?.token || !connection?.user) { + toast.error('Please connect your GitHub account in Settings > Connections first'); + return; + } + + if (!repoName.trim()) { + toast.error('Repository name is required'); + return; + } + + setIsLoading(true); + + try { + // Check if repository exists first + const octokit = new Octokit({ auth: connection.token }); + + try { + await octokit.repos.get({ + owner: connection.user.login, + repo: repoName, + }); + + // If we get here, the repo exists + const confirmOverwrite = window.confirm( + `Repository "${repoName}" already exists. Do you want to update it? This will add or modify files in the repository.`, + ); + + if (!confirmOverwrite) { + setIsLoading(false); + return; + } + } catch (error) { + // 404 means repo doesn't exist, which is what we want for new repos + if (error instanceof Error && 'status' in error && error.status !== 404) { + throw error; + } + } + + const repoUrl = await onPush(repoName, connection.user.login, connection.token, isPrivate); + setCreatedRepoUrl(repoUrl); + + // Get list of pushed files + const files = workbenchStore.files.get(); + const filesList = Object.entries(files as FileMap) + .filter(([, dirent]) => dirent?.type === 'file' && !dirent.isBinary) + .map(([path, dirent]) => ({ + path: extractRelativePath(path), + size: new TextEncoder().encode((dirent as File).content || '').length, + })); + + setPushedFiles(filesList); + setShowSuccessDialog(true); + } catch (error) { + console.error('Error pushing to GitHub:', error); + toast.error('Failed to push to GitHub. Please check your repository name and try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setRepoName(''); + setIsPrivate(false); + setShowSuccessDialog(false); + setCreatedRepoUrl(''); + onClose(); + }; + + // Success Dialog + if (showSuccessDialog) { + return ( + !open && handleClose()}> + + +
+ + +
+
+
+
+

Successfully pushed to GitHub

+
+ +
+ +
+ +
+

+ Repository URL +

+
+ + {createdRepoUrl} + + { + navigator.clipboard.writeText(createdRepoUrl); + toast.success('URL copied to clipboard'); + }} + className="p-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary dark:text-bolt-elements-textSecondary-dark dark:hover:text-bolt-elements-textPrimary-dark" + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > +
+ +
+
+ +
+

+ Pushed Files ({pushedFiles.length}) +

+
+ {pushedFiles.map((file) => ( +
+ {file.path} + + {formatSize(file.size)} + +
+ ))} +
+
+ +
+ +
+ View Repository + + { + navigator.clipboard.writeText(createdRepoUrl); + toast.success('URL copied to clipboard'); + }} + className="px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] text-gray-600 dark:text-gray-400 hover:bg-[#E5E5E5] dark:hover:bg-[#252525] text-sm inline-flex items-center gap-2" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > +
+ Copy URL + + + Close + +
+
+ + +
+ + + ); + } + + if (!user) { + return ( + !open && handleClose()}> + + +
+ + +
+ +
+ +

GitHub Connection Required

+

+ Please connect your GitHub account in Settings {'>'} Connections to push your code to GitHub. +

+ +
+ Close + +
+ + +
+ + + ); + } + + return ( + !open && handleClose()}> + + +
+ + +
+
+ +
+ +
+ + Push to GitHub + +

+ Push your code to a new or existing GitHub repository +

+
+ +
+ +
+ +
+ {user.login} +
+

{user.name || user.login}

+

@{user.login}

+
+
+ +
+
+ + setRepoName(e.target.value)} + placeholder="my-awesome-project" + className="w-full px-4 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-[#E5E5E5] dark:border-[#1A1A1A] text-gray-900 dark:text-white placeholder-gray-400" + required + /> +
+ + {recentRepos.length > 0 && ( +
+ +
+ {recentRepos.map((repo) => ( + setRepoName(repo.name)} + className="w-full p-3 text-left rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-3 dark:hover:bg-bolt-elements-background-depth-4 transition-colors group" + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + > +
+
+
+ + {repo.name} + +
+ {repo.private && ( + + Private + + )} +
+ {repo.description && ( +

+ {repo.description} +

+ )} +
+ {repo.language && ( + +
+ {repo.language} + + )} + +
+ {repo.stargazers_count.toLocaleString()} + + +
+ {repo.forks_count.toLocaleString()} + + +
+ {new Date(repo.updated_at).toLocaleDateString()} + +
+ + ))} +
+
+ )} + + {isFetchingRepos && ( +
+
+ Loading repositories... +
+ )} + +
+ setIsPrivate(e.target.checked)} + className="rounded border-[#E5E5E5] dark:border-[#1A1A1A] text-purple-500 focus:ring-purple-500 dark:bg-[#0A0A0A]" + /> + +
+ +
+ + Cancel + + + {isLoading ? ( + <> +
+ Pushing... + + ) : ( + <> +
+ Push to GitHub + + )} + +
+ +
+ + +
+ + + ); +} diff --git a/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx new file mode 100644 index 000000000..06202850e --- /dev/null +++ b/app/components/@settings/tabs/connections/components/RepositorySelectionDialog.tsx @@ -0,0 +1,693 @@ +import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub'; +import { useState, useEffect } from 'react'; +import { toast } from 'react-toastify'; +import * as Dialog from '@radix-ui/react-dialog'; +import { classNames } from '~/utils/classNames'; +import { getLocalStorage } from '~/lib/persistence'; +import { motion } from 'framer-motion'; +import { formatSize } from '~/utils/formatSize'; +import { Input } from '~/components/ui/Input'; + +interface GitHubTreeResponse { + tree: Array<{ + path: string; + type: string; + size?: number; + }>; +} + +interface RepositorySelectionDialogProps { + isOpen: boolean; + onClose: () => void; + onSelect: (url: string) => void; +} + +interface SearchFilters { + language?: string; + stars?: number; + forks?: number; +} + +interface StatsDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + stats: RepositoryStats; + isLargeRepo?: boolean; +} + +function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDialogProps) { + return ( + !open && onClose()}> + + +
+ + +
+
+

Repository Overview

+
+

Repository Statistics:

+
+
+ + Total Files: {stats.totalFiles} +
+
+ + Total Size: {formatSize(stats.totalSize)} +
+
+ + + Languages:{' '} + {Object.entries(stats.languages) + .sort(([, a], [, b]) => b - a) + .slice(0, 3) + .map(([lang, size]) => `${lang} (${formatSize(size)})`) + .join(', ')} + +
+ {stats.hasPackageJson && ( +
+ + Has package.json +
+ )} + {stats.hasDependencies && ( +
+ + Has dependencies +
+ )} +
+
+ {isLargeRepo && ( +
+ +
+ This repository is quite large ({formatSize(stats.totalSize)}). Importing it might take a while + and could impact performance. +
+
+ )} +
+
+
+ + +
+
+
+
+
+
+ ); +} + +export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) { + const [selectedRepository, setSelectedRepository] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [repositories, setRepositories] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [activeTab, setActiveTab] = useState<'my-repos' | 'search' | 'url'>('my-repos'); + const [customUrl, setCustomUrl] = useState(''); + const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]); + const [selectedBranch, setSelectedBranch] = useState(''); + const [filters, setFilters] = useState({}); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [stats, setStats] = useState(null); + const [showStatsDialog, setShowStatsDialog] = useState(false); + const [currentStats, setCurrentStats] = useState(null); + const [pendingGitUrl, setPendingGitUrl] = useState(''); + + // Fetch user's repositories when dialog opens + useEffect(() => { + if (isOpen && activeTab === 'my-repos') { + fetchUserRepos(); + } + }, [isOpen, activeTab]); + + const fetchUserRepos = async () => { + const connection = getLocalStorage('github_connection'); + + if (!connection?.token) { + toast.error('Please connect your GitHub account first'); + return; + } + + setIsLoading(true); + + try { + const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', { + headers: { + Authorization: `Bearer ${connection.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch repositories'); + } + + const data = await response.json(); + + // Add type assertion and validation + if ( + Array.isArray(data) && + data.every((item) => typeof item === 'object' && item !== null && 'full_name' in item) + ) { + setRepositories(data as GitHubRepoInfo[]); + } else { + throw new Error('Invalid repository data format'); + } + } catch (error) { + console.error('Error fetching repos:', error); + toast.error('Failed to fetch your repositories'); + } finally { + setIsLoading(false); + } + }; + + const handleSearch = async (query: string) => { + setIsLoading(true); + setSearchResults([]); + + try { + let searchQuery = query; + + if (filters.language) { + searchQuery += ` language:${filters.language}`; + } + + if (filters.stars) { + searchQuery += ` stars:>${filters.stars}`; + } + + if (filters.forks) { + searchQuery += ` forks:>${filters.forks}`; + } + + const response = await fetch( + `https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=stars&order=desc`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to search repositories'); + } + + const data = await response.json(); + + // Add type assertion and validation + if (typeof data === 'object' && data !== null && 'items' in data && Array.isArray(data.items)) { + setSearchResults(data.items as GitHubRepoInfo[]); + } else { + throw new Error('Invalid search results format'); + } + } catch (error) { + console.error('Error searching repos:', error); + toast.error('Failed to search repositories'); + } finally { + setIsLoading(false); + } + }; + + const fetchBranches = async (repo: GitHubRepoInfo) => { + setIsLoading(true); + + try { + const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, { + headers: { + Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch branches'); + } + + const data = await response.json(); + + // Add type assertion and validation + if (Array.isArray(data) && data.every((item) => typeof item === 'object' && item !== null && 'name' in item)) { + setBranches( + data.map((branch) => ({ + name: branch.name, + default: branch.name === repo.default_branch, + })), + ); + } else { + throw new Error('Invalid branch data format'); + } + } catch (error) { + console.error('Error fetching branches:', error); + toast.error('Failed to fetch branches'); + } finally { + setIsLoading(false); + } + }; + + const handleRepoSelect = async (repo: GitHubRepoInfo) => { + setSelectedRepository(repo); + await fetchBranches(repo); + }; + + const formatGitUrl = (url: string): string => { + // Remove any tree references and ensure .git extension + const baseUrl = url + .replace(/\/tree\/[^/]+/, '') // Remove /tree/branch-name + .replace(/\/$/, '') // Remove trailing slash + .replace(/\.git$/, ''); // Remove .git if present + return `${baseUrl}.git`; + }; + + const verifyRepository = async (repoUrl: string): Promise => { + try { + const [owner, repo] = repoUrl + .replace(/\.git$/, '') + .split('/') + .slice(-2); + + const connection = getLocalStorage('github_connection'); + const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {}; + + // Fetch repository tree + const treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, { + headers, + }); + + if (!treeResponse.ok) { + throw new Error('Failed to fetch repository structure'); + } + + const treeData = (await treeResponse.json()) as GitHubTreeResponse; + + // Calculate repository stats + let totalSize = 0; + let totalFiles = 0; + const languages: { [key: string]: number } = {}; + let hasPackageJson = false; + let hasDependencies = false; + + for (const file of treeData.tree) { + if (file.type === 'blob') { + totalFiles++; + + if (file.size) { + totalSize += file.size; + } + + // Check for package.json + if (file.path === 'package.json') { + hasPackageJson = true; + + // Fetch package.json content to check dependencies + const contentResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/package.json`, { + headers, + }); + + if (contentResponse.ok) { + const content = (await contentResponse.json()) as GitHubContent; + const packageJson = JSON.parse(Buffer.from(content.content, 'base64').toString()); + hasDependencies = !!( + packageJson.dependencies || + packageJson.devDependencies || + packageJson.peerDependencies + ); + } + } + + // Detect language based on file extension + const ext = file.path.split('.').pop()?.toLowerCase(); + + if (ext) { + languages[ext] = (languages[ext] || 0) + (file.size || 0); + } + } + } + + const stats: RepositoryStats = { + totalFiles, + totalSize, + languages, + hasPackageJson, + hasDependencies, + }; + + setStats(stats); + + return stats; + } catch (error) { + console.error('Error verifying repository:', error); + toast.error('Failed to verify repository'); + + return null; + } + }; + + const handleImport = async () => { + try { + let gitUrl: string; + + if (activeTab === 'url' && customUrl) { + gitUrl = formatGitUrl(customUrl); + } else if (selectedRepository) { + gitUrl = formatGitUrl(selectedRepository.html_url); + + if (selectedBranch) { + gitUrl = `${gitUrl}#${selectedBranch}`; + } + } else { + return; + } + + // Verify repository before importing + const stats = await verifyRepository(gitUrl); + + if (!stats) { + return; + } + + setCurrentStats(stats); + setPendingGitUrl(gitUrl); + setShowStatsDialog(true); + } catch (error) { + console.error('Error preparing repository:', error); + toast.error('Failed to prepare repository. Please try again.'); + } + }; + + const handleStatsConfirm = () => { + setShowStatsDialog(false); + + if (pendingGitUrl) { + onSelect(pendingGitUrl); + onClose(); + } + }; + + const handleFilterChange = (key: keyof SearchFilters, value: string) => { + let parsedValue: string | number | undefined = value; + + if (key === 'stars' || key === 'forks') { + parsedValue = value ? parseInt(value, 10) : undefined; + } + + setFilters((prev) => ({ ...prev, [key]: parsedValue })); + handleSearch(searchQuery); + }; + + // Handle dialog close properly + const handleClose = () => { + setIsLoading(false); // Reset loading state + setSearchQuery(''); // Reset search + setSearchResults([]); // Reset results + onClose(); + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + > + + + +
+ + Import GitHub Repository + + + +
+ +
+
+ setActiveTab('my-repos')}> + + My Repos + + setActiveTab('search')}> + + Search + + setActiveTab('url')}> + + URL + +
+ + {activeTab === 'url' ? ( +
+ setCustomUrl(e.target.value)} + className={classNames('w-full', { + 'border-red-500': false, + })} + /> + +
+ ) : ( + <> + {activeTab === 'search' && ( +
+
+ { + setSearchQuery(e.target.value); + handleSearch(e.target.value); + }} + className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary" + /> + +
+
+ { + setFilters({ ...filters, language: e.target.value }); + handleSearch(searchQuery); + }} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> + handleFilterChange('stars', e.target.value)} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> +
+ handleFilterChange('forks', e.target.value)} + className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]" + /> +
+ )} + +
+ {selectedRepository ? ( +
+
+ +

{selectedRepository.full_name}

+
+
+ + + +
+
+ ) : ( + + )} +
+ + )} +
+
+
+ {currentStats && ( + 50 * 1024 * 1024} + /> + )} +
+ ); +} + +function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +function RepositoryList({ + repos, + isLoading, + onSelect, + activeTab, +}: { + repos: GitHubRepoInfo[]; + isLoading: boolean; + onSelect: (repo: GitHubRepoInfo) => void; + activeTab: string; +}) { + if (isLoading) { + return ( +
+ + Loading repositories... +
+ ); + } + + if (repos.length === 0) { + return ( +
+ +

{activeTab === 'my-repos' ? 'No repositories found' : 'Search for repositories'}

+
+ ); + } + + return repos.map((repo) => onSelect(repo)} />); +} + +function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) { + return ( +
+
+
+ +

{repo.name}

+
+ +
+ {repo.description &&

{repo.description}

} +
+ {repo.language && ( + + + {repo.language} + + )} + + + {repo.stargazers_count.toLocaleString()} + + + + {new Date(repo.updated_at).toLocaleDateString()} + +
+
+ ); +} diff --git a/app/components/@settings/tabs/connections/types/GitHub.ts b/app/components/@settings/tabs/connections/types/GitHub.ts new file mode 100644 index 000000000..f2f1af6bc --- /dev/null +++ b/app/components/@settings/tabs/connections/types/GitHub.ts @@ -0,0 +1,95 @@ +export interface GitHubUserResponse { + login: string; + avatar_url: string; + html_url: string; + name: string; + bio: string; + public_repos: number; + followers: number; + following: number; + public_gists: number; + created_at: string; + updated_at: string; +} + +export interface GitHubRepoInfo { + name: string; + full_name: string; + html_url: string; + description: string; + stargazers_count: number; + forks_count: number; + default_branch: string; + updated_at: string; + language: string; + languages_url: string; +} + +export interface GitHubOrganization { + login: string; + avatar_url: string; + description: string; + html_url: string; +} + +export interface GitHubEvent { + id: string; + type: string; + created_at: string; + repo: { + name: string; + url: string; + }; + payload: { + action?: string; + ref?: string; + ref_type?: string; + description?: string; + }; +} + +export interface GitHubLanguageStats { + [key: string]: number; +} + +export interface GitHubStats { + repos: GitHubRepoInfo[]; + totalStars: number; + totalForks: number; + organizations: GitHubOrganization[]; + recentActivity: GitHubEvent[]; + languages: GitHubLanguageStats; + totalGists: number; +} + +export interface GitHubConnection { + user: GitHubUserResponse | null; + token: string; + tokenType: 'classic' | 'fine-grained'; + stats?: GitHubStats; +} + +export interface GitHubTokenInfo { + token: string; + scope: string[]; + avatar_url: string; + name: string | null; + created_at: string; + followers: number; +} + +export interface GitHubRateLimits { + limit: number; + remaining: number; + reset: Date; + used: number; +} + +export interface GitHubAuthState { + username: string; + tokenInfo: GitHubTokenInfo | null; + isConnected: boolean; + isVerifying: boolean; + isLoadingRepos: boolean; + rateLimits?: GitHubRateLimits; +} diff --git a/app/components/@settings/tabs/data/DataTab.tsx b/app/components/@settings/tabs/data/DataTab.tsx new file mode 100644 index 000000000..47e34ad4d --- /dev/null +++ b/app/components/@settings/tabs/data/DataTab.tsx @@ -0,0 +1,452 @@ +import { useState, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { toast } from 'react-toastify'; +import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog'; +import { db, getAll, deleteById } from '~/lib/persistence'; + +export default function DataTab() { + const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false); + const [isImportingKeys, setIsImportingKeys] = useState(false); + const [isResetting, setIsResetting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [showResetInlineConfirm, setShowResetInlineConfirm] = useState(false); + const [showDeleteInlineConfirm, setShowDeleteInlineConfirm] = useState(false); + const fileInputRef = useRef(null); + const apiKeyFileInputRef = useRef(null); + + const handleExportAllChats = async () => { + try { + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats from IndexedDB + const allChats = await getAll(db); + const exportData = { + chats: allChats, + exportDate: new Date().toISOString(), + }; + + // Download as JSON + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-chats-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Chats exported successfully'); + } catch (error) { + console.error('Export error:', error); + toast.error('Failed to export chats'); + } + }; + + const handleExportSettings = () => { + try { + const settings = { + userProfile: localStorage.getItem('bolt_user_profile'), + settings: localStorage.getItem('bolt_settings'), + exportDate: new Date().toISOString(), + }; + + const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-settings-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Settings exported successfully'); + } catch (error) { + console.error('Export error:', error); + toast.error('Failed to export settings'); + } + }; + + const handleImportSettings = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + try { + const content = await file.text(); + const settings = JSON.parse(content); + + if (settings.userProfile) { + localStorage.setItem('bolt_user_profile', settings.userProfile); + } + + if (settings.settings) { + localStorage.setItem('bolt_settings', settings.settings); + } + + window.location.reload(); // Reload to apply settings + toast.success('Settings imported successfully'); + } catch (error) { + console.error('Import error:', error); + toast.error('Failed to import settings'); + } + }; + + const handleImportAPIKeys = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + setIsImportingKeys(true); + + try { + const content = await file.text(); + const keys = JSON.parse(content); + + // Validate and save each key + Object.entries(keys).forEach(([key, value]) => { + if (typeof value !== 'string') { + throw new Error(`Invalid value for key: ${key}`); + } + + localStorage.setItem(`bolt_${key.toLowerCase()}`, value); + }); + + toast.success('API keys imported successfully'); + } catch (error) { + console.error('Error importing API keys:', error); + toast.error('Failed to import API keys'); + } finally { + setIsImportingKeys(false); + + if (apiKeyFileInputRef.current) { + apiKeyFileInputRef.current.value = ''; + } + } + }; + + const handleDownloadTemplate = () => { + setIsDownloadingTemplate(true); + + try { + const template = { + Anthropic_API_KEY: '', + OpenAI_API_KEY: '', + Google_API_KEY: '', + Groq_API_KEY: '', + HuggingFace_API_KEY: '', + OpenRouter_API_KEY: '', + Deepseek_API_KEY: '', + Mistral_API_KEY: '', + OpenAILike_API_KEY: '', + Together_API_KEY: '', + xAI_API_KEY: '', + Perplexity_API_KEY: '', + Cohere_API_KEY: '', + AzureOpenAI_API_KEY: '', + OPENAI_LIKE_API_BASE_URL: '', + LMSTUDIO_API_BASE_URL: '', + OLLAMA_API_BASE_URL: '', + TOGETHER_API_BASE_URL: '', + }; + + const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'bolt-api-keys-template.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Template downloaded successfully'); + } catch (error) { + console.error('Error downloading template:', error); + toast.error('Failed to download template'); + } finally { + setIsDownloadingTemplate(false); + } + }; + + const handleResetSettings = async () => { + setIsResetting(true); + + try { + // Clear all stored settings from localStorage + localStorage.removeItem('bolt_user_profile'); + localStorage.removeItem('bolt_settings'); + localStorage.removeItem('bolt_chat_history'); + + // Clear all data from IndexedDB + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats and delete them + const chats = await getAll(db as IDBDatabase); + const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id)); + await Promise.all(deletePromises); + + // Close the dialog first + setShowResetInlineConfirm(false); + + // Then reload and show success message + window.location.reload(); + toast.success('Settings reset successfully'); + } catch (error) { + console.error('Reset error:', error); + setShowResetInlineConfirm(false); + toast.error('Failed to reset settings'); + } finally { + setIsResetting(false); + } + }; + + const handleDeleteAllChats = async () => { + setIsDeleting(true); + + try { + // Clear chat history from localStorage + localStorage.removeItem('bolt_chat_history'); + + // Clear chats from IndexedDB + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats and delete them one by one + const chats = await getAll(db as IDBDatabase); + const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id)); + await Promise.all(deletePromises); + + // Close the dialog first + setShowDeleteInlineConfirm(false); + + // Then show the success message + toast.success('Chat history deleted successfully'); + } catch (error) { + console.error('Delete error:', error); + setShowDeleteInlineConfirm(false); + toast.error('Failed to delete chat history'); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+ + {/* Reset Settings Dialog */} + + +
+
+
+ Reset All Settings? +
+

+ This will reset all your settings to their default values. This action cannot be undone. +

+
+ + + + + {isResetting ? ( +
+ ) : ( +
+ )} + Reset Settings + +
+
+
+
+ + {/* Delete Confirmation Dialog */} + + +
+
+
+ Delete All Chats? +
+

+ This will permanently delete all your chat history. This action cannot be undone. +

+
+ + + + + {isDeleting ? ( +
+ ) : ( +
+ )} + Delete All + +
+
+
+
+ + {/* Chat History Section */} + +
+
+

Chat History

+
+

Export or delete all your chat history.

+
+ +
+ Export All Chats + + setShowDeleteInlineConfirm(true)} + > +
+ Delete All Chats + +
+ + + {/* Settings Backup Section */} + +
+
+

Settings Backup

+
+

+ Export your settings to a JSON file or import settings from a previously exported file. +

+
+ +
+ Export Settings + + fileInputRef.current?.click()} + > +
+ Import Settings + + setShowResetInlineConfirm(true)} + > +
+ Reset Settings + +
+ + + {/* API Keys Management Section */} + +
+
+

API Keys Management

+
+

+ Import API keys from a JSON file or download a template to fill in your keys. +

+
+ + + {isDownloadingTemplate ? ( +
+ ) : ( +
+ )} + Download Template + + apiKeyFileInputRef.current?.click()} + disabled={isImportingKeys} + > + {isImportingKeys ? ( +
+ ) : ( +
+ )} + Import API Keys + +
+ +
+ ); +} diff --git a/app/components/@settings/tabs/debug/DebugTab.tsx b/app/components/@settings/tabs/debug/DebugTab.tsx new file mode 100644 index 000000000..ae7a83398 --- /dev/null +++ b/app/components/@settings/tabs/debug/DebugTab.tsx @@ -0,0 +1,2020 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { toast } from 'react-toastify'; +import { classNames } from '~/utils/classNames'; +import { logStore, type LogEntry } from '~/lib/stores/logs'; +import { useStore } from '@nanostores/react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible'; +import { Progress } from '~/components/ui/Progress'; +import { ScrollArea } from '~/components/ui/ScrollArea'; +import { Badge } from '~/components/ui/Badge'; +import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { jsPDF } from 'jspdf'; +import { useSettings } from '~/lib/hooks/useSettings'; + +interface SystemInfo { + os: string; + arch: string; + platform: string; + cpus: string; + memory: { + total: string; + free: string; + used: string; + percentage: number; + }; + node: string; + browser: { + name: string; + version: string; + language: string; + userAgent: string; + cookiesEnabled: boolean; + online: boolean; + platform: string; + cores: number; + }; + screen: { + width: number; + height: number; + colorDepth: number; + pixelRatio: number; + }; + time: { + timezone: string; + offset: number; + locale: string; + }; + performance: { + memory: { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + usagePercentage: number; + }; + timing: { + loadTime: number; + domReadyTime: number; + readyStart: number; + redirectTime: number; + appcacheTime: number; + unloadEventTime: number; + lookupDomainTime: number; + connectTime: number; + requestTime: number; + initDomTreeTime: number; + loadEventTime: number; + }; + navigation: { + type: number; + redirectCount: number; + }; + }; + network: { + downlink: number; + effectiveType: string; + rtt: number; + saveData: boolean; + type: string; + }; + battery?: { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; + }; + storage: { + quota: number; + usage: number; + persistent: boolean; + temporary: boolean; + }; +} + +interface GitHubRepoInfo { + fullName: string; + defaultBranch: string; + stars: number; + forks: number; + openIssues?: number; +} + +interface GitInfo { + local: { + commitHash: string; + branch: string; + commitTime: string; + author: string; + email: string; + remoteUrl: string; + repoName: string; + }; + github?: { + currentRepo: GitHubRepoInfo; + upstream?: GitHubRepoInfo; + }; + isForked?: boolean; +} + +interface WebAppInfo { + name: string; + version: string; + description: string; + license: string; + environment: string; + timestamp: string; + runtimeInfo: { + nodeVersion: string; + }; + dependencies: { + production: Array<{ name: string; version: string; type: string }>; + development: Array<{ name: string; version: string; type: string }>; + peer: Array<{ name: string; version: string; type: string }>; + optional: Array<{ name: string; version: string; type: string }>; + }; + gitInfo: GitInfo; +} + +// Add Ollama service status interface +interface OllamaServiceStatus { + isRunning: boolean; + lastChecked: Date; + error?: string; + models?: Array<{ + name: string; + size: string; + quantization: string; + }>; +} + +interface ExportFormat { + id: string; + label: string; + icon: string; + handler: () => void; +} + +const DependencySection = ({ + title, + deps, +}: { + title: string; + deps: Array<{ name: string; version: string; type: string }>; +}) => { + const [isOpen, setIsOpen] = useState(false); + + if (deps.length === 0) { + return null; + } + + return ( + + +
+
+ + {title} Dependencies ({deps.length}) + +
+
+ {isOpen ? 'Hide' : 'Show'} +
+
+ + + +
+ {deps.map((dep) => ( +
+ {dep.name} + {dep.version} +
+ ))} +
+
+
+ + ); +}; + +export default function DebugTab() { + const [systemInfo, setSystemInfo] = useState(null); + const [webAppInfo, setWebAppInfo] = useState(null); + const [ollamaStatus, setOllamaStatus] = useState({ + isRunning: false, + lastChecked: new Date(), + }); + const [loading, setLoading] = useState({ + systemInfo: false, + webAppInfo: false, + errors: false, + performance: false, + }); + const [openSections, setOpenSections] = useState({ + system: false, + webapp: false, + errors: false, + performance: false, + }); + + const { isLocalModel, providers } = useSettings(); + + // Subscribe to logStore updates + const logs = useStore(logStore.logs); + const errorLogs = useMemo(() => { + return Object.values(logs).filter( + (log): log is LogEntry => typeof log === 'object' && log !== null && 'level' in log && log.level === 'error', + ); + }, [logs]); + + // Set up error listeners when component mounts + useEffect(() => { + const handleError = (event: ErrorEvent) => { + logStore.logError(event.message, event.error, { + filename: event.filename, + lineNumber: event.lineno, + columnNumber: event.colno, + }); + }; + + const handleRejection = (event: PromiseRejectionEvent) => { + logStore.logError('Unhandled Promise Rejection', event.reason); + }; + + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleRejection); + + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleRejection); + }; + }, []); + + // Check for errors when the errors section is opened + useEffect(() => { + if (openSections.errors) { + checkErrors(); + } + }, [openSections.errors]); + + // Load initial data when component mounts + useEffect(() => { + const loadInitialData = async () => { + await Promise.all([getSystemInfo(), getWebAppInfo()]); + }; + + loadInitialData(); + }, []); + + // Refresh data when sections are opened + useEffect(() => { + if (openSections.system) { + getSystemInfo(); + } + + if (openSections.webapp) { + getWebAppInfo(); + } + }, [openSections.system, openSections.webapp]); + + // Add periodic refresh of git info + useEffect(() => { + if (!openSections.webapp) { + return undefined; + } + + // Initial fetch + const fetchGitInfo = async () => { + try { + const response = await fetch('/api/system/git-info'); + const updatedGitInfo = (await response.json()) as GitInfo; + + setWebAppInfo((prev) => { + if (!prev) { + return null; + } + + // Only update if the data has changed + if (JSON.stringify(prev.gitInfo) === JSON.stringify(updatedGitInfo)) { + return prev; + } + + return { + ...prev, + gitInfo: updatedGitInfo, + }; + }); + } catch (error) { + console.error('Failed to fetch git info:', error); + } + }; + + fetchGitInfo(); + + // Refresh every 5 minutes instead of every second + const interval = setInterval(fetchGitInfo, 5 * 60 * 1000); + + return () => clearInterval(interval); + }, [openSections.webapp]); + + const getSystemInfo = async () => { + try { + setLoading((prev) => ({ ...prev, systemInfo: true })); + + // Get browser info + const ua = navigator.userAgent; + const browserName = ua.includes('Firefox') + ? 'Firefox' + : ua.includes('Chrome') + ? 'Chrome' + : ua.includes('Safari') + ? 'Safari' + : ua.includes('Edge') + ? 'Edge' + : 'Unknown'; + const browserVersion = ua.match(/(Firefox|Chrome|Safari|Edge)\/([0-9.]+)/)?.[2] || 'Unknown'; + + // Get performance metrics + const memory = (performance as any).memory || {}; + const timing = performance.timing; + const navigation = performance.navigation; + const connection = (navigator as any).connection; + + // Get battery info + let batteryInfo; + + try { + const battery = await (navigator as any).getBattery(); + batteryInfo = { + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + level: battery.level * 100, + }; + } catch { + console.log('Battery API not supported'); + } + + // Get storage info + let storageInfo = { + quota: 0, + usage: 0, + persistent: false, + temporary: false, + }; + + try { + const storage = await navigator.storage.estimate(); + const persistent = await navigator.storage.persist(); + storageInfo = { + quota: storage.quota || 0, + usage: storage.usage || 0, + persistent, + temporary: !persistent, + }; + } catch { + console.log('Storage API not supported'); + } + + // Get memory info from browser performance API + const performanceMemory = (performance as any).memory || {}; + const totalMemory = performanceMemory.jsHeapSizeLimit || 0; + const usedMemory = performanceMemory.usedJSHeapSize || 0; + const freeMemory = totalMemory - usedMemory; + const memoryPercentage = totalMemory ? (usedMemory / totalMemory) * 100 : 0; + + const systemInfo: SystemInfo = { + os: navigator.platform, + arch: navigator.userAgent.includes('x64') ? 'x64' : navigator.userAgent.includes('arm') ? 'arm' : 'unknown', + platform: navigator.platform, + cpus: navigator.hardwareConcurrency + ' cores', + memory: { + total: formatBytes(totalMemory), + free: formatBytes(freeMemory), + used: formatBytes(usedMemory), + percentage: Math.round(memoryPercentage), + }, + node: 'browser', + browser: { + name: browserName, + version: browserVersion, + language: navigator.language, + userAgent: navigator.userAgent, + cookiesEnabled: navigator.cookieEnabled, + online: navigator.onLine, + platform: navigator.platform, + cores: navigator.hardwareConcurrency, + }, + screen: { + width: window.screen.width, + height: window.screen.height, + colorDepth: window.screen.colorDepth, + pixelRatio: window.devicePixelRatio, + }, + time: { + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + offset: new Date().getTimezoneOffset(), + locale: navigator.language, + }, + performance: { + memory: { + jsHeapSizeLimit: memory.jsHeapSizeLimit || 0, + totalJSHeapSize: memory.totalJSHeapSize || 0, + usedJSHeapSize: memory.usedJSHeapSize || 0, + usagePercentage: memory.totalJSHeapSize ? (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100 : 0, + }, + timing: { + loadTime: timing.loadEventEnd - timing.navigationStart, + domReadyTime: timing.domContentLoadedEventEnd - timing.navigationStart, + readyStart: timing.fetchStart - timing.navigationStart, + redirectTime: timing.redirectEnd - timing.redirectStart, + appcacheTime: timing.domainLookupStart - timing.fetchStart, + unloadEventTime: timing.unloadEventEnd - timing.unloadEventStart, + lookupDomainTime: timing.domainLookupEnd - timing.domainLookupStart, + connectTime: timing.connectEnd - timing.connectStart, + requestTime: timing.responseEnd - timing.requestStart, + initDomTreeTime: timing.domInteractive - timing.responseEnd, + loadEventTime: timing.loadEventEnd - timing.loadEventStart, + }, + navigation: { + type: navigation.type, + redirectCount: navigation.redirectCount, + }, + }, + network: { + downlink: connection?.downlink || 0, + effectiveType: connection?.effectiveType || 'unknown', + rtt: connection?.rtt || 0, + saveData: connection?.saveData || false, + type: connection?.type || 'unknown', + }, + battery: batteryInfo, + storage: storageInfo, + }; + + setSystemInfo(systemInfo); + toast.success('System information updated'); + } catch (error) { + toast.error('Failed to get system information'); + console.error('Failed to get system information:', error); + } finally { + setLoading((prev) => ({ ...prev, systemInfo: false })); + } + }; + + const getWebAppInfo = async () => { + try { + setLoading((prev) => ({ ...prev, webAppInfo: true })); + + const [appResponse, gitResponse] = await Promise.all([ + fetch('/api/system/app-info'), + fetch('/api/system/git-info'), + ]); + + if (!appResponse.ok || !gitResponse.ok) { + throw new Error('Failed to fetch webapp info'); + } + + const appData = (await appResponse.json()) as Omit; + const gitData = (await gitResponse.json()) as GitInfo; + + console.log('Git Info Response:', gitData); // Add logging to debug + + setWebAppInfo({ + ...appData, + gitInfo: gitData, + }); + + toast.success('WebApp information updated'); + + return true; + } catch (error) { + console.error('Failed to fetch webapp info:', error); + toast.error('Failed to fetch webapp information'); + setWebAppInfo(null); + + return false; + } finally { + setLoading((prev) => ({ ...prev, webAppInfo: false })); + } + }; + + // Helper function to format bytes to human readable format + const formatBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${Math.round(size)} ${units[unitIndex]}`; + }; + + const handleLogPerformance = () => { + try { + setLoading((prev) => ({ ...prev, performance: true })); + + // Get performance metrics using modern Performance API + const performanceEntries = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + const memory = (performance as any).memory; + + // Calculate timing metrics + const timingMetrics = { + loadTime: performanceEntries.loadEventEnd - performanceEntries.startTime, + domReadyTime: performanceEntries.domContentLoadedEventEnd - performanceEntries.startTime, + fetchTime: performanceEntries.responseEnd - performanceEntries.fetchStart, + redirectTime: performanceEntries.redirectEnd - performanceEntries.redirectStart, + dnsTime: performanceEntries.domainLookupEnd - performanceEntries.domainLookupStart, + tcpTime: performanceEntries.connectEnd - performanceEntries.connectStart, + ttfb: performanceEntries.responseStart - performanceEntries.requestStart, + processingTime: performanceEntries.loadEventEnd - performanceEntries.responseEnd, + }; + + // Get resource timing data + const resourceEntries = performance.getEntriesByType('resource'); + const resourceStats = { + totalResources: resourceEntries.length, + totalSize: resourceEntries.reduce((total, entry) => total + ((entry as any).transferSize || 0), 0), + totalTime: Math.max(...resourceEntries.map((entry) => entry.duration)), + }; + + // Get memory metrics + const memoryMetrics = memory + ? { + jsHeapSizeLimit: memory.jsHeapSizeLimit, + totalJSHeapSize: memory.totalJSHeapSize, + usedJSHeapSize: memory.usedJSHeapSize, + heapUtilization: (memory.usedJSHeapSize / memory.totalJSHeapSize) * 100, + } + : null; + + // Get frame rate metrics + let fps = 0; + + if ('requestAnimationFrame' in window) { + const times: number[] = []; + + function calculateFPS(now: number) { + times.push(now); + + if (times.length > 10) { + const fps = Math.round((1000 * 10) / (now - times[0])); + times.shift(); + + return fps; + } + + requestAnimationFrame(calculateFPS); + + return 0; + } + + fps = calculateFPS(performance.now()); + } + + // Log all performance metrics + logStore.logSystem('Performance Metrics', { + timing: timingMetrics, + resources: resourceStats, + memory: memoryMetrics, + fps, + timestamp: new Date().toISOString(), + navigationEntry: { + type: performanceEntries.type, + redirectCount: performanceEntries.redirectCount, + }, + }); + + toast.success('Performance metrics logged'); + } catch (error) { + toast.error('Failed to log performance metrics'); + console.error('Failed to log performance metrics:', error); + } finally { + setLoading((prev) => ({ ...prev, performance: false })); + } + }; + + const checkErrors = async () => { + try { + setLoading((prev) => ({ ...prev, errors: true })); + + // Get errors from log store + const storedErrors = errorLogs; + + if (storedErrors.length === 0) { + toast.success('No errors found'); + } else { + toast.warning(`Found ${storedErrors.length} error(s)`); + } + } catch (error) { + toast.error('Failed to check errors'); + console.error('Failed to check errors:', error); + } finally { + setLoading((prev) => ({ ...prev, errors: false })); + } + }; + + const exportDebugInfo = () => { + try { + const debugData = { + timestamp: new Date().toISOString(), + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + const blob = new Blob([JSON.stringify(debugData, null, 2)], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-debug-info-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Debug information exported successfully'); + } catch (error) { + console.error('Failed to export debug info:', error); + toast.error('Failed to export debug information'); + } + }; + + const exportAsCSV = () => { + try { + const debugData = { + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + // Convert the data to CSV format + const csvData = [ + ['Category', 'Key', 'Value'], + ...Object.entries(debugData).flatMap(([category, data]) => + Object.entries(data || {}).map(([key, value]) => [ + category, + key, + typeof value === 'object' ? JSON.stringify(value) : String(value), + ]), + ), + ]; + + // Create CSV content + const csvContent = csvData.map((row) => row.join(',')).join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-debug-info-${new Date().toISOString()}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Debug information exported as CSV'); + } catch (error) { + console.error('Failed to export CSV:', error); + toast.error('Failed to export debug information as CSV'); + } + }; + + const exportAsPDF = () => { + try { + const debugData = { + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + // Create new PDF document + const doc = new jsPDF(); + const lineHeight = 7; + let yPos = 20; + const margin = 20; + const pageWidth = doc.internal.pageSize.getWidth(); + const maxLineWidth = pageWidth - 2 * margin; + + // Add key-value pair with better formatting + const addKeyValue = (key: string, value: any, indent = 0) => { + // Check if we need a new page + if (yPos > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + doc.setFontSize(10); + doc.setTextColor('#374151'); + doc.setFont('helvetica', 'bold'); + + // Format the key with proper spacing + const formattedKey = key.replace(/([A-Z])/g, ' $1').trim(); + doc.text(formattedKey + ':', margin + indent, yPos); + doc.setFont('helvetica', 'normal'); + doc.setTextColor('#6B7280'); + + let valueText; + + if (typeof value === 'object' && value !== null) { + // Skip rendering if value is empty object + if (Object.keys(value).length === 0) { + return; + } + + yPos += lineHeight; + Object.entries(value).forEach(([subKey, subValue]) => { + // Check for page break before each sub-item + if (yPos > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + const formattedSubKey = subKey.replace(/([A-Z])/g, ' $1').trim(); + addKeyValue(formattedSubKey, subValue, indent + 10); + }); + + return; + } else { + valueText = String(value); + } + + const valueX = margin + indent + doc.getTextWidth(formattedKey + ': '); + const maxValueWidth = maxLineWidth - indent - doc.getTextWidth(formattedKey + ': '); + const lines = doc.splitTextToSize(valueText, maxValueWidth); + + // Check if we need a new page for the value + if (yPos + lines.length * lineHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + doc.text(lines, valueX, yPos); + yPos += lines.length * lineHeight; + }; + + // Add section header with page break check + const addSectionHeader = (title: string) => { + // Check if we need a new page + if (yPos + 20 > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + yPos += lineHeight; + doc.setFillColor('#F3F4F6'); + doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setTextColor('#111827'); + doc.setFontSize(12); + doc.text(title.toUpperCase(), margin, yPos); + doc.setFont('helvetica', 'normal'); + yPos += lineHeight * 1.5; + }; + + // Add horizontal line with page break check + const addHorizontalLine = () => { + // Check if we need a new page + if (yPos + 10 > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + + return; // Skip drawing line if we just started a new page + } + + doc.setDrawColor('#E5E5E5'); + doc.line(margin, yPos, pageWidth - margin, yPos); + yPos += lineHeight; + }; + + // Helper function to add footer to all pages + const addFooters = () => { + const totalPages = doc.internal.pages.length - 1; + + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor('#9CA3AF'); + doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, { + align: 'center', + }); + } + }; + + // Title and Header (first page only) + doc.setFillColor('#6366F1'); + doc.rect(0, 0, pageWidth, 40, 'F'); + doc.setTextColor('#FFFFFF'); + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('Debug Information Report', margin, 25); + yPos = 50; + + // Timestamp and metadata + doc.setTextColor('#6B7280'); + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + + const timestamp = new Date().toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + doc.text(`Generated: ${timestamp}`, margin, yPos); + yPos += lineHeight * 2; + + // System Information Section + if (debugData.system) { + addSectionHeader('System Information'); + + // OS and Architecture + addKeyValue('Operating System', debugData.system.os); + addKeyValue('Architecture', debugData.system.arch); + addKeyValue('Platform', debugData.system.platform); + addKeyValue('CPU Cores', debugData.system.cpus); + + // Memory + const memory = debugData.system.memory; + addKeyValue('Memory', { + 'Total Memory': memory.total, + 'Used Memory': memory.used, + 'Free Memory': memory.free, + Usage: memory.percentage + '%', + }); + + // Browser Information + const browser = debugData.system.browser; + addKeyValue('Browser', { + Name: browser.name, + Version: browser.version, + Language: browser.language, + Platform: browser.platform, + 'Cookies Enabled': browser.cookiesEnabled ? 'Yes' : 'No', + 'Online Status': browser.online ? 'Online' : 'Offline', + }); + + // Screen Information + const screen = debugData.system.screen; + addKeyValue('Screen', { + Resolution: `${screen.width}x${screen.height}`, + 'Color Depth': screen.colorDepth + ' bit', + 'Pixel Ratio': screen.pixelRatio + 'x', + }); + + // Time Information + const time = debugData.system.time; + addKeyValue('Time Settings', { + Timezone: time.timezone, + 'UTC Offset': time.offset / 60 + ' hours', + Locale: time.locale, + }); + + addHorizontalLine(); + } + + // Web App Information Section + if (debugData.webApp) { + addSectionHeader('Web App Information'); + + // Basic Info + addKeyValue('Application', { + Name: debugData.webApp.name, + Version: debugData.webApp.version, + Environment: debugData.webApp.environment, + 'Node Version': debugData.webApp.runtimeInfo.nodeVersion, + }); + + // Git Information + if (debugData.webApp.gitInfo) { + const gitInfo = debugData.webApp.gitInfo.local; + addKeyValue('Git Information', { + Branch: gitInfo.branch, + Commit: gitInfo.commitHash, + Author: gitInfo.author, + 'Commit Time': gitInfo.commitTime, + Repository: gitInfo.repoName, + }); + + if (debugData.webApp.gitInfo.github) { + const githubInfo = debugData.webApp.gitInfo.github.currentRepo; + addKeyValue('GitHub Information', { + Repository: githubInfo.fullName, + 'Default Branch': githubInfo.defaultBranch, + Stars: githubInfo.stars, + Forks: githubInfo.forks, + 'Open Issues': githubInfo.openIssues || 0, + }); + } + } + + addHorizontalLine(); + } + + // Performance Section + if (debugData.performance) { + addSectionHeader('Performance Metrics'); + + // Memory Usage + const memory = debugData.performance.memory || {}; + const totalHeap = memory.totalJSHeapSize || 0; + const usedHeap = memory.usedJSHeapSize || 0; + const usagePercentage = memory.usagePercentage || 0; + + addKeyValue('Memory Usage', { + 'Total Heap Size': formatBytes(totalHeap), + 'Used Heap Size': formatBytes(usedHeap), + Usage: usagePercentage.toFixed(1) + '%', + }); + + // Timing Metrics + const timing = debugData.performance.timing || {}; + const navigationStart = timing.navigationStart || 0; + const loadEventEnd = timing.loadEventEnd || 0; + const domContentLoadedEventEnd = timing.domContentLoadedEventEnd || 0; + const responseEnd = timing.responseEnd || 0; + const requestStart = timing.requestStart || 0; + + const loadTime = loadEventEnd > navigationStart ? loadEventEnd - navigationStart : 0; + const domReadyTime = + domContentLoadedEventEnd > navigationStart ? domContentLoadedEventEnd - navigationStart : 0; + const requestTime = responseEnd > requestStart ? responseEnd - requestStart : 0; + + addKeyValue('Page Load Metrics', { + 'Total Load Time': (loadTime / 1000).toFixed(2) + ' seconds', + 'DOM Ready Time': (domReadyTime / 1000).toFixed(2) + ' seconds', + 'Request Time': (requestTime / 1000).toFixed(2) + ' seconds', + }); + + // Network Information + if (debugData.system?.network) { + const network = debugData.system.network; + addKeyValue('Network Information', { + 'Connection Type': network.type || 'Unknown', + 'Effective Type': network.effectiveType || 'Unknown', + 'Download Speed': (network.downlink || 0) + ' Mbps', + 'Latency (RTT)': (network.rtt || 0) + ' ms', + 'Data Saver': network.saveData ? 'Enabled' : 'Disabled', + }); + } + + addHorizontalLine(); + } + + // Errors Section + if (debugData.errors && debugData.errors.length > 0) { + addSectionHeader('Error Log'); + + debugData.errors.forEach((error: LogEntry, index: number) => { + doc.setTextColor('#DC2626'); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.text(`Error ${index + 1}:`, margin, yPos); + yPos += lineHeight; + + doc.setFont('helvetica', 'normal'); + doc.setTextColor('#6B7280'); + addKeyValue('Message', error.message, 10); + + if (error.stack) { + addKeyValue('Stack', error.stack, 10); + } + + if (error.source) { + addKeyValue('Source', error.source, 10); + } + + yPos += lineHeight; + }); + } + + // Add footers to all pages at the end + addFooters(); + + // Save the PDF + doc.save(`bolt-debug-info-${new Date().toISOString()}.pdf`); + toast.success('Debug information exported as PDF'); + } catch (error) { + console.error('Failed to export PDF:', error); + toast.error('Failed to export debug information as PDF'); + } + }; + + const exportAsText = () => { + try { + const debugData = { + system: systemInfo, + webApp: webAppInfo, + errors: logStore.getLogs().filter((log: LogEntry) => log.level === 'error'), + performance: { + memory: (performance as any).memory || {}, + timing: performance.timing, + navigation: performance.navigation, + }, + }; + + const textContent = Object.entries(debugData) + .map(([category, data]) => { + return `${category.toUpperCase()}\n${'-'.repeat(30)}\n${JSON.stringify(data, null, 2)}\n\n`; + }) + .join('\n'); + + const blob = new Blob([textContent], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-debug-info-${new Date().toISOString()}.txt`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Debug information exported as text file'); + } catch (error) { + console.error('Failed to export text file:', error); + toast.error('Failed to export debug information as text file'); + } + }; + + const exportFormats: ExportFormat[] = [ + { + id: 'json', + label: 'Export as JSON', + icon: 'i-ph:file-json', + handler: exportDebugInfo, + }, + { + id: 'csv', + label: 'Export as CSV', + icon: 'i-ph:file-csv', + handler: exportAsCSV, + }, + { + id: 'pdf', + label: 'Export as PDF', + icon: 'i-ph:file-pdf', + handler: exportAsPDF, + }, + { + id: 'txt', + label: 'Export as Text', + icon: 'i-ph:file-text', + handler: exportAsText, + }, + ]; + + // Add Ollama health check function + const checkOllamaStatus = useCallback(async () => { + try { + // First check if service is running + const versionResponse = await fetch('http://127.0.0.1:11434/api/version'); + + if (!versionResponse.ok) { + throw new Error('Service not running'); + } + + // Then fetch installed models + const modelsResponse = await fetch('http://127.0.0.1:11434/api/tags'); + + const modelsData = (await modelsResponse.json()) as { + models: Array<{ name: string; size: string; quantization: string }>; + }; + + setOllamaStatus({ + isRunning: true, + lastChecked: new Date(), + models: modelsData.models, + }); + } catch { + setOllamaStatus({ + isRunning: false, + error: 'Connection failed', + lastChecked: new Date(), + models: undefined, + }); + } + }, []); + + // Monitor isLocalModel changes and check status periodically + useEffect(() => { + // Check immediately when isLocalModel changes + checkOllamaStatus(); + + // Set up periodic checks every 10 seconds + const intervalId = setInterval(checkOllamaStatus, 10000); + + return () => clearInterval(intervalId); + }, [isLocalModel, checkOllamaStatus]); + + // Replace the existing export button with this new component + const ExportButton = () => { + const [isOpen, setIsOpen] = useState(false); + + const handleOpenChange = useCallback((open: boolean) => { + setIsOpen(open); + }, []); + + const handleFormatClick = useCallback((handler: () => void) => { + handler(); + setIsOpen(false); + }, []); + + return ( + + + + +
+ +
+ Export Debug Information + + +
+ {exportFormats.map((format) => ( + + ))} +
+
+
+
+ ); + }; + + // Add helper function to get Ollama status text and color + const getOllamaStatus = () => { + const ollamaProvider = providers?.Ollama; + const isOllamaEnabled = ollamaProvider?.settings?.enabled; + + if (!isLocalModel) { + return { + status: 'Disabled', + color: 'text-red-500', + bgColor: 'bg-red-500', + message: 'Local models are disabled in settings', + }; + } + + if (!isOllamaEnabled) { + return { + status: 'Disabled', + color: 'text-red-500', + bgColor: 'bg-red-500', + message: 'Ollama provider is disabled in settings', + }; + } + + if (!ollamaStatus.isRunning) { + return { + status: 'Not Running', + color: 'text-red-500', + bgColor: 'bg-red-500', + message: ollamaStatus.error || 'Ollama service is not running', + }; + } + + const modelCount = ollamaStatus.models?.length ?? 0; + + return { + status: 'Running', + color: 'text-green-500', + bgColor: 'bg-green-500', + message: `Ollama service is running with ${modelCount} installed models (Provider: Enabled)`, + }; + }; + + // Add type for status result + type StatusResult = { + status: string; + color: string; + bgColor: string; + message: string; + }; + + const status = getOllamaStatus() as StatusResult; + + return ( +
+ {/* Quick Stats Banner */} +
+ {/* Ollama Service Status Card */} +
+
+
+
Ollama Service
+
+
+
+ + {status.status === 'Running' &&
} + {status.status === 'Not Running' &&
} + {status.status === 'Disabled' &&
} + {status.status} + +
+
+
+ {status.message} +
+ {ollamaStatus.models && ollamaStatus.models.length > 0 && ( +
+
+
+ Installed Models +
+ {ollamaStatus.models.map((model) => ( +
+
+ {model.name} + + ({Math.round(parseInt(model.size) / 1024 / 1024)}MB, {model.quantization}) + +
+ ))} +
+ )} +
+
+ Last checked: {ollamaStatus.lastChecked.toLocaleTimeString()} +
+
+ + {/* Memory Usage Card */} +
+
+
+
Memory Usage
+
+
+ 80 + ? 'text-red-500' + : (systemInfo?.memory?.percentage ?? 0) > 60 + ? 'text-yellow-500' + : 'text-green-500', + )} + > + {systemInfo?.memory?.percentage ?? 0}% + +
+ 80 + ? '[&>div]:bg-red-500' + : (systemInfo?.memory?.percentage ?? 0) > 60 + ? '[&>div]:bg-yellow-500' + : '[&>div]:bg-green-500', + )} + /> +
+
+ Used: {systemInfo?.memory.used ?? '0 GB'} / {systemInfo?.memory.total ?? '0 GB'} +
+
+ + {/* Page Load Time Card */} +
+
+
+
Page Load Time
+
+
+ 2000 + ? 'text-red-500' + : (systemInfo?.performance.timing.loadTime ?? 0) > 1000 + ? 'text-yellow-500' + : 'text-green-500', + )} + > + {systemInfo ? (systemInfo.performance.timing.loadTime / 1000).toFixed(2) : '-'}s + +
+
+
+ DOM Ready: {systemInfo ? (systemInfo.performance.timing.domReadyTime / 1000).toFixed(2) : '-'}s +
+
+ + {/* Network Speed Card */} +
+
+
+
Network Speed
+
+
+ + {systemInfo?.network.downlink ?? '-'} Mbps + +
+
+
+ RTT: {systemInfo?.network.rtt ?? '-'} ms +
+
+ + {/* Errors Card */} +
+
+
+
Errors
+
+
+ 0 ? 'text-red-500' : 'text-green-500')} + > + {errorLogs.length} + +
+
+
0 ? 'i-ph:warning text-red-500' : 'i-ph:check-circle text-green-500', + )} + /> + {errorLogs.length > 0 ? 'Errors detected' : 'No errors detected'} +
+
+
+ + {/* Action Buttons */} +
+ + + + + + + + + +
+ + {/* System Information */} + setOpenSections((prev) => ({ ...prev, system: open }))} + className="w-full" + > + +
+
+
+

System Information

+
+
+
+ + + +
+ {systemInfo ? ( +
+
+
+
+ OS: + {systemInfo.os} +
+
+
+ Platform: + {systemInfo.platform} +
+
+
+ Architecture: + {systemInfo.arch} +
+
+
+ CPU Cores: + {systemInfo.cpus} +
+
+
+ Node Version: + {systemInfo.node} +
+
+
+ Network Type: + + {systemInfo.network.type} ({systemInfo.network.effectiveType}) + +
+
+
+ Network Speed: + + {systemInfo.network.downlink}Mbps (RTT: {systemInfo.network.rtt}ms) + +
+ {systemInfo.battery && ( +
+
+ Battery: + + {systemInfo.battery.level.toFixed(1)}% {systemInfo.battery.charging ? '(Charging)' : ''} + +
+ )} +
+
+ Storage: + + {(systemInfo.storage.usage / (1024 * 1024 * 1024)).toFixed(2)}GB /{' '} + {(systemInfo.storage.quota / (1024 * 1024 * 1024)).toFixed(2)}GB + +
+
+
+
+
+ Memory Usage: + + {systemInfo.memory.used} / {systemInfo.memory.total} ({systemInfo.memory.percentage}%) + +
+
+
+ Browser: + + {systemInfo.browser.name} {systemInfo.browser.version} + +
+
+
+ Screen: + + {systemInfo.screen.width}x{systemInfo.screen.height} ({systemInfo.screen.pixelRatio}x) + +
+
+
+ Timezone: + {systemInfo.time.timezone} +
+
+
+ Language: + {systemInfo.browser.language} +
+
+
+ JS Heap: + + {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '} + {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB ( + {systemInfo.performance.memory.usagePercentage.toFixed(1)}%) + +
+
+
+ Page Load: + + {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s + +
+
+
+ DOM Ready: + + {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s + +
+
+
+ ) : ( +
Loading system information...
+ )} +
+ + + + {/* Performance Metrics */} + setOpenSections((prev) => ({ ...prev, performance: open }))} + className="w-full" + > + +
+
+
+

Performance Metrics

+
+
+
+ + + +
+ {systemInfo && ( +
+
+
+ Page Load Time: + + {(systemInfo.performance.timing.loadTime / 1000).toFixed(2)}s + +
+
+ DOM Ready Time: + + {(systemInfo.performance.timing.domReadyTime / 1000).toFixed(2)}s + +
+
+ Request Time: + + {(systemInfo.performance.timing.requestTime / 1000).toFixed(2)}s + +
+
+ Redirect Time: + + {(systemInfo.performance.timing.redirectTime / 1000).toFixed(2)}s + +
+
+
+
+ JS Heap Usage: + + {(systemInfo.performance.memory.usedJSHeapSize / (1024 * 1024)).toFixed(1)}MB /{' '} + {(systemInfo.performance.memory.totalJSHeapSize / (1024 * 1024)).toFixed(1)}MB + +
+
+ Heap Utilization: + + {systemInfo.performance.memory.usagePercentage.toFixed(1)}% + +
+
+ Navigation Type: + + {systemInfo.performance.navigation.type === 0 + ? 'Navigate' + : systemInfo.performance.navigation.type === 1 + ? 'Reload' + : systemInfo.performance.navigation.type === 2 + ? 'Back/Forward' + : 'Other'} + +
+
+ Redirects: + + {systemInfo.performance.navigation.redirectCount} + +
+
+
+ )} +
+
+ + + {/* WebApp Information */} + setOpenSections((prev) => ({ ...prev, webapp: open }))} + className="w-full" + > + +
+
+
+

WebApp Information

+ {loading.webAppInfo && } +
+
+
+ + + +
+ {loading.webAppInfo ? ( +
+ +
+ ) : !webAppInfo ? ( +
+
+

Failed to load WebApp information

+ +
+ ) : ( +
+
+

Basic Information

+
+
+
+ Name: + {webAppInfo.name} +
+
+
+ Version: + {webAppInfo.version} +
+
+
+ License: + {webAppInfo.license} +
+
+
+ Environment: + {webAppInfo.environment} +
+
+
+ Node Version: + {webAppInfo.runtimeInfo.nodeVersion} +
+
+
+ +
+

Git Information

+
+
+
+ Branch: + {webAppInfo.gitInfo.local.branch} +
+
+
+ Commit: + {webAppInfo.gitInfo.local.commitHash} +
+
+
+ Author: + {webAppInfo.gitInfo.local.author} +
+
+
+ Commit Time: + {webAppInfo.gitInfo.local.commitTime} +
+ + {webAppInfo.gitInfo.github && ( + <> +
+
+
+ Repository: + + {webAppInfo.gitInfo.github.currentRepo.fullName} + {webAppInfo.gitInfo.isForked && ' (fork)'} + +
+ +
+
+
+ + {webAppInfo.gitInfo.github.currentRepo.stars} + +
+
+
+ + {webAppInfo.gitInfo.github.currentRepo.forks} + +
+
+
+ + {webAppInfo.gitInfo.github.currentRepo.openIssues} + +
+
+
+ + {webAppInfo.gitInfo.github.upstream && ( +
+
+
+ Upstream: + + {webAppInfo.gitInfo.github.upstream.fullName} + +
+ +
+
+
+ + {webAppInfo.gitInfo.github.upstream.stars} + +
+
+
+ + {webAppInfo.gitInfo.github.upstream.forks} + +
+
+
+ )} + + )} +
+
+
+ )} + + {webAppInfo && ( +
+

Dependencies

+
+ + + + +
+
+ )} +
+ + + + {/* Error Check */} + setOpenSections((prev) => ({ ...prev, errors: open }))} + className="w-full" + > + +
+
+
+

Error Check

+ {errorLogs.length > 0 && ( + + {errorLogs.length} Errors + + )} +
+
+
+ + + +
+ +
+
+ Checks for: +
    +
  • Unhandled JavaScript errors
  • +
  • Unhandled Promise rejections
  • +
  • Runtime exceptions
  • +
  • Network errors
  • +
+
+
+ Status: + + {loading.errors + ? 'Checking...' + : errorLogs.length > 0 + ? `${errorLogs.length} errors found` + : 'No errors found'} + +
+ {errorLogs.length > 0 && ( +
+
Recent Errors:
+
+ {errorLogs.map((error) => ( +
+
{error.message}
+ {error.source && ( +
+ Source: {error.source} + {error.details?.lineNumber && `:${error.details.lineNumber}`} +
+ )} + {error.stack && ( +
{error.stack}
+ )} +
+ ))} +
+
+ )} +
+
+
+
+ +
+ ); +} diff --git a/app/components/@settings/tabs/event-logs/EventLogsTab.tsx b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx new file mode 100644 index 000000000..8d28c26eb --- /dev/null +++ b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx @@ -0,0 +1,1013 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { Switch } from '~/components/ui/Switch'; +import { logStore, type LogEntry } from '~/lib/stores/logs'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { jsPDF } from 'jspdf'; +import { toast } from 'react-toastify'; + +interface SelectOption { + value: string; + label: string; + icon?: string; + color?: string; +} + +const logLevelOptions: SelectOption[] = [ + { + value: 'all', + label: 'All Types', + icon: 'i-ph:funnel', + color: '#9333ea', + }, + { + value: 'provider', + label: 'LLM', + icon: 'i-ph:robot', + color: '#10b981', + }, + { + value: 'api', + label: 'API', + icon: 'i-ph:cloud', + color: '#3b82f6', + }, + { + value: 'error', + label: 'Errors', + icon: 'i-ph:warning-circle', + color: '#ef4444', + }, + { + value: 'warning', + label: 'Warnings', + icon: 'i-ph:warning', + color: '#f59e0b', + }, + { + value: 'info', + label: 'Info', + icon: 'i-ph:info', + color: '#3b82f6', + }, + { + value: 'debug', + label: 'Debug', + icon: 'i-ph:bug', + color: '#6b7280', + }, +]; + +interface LogEntryItemProps { + log: LogEntry; + isExpanded: boolean; + use24Hour: boolean; + showTimestamp: boolean; +} + +const LogEntryItem = ({ log, isExpanded: forceExpanded, use24Hour, showTimestamp }: LogEntryItemProps) => { + const [localExpanded, setLocalExpanded] = useState(forceExpanded); + + useEffect(() => { + setLocalExpanded(forceExpanded); + }, [forceExpanded]); + + const timestamp = useMemo(() => { + const date = new Date(log.timestamp); + return date.toLocaleTimeString('en-US', { hour12: !use24Hour }); + }, [log.timestamp, use24Hour]); + + const style = useMemo(() => { + if (log.category === 'provider') { + return { + icon: 'i-ph:robot', + color: 'text-emerald-500 dark:text-emerald-400', + bg: 'hover:bg-emerald-500/10 dark:hover:bg-emerald-500/20', + badge: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-500/10', + }; + } + + if (log.category === 'api') { + return { + icon: 'i-ph:cloud', + color: 'text-blue-500 dark:text-blue-400', + bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', + badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10', + }; + } + + switch (log.level) { + case 'error': + return { + icon: 'i-ph:warning-circle', + color: 'text-red-500 dark:text-red-400', + bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20', + badge: 'text-red-500 bg-red-50 dark:bg-red-500/10', + }; + case 'warning': + return { + icon: 'i-ph:warning', + color: 'text-yellow-500 dark:text-yellow-400', + bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20', + badge: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-500/10', + }; + case 'debug': + return { + icon: 'i-ph:bug', + color: 'text-gray-500 dark:text-gray-400', + bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20', + badge: 'text-gray-500 bg-gray-50 dark:bg-gray-500/10', + }; + default: + return { + icon: 'i-ph:info', + color: 'text-blue-500 dark:text-blue-400', + bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', + badge: 'text-blue-500 bg-blue-50 dark:bg-blue-500/10', + }; + } + }, [log.level, log.category]); + + const renderDetails = (details: any) => { + if (log.category === 'provider') { + return ( +
+
+ Model: {details.model} + β€’ + Tokens: {details.totalTokens} + β€’ + Duration: {details.duration}ms +
+ {details.prompt && ( +
+
Prompt:
+
+                {details.prompt}
+              
+
+ )} + {details.response && ( +
+
Response:
+
+                {details.response}
+              
+
+ )} +
+ ); + } + + if (log.category === 'api') { + return ( +
+
+ {details.method} + β€’ + Status: {details.statusCode} + β€’ + Duration: {details.duration}ms +
+
{details.url}
+ {details.request && ( +
+
Request:
+
+                {JSON.stringify(details.request, null, 2)}
+              
+
+ )} + {details.response && ( +
+
Response:
+
+                {JSON.stringify(details.response, null, 2)}
+              
+
+ )} + {details.error && ( +
+
Error:
+
+                {JSON.stringify(details.error, null, 2)}
+              
+
+ )} +
+ ); + } + + return ( +
+        {JSON.stringify(details, null, 2)}
+      
+ ); + }; + + return ( + +
+
+ +
+
{log.message}
+ {log.details && ( + <> + + {localExpanded && renderDetails(log.details)} + + )} +
+
+ {log.level} +
+ {log.category && ( +
+ {log.category} +
+ )} +
+
+
+ {showTimestamp && } +
+
+ ); +}; + +interface ExportFormat { + id: string; + label: string; + icon: string; + handler: () => void; +} + +export function EventLogsTab() { + const logs = useStore(logStore.logs); + const [selectedLevel, setSelectedLevel] = useState<'all' | string>('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [use24Hour, setUse24Hour] = useState(false); + const [autoExpand, setAutoExpand] = useState(false); + const [showTimestamps, setShowTimestamps] = useState(true); + const [showLevelFilter, setShowLevelFilter] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const levelFilterRef = useRef(null); + + const filteredLogs = useMemo(() => { + const allLogs = Object.values(logs); + + if (selectedLevel === 'all') { + return allLogs.filter((log) => + searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true, + ); + } + + return allLogs.filter((log) => { + const matchesType = log.category === selectedLevel || log.level === selectedLevel; + const matchesSearch = searchQuery ? log.message.toLowerCase().includes(searchQuery.toLowerCase()) : true; + + return matchesType && matchesSearch; + }); + }, [logs, selectedLevel, searchQuery]); + + // Add performance tracking on mount + useEffect(() => { + const startTime = performance.now(); + + logStore.logInfo('Event Logs tab mounted', { + type: 'component_mount', + message: 'Event Logs tab component mounted', + component: 'EventLogsTab', + }); + + return () => { + const duration = performance.now() - startTime; + logStore.logPerformanceMetric('EventLogsTab', 'mount-duration', duration); + }; + }, []); + + // Log filter changes + const handleLevelFilterChange = useCallback( + (newLevel: string) => { + logStore.logInfo('Log level filter changed', { + type: 'filter_change', + message: `Log level filter changed from ${selectedLevel} to ${newLevel}`, + component: 'EventLogsTab', + previousLevel: selectedLevel, + newLevel, + }); + setSelectedLevel(newLevel as string); + setShowLevelFilter(false); + }, + [selectedLevel], + ); + + // Log search changes with debounce + useEffect(() => { + const timeoutId = setTimeout(() => { + if (searchQuery) { + logStore.logInfo('Log search performed', { + type: 'search', + message: `Search performed with query "${searchQuery}" (${filteredLogs.length} results)`, + component: 'EventLogsTab', + query: searchQuery, + resultsCount: filteredLogs.length, + }); + } + }, 1000); + + return () => clearTimeout(timeoutId); + }, [searchQuery, filteredLogs.length]); + + // Enhanced refresh handler + const handleRefresh = useCallback(async () => { + const startTime = performance.now(); + setIsRefreshing(true); + + try { + await logStore.refreshLogs(); + + const duration = performance.now() - startTime; + + logStore.logSuccess('Logs refreshed successfully', { + type: 'refresh', + message: `Successfully refreshed ${Object.keys(logs).length} logs`, + component: 'EventLogsTab', + duration, + logsCount: Object.keys(logs).length, + }); + } catch (error) { + logStore.logError('Failed to refresh logs', error, { + type: 'refresh_error', + message: 'Failed to refresh logs', + component: 'EventLogsTab', + }); + } finally { + setTimeout(() => setIsRefreshing(false), 500); + } + }, [logs]); + + // Log preference changes + const handlePreferenceChange = useCallback((type: string, value: boolean) => { + logStore.logInfo('Log preference changed', { + type: 'preference_change', + message: `Log preference "${type}" changed to ${value}`, + component: 'EventLogsTab', + preference: type, + value, + }); + + switch (type) { + case 'timestamps': + setShowTimestamps(value); + break; + case '24hour': + setUse24Hour(value); + break; + case 'autoExpand': + setAutoExpand(value); + break; + } + }, []); + + // Close filters when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (levelFilterRef.current && !levelFilterRef.current.contains(event.target as Node)) { + setShowLevelFilter(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const selectedLevelOption = logLevelOptions.find((opt) => opt.value === selectedLevel); + + // Export functions + const exportAsJSON = () => { + try { + const exportData = { + timestamp: new Date().toISOString(), + logs: filteredLogs, + filters: { + level: selectedLevel, + searchQuery, + }, + preferences: { + use24Hour, + showTimestamps, + autoExpand, + }, + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-event-logs-${new Date().toISOString()}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Event logs exported successfully as JSON'); + } catch (error) { + console.error('Failed to export JSON:', error); + toast.error('Failed to export event logs as JSON'); + } + }; + + const exportAsCSV = () => { + try { + // Convert logs to CSV format + const headers = ['Timestamp', 'Level', 'Category', 'Message', 'Details']; + const csvData = [ + headers, + ...filteredLogs.map((log) => [ + new Date(log.timestamp).toISOString(), + log.level, + log.category || '', + log.message, + log.details ? JSON.stringify(log.details) : '', + ]), + ]; + + const csvContent = csvData + .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-event-logs-${new Date().toISOString()}.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Event logs exported successfully as CSV'); + } catch (error) { + console.error('Failed to export CSV:', error); + toast.error('Failed to export event logs as CSV'); + } + }; + + const exportAsPDF = () => { + try { + // Create new PDF document + const doc = new jsPDF(); + const lineHeight = 7; + let yPos = 20; + const margin = 20; + const pageWidth = doc.internal.pageSize.getWidth(); + const maxLineWidth = pageWidth - 2 * margin; + + // Helper function to add section header + const addSectionHeader = (title: string) => { + // Check if we need a new page + if (yPos > doc.internal.pageSize.getHeight() - 30) { + doc.addPage(); + yPos = margin; + } + + doc.setFillColor('#F3F4F6'); + doc.rect(margin - 2, yPos - 5, pageWidth - 2 * (margin - 2), lineHeight + 6, 'F'); + doc.setFont('helvetica', 'bold'); + doc.setTextColor('#111827'); + doc.setFontSize(12); + doc.text(title.toUpperCase(), margin, yPos); + yPos += lineHeight * 2; + }; + + // Add title and header + doc.setFillColor('#6366F1'); + doc.rect(0, 0, pageWidth, 50, 'F'); + doc.setTextColor('#FFFFFF'); + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('Event Logs Report', margin, 35); + + // Add subtitle with bolt.diy + doc.setFontSize(12); + doc.setFont('helvetica', 'normal'); + doc.text('bolt.diy - AI Development Platform', margin, 45); + yPos = 70; + + // Add report summary section + addSectionHeader('Report Summary'); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.setTextColor('#374151'); + + const summaryItems = [ + { label: 'Generated', value: new Date().toLocaleString() }, + { label: 'Total Logs', value: filteredLogs.length.toString() }, + { label: 'Filter Applied', value: selectedLevel === 'all' ? 'All Types' : selectedLevel }, + { label: 'Search Query', value: searchQuery || 'None' }, + { label: 'Time Format', value: use24Hour ? '24-hour' : '12-hour' }, + ]; + + summaryItems.forEach((item) => { + doc.setFont('helvetica', 'bold'); + doc.text(`${item.label}:`, margin, yPos); + doc.setFont('helvetica', 'normal'); + doc.text(item.value, margin + 60, yPos); + yPos += lineHeight; + }); + + yPos += lineHeight * 2; + + // Add statistics section + addSectionHeader('Log Statistics'); + + // Calculate statistics + const stats = { + error: filteredLogs.filter((log) => log.level === 'error').length, + warning: filteredLogs.filter((log) => log.level === 'warning').length, + info: filteredLogs.filter((log) => log.level === 'info').length, + debug: filteredLogs.filter((log) => log.level === 'debug').length, + provider: filteredLogs.filter((log) => log.category === 'provider').length, + api: filteredLogs.filter((log) => log.category === 'api').length, + }; + + // Create two columns for statistics + const leftStats = [ + { label: 'Error Logs', value: stats.error, color: '#DC2626' }, + { label: 'Warning Logs', value: stats.warning, color: '#F59E0B' }, + { label: 'Info Logs', value: stats.info, color: '#3B82F6' }, + ]; + + const rightStats = [ + { label: 'Debug Logs', value: stats.debug, color: '#6B7280' }, + { label: 'LLM Logs', value: stats.provider, color: '#10B981' }, + { label: 'API Logs', value: stats.api, color: '#3B82F6' }, + ]; + + const colWidth = (pageWidth - 2 * margin) / 2; + + // Draw statistics in two columns + leftStats.forEach((stat, index) => { + doc.setTextColor(stat.color); + doc.setFont('helvetica', 'bold'); + doc.text(stat.value.toString(), margin, yPos); + doc.setTextColor('#374151'); + doc.setFont('helvetica', 'normal'); + doc.text(stat.label, margin + 20, yPos); + + if (rightStats[index]) { + doc.setTextColor(rightStats[index].color); + doc.setFont('helvetica', 'bold'); + doc.text(rightStats[index].value.toString(), margin + colWidth, yPos); + doc.setTextColor('#374151'); + doc.setFont('helvetica', 'normal'); + doc.text(rightStats[index].label, margin + colWidth + 20, yPos); + } + + yPos += lineHeight; + }); + + yPos += lineHeight * 2; + + // Add logs section + addSectionHeader('Event Logs'); + + // Helper function to add a log entry with improved formatting + const addLogEntry = (log: LogEntry) => { + const entryHeight = 20 + (log.details ? 40 : 0); // Estimate entry height + + // Check if we need a new page + if (yPos + entryHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPos = margin; + } + + // Add timestamp and level + const timestamp = new Date(log.timestamp).toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: !use24Hour, + }); + + // Draw log level badge background + const levelColors: Record = { + error: '#FEE2E2', + warning: '#FEF3C7', + info: '#DBEAFE', + debug: '#F3F4F6', + }; + + const textColors: Record = { + error: '#DC2626', + warning: '#F59E0B', + info: '#3B82F6', + debug: '#6B7280', + }; + + const levelWidth = doc.getTextWidth(log.level.toUpperCase()) + 10; + doc.setFillColor(levelColors[log.level] || '#F3F4F6'); + doc.roundedRect(margin, yPos - 4, levelWidth, lineHeight + 4, 1, 1, 'F'); + + // Add log level text + doc.setTextColor(textColors[log.level] || '#6B7280'); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8); + doc.text(log.level.toUpperCase(), margin + 5, yPos); + + // Add timestamp + doc.setTextColor('#6B7280'); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + doc.text(timestamp, margin + levelWidth + 10, yPos); + + // Add category if present + if (log.category) { + const categoryX = margin + levelWidth + doc.getTextWidth(timestamp) + 20; + doc.setFillColor('#F3F4F6'); + + const categoryWidth = doc.getTextWidth(log.category) + 10; + doc.roundedRect(categoryX, yPos - 4, categoryWidth, lineHeight + 4, 2, 2, 'F'); + doc.setTextColor('#6B7280'); + doc.text(log.category, categoryX + 5, yPos); + } + + yPos += lineHeight * 1.5; + + // Add message + doc.setTextColor('#111827'); + doc.setFontSize(10); + + const messageLines = doc.splitTextToSize(log.message, maxLineWidth - 10); + doc.text(messageLines, margin + 5, yPos); + yPos += messageLines.length * lineHeight; + + // Add details if present + if (log.details) { + doc.setTextColor('#6B7280'); + doc.setFontSize(8); + + const detailsStr = JSON.stringify(log.details, null, 2); + const detailsLines = doc.splitTextToSize(detailsStr, maxLineWidth - 15); + + // Add details background + doc.setFillColor('#F9FAFB'); + doc.roundedRect(margin + 5, yPos - 2, maxLineWidth - 10, detailsLines.length * lineHeight + 8, 1, 1, 'F'); + + doc.text(detailsLines, margin + 10, yPos + 4); + yPos += detailsLines.length * lineHeight + 10; + } + + // Add separator line + doc.setDrawColor('#E5E7EB'); + doc.setLineWidth(0.1); + doc.line(margin, yPos, pageWidth - margin, yPos); + yPos += lineHeight * 1.5; + }; + + // Add all logs + filteredLogs.forEach((log) => { + addLogEntry(log); + }); + + // Add footer to all pages + const totalPages = doc.internal.pages.length - 1; + + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor('#9CA3AF'); + + // Add page numbers + doc.text(`Page ${i} of ${totalPages}`, pageWidth / 2, doc.internal.pageSize.getHeight() - 10, { + align: 'center', + }); + + // Add footer text + doc.text('Generated by bolt.diy', margin, doc.internal.pageSize.getHeight() - 10); + + const dateStr = new Date().toLocaleDateString(); + doc.text(dateStr, pageWidth - margin, doc.internal.pageSize.getHeight() - 10, { align: 'right' }); + } + + // Save the PDF + doc.save(`bolt-event-logs-${new Date().toISOString()}.pdf`); + toast.success('Event logs exported successfully as PDF'); + } catch (error) { + console.error('Failed to export PDF:', error); + toast.error('Failed to export event logs as PDF'); + } + }; + + const exportAsText = () => { + try { + const textContent = filteredLogs + .map((log) => { + const timestamp = new Date(log.timestamp).toLocaleString(); + let content = `[${timestamp}] ${log.level.toUpperCase()}: ${log.message}\n`; + + if (log.category) { + content += `Category: ${log.category}\n`; + } + + if (log.details) { + content += `Details:\n${JSON.stringify(log.details, null, 2)}\n`; + } + + return content + '-'.repeat(80) + '\n'; + }) + .join('\n'); + + const blob = new Blob([textContent], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bolt-event-logs-${new Date().toISOString()}.txt`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Event logs exported successfully as text file'); + } catch (error) { + console.error('Failed to export text file:', error); + toast.error('Failed to export event logs as text file'); + } + }; + + const exportFormats: ExportFormat[] = [ + { + id: 'json', + label: 'Export as JSON', + icon: 'i-ph:file-json', + handler: exportAsJSON, + }, + { + id: 'csv', + label: 'Export as CSV', + icon: 'i-ph:file-csv', + handler: exportAsCSV, + }, + { + id: 'pdf', + label: 'Export as PDF', + icon: 'i-ph:file-pdf', + handler: exportAsPDF, + }, + { + id: 'txt', + label: 'Export as Text', + icon: 'i-ph:file-text', + handler: exportAsText, + }, + ]; + + const ExportButton = () => { + const [isOpen, setIsOpen] = useState(false); + + const handleOpenChange = useCallback((open: boolean) => { + setIsOpen(open); + }, []); + + const handleFormatClick = useCallback((handler: () => void) => { + handler(); + setIsOpen(false); + }, []); + + return ( + + + + +
+ +
+ Export Event Logs + + +
+ {exportFormats.map((format) => ( + + ))} +
+
+
+
+ ); + }; + + return ( +
+
+ + + + + + + + {logLevelOptions.map((option) => ( + handleLevelFilterChange(option.value)} + > +
+
+
+ {option.label} + + ))} + + + + +
+
+ handlePreferenceChange('timestamps', value)} + className="data-[state=checked]:bg-purple-500" + /> + Show Timestamps +
+ +
+ handlePreferenceChange('24hour', value)} + className="data-[state=checked]:bg-purple-500" + /> + 24h Time +
+ +
+ handlePreferenceChange('autoExpand', value)} + className="data-[state=checked]:bg-purple-500" + /> + Auto Expand +
+ +
+ + + + +
+
+ +
+
+ setSearchQuery(e.target.value)} + className={classNames( + 'w-full px-4 py-2 pl-10 rounded-lg', + 'bg-[#FAFAFA] dark:bg-[#0A0A0A]', + 'border border-[#E5E5E5] dark:border-[#1A1A1A]', + 'text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500', + 'transition-all duration-200', + )} + /> +
+
+
+
+ + {filteredLogs.length === 0 ? ( + + +
+

No Logs Found

+

Try adjusting your search or filters

+
+
+ ) : ( + filteredLogs.map((log) => ( + + )) + )} +
+
+ ); +} diff --git a/app/components/@settings/tabs/features/FeaturesTab.tsx b/app/components/@settings/tabs/features/FeaturesTab.tsx new file mode 100644 index 000000000..20fbd10e6 --- /dev/null +++ b/app/components/@settings/tabs/features/FeaturesTab.tsx @@ -0,0 +1,285 @@ +// Remove unused imports +import React, { memo, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { Switch } from '~/components/ui/Switch'; +import { useSettings } from '~/lib/hooks/useSettings'; +import { classNames } from '~/utils/classNames'; +import { toast } from 'react-toastify'; +import { PromptLibrary } from '~/lib/common/prompt-library'; + +interface FeatureToggle { + id: string; + title: string; + description: string; + icon: string; + enabled: boolean; + beta?: boolean; + experimental?: boolean; + tooltip?: string; +} + +const FeatureCard = memo( + ({ + feature, + index, + onToggle, + }: { + feature: FeatureToggle; + index: number; + onToggle: (id: string, enabled: boolean) => void; + }) => ( + +
+
+
+
+
+

{feature.title}

+ {feature.beta && ( + Beta + )} + {feature.experimental && ( + + Experimental + + )} +
+
+ onToggle(feature.id, checked)} /> +
+

{feature.description}

+ {feature.tooltip &&

{feature.tooltip}

} +
+ + ), +); + +const FeatureSection = memo( + ({ + title, + features, + icon, + description, + onToggleFeature, + }: { + title: string; + features: FeatureToggle[]; + icon: string; + description: string; + onToggleFeature: (id: string, enabled: boolean) => void; + }) => ( + +
+
+
+

{title}

+

{description}

+
+
+ +
+ {features.map((feature, index) => ( + + ))} +
+ + ), +); + +export default function FeaturesTab() { + const { + autoSelectTemplate, + isLatestBranch, + contextOptimizationEnabled, + eventLogs, + setAutoSelectTemplate, + enableLatestBranch, + enableContextOptimization, + setEventLogs, + setPromptId, + promptId, + } = useSettings(); + + // Enable features by default on first load + React.useEffect(() => { + // Force enable these features by default + enableLatestBranch(true); + enableContextOptimization(true); + setAutoSelectTemplate(true); + setPromptId('optimized'); + + // Only enable event logs if not explicitly set before + if (eventLogs === undefined) { + setEventLogs(true); + } + }, []); // Only run once on component mount + + const handleToggleFeature = useCallback( + (id: string, enabled: boolean) => { + switch (id) { + case 'latestBranch': { + enableLatestBranch(enabled); + toast.success(`Main branch updates ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + case 'autoSelectTemplate': { + setAutoSelectTemplate(enabled); + toast.success(`Auto select template ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + case 'contextOptimization': { + enableContextOptimization(enabled); + toast.success(`Context optimization ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + case 'eventLogs': { + setEventLogs(enabled); + toast.success(`Event logging ${enabled ? 'enabled' : 'disabled'}`); + break; + } + + default: + break; + } + }, + [enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs], + ); + + const features = { + stable: [ + { + id: 'latestBranch', + title: 'Main Branch Updates', + description: 'Get the latest updates from the main branch', + icon: 'i-ph:git-branch', + enabled: isLatestBranch, + tooltip: 'Enabled by default to receive updates from the main development branch', + }, + { + id: 'autoSelectTemplate', + title: 'Auto Select Template', + description: 'Automatically select starter template', + icon: 'i-ph:selection', + enabled: autoSelectTemplate, + tooltip: 'Enabled by default to automatically select the most appropriate starter template', + }, + { + id: 'contextOptimization', + title: 'Context Optimization', + description: 'Optimize context for better responses', + icon: 'i-ph:brain', + enabled: contextOptimizationEnabled, + tooltip: 'Enabled by default for improved AI responses', + }, + { + id: 'eventLogs', + title: 'Event Logging', + description: 'Enable detailed event logging and history', + icon: 'i-ph:list-bullets', + enabled: eventLogs, + tooltip: 'Enabled by default to record detailed logs of system events and user actions', + }, + ], + beta: [], + }; + + return ( +
+ + + {features.beta.length > 0 && ( + + )} + + +
+
+
+
+
+

+ Prompt Library +

+

+ Choose a prompt from the library to use as the system prompt +

+
+ +
+ +
+ ); +} diff --git a/app/components/@settings/tabs/notifications/NotificationsTab.tsx b/app/components/@settings/tabs/notifications/NotificationsTab.tsx new file mode 100644 index 000000000..cb5f3da1c --- /dev/null +++ b/app/components/@settings/tabs/notifications/NotificationsTab.tsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { logStore } from '~/lib/stores/logs'; +import { useStore } from '@nanostores/react'; +import { formatDistanceToNow } from 'date-fns'; +import { classNames } from '~/utils/classNames'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; + +interface NotificationDetails { + type?: string; + message?: string; + currentVersion?: string; + latestVersion?: string; + branch?: string; + updateUrl?: string; +} + +type FilterType = 'all' | 'system' | 'error' | 'warning' | 'update' | 'info' | 'provider' | 'network'; + +const NotificationsTab = () => { + const [filter, setFilter] = useState('all'); + const logs = useStore(logStore.logs); + + useEffect(() => { + const startTime = performance.now(); + + return () => { + const duration = performance.now() - startTime; + logStore.logPerformanceMetric('NotificationsTab', 'mount-duration', duration); + }; + }, []); + + const handleClearNotifications = () => { + const count = Object.keys(logs).length; + logStore.logInfo('Cleared notifications', { + type: 'notification_clear', + message: `Cleared ${count} notifications`, + clearedCount: count, + component: 'notifications', + }); + logStore.clearLogs(); + }; + + const handleUpdateAction = (updateUrl: string) => { + logStore.logInfo('Update link clicked', { + type: 'update_click', + message: 'User clicked update link', + updateUrl, + component: 'notifications', + }); + window.open(updateUrl, '_blank'); + }; + + const handleFilterChange = (newFilter: FilterType) => { + logStore.logInfo('Notification filter changed', { + type: 'filter_change', + message: `Filter changed to ${newFilter}`, + previousFilter: filter, + newFilter, + component: 'notifications', + }); + setFilter(newFilter); + }; + + const filteredLogs = Object.values(logs) + .filter((log) => { + if (filter === 'all') { + return true; + } + + if (filter === 'update') { + return log.details?.type === 'update'; + } + + if (filter === 'system') { + return log.category === 'system'; + } + + if (filter === 'provider') { + return log.category === 'provider'; + } + + if (filter === 'network') { + return log.category === 'network'; + } + + return log.level === filter; + }) + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + const getNotificationStyle = (level: string, type?: string) => { + if (type === 'update') { + return { + icon: 'i-ph:arrow-circle-up', + color: 'text-purple-500 dark:text-purple-400', + bg: 'hover:bg-purple-500/10 dark:hover:bg-purple-500/20', + }; + } + + switch (level) { + case 'error': + return { + icon: 'i-ph:warning-circle', + color: 'text-red-500 dark:text-red-400', + bg: 'hover:bg-red-500/10 dark:hover:bg-red-500/20', + }; + case 'warning': + return { + icon: 'i-ph:warning', + color: 'text-yellow-500 dark:text-yellow-400', + bg: 'hover:bg-yellow-500/10 dark:hover:bg-yellow-500/20', + }; + case 'info': + return { + icon: 'i-ph:info', + color: 'text-blue-500 dark:text-blue-400', + bg: 'hover:bg-blue-500/10 dark:hover:bg-blue-500/20', + }; + default: + return { + icon: 'i-ph:bell', + color: 'text-gray-500 dark:text-gray-400', + bg: 'hover:bg-gray-500/10 dark:hover:bg-gray-500/20', + }; + } + }; + + const renderNotificationDetails = (details: NotificationDetails) => { + if (details.type === 'update') { + return ( +
+

{details.message}

+
+

Current Version: {details.currentVersion}

+

Latest Version: {details.latestVersion}

+

Branch: {details.branch}

+
+ +
+ ); + } + + return details.message ?

{details.message}

: null; + }; + + const filterOptions: { id: FilterType; label: string; icon: string; color: string }[] = [ + { id: 'all', label: 'All Notifications', icon: 'i-ph:bell', color: '#9333ea' }, + { id: 'system', label: 'System', icon: 'i-ph:gear', color: '#6b7280' }, + { id: 'update', label: 'Updates', icon: 'i-ph:arrow-circle-up', color: '#9333ea' }, + { id: 'error', label: 'Errors', icon: 'i-ph:warning-circle', color: '#ef4444' }, + { id: 'warning', label: 'Warnings', icon: 'i-ph:warning', color: '#f59e0b' }, + { id: 'info', label: 'Information', icon: 'i-ph:info', color: '#3b82f6' }, + { id: 'provider', label: 'Providers', icon: 'i-ph:robot', color: '#10b981' }, + { id: 'network', label: 'Network', icon: 'i-ph:wifi-high', color: '#6366f1' }, + ]; + + return ( +
+
+ + + + + + + + {filterOptions.map((option) => ( + handleFilterChange(option.id)} + > +
+
+
+ {option.label} + + ))} + + + + + +
+ +
+ {filteredLogs.length === 0 ? ( + + +
+

No Notifications

+

You're all caught up!

+
+
+ ) : ( + filteredLogs.map((log) => { + const style = getNotificationStyle(log.level, log.details?.type); + return ( + +
+
+ +
+

{log.message}

+ {log.details && renderNotificationDetails(log.details as NotificationDetails)} +

+ Category: {log.category} + {log.subCategory ? ` > ${log.subCategory}` : ''} +

+
+
+ +
+
+ ); + }) + )} +
+
+ ); +}; + +export default NotificationsTab; diff --git a/app/components/@settings/tabs/profile/ProfileTab.tsx b/app/components/@settings/tabs/profile/ProfileTab.tsx new file mode 100644 index 000000000..6ea19fe41 --- /dev/null +++ b/app/components/@settings/tabs/profile/ProfileTab.tsx @@ -0,0 +1,174 @@ +import { useState } from 'react'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import { profileStore, updateProfile } from '~/lib/stores/profile'; +import { toast } from 'react-toastify'; + +export default function ProfileTab() { + const profile = useStore(profileStore); + const [isUploading, setIsUploading] = useState(false); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + try { + setIsUploading(true); + + // Convert the file to base64 + const reader = new FileReader(); + + reader.onloadend = () => { + const base64String = reader.result as string; + updateProfile({ avatar: base64String }); + setIsUploading(false); + toast.success('Profile picture updated'); + }; + + reader.onerror = () => { + console.error('Error reading file:', reader.error); + setIsUploading(false); + toast.error('Failed to update profile picture'); + }; + reader.readAsDataURL(file); + } catch (error) { + console.error('Error uploading avatar:', error); + setIsUploading(false); + toast.error('Failed to update profile picture'); + } + }; + + const handleProfileUpdate = (field: 'username' | 'bio', value: string) => { + updateProfile({ [field]: value }); + + // Only show toast for completed typing (after 1 second of no typing) + const debounceToast = setTimeout(() => { + toast.success(`${field.charAt(0).toUpperCase() + field.slice(1)} updated`); + }, 1000); + + return () => clearTimeout(debounceToast); + }; + + return ( +
+
+ {/* Personal Information Section */} +
+ {/* Avatar Upload */} +
+
+ {profile.avatar ? ( + Profile + ) : ( +
+ )} + +