From 1f4fb103e98885307297b3837e55990991b98cbc Mon Sep 17 00:00:00 2001 From: LIlGG <1103069291@qq.com> Date: Wed, 24 Sep 2025 13:06:25 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20first=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 40 + .editorconfig | 13 + .env.example | 158 + .../actions/docker-buildx-push/action.yaml | 75 + .github/actions/setup-and-build/action.yaml | 32 + .github/workflows/ci.yaml | 27 + .github/workflows/docker.yaml | 39 + .github/workflows/docs.yaml | 35 + .gitignore | 54 + .husky/pre-commit | 32 + .vscode/settings.json | 18 + Dockerfile | 83 + README.md | 36 + .../@settings/core/AvatarDropdown.tsx | 140 + .../@settings/core/ControlPanel.tsx | 329 + app/components/@settings/core/TabTile.tsx | 135 + app/components/@settings/core/constants.ts | 49 + app/components/@settings/core/types.ts | 77 + app/components/@settings/index.ts | 10 + .../@settings/tabs/debug/DebugTab.tsx | 1919 +++ .../tabs/event-logs/EventLogsTab.tsx | 1013 ++ .../tabs/notifications/NotificationsTab.tsx | 300 + .../@settings/tabs/settings/SettingsTab.tsx | 215 + .../tabs/task-manager/TaskManagerTab.tsx | 1605 ++ app/components/@settings/utils/animations.ts | 41 + app/components/@settings/utils/tab-helpers.ts | 89 + app/components/AuthErrorToast.client.tsx | 37 + app/components/ErrorBoundary.tsx | 68 + app/components/auth/AuthButtons.tsx | 70 + app/components/auth/UserProfile.tsx | 31 + app/components/chat/Artifact.tsx | 227 + app/components/chat/AssistantMessage.tsx | 54 + app/components/chat/BaseChat.module.scss | 55 + app/components/chat/Chat.client.tsx | 158 + app/components/chat/ChatAlert.tsx | 112 + app/components/chat/ChatTextarea.tsx | 260 + app/components/chat/CodeBlock.module.scss | 10 + app/components/chat/CodeBlock.tsx | 82 + app/components/chat/ElementEditPreview.tsx | 54 + app/components/chat/ElementPreview.tsx | 58 + app/components/chat/ExamplePrompts.tsx | 36 + app/components/chat/FilePreview.tsx | 37 + app/components/chat/Markdown.module.scss | 171 + app/components/chat/Markdown.spec.ts | 48 + app/components/chat/Markdown.tsx | 124 + app/components/chat/Messages.client.tsx | 192 + app/components/chat/Messages.module.scss | 57 + app/components/chat/ModelSelector.tsx | 315 + .../chat/NetlifyDeploymentLink.client.tsx | 43 + app/components/chat/ProgressCompilation.tsx | 118 + .../chat/ScreenshotStateManager.tsx | 22 + app/components/chat/SendButton.client.tsx | 43 + app/components/chat/SpeechRecognition.tsx | 27 + app/components/chat/ThoughtBox.tsx | 44 + app/components/chat/UserMessage.tsx | 36 + .../chat/VercelDeploymentLink.client.tsx | 44 + .../chat/_1PanelDeploymentLink.client.tsx | 44 + .../chatExportAndImport/ExportChatButton.tsx | 12 + app/components/chat/usage/ChatUsageDialog.tsx | 424 + .../chat/usage/ChatUsageVisualization.tsx | 372 + .../chat/usage/DeploymentRecordsDialog.tsx | 639 + app/components/editor/EditDialog.tsx | 159 + app/components/editor/Editor.tsx | 201 + app/components/editor/EditorComponent.tsx | 116 + app/components/editor/EditorController.tsx | 171 + app/components/editor/EditorOverlay.tsx | 330 + app/components/editor/EditorRender.tsx | 94 + app/components/editor/PageRender.tsx | 427 + .../editor/editors/DefaultEditor.tsx | 94 + app/components/editor/editors/EditorProps.ts | 31 + app/components/editor/editors/IconEditor.tsx | 868 ++ app/components/editor/editors/ImageEditor.tsx | 248 + app/components/editor/editors/LinkEditor.tsx | 93 + app/components/editor/editors/TextEditor.tsx | 92 + app/components/editor/editors/index.ts | 5 + app/components/editor/icons/close.svg | 3 + app/components/editor/icons/loading.svg | 13 + app/components/editor/icons/send.svg | 4 + app/components/editor/icons/upload.svg | 3 + app/components/header/ChatDescription.tsx | 85 + .../header/DeployTo1PanelDialog.tsx | 201 + .../header/DeployToNetlifyDialog.tsx | 163 + .../header/DeployToVercelDialog.tsx | 159 + app/components/header/Header.tsx | 58 + app/components/header/HeaderActionButtons.tsx | 442 + .../header/MinimalAvatarDropdown.tsx | 194 + .../header/connections/GithubConnection.tsx | 957 ++ .../header/connections/NetlifyConnection.tsx | 694 + .../header/connections/VercelConnection.tsx | 314 + .../header/connections/_1PanelConnection.tsx | 581 + .../components/ConnectionBorder.tsx | 18 + .../components/PushToGitHubDialog.tsx | 699 + app/components/sidebar/HistoryItem.tsx | 178 + app/components/sidebar/HistorySwitch.tsx | 23 + app/components/sidebar/Menu.client.tsx | 381 + app/components/sidebar/date-binning.ts | 60 + app/components/ui/BackgroundRays/index.tsx | 18 + .../ui/BackgroundRays/styles.module.scss | 246 + app/components/ui/Badge.tsx | 32 + app/components/ui/Button.tsx | 47 + app/components/ui/Card.tsx | 55 + app/components/ui/Checkbox.tsx | 31 + app/components/ui/Collapsible.tsx | 9 + app/components/ui/Dialog.tsx | 445 + app/components/ui/Dropdown.tsx | 63 + app/components/ui/IconButton.tsx | 84 + app/components/ui/Input.tsx | 22 + app/components/ui/Label.tsx | 20 + app/components/ui/LoadingDots.tsx | 27 + app/components/ui/LoadingOverlay.tsx | 32 + app/components/ui/PanelHeader.tsx | 20 + app/components/ui/PanelHeaderButton.tsx | 36 + app/components/ui/Popover.tsx | 29 + app/components/ui/Progress.tsx | 42 + app/components/ui/ScrollArea.tsx | 41 + app/components/ui/Separator.tsx | 22 + app/components/ui/SettingsButton.tsx | 19 + app/components/ui/Slider.tsx | 82 + app/components/ui/Switch.tsx | 37 + app/components/ui/Tabs.tsx | 52 + app/components/ui/ThemeSwitch.tsx | 36 + app/components/ui/Tooltip.tsx | 79 + app/components/upage/Brand.tsx | 107 + app/components/upage/Index.tsx | 11 + app/components/upage/Share.tsx | 605 + app/components/upage/icons/close.svg | 3 + app/components/upage/icons/copy.svg | 6 + app/components/upage/icons/logo.svg | 1 + app/components/upage/icons/share.svg | 3 + app/components/upage/icons/twitter.svg | 3 + app/components/upage/icons/wechat.svg | 3 + app/components/upage/icons/weibo.svg | 3 + app/components/webbuilder/DiffView.tsx | 877 ++ app/components/webbuilder/EditorPanel.tsx | 102 + app/components/webbuilder/InlineInput.tsx | 65 + app/components/webbuilder/PageDropdown.tsx | 86 + app/components/webbuilder/PageTree.tsx | 453 + app/components/webbuilder/Preview.tsx | 1020 ++ .../webbuilder/ScreenshotSelector.tsx | 301 + .../webbuilder/WebBuilder.client.tsx | 427 + app/entry.client.tsx | 12 + app/entry.server.tsx | 121 + app/lib/.server/auth.ts | 427 + app/lib/.server/chat.ts | 329 + app/lib/.server/chatUsage.ts | 303 + app/lib/.server/connectionSettings.ts | 235 + app/lib/.server/deployment.ts | 316 + app/lib/.server/llm/chat-stream-text.ts | 149 + app/lib/.server/llm/constants.ts | 5 + app/lib/.server/llm/create-summary.ts | 148 + app/lib/.server/llm/select-context.ts | 124 + app/lib/.server/llm/stream-enhancer.ts | 45 + .../.server/llm/structured-page-snapshot.ts | 149 + app/lib/.server/llm/switchable-stream.ts | 66 + app/lib/.server/llm/tools/index.ts | 9 + app/lib/.server/llm/tools/serper.ts | 54 + app/lib/.server/llm/tools/weather.ts | 43 + app/lib/.server/llm/utils.ts | 92 + app/lib/.server/logger.server.ts | 175 + app/lib/.server/logger.ts | 175 + app/lib/.server/message.ts | 317 + app/lib/.server/page.ts | 278 + app/lib/.server/prisma.ts | 22 + app/lib/.server/projectService.ts | 142 + app/lib/.server/section.ts | 234 + app/lib/.server/userSettings.ts | 181 + 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/bridge/index.ts | 187 + app/lib/common/prompt-library.ts | 43 + app/lib/common/prompts/prompts.ts | 328 + app/lib/crypto.ts | 58 + app/lib/fetch.ts | 10 + app/lib/hooks/index.ts | 10 + app/lib/hooks/useAuth.ts | 79 + app/lib/hooks/useChatDeployment.ts | 18 + app/lib/hooks/useChatEntries.ts | 83 + app/lib/hooks/useChatHistory.ts | 75 + app/lib/hooks/useChatMessage.ts | 221 + app/lib/hooks/useChatOperate.ts | 186 + app/lib/hooks/useChatUsage.ts | 70 + app/lib/hooks/useDebugStatus.ts | 89 + app/lib/hooks/useDeploymentRecords.ts | 228 + app/lib/hooks/useEditChatDescription.ts | 126 + app/lib/hooks/useEditorCommands.ts | 53 + app/lib/hooks/useMessageParser.ts | 106 + app/lib/hooks/useNotifications.ts | 51 + app/lib/hooks/useProject.ts | 108 + app/lib/hooks/usePromptEnhancer.ts | 50 + app/lib/hooks/useSettings.ts | 148 + app/lib/hooks/useShortcuts.ts | 93 + app/lib/hooks/useSnapScroll.ts | 177 + app/lib/hooks/useViewport.ts | 18 + app/lib/modules/llm/base-provider.ts | 112 + app/lib/modules/llm/manager.ts | 279 + .../modules/llm/providers/amazon-bedrock.ts | 117 + app/lib/modules/llm/providers/anthropic.ts | 95 + app/lib/modules/llm/providers/cohere.ts | 52 + app/lib/modules/llm/providers/deepseek.ts | 45 + app/lib/modules/llm/providers/github.ts | 51 + app/lib/modules/llm/providers/google.ts | 86 + app/lib/modules/llm/providers/groq.ts | 87 + app/lib/modules/llm/providers/huggingface.ts | 109 + app/lib/modules/llm/providers/hyperbolic.ts | 104 + app/lib/modules/llm/providers/lmstudio.ts | 87 + app/lib/modules/llm/providers/mistral.ts | 51 + app/lib/modules/llm/providers/ollama.ts | 113 + app/lib/modules/llm/providers/open-router.ts | 128 + app/lib/modules/llm/providers/openai-like.ts | 65 + app/lib/modules/llm/providers/openai.ts | 83 + app/lib/modules/llm/providers/perplexity.ts | 61 + app/lib/modules/llm/providers/together.ts | 88 + app/lib/modules/llm/providers/xai.ts | 45 + app/lib/modules/llm/registry.ts | 39 + app/lib/modules/llm/types.ts | 28 + app/lib/persistence/editor.ts | 514 + app/lib/persistence/index.ts | 2 + app/lib/persistence/local-storage.ts | 28 + .../__snapshots__/message-parser.spec.ts.snap | 238 + app/lib/runtime/action-runner.ts | 237 + app/lib/runtime/message-parser.spec.ts | 212 + app/lib/runtime/message-parser.ts | 325 + app/lib/storage/base-provider.server.ts | 66 + app/lib/storage/index.server.ts | 35 + app/lib/storage/local-provider.server.ts | 176 + app/lib/storage/types.ts | 75 + app/lib/stores/1panel.ts | 80 + app/lib/stores/ai-state.ts | 135 + app/lib/stores/chat-message.ts | 16 + app/lib/stores/chat.ts | 211 + app/lib/stores/editor.ts | 194 + app/lib/stores/logs.ts | 519 + app/lib/stores/netlify.ts | 64 + app/lib/stores/pages.ts | 423 + app/lib/stores/previews.ts | 39 + app/lib/stores/profile.ts | 28 + app/lib/stores/settings.ts | 217 + app/lib/stores/sidebar.ts | 15 + app/lib/stores/tab-configuration.ts | 52 + app/lib/stores/theme.ts | 53 + app/lib/stores/vercel.ts | 63 + app/lib/stores/web-builder.ts | 596 + app/root.tsx | 248 + app/routes/_index.tsx | 36 + .../api.1panel.$action/1panel.server.ts | 302 + app/routes/api.1panel.$action/auth.server.ts | 51 + .../api.1panel.$action/delete.server.ts | 46 + .../api.1panel.$action/deploy.server.ts | 217 + app/routes/api.1panel.$action/route.tsx | 64 + app/routes/api.1panel.$action/stats.server.ts | 46 + .../toggle-access.server.ts | 54 + .../api.1panel.$action/websites.server.ts | 88 + .../api.auth.$action/check-error.server.ts | 22 + app/routes/api.auth.$action/route.tsx | 41 + app/routes/api.auth.$action/user.server.ts | 16 + app/routes/api.chat.$action/delete.server.ts | 93 + app/routes/api.chat.$action/fork.server.ts | 167 + app/routes/api.chat.$action/list.server.ts | 51 + app/routes/api.chat.$action/route.tsx | 74 + app/routes/api.chat.$action/update.server.ts | 61 + app/routes/api.chat/chat.server.ts | 412 + app/routes/api.chat/mock-chat.server.ts | 61 + app/routes/api.chat/route.tsx | 25 + app/routes/api.deployments.$action/cache.ts | 46 + app/routes/api.deployments.$action/route.tsx | 23 + .../api.deployments.$action/stats.server.ts | 45 + app/routes/api.deployments/route.tsx | 34 + app/routes/api.enhancer/route.tsx | 40 + app/routes/api.git-proxy.$.ts | 181 + app/routes/api.health/route.tsx | 8 + app/routes/api.netlify.$action/auth.server.ts | 46 + .../api.netlify.$action/delete.server.ts | 68 + .../api.netlify.$action/deploy.server.ts | 319 + app/routes/api.netlify.$action/route.tsx | 60 + .../api.netlify.$action/stats.server.ts | 80 + .../toggle-access.server.ts | 89 + .../api.netlify.deploys.$deployId.$action.ts | 67 + app/routes/api.netlify.sites.$siteId.cache.ts | 53 + app/routes/api.netlify.sites.$siteId.ts | 60 + app/routes/api.project/route.tsx | 67 + .../api.system.$action/app-info.server.ts | 134 + app/routes/api.system.$action/disk.server.ts | 314 + .../api.system.$action/git-info.server.ts | 335 + .../api.system.$action/memory.server.ts | 283 + .../api.system.$action/process.server.ts | 419 + app/routes/api.system.$action/route.tsx | 53 + app/routes/api.upload/route.tsx | 73 + app/routes/api.user.settings.ts | 125 + app/routes/api.vercel.$action/auth.server.ts | 54 + .../api.vercel.$action/delete.server.ts | 75 + .../api.vercel.$action/deploy.server.ts | 308 + app/routes/api.vercel.$action/route.tsx | 64 + app/routes/api.vercel.$action/stats.server.ts | 75 + .../toggle-access.server.ts | 190 + app/routes/api.vercel.$action/type.ts | 22 + app/routes/assets.$userId.$filename.ts | 53 + app/routes/chat.$id.tsx | 44 + app/styles/animations.scss | 49 + app/styles/components/code.scss | 9 + app/styles/components/editor.scss | 135 + app/styles/components/resize-handle.scss | 30 + app/styles/diff-view.css | 72 + app/styles/index.scss | 23 + app/styles/scrollbar.scss | 18 + app/styles/variables.scss | 191 + app/styles/z-index.scss | 33 + app/types/1panel.ts | 84 + app/types/actions.ts | 51 + app/types/artifact.ts | 12 + app/types/chat.ts | 10 + app/types/deployment.ts | 29 + app/types/editor.ts | 25 + app/types/github.ts | 133 + app/types/global.d.ts | 40 + app/types/logto.ts | 61 + app/types/message.ts | 35 + app/types/model.ts | 24 + app/types/netlify.ts | 89 + app/types/settings.ts | 0 app/types/theme.ts | 1 + app/types/vercel.ts | 40 + app/utils/api-response.ts | 57 + app/utils/constants.ts | 31 + app/utils/debounce.ts | 13 + app/utils/diff.ts | 112 + app/utils/easings.ts | 3 + app/utils/execute-scripts.ts | 33 + app/utils/file-utils.ts | 158 + app/utils/format.ts | 12 + app/utils/html-parse.ts | 171 + app/utils/logger.ts | 123 + app/utils/markdown.ts | 144 + app/utils/mobile.ts | 4 + app/utils/os.ts | 4 + app/utils/page.ts | 35 + app/utils/path.ts | 19 + app/utils/prettier.ts | 52 + app/utils/react.ts | 6 + app/utils/sampler.ts | 154 + app/utils/strip-indent.ts | 23 + app/utils/throttle.ts | 64 + app/utils/token.ts | 34 + app/utils/unreachable.ts | 3 + app/utils/uuid.ts | 17 + bindings.sh | 33 + biome.json | 156 + docker-compose-dev.yaml | 70 + docker-compose-prod.yaml | 76 + docker-entrypoint.sh | 131 + docs/index.md | 14 + icons/angular.svg | 1 + icons/astro.svg | 1 + icons/chat.svg | 1 + icons/logo-text.svg | 1 + icons/logo.svg | 4 + icons/nativescript.svg | 1 + icons/nextjs.svg | 1 + icons/nuxt.svg | 1 + icons/qwik.svg | 1 + icons/react.svg | 1 + icons/remix.svg | 1 + icons/remotion.svg | 1 + icons/slidev.svg | 1 + icons/stars.svg | 1 + icons/svelte.svg | 1 + icons/typescript.svg | 1 + icons/vite.svg | 1 + icons/vue.svg | 1 + logto/.env | 5 + logto/docker-compose.yaml | 41 + package.json | 144 + pnpm-lock.yaml | 12673 ++++++++++++++++ pre-start.mjs | 6 + .../20250922030707_init/migration.sql | 201 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 225 + public/favicon.ico | Bin 0 -> 4286 bytes public/favicon.svg | 1 + public/iconify-icon.min.js | 12 + public/icons/1panel.png | Bin 0 -> 6273 bytes public/icons/AmazonBedrock.svg | 1 + public/icons/Anthropic.svg | 4 + public/icons/Cohere.svg | 4 + public/icons/Deepseek.svg | 5 + public/icons/Default.svg | 4 + public/icons/Google.svg | 4 + public/icons/Groq.svg | 4 + public/icons/HuggingFace.svg | 4 + public/icons/Hyperbolic.svg | 3 + public/icons/LMStudio.svg | 5 + public/icons/Mistral.svg | 4 + public/icons/Ollama.svg | 4 + public/icons/OpenAI.svg | 4 + public/icons/OpenAILike.svg | 4 + public/icons/OpenRouter.svg | 4 + public/icons/Perplexity.svg | 4 + public/icons/Together.svg | 4 + public/icons/UPage.svg | 1 + public/icons/xAI.svg | 5 + public/logo.svg | 1 + public/tailwindcss.js | 83 + scripts/clean.js | 45 + server.mjs | 98 + tsconfig.json | 38 + uno.config.ts | 283 + vite.config.mts | 79 + 409 files changed, 61222 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/actions/docker-buildx-push/action.yaml create mode 100644 .github/actions/setup-and-build/action.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/docker.yaml create mode 100644 .github/workflows/docs.yaml create mode 100644 .gitignore create mode 100644 .husky/pre-commit create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 README.md 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/TabTile.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/tabs/debug/DebugTab.tsx create mode 100644 app/components/@settings/tabs/event-logs/EventLogsTab.tsx create mode 100644 app/components/@settings/tabs/notifications/NotificationsTab.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/utils/animations.ts create mode 100644 app/components/@settings/utils/tab-helpers.ts create mode 100644 app/components/AuthErrorToast.client.tsx create mode 100644 app/components/ErrorBoundary.tsx create mode 100644 app/components/auth/AuthButtons.tsx create mode 100644 app/components/auth/UserProfile.tsx create mode 100644 app/components/chat/Artifact.tsx create mode 100644 app/components/chat/AssistantMessage.tsx create mode 100644 app/components/chat/BaseChat.module.scss create mode 100644 app/components/chat/Chat.client.tsx create mode 100644 app/components/chat/ChatAlert.tsx create mode 100644 app/components/chat/ChatTextarea.tsx create mode 100644 app/components/chat/CodeBlock.module.scss create mode 100644 app/components/chat/CodeBlock.tsx create mode 100644 app/components/chat/ElementEditPreview.tsx create mode 100644 app/components/chat/ElementPreview.tsx create mode 100644 app/components/chat/ExamplePrompts.tsx create mode 100644 app/components/chat/FilePreview.tsx create mode 100644 app/components/chat/Markdown.module.scss create mode 100644 app/components/chat/Markdown.spec.ts create mode 100644 app/components/chat/Markdown.tsx create mode 100644 app/components/chat/Messages.client.tsx create mode 100644 app/components/chat/Messages.module.scss create mode 100644 app/components/chat/ModelSelector.tsx create mode 100644 app/components/chat/NetlifyDeploymentLink.client.tsx create mode 100644 app/components/chat/ProgressCompilation.tsx create mode 100644 app/components/chat/ScreenshotStateManager.tsx create mode 100644 app/components/chat/SendButton.client.tsx create mode 100644 app/components/chat/SpeechRecognition.tsx create mode 100644 app/components/chat/ThoughtBox.tsx create mode 100644 app/components/chat/UserMessage.tsx create mode 100644 app/components/chat/VercelDeploymentLink.client.tsx create mode 100644 app/components/chat/_1PanelDeploymentLink.client.tsx create mode 100644 app/components/chat/chatExportAndImport/ExportChatButton.tsx create mode 100644 app/components/chat/usage/ChatUsageDialog.tsx create mode 100644 app/components/chat/usage/ChatUsageVisualization.tsx create mode 100644 app/components/chat/usage/DeploymentRecordsDialog.tsx create mode 100644 app/components/editor/EditDialog.tsx create mode 100644 app/components/editor/Editor.tsx create mode 100644 app/components/editor/EditorComponent.tsx create mode 100644 app/components/editor/EditorController.tsx create mode 100644 app/components/editor/EditorOverlay.tsx create mode 100644 app/components/editor/EditorRender.tsx create mode 100644 app/components/editor/PageRender.tsx create mode 100644 app/components/editor/editors/DefaultEditor.tsx create mode 100644 app/components/editor/editors/EditorProps.ts create mode 100644 app/components/editor/editors/IconEditor.tsx create mode 100644 app/components/editor/editors/ImageEditor.tsx create mode 100644 app/components/editor/editors/LinkEditor.tsx create mode 100644 app/components/editor/editors/TextEditor.tsx create mode 100644 app/components/editor/editors/index.ts create mode 100644 app/components/editor/icons/close.svg create mode 100644 app/components/editor/icons/loading.svg create mode 100644 app/components/editor/icons/send.svg create mode 100644 app/components/editor/icons/upload.svg create mode 100644 app/components/header/ChatDescription.tsx create mode 100644 app/components/header/DeployTo1PanelDialog.tsx create mode 100644 app/components/header/DeployToNetlifyDialog.tsx create mode 100644 app/components/header/DeployToVercelDialog.tsx create mode 100644 app/components/header/Header.tsx create mode 100644 app/components/header/HeaderActionButtons.tsx create mode 100644 app/components/header/MinimalAvatarDropdown.tsx create mode 100644 app/components/header/connections/GithubConnection.tsx create mode 100644 app/components/header/connections/NetlifyConnection.tsx create mode 100644 app/components/header/connections/VercelConnection.tsx create mode 100644 app/components/header/connections/_1PanelConnection.tsx create mode 100644 app/components/header/connections/components/ConnectionBorder.tsx create mode 100644 app/components/header/connections/components/PushToGitHubDialog.tsx create mode 100644 app/components/sidebar/HistoryItem.tsx create mode 100644 app/components/sidebar/HistorySwitch.tsx create mode 100644 app/components/sidebar/Menu.client.tsx create mode 100644 app/components/sidebar/date-binning.ts create mode 100644 app/components/ui/BackgroundRays/index.tsx create mode 100644 app/components/ui/BackgroundRays/styles.module.scss 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/Checkbox.tsx create mode 100644 app/components/ui/Collapsible.tsx create mode 100644 app/components/ui/Dialog.tsx create mode 100644 app/components/ui/Dropdown.tsx create mode 100644 app/components/ui/IconButton.tsx create mode 100644 app/components/ui/Input.tsx create mode 100644 app/components/ui/Label.tsx create mode 100644 app/components/ui/LoadingDots.tsx create mode 100644 app/components/ui/LoadingOverlay.tsx create mode 100644 app/components/ui/PanelHeader.tsx create mode 100644 app/components/ui/PanelHeaderButton.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/SettingsButton.tsx create mode 100644 app/components/ui/Slider.tsx create mode 100644 app/components/ui/Switch.tsx create mode 100644 app/components/ui/Tabs.tsx create mode 100644 app/components/ui/ThemeSwitch.tsx create mode 100644 app/components/ui/Tooltip.tsx create mode 100644 app/components/upage/Brand.tsx create mode 100644 app/components/upage/Index.tsx create mode 100644 app/components/upage/Share.tsx create mode 100644 app/components/upage/icons/close.svg create mode 100644 app/components/upage/icons/copy.svg create mode 100644 app/components/upage/icons/logo.svg create mode 100644 app/components/upage/icons/share.svg create mode 100644 app/components/upage/icons/twitter.svg create mode 100644 app/components/upage/icons/wechat.svg create mode 100644 app/components/upage/icons/weibo.svg create mode 100644 app/components/webbuilder/DiffView.tsx create mode 100644 app/components/webbuilder/EditorPanel.tsx create mode 100644 app/components/webbuilder/InlineInput.tsx create mode 100644 app/components/webbuilder/PageDropdown.tsx create mode 100644 app/components/webbuilder/PageTree.tsx create mode 100644 app/components/webbuilder/Preview.tsx create mode 100644 app/components/webbuilder/ScreenshotSelector.tsx create mode 100644 app/components/webbuilder/WebBuilder.client.tsx create mode 100644 app/entry.client.tsx create mode 100644 app/entry.server.tsx create mode 100644 app/lib/.server/auth.ts create mode 100644 app/lib/.server/chat.ts create mode 100644 app/lib/.server/chatUsage.ts create mode 100644 app/lib/.server/connectionSettings.ts create mode 100644 app/lib/.server/deployment.ts create mode 100644 app/lib/.server/llm/chat-stream-text.ts create mode 100644 app/lib/.server/llm/constants.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/stream-enhancer.ts create mode 100644 app/lib/.server/llm/structured-page-snapshot.ts create mode 100644 app/lib/.server/llm/switchable-stream.ts create mode 100644 app/lib/.server/llm/tools/index.ts create mode 100644 app/lib/.server/llm/tools/serper.ts create mode 100644 app/lib/.server/llm/tools/weather.ts create mode 100644 app/lib/.server/llm/utils.ts create mode 100644 app/lib/.server/logger.server.ts create mode 100644 app/lib/.server/logger.ts create mode 100644 app/lib/.server/message.ts create mode 100644 app/lib/.server/page.ts create mode 100644 app/lib/.server/prisma.ts create mode 100644 app/lib/.server/projectService.ts create mode 100644 app/lib/.server/section.ts create mode 100644 app/lib/.server/userSettings.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/bridge/index.ts create mode 100644 app/lib/common/prompt-library.ts create mode 100644 app/lib/common/prompts/prompts.ts create mode 100644 app/lib/crypto.ts create mode 100644 app/lib/fetch.ts create mode 100644 app/lib/hooks/index.ts create mode 100644 app/lib/hooks/useAuth.ts create mode 100644 app/lib/hooks/useChatDeployment.ts create mode 100644 app/lib/hooks/useChatEntries.ts create mode 100644 app/lib/hooks/useChatHistory.ts create mode 100644 app/lib/hooks/useChatMessage.ts create mode 100644 app/lib/hooks/useChatOperate.ts create mode 100644 app/lib/hooks/useChatUsage.ts create mode 100644 app/lib/hooks/useDebugStatus.ts create mode 100644 app/lib/hooks/useDeploymentRecords.ts create mode 100644 app/lib/hooks/useEditChatDescription.ts create mode 100644 app/lib/hooks/useEditorCommands.ts create mode 100644 app/lib/hooks/useMessageParser.ts create mode 100644 app/lib/hooks/useNotifications.ts create mode 100644 app/lib/hooks/useProject.ts create mode 100644 app/lib/hooks/usePromptEnhancer.ts create mode 100644 app/lib/hooks/useSettings.ts create mode 100644 app/lib/hooks/useShortcuts.ts create mode 100644 app/lib/hooks/useSnapScroll.ts create mode 100644 app/lib/hooks/useViewport.ts create mode 100644 app/lib/modules/llm/base-provider.ts create mode 100644 app/lib/modules/llm/manager.ts create mode 100644 app/lib/modules/llm/providers/amazon-bedrock.ts create mode 100644 app/lib/modules/llm/providers/anthropic.ts create mode 100644 app/lib/modules/llm/providers/cohere.ts create mode 100644 app/lib/modules/llm/providers/deepseek.ts create mode 100644 app/lib/modules/llm/providers/github.ts create mode 100644 app/lib/modules/llm/providers/google.ts create mode 100644 app/lib/modules/llm/providers/groq.ts create mode 100644 app/lib/modules/llm/providers/huggingface.ts create mode 100644 app/lib/modules/llm/providers/hyperbolic.ts create mode 100644 app/lib/modules/llm/providers/lmstudio.ts create mode 100644 app/lib/modules/llm/providers/mistral.ts create mode 100644 app/lib/modules/llm/providers/ollama.ts create mode 100644 app/lib/modules/llm/providers/open-router.ts create mode 100644 app/lib/modules/llm/providers/openai-like.ts create mode 100644 app/lib/modules/llm/providers/openai.ts create mode 100644 app/lib/modules/llm/providers/perplexity.ts create mode 100644 app/lib/modules/llm/providers/together.ts create mode 100644 app/lib/modules/llm/providers/xai.ts create mode 100644 app/lib/modules/llm/registry.ts create mode 100644 app/lib/modules/llm/types.ts create mode 100644 app/lib/persistence/editor.ts create mode 100644 app/lib/persistence/index.ts create mode 100644 app/lib/persistence/local-storage.ts create mode 100644 app/lib/runtime/__snapshots__/message-parser.spec.ts.snap create mode 100644 app/lib/runtime/action-runner.ts create mode 100644 app/lib/runtime/message-parser.spec.ts create mode 100644 app/lib/runtime/message-parser.ts create mode 100644 app/lib/storage/base-provider.server.ts create mode 100644 app/lib/storage/index.server.ts create mode 100644 app/lib/storage/local-provider.server.ts create mode 100644 app/lib/storage/types.ts create mode 100644 app/lib/stores/1panel.ts create mode 100644 app/lib/stores/ai-state.ts create mode 100644 app/lib/stores/chat-message.ts create mode 100644 app/lib/stores/chat.ts create mode 100644 app/lib/stores/editor.ts create mode 100644 app/lib/stores/logs.ts create mode 100644 app/lib/stores/netlify.ts create mode 100644 app/lib/stores/pages.ts create mode 100644 app/lib/stores/previews.ts create mode 100644 app/lib/stores/profile.ts create mode 100644 app/lib/stores/settings.ts create mode 100644 app/lib/stores/sidebar.ts create mode 100644 app/lib/stores/tab-configuration.ts create mode 100644 app/lib/stores/theme.ts create mode 100644 app/lib/stores/vercel.ts create mode 100644 app/lib/stores/web-builder.ts create mode 100644 app/root.tsx create mode 100644 app/routes/_index.tsx create mode 100644 app/routes/api.1panel.$action/1panel.server.ts create mode 100644 app/routes/api.1panel.$action/auth.server.ts create mode 100644 app/routes/api.1panel.$action/delete.server.ts create mode 100644 app/routes/api.1panel.$action/deploy.server.ts create mode 100644 app/routes/api.1panel.$action/route.tsx create mode 100644 app/routes/api.1panel.$action/stats.server.ts create mode 100644 app/routes/api.1panel.$action/toggle-access.server.ts create mode 100644 app/routes/api.1panel.$action/websites.server.ts create mode 100644 app/routes/api.auth.$action/check-error.server.ts create mode 100644 app/routes/api.auth.$action/route.tsx create mode 100644 app/routes/api.auth.$action/user.server.ts create mode 100644 app/routes/api.chat.$action/delete.server.ts create mode 100644 app/routes/api.chat.$action/fork.server.ts create mode 100644 app/routes/api.chat.$action/list.server.ts create mode 100644 app/routes/api.chat.$action/route.tsx create mode 100644 app/routes/api.chat.$action/update.server.ts create mode 100644 app/routes/api.chat/chat.server.ts create mode 100644 app/routes/api.chat/mock-chat.server.ts create mode 100644 app/routes/api.chat/route.tsx create mode 100644 app/routes/api.deployments.$action/cache.ts create mode 100644 app/routes/api.deployments.$action/route.tsx create mode 100644 app/routes/api.deployments.$action/stats.server.ts create mode 100644 app/routes/api.deployments/route.tsx create mode 100644 app/routes/api.enhancer/route.tsx create mode 100644 app/routes/api.git-proxy.$.ts create mode 100644 app/routes/api.health/route.tsx create mode 100644 app/routes/api.netlify.$action/auth.server.ts create mode 100644 app/routes/api.netlify.$action/delete.server.ts create mode 100644 app/routes/api.netlify.$action/deploy.server.ts create mode 100644 app/routes/api.netlify.$action/route.tsx create mode 100644 app/routes/api.netlify.$action/stats.server.ts create mode 100644 app/routes/api.netlify.$action/toggle-access.server.ts create mode 100644 app/routes/api.netlify.deploys.$deployId.$action.ts create mode 100644 app/routes/api.netlify.sites.$siteId.cache.ts create mode 100644 app/routes/api.netlify.sites.$siteId.ts create mode 100644 app/routes/api.project/route.tsx create mode 100644 app/routes/api.system.$action/app-info.server.ts create mode 100644 app/routes/api.system.$action/disk.server.ts create mode 100644 app/routes/api.system.$action/git-info.server.ts create mode 100644 app/routes/api.system.$action/memory.server.ts create mode 100644 app/routes/api.system.$action/process.server.ts create mode 100644 app/routes/api.system.$action/route.tsx create mode 100644 app/routes/api.upload/route.tsx create mode 100644 app/routes/api.user.settings.ts create mode 100644 app/routes/api.vercel.$action/auth.server.ts create mode 100644 app/routes/api.vercel.$action/delete.server.ts create mode 100644 app/routes/api.vercel.$action/deploy.server.ts create mode 100644 app/routes/api.vercel.$action/route.tsx create mode 100644 app/routes/api.vercel.$action/stats.server.ts create mode 100644 app/routes/api.vercel.$action/toggle-access.server.ts create mode 100644 app/routes/api.vercel.$action/type.ts create mode 100644 app/routes/assets.$userId.$filename.ts create mode 100644 app/routes/chat.$id.tsx create mode 100644 app/styles/animations.scss create mode 100644 app/styles/components/code.scss create mode 100644 app/styles/components/editor.scss create mode 100644 app/styles/components/resize-handle.scss create mode 100644 app/styles/diff-view.css create mode 100644 app/styles/index.scss create mode 100644 app/styles/scrollbar.scss create mode 100644 app/styles/variables.scss create mode 100644 app/styles/z-index.scss create mode 100644 app/types/1panel.ts create mode 100644 app/types/actions.ts create mode 100644 app/types/artifact.ts create mode 100644 app/types/chat.ts create mode 100644 app/types/deployment.ts create mode 100644 app/types/editor.ts create mode 100644 app/types/github.ts create mode 100644 app/types/global.d.ts create mode 100644 app/types/logto.ts create mode 100644 app/types/message.ts create mode 100644 app/types/model.ts create mode 100644 app/types/netlify.ts create mode 100644 app/types/settings.ts create mode 100644 app/types/theme.ts create mode 100644 app/types/vercel.ts create mode 100644 app/utils/api-response.ts create mode 100644 app/utils/constants.ts create mode 100644 app/utils/debounce.ts create mode 100644 app/utils/diff.ts create mode 100644 app/utils/easings.ts create mode 100644 app/utils/execute-scripts.ts create mode 100644 app/utils/file-utils.ts create mode 100644 app/utils/format.ts create mode 100644 app/utils/html-parse.ts create mode 100644 app/utils/logger.ts create mode 100644 app/utils/markdown.ts create mode 100644 app/utils/mobile.ts create mode 100644 app/utils/os.ts create mode 100644 app/utils/page.ts create mode 100644 app/utils/path.ts create mode 100644 app/utils/prettier.ts create mode 100644 app/utils/react.ts create mode 100644 app/utils/sampler.ts create mode 100644 app/utils/strip-indent.ts create mode 100644 app/utils/throttle.ts create mode 100644 app/utils/token.ts create mode 100644 app/utils/unreachable.ts create mode 100644 app/utils/uuid.ts create mode 100755 bindings.sh create mode 100644 biome.json create mode 100644 docker-compose-dev.yaml create mode 100644 docker-compose-prod.yaml create mode 100644 docker-entrypoint.sh create mode 100644 docs/index.md create mode 100644 icons/angular.svg create mode 100644 icons/astro.svg create mode 100644 icons/chat.svg create mode 100644 icons/logo-text.svg create mode 100644 icons/logo.svg create mode 100644 icons/nativescript.svg create mode 100644 icons/nextjs.svg create mode 100644 icons/nuxt.svg create mode 100644 icons/qwik.svg create mode 100644 icons/react.svg create mode 100644 icons/remix.svg create mode 100644 icons/remotion.svg create mode 100644 icons/slidev.svg create mode 100644 icons/stars.svg create mode 100644 icons/svelte.svg create mode 100644 icons/typescript.svg create mode 100644 icons/vite.svg create mode 100644 icons/vue.svg create mode 100644 logto/.env create mode 100644 logto/docker-compose.yaml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pre-start.mjs create mode 100644 prisma/migrations/20250922030707_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 public/iconify-icon.min.js create mode 100644 public/icons/1panel.png create mode 100644 public/icons/AmazonBedrock.svg create mode 100644 public/icons/Anthropic.svg create mode 100644 public/icons/Cohere.svg create mode 100644 public/icons/Deepseek.svg create mode 100644 public/icons/Default.svg create mode 100644 public/icons/Google.svg create mode 100644 public/icons/Groq.svg create mode 100644 public/icons/HuggingFace.svg create mode 100644 public/icons/Hyperbolic.svg create mode 100644 public/icons/LMStudio.svg create mode 100644 public/icons/Mistral.svg create mode 100644 public/icons/Ollama.svg create mode 100644 public/icons/OpenAI.svg create mode 100644 public/icons/OpenAILike.svg create mode 100644 public/icons/OpenRouter.svg create mode 100644 public/icons/Perplexity.svg create mode 100644 public/icons/Together.svg create mode 100644 public/icons/UPage.svg create mode 100644 public/icons/xAI.svg create mode 100644 public/logo.svg create mode 100644 public/tailwindcss.js create mode 100644 scripts/clean.js create mode 100644 server.mjs create mode 100644 tsconfig.json create mode 100644 uno.config.ts create mode 100644 vite.config.mts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ce905b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# Ignore Git and GitHub files +.git +.github/ + +# Ignore Husky configuration files +.husky/ + +# Ignore documentation and metadata files +CONTRIBUTING.md +LICENSE +README.md + +# Ignore environment examples and sensitive info +.env +.env.* +*.local +*.example +.env.development +.env.production +.env.test +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Ignore node modules, logs and cache files +**/*.log +**/node_modules +**/dist +**/build +**/.cache +logs +dist-ssr +.DS_Store + +# Ignore any potential secrets or key files +**/*.pem +**/*.key +**/secrets/ +**/credentials/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5274ff0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e86d4ae --- /dev/null +++ b/.env.example @@ -0,0 +1,158 @@ +# Rename this file to .env once you have filled in the below environment variables! + +# Get your GROQ API Key here - +# https://console.groq.com/keys +# You only need this environment variable set if you want to use Groq models +GROQ_API_KEY= + +# Get your HuggingFace API Key here - +# https://huggingface.co/settings/tokens +# You only need this environment variable set if you want to use HuggingFace models +HuggingFace_API_KEY= + + +# Get your Open AI API Key by following these instructions - +# https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key +# You only need this environment variable set if you want to use GPT models +OPENAI_API_KEY= + +# Get your Anthropic API Key in your account settings - +# https://console.anthropic.com/settings/keys +# You only need this environment variable set if you want to use Claude models +ANTHROPIC_API_KEY= + +# Get your OpenRouter API Key in your account settings - +# https://openrouter.ai/settings/keys +# You only need this environment variable set if you want to use OpenRouter models +OPEN_ROUTER_API_KEY= + +# Get your Google Generative AI API Key by following these instructions - +# https://console.cloud.google.com/apis/credentials +# You only need this environment variable set if you want to use Google Generative AI models +GOOGLE_GENERATIVE_AI_API_KEY= + +# You only need this environment variable set if you want to use oLLAMA models +# DONT USE http://localhost:11434 due to IPV6 issues +# USE EXAMPLE http://127.0.0.1:11434 +OLLAMA_API_BASE_URL= + +# You only need this environment variable set if you want to use OpenAI Like models +OPENAI_LIKE_API_BASE_URL= + +# You only need this environment variable set if you want to use Together AI models +TOGETHER_API_BASE_URL= + +# You only need this environment variable set if you want to use DeepSeek models through their API +DEEPSEEK_API_KEY= + +# Get your OpenAI Like API Key +OPENAI_LIKE_API_KEY= + +# Get your Together API Key +TOGETHER_API_KEY= + +# You only need this environment variable set if you want to use Hyperbolic models +#Get your Hyperbolics API Key at https://app.hyperbolic.xyz/settings +#baseURL="https://api.hyperbolic.xyz/v1/chat/completions" +HYPERBOLIC_API_KEY= +HYPERBOLIC_API_BASE_URL= + +# Get your Mistral API Key by following these instructions - +# https://console.mistral.ai/api-keys/ +# You only need this environment variable set if you want to use Mistral models +MISTRAL_API_KEY= + +# Get the Cohere Api key by following these instructions - +# https://dashboard.cohere.com/api-keys +# You only need this environment variable set if you want to use Cohere models +COHERE_API_KEY= + +# Get LMStudio Base URL from LM Studio Developer Console +# Make sure to enable CORS +# DONT USE http://localhost:1234 due to IPV6 issues +# Example: http://127.0.0.1:1234 +LMSTUDIO_API_BASE_URL= + +# Get your xAI API key +# https://x.ai/api +# You only need this environment variable set if you want to use xAI models +XAI_API_KEY= + +# Get your Perplexity API Key here - +# https://www.perplexity.ai/settings/api +# You only need this environment variable set if you want to use Perplexity models +PERPLEXITY_API_KEY= + +# Get your AWS configuration +# https://console.aws.amazon.com/iam/home +# The JSON should include the following keys: +# - region: The AWS region where Bedrock is available. +# - accessKeyId: Your AWS access key ID. +# - secretAccessKey: Your AWS secret access key. +# - sessionToken (optional): Temporary session token if using an IAM role or temporary credentials. +# Example JSON: +# {"region": "us-east-1", "accessKeyId": "yourAccessKeyId", "secretAccessKey": "yourSecretAccessKey", "sessionToken": "yourSessionToken"} +AWS_BEDROCK_CONFIG= + +# 是否开启文件日志 +USAGE_LOG_FILE=false +# Include this environment variable if you want more logging for debugging locally +LOG_LEVEL=debug + +# Example Context Values for qwen2.5-coder:32b +# +# DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM +# DEFAULT_NUM_CTX=24576 # Consumes 32GB of VRAM +# DEFAULT_NUM_CTX=12288 # Consumes 26GB of VRAM +# DEFAULT_NUM_CTX=6144 # Consumes 24GB of VRAM +DEFAULT_NUM_CTX= + +# Get your Serper API Key https://serper.dev/ +SERPER_API_KEY= + +# Get your Weather API Key https://www.weatherapi.com/my/ +WEATHER_API_KEY= + +# LLM Configuration Options + +# Default LLM provider to use (e.g.,OpenAILike,OpenAI, Anthropic, Mistral) +LLM_DEFAULT_PROVIDER= + +# 生成页面所使用的 MODEL(应该与 LLM_DEFAULT_PROVIDER 相对应) +LLM_DEFAULT_MODEL= + +# 用于辅助页面生成所使用的 MODEL,例如总结和预分析。(应该与 LLM_DEFAULT_PROVIDER 相对应) +LLM_MINOR_MODEL= + +# Comma-separated list of enabled providers (empty means all providers) +# Example: OpenAILike,OpenAI,Anthropic,Mistral +LLM_ENABLED_PROVIDERS= + +# Logto 集成所需环境变量 +# Logto 地址 +LOGTO_ENDPOINT= +# Logto 应用 ID +LOGTO_APP_ID= +# Logto 应用密钥 +LOGTO_APP_SECRET= +# 应用基础 URL,根据实际部署环境修改 +LOGTO_BASE_URL=http://localhost:5173 +# 随机任意的 36 位字符串,用于加密 Logto 的 cookie。 +LOGTO_COOKIE_SECRET= +# 是否在开发环境中启用 Logto 认证,设置为 false 则在开发环境不强制认证 +LOGTO_ENABLE_DEV=false +# 运行环境,与 NODE_ENV 有所不同, NODE_ENV 在打包时就已确定,而此变量用于某些功能在不同环境下的开放 +# development | production | test +OPERATING_ENV=production + +# 资源文件存储位置 +STORAGE_DIR=/public/uploads +# 附件上传的最大大小 +MAX_UPLOAD_SIZE_MB=5 + + +POSTGRES_PASSWORD=p0stgr3s +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings +DATABASE_URL="postgresql://upage:${POSTGRES_PASSWORD}@localhost:5433/upage?schema=upage_schema" + diff --git a/.github/actions/docker-buildx-push/action.yaml b/.github/actions/docker-buildx-push/action.yaml new file mode 100644 index 0000000..681afa7 --- /dev/null +++ b/.github/actions/docker-buildx-push/action.yaml @@ -0,0 +1,75 @@ +name: "Docker buildx and push" +description: "Buildx and push the Docker image." + +inputs: + ghcr-token: + description: Token of current GitHub account in GitHub container registry. + required: false + default: "" + dockerhub-user: + description: "User name for the DockerHub account" + required: false + default: "" + dockerhub-token: + description: Token for the DockerHub account + required: false + default: "" + push: + description: Should push the docker image or not. + required: false + default: "false" + platforms: + description: Target platforms for building image + required: false + default: "linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x" + image-name: + description: The basic name of docker. + required: false + default: "upage" + +runs: + using: "composite" + steps: + - name: Docker meta for UPage + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }} + halohub/${{ inputs.image-name }} + tags: | + type=schedule,pattern=nightly-{{date 'YYYYMMDD'}},enabled=${{ github.event_name == 'schedule' }} + type=ref,event=branch,enabled=${{ github.event_name == 'push' }} + type=ref,event=pr,enabled=${{ github.event_name == 'pull_request' }} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{ version }} + type=sha,enabled=${{ github.event_name == 'push' }} + flavor: | + latest=false + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GHCR + uses: docker/login-action@v3 + if: inputs.ghcr-token != '' && github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ inputs.ghcr-token }} + - name: Login to DockerHub + if: inputs.dockerhub-token != '' && github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ inputs.dockerhub-user }} + password: ${{ inputs.dockerhub-token }} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: ${{ inputs.platforms }} + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta.outputs.tags }} + push: ${{ (inputs.ghcr-token != '' || inputs.dockerhub-token != '') && inputs.push == 'true' }} diff --git a/.github/actions/setup-and-build/action.yaml b/.github/actions/setup-and-build/action.yaml new file mode 100644 index 0000000..b27bc6f --- /dev/null +++ b/.github/actions/setup-and-build/action.yaml @@ -0,0 +1,32 @@ +name: Setup and Build +description: Generic setup action +inputs: + pnpm-version: + required: false + type: string + default: '9.4.0' + node-version: + required: false + type: string + default: '20.15.1' + +runs: + using: composite + + steps: + - uses: pnpm/action-setup@v4 + with: + version: ${{ inputs.pnpm-version }} + run_install: false + + - name: Set Node.js version to ${{ inputs.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: pnpm + + - name: Install dependencies and build project + shell: bash + run: | + pnpm install + pnpm run build diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..274f480 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,27 @@ +name: CI/CD + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup and Build + uses: ./.github/actions/setup-and-build + + - name: Run type check + run: pnpm run typecheck + + - name: Run check + run: pnpm run check + + - name: Run tests + run: pnpm run test diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..28296c6 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,39 @@ +name: Docker Publish + +on: + push: + branches: + - main + paths: + - "**" + - "!**.md" + - '!docs/**' + release: + types: + - published + +concurrency: + group: ${{github.workflow}} - ${{github.ref}} + cancel-in-progress: true + +permissions: + packages: write + contents: read + +jobs: + docker-build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup and Build + uses: ./.github/actions/setup-and-build + - name: Docker Buildx and Push + uses: ./.github/actions/docker-buildx-push + with: + image-name: ${{ github.event_name == 'release' && 'upage' || 'upage-dev' }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + dockerhub-user: ${{ secrets.DOCKER_USERNAME }} + dockerhub-token: ${{ secrets.DOCKER_TOKEN }} + push: true + platforms: linux/amd64,linux/arm64/v8,linux/ppc64le,linux/s390x diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..c0f117b --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,35 @@ +name: Docs CI/CD + +on: + push: + branches: + - main + paths: + - 'docs/**' # This will only trigger the workflow when files in docs directory change +permissions: + contents: write +jobs: + build_docs: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./docs + steps: + - uses: actions/checkout@v4 + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + restore-keys: | + mkdocs-material- + + - run: pip install mkdocs-material + - run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b810e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +!.vscode/extensions.json +.idea +.cursor +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +/.history +/.cache +/build +functions/build/ +.env.local +/.env +.dev.vars +*.vars +_worker.bundle + +Modelfile +modelfiles + +# docs ignore +site + +# commit file ignore +app/commit.json +changelogUI.md +docs/instructions/Roadmap.md +.qodo + + +# prisma +generated/prisma/ + +# mock +mock + +# storage +/public/uploads diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..981a123 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,32 @@ +#!/bin/sh + +echo "🔍 Running pre-commit hook to check the code looks good... 🔍" + +# Load NVM if available (useful for managing Node.js versions) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Ensure `pnpm` is available +echo "Checking if pnpm is available..." +if ! command -v pnpm >/dev/null 2>&1; then + echo "❌ pnpm not found! Please ensure pnpm is installed and available in PATH." + exit 1 +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 + +# Run check +echo "Running check..." +if ! pnpm check:stage; then + echo "❌ Check failed! Run 'pnpm check:stage' to fix the easy issues." + echo "Once you're done, don't forget to add your beautification to the commit! 🤩" + exit 1 +fi + +echo "👍 All checks passed! Committing changes..." diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2a8ffe1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll": "never", + "source.organizeImports.biome": "explicit" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8096f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,83 @@ +FROM node:20.18.0-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache python3 make g++ bash + +COPY package.json pnpm-lock.yaml ./ + +ENV HUSKY=0 + +RUN npm install -g pnpm && pnpm install + +COPY . . + +RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm run build + +FROM node:20.18.0-alpine AS deps + +WORKDIR /app + +RUN apk add --no-cache python3 make g++ bash + +COPY package.json pnpm-lock.yaml ./ + +ENV HUSKY=0 + +RUN npm install -g pnpm && pnpm install --prod + +COPY prisma ./prisma + +RUN pnpm prisma generate + +FROM node:20.18.0-alpine AS upage-ai-production + +WORKDIR /app + +RUN apk add --no-cache bash + +RUN npm install -g pnpm + +ARG LOG_LEVEL=debug +ARG PORT=3000 +ARG LLM_DEFAULT_PROVIDER +ARG LLM_DEFAULT_MODEL +ARG LLM_ENABLED_PROVIDERS +ARG DEFAULT_NUM_CTX +ARG OLLAMA_API_BASE_URL +ARG TOGETHER_API_BASE_URL +ARG LOGTO_ENDPOINT +ARG LOGTO_APP_ID +ARG LOGTO_BASE_URL + +ENV NODE_ENV=production \ + PORT=${PORT} \ + LOG_LEVEL=${LOG_LEVEL} \ + DEFAULT_NUM_CTX=${DEFAULT_NUM_CTX} \ + LLM_DEFAULT_PROVIDER=${LLM_DEFAULT_PROVIDER} \ + LLM_DEFAULT_MODEL=${LLM_DEFAULT_MODEL} \ + LLM_ENABLED_PROVIDERS=${LLM_ENABLED_PROVIDERS} \ + OLLAMA_API_BASE_URL=${OLLAMA_API_BASE_URL} \ + TOGETHER_API_BASE_URL=${TOGETHER_API_BASE_URL} \ + LOGTO_ENDPOINT=${LOGTO_ENDPOINT} \ + LOGTO_APP_ID=${LOGTO_APP_ID} \ + LOGTO_BASE_URL=${LOGTO_BASE_URL} \ + RUNNING_IN_DOCKER=true \ + STORAGE_DIR=/app/storage + +COPY --from=builder /app/build ./build +COPY --from=builder /app/public ./public +COPY --from=builder /app/server.mjs ./ + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/prisma ./prisma + +COPY package.json ./ + +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 3000 + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD [ "pnpm", "run", "start" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5c29be --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +

UPage

+

基于人工智能的可视化网页构建平台

+ +

+GitHub release +GitHub last commit +GitHub Workflow Status +

+ +------------------------------ + +UPage 是一款基于人工智能的可视化网页构建平台,支持多种 AI 提供商集成,快速实现定制化网页。 + +------------------------------ + +特别感谢 [bolt.diy](https://github.com/stackblitz-labs/bolt.diy) 项目,UPage 的实现基于该项目的代码结构。 + +------------------------------ + +## 快速开始 + +UPage 提供基于 Docker 的部署方案,可以使用以下脚本进行快速部署: + +```bash +docker run -d \ + --name upage \ + --restart unless-stopped \ + -p 3000:3000 \ + -e LLM_DEFAULT_PROVIDER=OpenAILike \ + -e OPENAI_LIKE_API_KEY=your-openai-like-api-key \ + -e LLM_DEFAULT_MODEL=your-default-model \ + -e LLM_MINOR_MODEL=your-minor-model \ + -v ./logs:/app/logs \ + -v ./storage:/app/storage \ + ghcr.io/halo-dev/upage:latest +``` diff --git a/app/components/@settings/core/AvatarDropdown.tsx b/app/components/@settings/core/AvatarDropdown.tsx new file mode 100644 index 0000000..be49edc --- /dev/null +++ b/app/components/@settings/core/AvatarDropdown.tsx @@ -0,0 +1,140 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import classNames from 'classnames'; +import { motion } from 'framer-motion'; +import { useMemo } from 'react'; +import { useAuth } from '~/lib/hooks/useAuth'; +import type { TabType } from './types'; + +interface AvatarDropdownProps { + onSelectTab: (tab: TabType) => void; +} + +export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { + const { userInfo, isAuthenticated } = useAuth(); + + const displayName = useMemo(() => { + if (!isAuthenticated || !userInfo) { + return 'Guest User'; + } + + return userInfo.name || userInfo.username; + }, [userInfo]); + + const contactInfo = useMemo(() => { + if (!isAuthenticated || !userInfo) { + return null; + } + + if (userInfo.phone_number) { + return `+${userInfo.phone_number}`; + } + + return userInfo.email; + }, [userInfo]); + + const avatarUrl = isAuthenticated && userInfo?.picture ? userInfo.picture : ''; + + return ( + + + + {avatarUrl ? ( + {displayName} + ) : ( +
+
+
+ )} + + + + + +
+
+ {avatarUrl ? ( + {displayName} + ) : ( +
+ ? +
+ )} +
+
+
{displayName}
+ {isAuthenticated && userInfo?.email && ( +
{contactInfo}
+ )} +
+
+ + onSelectTab('settings')} + > +
+ Settings + + +
+ + onSelectTab('task-manager')} + > +
+ Task Manager + + + + + ); +}; diff --git a/app/components/@settings/core/ControlPanel.tsx b/app/components/@settings/core/ControlPanel.tsx new file mode 100644 index 0000000..a8e5f31 --- /dev/null +++ b/app/components/@settings/core/ControlPanel.tsx @@ -0,0 +1,329 @@ +import { useStore } from '@nanostores/react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import classNames from 'classnames'; +import { AnimatePresence, motion, type Variants } from 'framer-motion'; +import { useEffect, useMemo, useState } from 'react'; +import { TabTile } from '~/components/@settings/core/TabTile'; +import DebugTab from '~/components/@settings/tabs/debug/DebugTab'; +import { EventLogsTab } from '~/components/@settings/tabs/event-logs/EventLogsTab'; +import NotificationsTab from '~/components/@settings/tabs/notifications/NotificationsTab'; +import SettingsTab from '~/components/@settings/tabs/settings/SettingsTab'; +import TaskManagerTab from '~/components/@settings/tabs/task-manager/TaskManagerTab'; +import BackgroundRays from '~/components/ui/BackgroundRays'; +import { useDebugStatus } from '~/lib/hooks/useDebugStatus'; +import { useNotifications } from '~/lib/hooks/useNotifications'; +import { profileStore } from '~/lib/stores/profile'; +import { resetTabConfiguration, tabConfigurationStore } from '~/lib/stores/settings'; +import { logger } from '~/utils/logger'; +import { AvatarDropdown } from './AvatarDropdown'; +import { DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS } from './constants'; +import type { Profile, TabType, TabVisibilityConfig } from './types'; + +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; +} + +export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { + // State + const [activeTab, setActiveTab] = useState(null); + const [loadingTab, setLoadingTab] = useState(null); + + // Store values + const tabConfiguration = useStore(tabConfigurationStore); + const profile = useStore(profileStore) as Profile; + + // Status hooks + const { hasUnreadNotifications, unreadNotifications, markAllAsRead } = useNotifications(); + 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)) { + logger.warn('Invalid tab configuration, resetting to defaults'); + resetTabConfiguration(); + + return []; + } + + 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)); + + return devTabs.sort((a, b) => a.order - b.order); + }, [tabConfiguration, 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: Variants = { + hidden: { opacity: 0, scale: 0.8 }, + visible: { + opacity: 1, + scale: 1, + transition: { + type: 'spring', + stiffness: 200, + damping: 20, + mass: 0.6, + }, + }, + }; + + // Reset to default view when modal opens/closes + useEffect(() => { + if (!open) { + // Reset when closing + setActiveTab(null); + setLoadingTab(null); + } else { + // When opening, set to null to show the main view + setActiveTab(null); + } + }, [open]); + + // Handle closing + const handleClose = () => { + setActiveTab(null); + setLoadingTab(null); + onClose(); + }; + + // Handlers + const handleBack = () => { + setActiveTab(null); + }; + + const getTabComponent = (tabId: TabType) => { + switch (tabId) { + case 'settings': + return ; + case 'notifications': + return ; + case 'debug': + return ; + case 'event-logs': + return ; + case 'task-manager': + return ; + default: + return null; + } + }; + + const getTabUpdateStatus = (tabId: TabType): boolean => { + switch (tabId) { + case 'notifications': + return hasUnreadNotifications; + case 'debug': + return hasActiveWarnings; + default: + return false; + } + }; + + const getStatusMessage = (tabId: TabType): string => { + switch (tabId) { + case 'notifications': + return `${unreadNotifications.length} unread notification${unreadNotifications.length === 1 ? '' : 's'}`; + 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); + + // Acknowledge notifications based on tab + switch (tabId) { + case 'notifications': + markAllAsRead(); + break; + case 'debug': + acknowledgeAllIssues(); + break; + } + + // Clear loading state after a delay + setTimeout(() => setLoadingTab(null), 500); + }; + + return ( + + +
+ + + + + + +
+ +
+
+ {/* Header */} +
+
+ {activeTab && ( + + )} +
+ +
+ {/* Avatar and Dropdown */} +
+ +
+ + {/* Close Button */} + +
+
+ + {/* Content */} +
+ + {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" + > + + ))} + + + )} + +
+
+
+
+
+
+
+ ); +}; diff --git a/app/components/@settings/core/TabTile.tsx b/app/components/@settings/core/TabTile.tsx new file mode 100644 index 0000000..9010be8 --- /dev/null +++ b/app/components/@settings/core/TabTile.tsx @@ -0,0 +1,135 @@ +import * as Tooltip from '@radix-ui/react-tooltip'; +import classNames from 'classnames'; +import { motion } from 'framer-motion'; +import { TAB_ICONS, TAB_LABELS } from '~/components/@settings/core/constants'; +import type { TabVisibilityConfig } from '~/components/@settings/core/types'; + +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/core/constants.ts b/app/components/@settings/core/constants.ts new file mode 100644 index 0000000..187464a --- /dev/null +++ b/app/components/@settings/core/constants.ts @@ -0,0 +1,49 @@ +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', + debug: 'i-ph:bug-fill', + 'event-logs': 'i-ph:list-bullets-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', + debug: 'Debug', + 'event-logs': 'Event Logs', + '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', + debug: 'Debug tools and system information', + 'event-logs': 'View system events and logs', + 'task-manager': 'Monitor system resources and processes', + 'tab-management': 'Configure visible tabs and their order', +}; + +export const DEFAULT_TAB_CONFIG = [ + { id: 'notifications', visible: true, window: 'user' as const, order: 5 }, + { id: 'event-logs', visible: true, window: 'user' as const, order: 6 }, + + { id: 'settings', visible: false, window: 'user' as const, order: 8 }, + { id: 'task-manager', visible: false, window: 'user' as const, order: 9 }, + + // User Window Tabs (Hidden, controlled by TaskManagerTab) + { id: 'debug', visible: false, window: 'user' as const, order: 11 }, + + // Developer Window Tabs (All visible by default) + { id: 'notifications', visible: true, window: 'developer' as const, order: 5 }, + { id: 'event-logs', visible: true, window: 'developer' as const, order: 6 }, + { id: 'settings', visible: true, window: 'developer' as const, order: 8 }, + { id: 'task-manager', visible: true, window: 'developer' as const, order: 9 }, + { id: 'debug', visible: true, window: 'developer' as const, order: 11 }, +]; diff --git a/app/components/@settings/core/types.ts b/app/components/@settings/core/types.ts new file mode 100644 index 0000000..a90a65e --- /dev/null +++ b/app/components/@settings/core/types.ts @@ -0,0 +1,77 @@ +export type SettingCategory = 'profile' | 'file_sharing' | 'connectivity' | 'system' | 'services' | 'preferences'; + +export type TabType = + | 'profile' + | 'settings' + | 'notifications' + | 'debug' + | 'event-logs' + | '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 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 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 0000000..b6818e8 --- /dev/null +++ b/app/components/@settings/index.ts @@ -0,0 +1,10 @@ +// Core exports +export { ControlPanel } from './core/ControlPanel'; +// Constants +export { DEFAULT_TAB_CONFIG, TAB_DESCRIPTIONS, TAB_LABELS } from './core/constants'; +// Shared components +export { TabTile } from './core/TabTile'; +export type { TabType, TabVisibilityConfig } from './core/types'; +export * from './utils/animations'; +// Utils +export { getVisibleTabs, reorderTabs, resetToDefaultConfig } from './utils/tab-helpers'; diff --git a/app/components/@settings/tabs/debug/DebugTab.tsx b/app/components/@settings/tabs/debug/DebugTab.tsx new file mode 100644 index 0000000..86aae95 --- /dev/null +++ b/app/components/@settings/tabs/debug/DebugTab.tsx @@ -0,0 +1,1919 @@ +import { useStore } from '@nanostores/react'; +import classNames from 'classnames'; +import { jsPDF } from 'jspdf'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Badge } from '~/components/ui/Badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/Collapsible'; +import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { Progress } from '~/components/ui/Progress'; +import { ScrollArea } from '~/components/ui/ScrollArea'; +import { type LogEntry, logStore } from '~/lib/stores/logs'; + +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; +} + +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 [loading, setLoading] = useState({ + systemInfo: false, + webAppInfo: false, + errors: false, + performance: false, + }); + const [openSections, setOpenSections] = useState({ + system: false, + webapp: false, + errors: false, + performance: false, + }); + + // 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 better OS detection + const userAgent = navigator.userAgent; + let detectedOS = 'Unknown'; + let detectedArch = 'unknown'; + + // Improved OS detection + if (userAgent.indexOf('Win') !== -1) { + detectedOS = 'Windows'; + } else if (userAgent.indexOf('Mac') !== -1) { + detectedOS = 'macOS'; + } else if (userAgent.indexOf('Linux') !== -1) { + detectedOS = 'Linux'; + } else if (userAgent.indexOf('Android') !== -1) { + detectedOS = 'Android'; + } else if (/iPhone|iPad|iPod/.test(userAgent)) { + detectedOS = 'iOS'; + } + + // Better architecture detection + if (userAgent.indexOf('x86_64') !== -1 || userAgent.indexOf('x64') !== -1 || userAgent.indexOf('WOW64') !== -1) { + detectedArch = 'x64'; + } else if (userAgent.indexOf('x86') !== -1 || userAgent.indexOf('i686') !== -1) { + detectedArch = 'x86'; + } else if (userAgent.indexOf('arm64') !== -1 || userAgent.indexOf('aarch64') !== -1) { + detectedArch = 'arm64'; + } else if (userAgent.indexOf('arm') !== -1) { + detectedArch = 'arm'; + } + + // Get browser info with improved detection + const browserName = (() => { + if (userAgent.indexOf('Edge') !== -1 || userAgent.indexOf('Edg/') !== -1) { + return 'Edge'; + } + + if (userAgent.indexOf('Chrome') !== -1) { + return 'Chrome'; + } + + if (userAgent.indexOf('Firefox') !== -1) { + return 'Firefox'; + } + + if (userAgent.indexOf('Safari') !== -1) { + return 'Safari'; + } + + return 'Unknown'; + })(); + + const browserVersionMatch = userAgent.match(/(Edge|Edg|Chrome|Firefox|Safari)[\s/](\d+(\.\d+)*)/); + const browserVersion = browserVersionMatch ? browserVersionMatch[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 || {}; + + // Try to use Navigation Timing API Level 2 when available + let loadTime = 0; + let domReadyTime = 0; + + try { + const navEntries = performance.getEntriesByType('navigation'); + + if (navEntries.length > 0) { + const navTiming = navEntries[0] as PerformanceNavigationTiming; + loadTime = navTiming.loadEventEnd - navTiming.startTime; + domReadyTime = navTiming.domContentLoadedEventEnd - navTiming.startTime; + } else { + // Fall back to older API + loadTime = timing.loadEventEnd - timing.navigationStart; + domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart; + } + } catch { + // Fall back to older API if Navigation Timing API Level 2 is not available + loadTime = timing.loadEventEnd - timing.navigationStart; + domReadyTime = timing.domContentLoadedEventEnd - timing.navigationStart; + } + + // 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: detectedOS, + arch: detectedArch, + platform: navigator.platform || 'unknown', + 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 || 'unknown', + 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, + domReadyTime, + 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 })); + } + }; + + // Helper function to format bytes to human readable format with better precision + const formatBytes = (bytes: number) => { + if (bytes === 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + // Return with proper precision based on unit size + if (i === 0) { + return `${bytes} ${units[i]}`; + } + + return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; + }; + + 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 })); + } + }; + + 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 = `upage-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 = `upage-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(`upage-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 = `upage-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, + }, + ]; + + // 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) => ( + + ))} +
+
+
+
+ ); + }; + + return ( +
+ {/* Quick Stats Banner */} +
+ {/* 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'} +
+
+ + {/* 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 +
+
+
+ + {/* 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 0000000..a66b1ca --- /dev/null +++ b/app/components/@settings/tabs/event-logs/EventLogsTab.tsx @@ -0,0 +1,1013 @@ +import { useStore } from '@nanostores/react'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import classNames from 'classnames'; +import { motion } from 'framer-motion'; +import { jsPDF } from 'jspdf'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { Dialog, DialogRoot, DialogTitle } from '~/components/ui/Dialog'; +import { Switch } from '~/components/ui/Switch'; +import { type LogEntry, logStore } from '~/lib/stores/logs'; + +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 = `upage-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 = `upage-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 upage + doc.setFontSize(12); + doc.setFont('helvetica', 'normal'); + doc.text('upage - 使用人工智能构建可视化网页', 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 upage', 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(`upage-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 = `upage-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-text', + 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/notifications/NotificationsTab.tsx b/app/components/@settings/tabs/notifications/NotificationsTab.tsx new file mode 100644 index 0000000..c804146 --- /dev/null +++ b/app/components/@settings/tabs/notifications/NotificationsTab.tsx @@ -0,0 +1,300 @@ +import { useStore } from '@nanostores/react'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import classNames from 'classnames'; +import { formatDistanceToNow } from 'date-fns'; +import { motion } from 'framer-motion'; +import { useEffect, useState } from 'react'; +import { logStore } from '~/lib/stores/logs'; + +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/settings/SettingsTab.tsx b/app/components/@settings/tabs/settings/SettingsTab.tsx new file mode 100644 index 0000000..07d4fe8 --- /dev/null +++ b/app/components/@settings/tabs/settings/SettingsTab.tsx @@ -0,0 +1,215 @@ +import classNames from 'classnames'; +import { motion } from 'framer-motion'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import type { UserProfile } from '~/components/@settings/core/types'; +import { Switch } from '~/components/ui/Switch'; +import { isMac } from '~/utils/os'; + +// Helper to get modifier key symbols/text +const getModifierSymbol = (modifier: string): string => { + switch (modifier) { + case 'meta': + return isMac ? '⌘' : 'Win'; + case 'alt': + return isMac ? '⌥' : 'Alt'; + case 'shift': + return '⇧'; + default: + return modifier; + } +}; + +export default function SettingsTab() { + const [currentTimezone, setCurrentTimezone] = useState(''); + const [settings, setSettings] = useState(() => { + const saved = localStorage.getItem('upage_user_profile'); + return saved + ? JSON.parse(saved) + : { + notifications: true, + language: 'en', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + }); + + useEffect(() => { + setCurrentTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); + }, []); + + // Save settings automatically when they change + useEffect(() => { + try { + // Get existing profile data + const existingProfile = JSON.parse(localStorage.getItem('upage_user_profile') || '{}'); + + // Merge with new settings + const updatedProfile = { + ...existingProfile, + notifications: settings.notifications, + language: settings.language, + timezone: settings.timezone, + }; + + localStorage.setItem('upage_user_profile', JSON.stringify(updatedProfile)); + toast.success('Settings updated'); + } catch (error) { + console.error('Error saving settings:', error); + toast.error('Failed to update settings'); + } + }, [settings]); + + return ( +
+ {/* Language & Notifications */} + +
+
+ Preferences +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+
+ + {settings.notifications ? 'Notifications are enabled' : 'Notifications are disabled'} + + { + // Update local state + setSettings((prev) => ({ ...prev, notifications: checked })); + + // Update localStorage immediately + const existingProfile = JSON.parse(localStorage.getItem('upage_user_profile') || '{}'); + const updatedProfile = { + ...existingProfile, + notifications: checked, + }; + localStorage.setItem('upage_user_profile', JSON.stringify(updatedProfile)); + + // Dispatch storage event for other components + window.dispatchEvent( + new StorageEvent('storage', { + key: 'upage_user_profile', + newValue: JSON.stringify(updatedProfile), + }), + ); + + toast.success(`Notifications ${checked ? 'enabled' : 'disabled'}`); + }} + /> +
+
+ + + {/* Timezone */} + +
+
+ Time Settings +
+ +
+
+
+ +
+ +
+ + + {/* Simplified Keyboard Shortcuts */} + +
+
+ Keyboard Shortcuts +
+ +
+
+
+ Toggle Theme + Switch between light and dark mode +
+
+ + {getModifierSymbol('meta')} + + + {getModifierSymbol('alt')} + + + {getModifierSymbol('shift')} + + + D + +
+
+
+ +
+ ); +} diff --git a/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx b/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx new file mode 100644 index 0000000..bdeb771 --- /dev/null +++ b/app/components/@settings/tabs/task-manager/TaskManagerTab.tsx @@ -0,0 +1,1605 @@ +import { useStore } from '@nanostores/react'; +import { + CategoryScale, + type Chart, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from 'chart.js'; +import classNames from 'classnames'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Line } from 'react-chartjs-2'; +import { toast } from 'sonner'; +import { tabConfigurationStore } from '~/lib/stores/settings'; + +// Register ChartJS components +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); + +interface BatteryManager extends EventTarget { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; +} + +interface SystemMemoryInfo { + total: number; + free: number; + used: number; + percentage: number; + swap?: { + total: number; + free: number; + used: number; + percentage: number; + }; + timestamp: string; + error?: string; +} + +interface ProcessInfo { + pid: number; + name: string; + cpu: number; + memory: number; + command?: string; + timestamp: string; + error?: string; +} + +interface DiskInfo { + filesystem: string; + size: number; + used: number; + available: number; + percentage: number; + mountpoint: string; + timestamp: string; + error?: string; +} + +interface SystemMetrics { + memory: { + used: number; + total: number; + percentage: number; + process?: { + heapUsed: number; + heapTotal: number; + external: number; + rss: number; + }; + }; + systemMemory?: SystemMemoryInfo; + processes?: ProcessInfo[]; + disks?: DiskInfo[]; + battery?: { + level: number; + charging: boolean; + timeRemaining?: number; + }; + network: { + downlink: number; + uplink?: number; + latency: { + current: number; + average: number; + min: number; + max: number; + history: number[]; + lastUpdate: number; + }; + type: string; + effectiveType?: string; + }; + performance: { + pageLoad: number; + domReady: number; + resources: { + total: number; + size: number; + loadTime: number; + }; + timing: { + ttfb: number; + fcp: number; + lcp: number; + }; + }; +} + +type SortField = 'name' | 'pid' | 'cpu' | 'memory'; +type SortDirection = 'asc' | 'desc'; + +interface MetricsHistory { + timestamps: string[]; + memory: number[]; + battery: number[]; + network: number[]; + cpu: number[]; + disk: number[]; +} + +interface PerformanceAlert { + type: 'warning' | 'error' | 'info'; + message: string; + timestamp: number; + metric: string; + threshold: number; + value: number; +} + +declare global { + interface Navigator { + getBattery(): Promise; + } + interface Performance { + memory?: { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + }; + } +} + +// Constants for performance thresholds +const PERFORMANCE_THRESHOLDS = { + memory: { + warning: 75, + critical: 90, + }, + network: { + latency: { + warning: 200, + critical: 500, + }, + }, + battery: { + warning: 20, + critical: 10, + }, +}; + +// Default metrics state +const DEFAULT_METRICS_STATE: SystemMetrics = { + memory: { + used: 0, + total: 0, + percentage: 0, + }, + network: { + downlink: 0, + latency: { + current: 0, + average: 0, + min: 0, + max: 0, + history: [], + lastUpdate: 0, + }, + type: 'unknown', + }, + performance: { + pageLoad: 0, + domReady: 0, + resources: { + total: 0, + size: 0, + loadTime: 0, + }, + timing: { + ttfb: 0, + fcp: 0, + lcp: 0, + }, + }, +}; + +// Default metrics history +const DEFAULT_METRICS_HISTORY: MetricsHistory = { + timestamps: Array(8).fill(new Date().toLocaleTimeString()), + memory: Array(8).fill(0), + battery: Array(8).fill(0), + network: Array(8).fill(0), + cpu: Array(8).fill(0), + disk: Array(8).fill(0), +}; + +// Maximum number of history points to keep +const MAX_HISTORY_POINTS = 8; + +// Used for environment detection in updateMetrics function +const isLocalDevelopment = + typeof window !== 'undefined' && + window.location && + (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'); + +// For development environments, we'll always provide mock data if real data isn't available +const isDevelopment = + typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + window.location.hostname.includes('192.168.') || + window.location.hostname.includes('.local')); + +// Function to detect Cloudflare and similar serverless environments where TaskManager is not useful +const isServerlessHosting = (): boolean => { + if (typeof window === 'undefined') { + return false; + } + + // For testing: Allow forcing serverless mode via URL param for easy testing + if (typeof window !== 'undefined' && window.location.search.includes('simulate-serverless=true')) { + console.log('Simulating serverless environment for testing'); + return true; + } + + // Check for common serverless hosting domains + const hostname = window.location.hostname; + + return ( + hostname.includes('.cloudflare.') || + hostname.includes('.netlify.app') || + hostname.includes('.vercel.app') || + hostname.endsWith('.workers.dev') + ); +}; + +const TaskManagerTab: React.FC = () => { + const [metrics, setMetrics] = useState(() => DEFAULT_METRICS_STATE); + const [metricsHistory, setMetricsHistory] = useState(() => DEFAULT_METRICS_HISTORY); + const [alerts, setAlerts] = useState([]); + const [lastAlertState, setLastAlertState] = useState('normal'); + const [sortField, setSortField] = useState('memory'); + const [sortDirection, setSortDirection] = useState('desc'); + const [isNotSupported, setIsNotSupported] = useState(false); + + // Chart refs for cleanup + const memoryChartRef = React.useRef>(null); + const batteryChartRef = React.useRef>(null); + const networkChartRef = React.useRef>(null); + const cpuChartRef = React.useRef>(null); + const diskChartRef = React.useRef>(null); + + // Cleanup chart instances on unmount + React.useEffect(() => { + const cleanupCharts = () => { + if (memoryChartRef.current) { + memoryChartRef.current.destroy(); + } + + if (batteryChartRef.current) { + batteryChartRef.current.destroy(); + } + + if (networkChartRef.current) { + networkChartRef.current.destroy(); + } + + if (cpuChartRef.current) { + cpuChartRef.current.destroy(); + } + + if (diskChartRef.current) { + diskChartRef.current.destroy(); + } + }; + + return cleanupCharts; + }, []); + + const tabConfig = useStore(tabConfigurationStore); + + const resetTabConfiguration = useCallback(() => { + const defaultConfig = { + userTabs: [], + developerTabs: [], + }; + tabConfigurationStore.set(defaultConfig); + return defaultConfig; + }, []); + + // Effect to handle tab visibility + useEffect(() => { + const handleTabVisibility = () => { + const currentConfig = tabConfigurationStore.get(); + const controlledTabs = ['debug', 'update']; + + // Update visibility based on conditions + const updatedTabs = currentConfig.userTabs.map((tab) => { + if (controlledTabs.includes(tab.id)) { + return { + ...tab, + visible: metrics.memory.percentage > 80, + }; + } + + return tab; + }); + + tabConfigurationStore.set({ + ...currentConfig, + userTabs: updatedTabs, + }); + }; + + const checkInterval = setInterval(handleTabVisibility, 5000); + + return () => { + clearInterval(checkInterval); + }; + }, [metrics.memory.percentage, tabConfig]); + + // Effect to handle reset and initialization + useEffect(() => { + const resetToDefaults = () => { + console.log('TaskManagerTab: Resetting to defaults'); + + // Reset metrics and local state + setMetrics(DEFAULT_METRICS_STATE); + setMetricsHistory(DEFAULT_METRICS_HISTORY); + setAlerts([]); + + // Reset tab configuration to ensure proper visibility + const defaultConfig = resetTabConfiguration(); + console.log('TaskManagerTab: Reset tab configuration:', defaultConfig); + }; + + // Listen for both storage changes and custom reset event + const handleReset = (event: Event | StorageEvent) => { + if (event instanceof StorageEvent) { + if (event.key === 'tabConfiguration' && event.newValue === null) { + resetToDefaults(); + } + } else if (event instanceof CustomEvent && event.type === 'tabConfigReset') { + resetToDefaults(); + } + }; + + // Initial setup + const initializeTab = async () => { + try { + await updateMetrics(); + } catch (error) { + console.error('Failed to initialize TaskManagerTab:', error); + resetToDefaults(); + } + }; + + window.addEventListener('storage', handleReset); + window.addEventListener('tabConfigReset', handleReset); + initializeTab(); + + return () => { + window.removeEventListener('storage', handleReset); + window.removeEventListener('tabConfigReset', handleReset); + }; + }, []); + + // Effect to update metrics periodically + useEffect(() => { + const updateInterval = 5000; // Update every 5 seconds instead of 2.5 seconds + let metricsInterval: NodeJS.Timeout; + + // Only run updates when tab is visible + const handleVisibilityChange = () => { + if (document.hidden) { + clearInterval(metricsInterval); + } else { + updateMetrics(); + metricsInterval = setInterval(updateMetrics, updateInterval); + } + }; + + // Initial setup + handleVisibilityChange(); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + clearInterval(metricsInterval); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + // Effect to disable taskmanager on serverless environments + useEffect(() => { + const checkEnvironment = async () => { + // If we're on Cloudflare/Netlify/etc., set not supported + if (isServerlessHosting()) { + setIsNotSupported(true); + return; + } + + // For testing: Allow forcing API failures via URL param + if (typeof window !== 'undefined' && window.location.search.includes('simulate-api-failure=true')) { + console.log('Simulating API failures for testing'); + setIsNotSupported(true); + + return; + } + + // Try to fetch system metrics once as detection + try { + const response = await fetch('/api/system/memory'); + const diskResponse = await fetch('/api/system/disk'); + const processResponse = await fetch('/api/system/process'); + + // If all these return errors or not found, system monitoring is not supported + if (!response.ok && !diskResponse.ok && !processResponse.ok) { + setIsNotSupported(true); + } + } catch (error) { + console.warn('Failed to fetch system metrics. TaskManager features may be limited:', error); + + // Don't automatically disable - we'll show partial data based on what's available + } + }; + + checkEnvironment(); + }, []); + + // Get detailed performance metrics + const getPerformanceMetrics = async (): Promise> => { + try { + // Get page load metrics + const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + const pageLoad = navigation.loadEventEnd - navigation.startTime; + const domReady = navigation.domContentLoadedEventEnd - navigation.startTime; + + // Get resource metrics + const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + const resourceMetrics = { + total: resources.length, + size: resources.reduce((total, r) => total + (r.transferSize || 0), 0), + loadTime: Math.max(0, ...resources.map((r) => r.duration)), + }; + + // Get Web Vitals + const ttfb = navigation.responseStart - navigation.requestStart; + const paintEntries = performance.getEntriesByType('paint'); + const fcp = paintEntries.find((entry) => entry.name === 'first-contentful-paint')?.startTime || 0; + + // Get LCP using PerformanceObserver + const lcp = await new Promise((resolve) => { + new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + resolve(lastEntry?.startTime || 0); + }).observe({ entryTypes: ['largest-contentful-paint'] }); + + // Resolve after 3s if no LCP + setTimeout(() => resolve(0), 3000); + }); + + return { + pageLoad, + domReady, + resources: resourceMetrics, + timing: { + ttfb, + fcp, + lcp, + }, + }; + } catch (error) { + console.error('Failed to get performance metrics:', error); + return {}; + } + }; + + // Function to measure endpoint latency + const measureLatency = async (): Promise => { + try { + const headers = new Headers(); + headers.append('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.append('Pragma', 'no-cache'); + headers.append('Expires', '0'); + + const attemptMeasurement = async (): Promise => { + const start = performance.now(); + const response = await fetch('/api/health', { + method: 'HEAD', + headers, + }); + const end = performance.now(); + + if (!response.ok) { + throw new Error(`Health check failed with status: ${response.status}`); + } + + return Math.round(end - start); + }; + + try { + const latency = await attemptMeasurement(); + console.log(`Measured latency: ${latency}ms`); + + return latency; + } catch (error) { + console.warn(`Latency measurement failed, retrying: ${error}`); + + try { + // Retry once + const latency = await attemptMeasurement(); + console.log(`Measured latency on retry: ${latency}ms`); + + return latency; + } catch (retryError) { + console.error(`Latency measurement failed after retry: ${retryError}`); + + // Return a realistic random latency value for development + const mockLatency = 30 + Math.floor(Math.random() * 120); // 30-150ms + console.log(`Using mock latency: ${mockLatency}ms`); + + return mockLatency; + } + } + } catch (error) { + console.error(`Error in latency measurement: ${error}`); + + // Return a realistic random latency value + const mockLatency = 30 + Math.floor(Math.random() * 120); // 30-150ms + console.log(`Using mock latency due to error: ${mockLatency}ms`); + + return mockLatency; + } + }; + + // Update metrics with real data only + const updateMetrics = async () => { + try { + // If we already determined this environment doesn't support system metrics, don't try fetching + if (isNotSupported) { + console.log('TaskManager: System metrics not supported in this environment'); + return; + } + + // Get system memory info first as it's most important + let systemMemoryInfo: SystemMemoryInfo | undefined; + let memoryMetrics = { + used: 0, + total: 0, + percentage: 0, + }; + + try { + const response = await fetch('/api/system/memory'); + + if (response.ok) { + systemMemoryInfo = await response.json(); + console.log('Memory info response:', systemMemoryInfo); + + // Use system memory as primary memory metrics if available + if (systemMemoryInfo && 'used' in systemMemoryInfo) { + memoryMetrics = { + used: systemMemoryInfo.used || 0, + total: systemMemoryInfo.total || 1, + percentage: systemMemoryInfo.percentage || 0, + }; + } + } + } catch (error) { + console.error('Failed to fetch system memory info:', error); + } + + // Get process information + let processInfo: ProcessInfo[] | undefined; + + try { + const response = await fetch('/api/system/process'); + + if (response.ok) { + processInfo = await response.json(); + console.log('Process info response:', processInfo); + } + } catch (error) { + console.error('Failed to fetch process info:', error); + } + + // Get disk information + let diskInfo: DiskInfo[] | undefined; + + try { + const response = await fetch('/api/system/disk'); + + if (response.ok) { + diskInfo = await response.json(); + console.log('Disk info response:', diskInfo); + } + } catch (error) { + console.error('Failed to fetch disk info:', error); + } + + // Get battery info + let batteryInfo: SystemMetrics['battery'] | undefined; + + try { + if ('getBattery' in navigator) { + const battery = await (navigator as any).getBattery(); + batteryInfo = { + level: battery.level * 100, + charging: battery.charging, + timeRemaining: battery.charging ? battery.chargingTime : battery.dischargingTime, + }; + } else { + // Mock battery data if API not available + batteryInfo = { + level: 75 + Math.floor(Math.random() * 20), + charging: Math.random() > 0.3, + timeRemaining: 7200 + Math.floor(Math.random() * 3600), + }; + console.log('Battery API not available, using mock data'); + } + } catch (error) { + console.log('Battery API error, using mock data:', error); + batteryInfo = { + level: 75 + Math.floor(Math.random() * 20), + charging: Math.random() > 0.3, + timeRemaining: 7200 + Math.floor(Math.random() * 3600), + }; + } + + // Enhanced network metrics + const connection = + (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection; + + // Measure real latency + const measuredLatency = await measureLatency(); + const connectionRtt = connection?.rtt || 0; + + // Use measured latency if available, fall back to connection.rtt + const currentLatency = measuredLatency || connectionRtt || Math.floor(Math.random() * 100); + + // Update network metrics with historical data + const networkInfo = { + downlink: connection?.downlink || 1.5 + Math.random(), + uplink: connection?.uplink || 0.5 + Math.random(), + latency: { + current: currentLatency, + average: + metrics.network.latency.history.length > 0 + ? [...metrics.network.latency.history, currentLatency].reduce((a, b) => a + b, 0) / + (metrics.network.latency.history.length + 1) + : currentLatency, + min: + metrics.network.latency.history.length > 0 + ? Math.min(...metrics.network.latency.history, currentLatency) + : currentLatency, + max: + metrics.network.latency.history.length > 0 + ? Math.max(...metrics.network.latency.history, currentLatency) + : currentLatency, + history: [...metrics.network.latency.history, currentLatency].slice(-30), // Keep last 30 measurements + lastUpdate: Date.now(), + }, + type: connection?.type || 'unknown', + effectiveType: connection?.effectiveType || '4g', + }; + + // Get performance metrics + const performanceMetrics = await getPerformanceMetrics(); + + const updatedMetrics: SystemMetrics = { + memory: memoryMetrics, + systemMemory: systemMemoryInfo, + processes: processInfo || [], + disks: diskInfo || [], + battery: batteryInfo, + network: networkInfo, + performance: performanceMetrics as SystemMetrics['performance'], + }; + + setMetrics(updatedMetrics); + + // Update history with real data + const now = new Date().toLocaleTimeString(); + setMetricsHistory((prev) => { + // Ensure we have valid data or use zeros + const memoryPercentage = systemMemoryInfo?.percentage || 0; + const batteryLevel = batteryInfo?.level || 0; + const networkDownlink = networkInfo.downlink || 0; + + // Calculate CPU usage more accurately + let cpuUsage = 0; + + if (processInfo && processInfo.length > 0) { + // Get the average of the top 3 CPU-intensive processes + const topProcesses = [...processInfo].sort((a, b) => b.cpu - a.cpu).slice(0, 3); + const topCpuUsage = topProcesses.reduce((total, proc) => total + proc.cpu, 0); + + // Get the sum of all processes + const totalCpuUsage = processInfo.reduce((total, proc) => total + proc.cpu, 0); + + // Use the higher of the two values, but cap at 100% + cpuUsage = Math.min(Math.max(topCpuUsage, (totalCpuUsage / processInfo.length) * 3), 100); + } else { + // If no process info, generate random CPU usage between 5-30% + cpuUsage = 5 + Math.floor(Math.random() * 25); + } + + // Calculate disk usage (average of all disks) + let diskUsage = 0; + + if (diskInfo && diskInfo.length > 0) { + diskUsage = diskInfo.reduce((total, disk) => total + disk.percentage, 0) / diskInfo.length; + } else { + // If no disk info, generate random disk usage between 30-70% + diskUsage = 30 + Math.floor(Math.random() * 40); + } + + // Create new arrays with the latest data + const timestamps = [...prev.timestamps, now].slice(-MAX_HISTORY_POINTS); + const memory = [...prev.memory, memoryPercentage].slice(-MAX_HISTORY_POINTS); + const battery = [...prev.battery, batteryLevel].slice(-MAX_HISTORY_POINTS); + const network = [...prev.network, networkDownlink].slice(-MAX_HISTORY_POINTS); + const cpu = [...prev.cpu, cpuUsage].slice(-MAX_HISTORY_POINTS); + const disk = [...prev.disk, diskUsage].slice(-MAX_HISTORY_POINTS); + + console.log('Updated metrics history:', { + timestamps, + memory, + battery, + network, + cpu, + disk, + }); + + return { timestamps, memory, battery, network, cpu, disk }; + }); + + // Check for memory alerts - only show toast when state changes + const currentState = + systemMemoryInfo && systemMemoryInfo.percentage > PERFORMANCE_THRESHOLDS.memory.critical + ? 'critical-memory' + : networkInfo.latency.current > PERFORMANCE_THRESHOLDS.network.latency.critical + ? 'critical-network' + : batteryInfo && !batteryInfo.charging && batteryInfo.level < PERFORMANCE_THRESHOLDS.battery.critical + ? 'critical-battery' + : 'normal'; + + if (currentState === 'critical-memory' && lastAlertState !== 'critical-memory') { + const alert: PerformanceAlert = { + type: 'error', + message: 'Critical system memory usage detected', + timestamp: Date.now(), + metric: 'memory', + threshold: PERFORMANCE_THRESHOLDS.memory.critical, + value: systemMemoryInfo?.percentage || 0, + }; + setAlerts((prev) => { + const newAlerts = [...prev, alert]; + return newAlerts.slice(-10); + }); + toast.warning(alert.message, { + id: 'memory-critical', + }); + } else if (currentState === 'critical-network' && lastAlertState !== 'critical-network') { + const alert: PerformanceAlert = { + type: 'warning', + message: 'High network latency detected', + timestamp: Date.now(), + metric: 'network', + threshold: PERFORMANCE_THRESHOLDS.network.latency.critical, + value: networkInfo.latency.current, + }; + setAlerts((prev) => { + const newAlerts = [...prev, alert]; + return newAlerts.slice(-10); + }); + toast.warning(alert.message, { + id: 'network-critical', + }); + } else if (currentState === 'critical-battery' && lastAlertState !== 'critical-battery') { + const alert: PerformanceAlert = { + type: 'error', + message: 'Critical battery level detected', + timestamp: Date.now(), + metric: 'battery', + threshold: PERFORMANCE_THRESHOLDS.battery.critical, + value: batteryInfo?.level || 0, + }; + setAlerts((prev) => { + const newAlerts = [...prev, alert]; + return newAlerts.slice(-10); + }); + toast.error(alert.message, { + id: 'battery-critical', + }); + } + + setLastAlertState(currentState); + + // Then update the environment detection + const isCloudflare = + !isDevelopment && // Not in development mode + ((systemMemoryInfo?.error && systemMemoryInfo.error.includes('not available')) || + (processInfo?.[0]?.error && processInfo[0].error.includes('not available')) || + (diskInfo?.[0]?.error && diskInfo[0].error.includes('not available'))); + + // If we detect that we're in a serverless environment, set the flag + if (isCloudflare || isServerlessHosting()) { + setIsNotSupported(true); + } + + if (isCloudflare) { + console.log('Running in Cloudflare environment. System metrics not available.'); + } else if (isLocalDevelopment) { + console.log('Running in local development environment. Using real or mock system metrics as available.'); + } else if (isDevelopment) { + console.log('Running in development environment. Using real or mock system metrics as available.'); + } else { + console.log('Running in production environment. Using real system metrics.'); + } + } catch (error) { + console.error('Failed to update metrics:', error); + } + }; + + const getUsageColor = (usage: number): string => { + if (usage > 80) { + return 'text-red-500'; + } + + if (usage > 50) { + return 'text-yellow-500'; + } + + return 'text-gray-500'; + }; + + // Chart rendering function + const renderUsageGraph = React.useMemo( + () => + ( + data: number[], + label: string, + color: string, + chartRef: React.RefObject | null>, + ) => { + // Ensure we have valid data + const validData = data.map((value) => (isNaN(value) ? 0 : value)); + + // Ensure we have at least 2 data points + if (validData.length < 2) { + // Add a second point if we only have one + if (validData.length === 1) { + validData.push(validData[0]); + } else { + // Add two points if we have none + validData.push(0, 0); + } + } + + const chartData = { + labels: + metricsHistory.timestamps.length > 0 + ? metricsHistory.timestamps + : Array(validData.length) + .fill('') + .map((_, _i) => new Date().toLocaleTimeString()), + datasets: [ + { + label, + data: validData.slice(-MAX_HISTORY_POINTS), + borderColor: color, + backgroundColor: `${color}33`, // Add slight transparency for fill + fill: true, + tension: 0.4, + pointRadius: 2, // Small points for better UX + borderWidth: 2, + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + max: label === 'Network' ? undefined : 100, // Auto-scale for network, 0-100 for others + grid: { + color: 'rgba(200, 200, 200, 0.1)', + drawBorder: false, + }, + ticks: { + maxTicksLimit: 5, + callback: (value: any) => { + if (label === 'Network') { + return `${value} Mbps`; + } + + return `${value}%`; + }, + }, + }, + x: { + grid: { + display: false, + }, + ticks: { + maxTicksLimit: 4, + maxRotation: 0, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: true, + mode: 'index' as const, + intersect: false, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + titleColor: 'white', + bodyColor: 'white', + borderColor: color, + borderWidth: 1, + padding: 10, + cornerRadius: 4, + displayColors: false, + callbacks: { + title: (tooltipItems: any) => { + return tooltipItems[0].label; // Show timestamp + }, + label: (context: any) => { + const value = context.raw; + + if (label === 'Memory') { + return `Memory: ${value.toFixed(1)}%`; + } else if (label === 'CPU') { + return `CPU: ${value.toFixed(1)}%`; + } else if (label === 'Battery') { + return `Battery: ${value.toFixed(1)}%`; + } else if (label === 'Network') { + return `Network: ${value.toFixed(1)} Mbps`; + } else if (label === 'Disk') { + return `Disk: ${value.toFixed(1)}%`; + } + + return `${label}: ${value.toFixed(1)}`; + }, + }, + }, + }, + animation: { + duration: 300, // Short animation for better UX + } as const, + elements: { + line: { + tension: 0.3, + }, + }, + }; + + return ( +
+ +
+ ); + }, + [metricsHistory.timestamps], + ); + + // Function to handle sorting + const handleSort = (field: SortField) => { + if (sortField === field) { + // Toggle direction if clicking the same field + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + // Set new field and default to descending + setSortField(field); + setSortDirection('desc'); + } + }; + + // Function to sort processes + const getSortedProcesses = () => { + if (!metrics.processes) { + return []; + } + + return [...metrics.processes].sort((a, b) => { + let comparison = 0; + + switch (sortField) { + case 'name': + comparison = a.name.localeCompare(b.name); + break; + case 'pid': + comparison = a.pid - b.pid; + break; + case 'cpu': + comparison = a.cpu - b.cpu; + break; + case 'memory': + comparison = a.memory - b.memory; + break; + } + + return sortDirection === 'asc' ? comparison : -comparison; + }); + }; + + // If we're in an environment where the task manager won't work, show a message + if (isNotSupported) { + return ( +
+
+

System Monitoring Not Available

+

+ System monitoring is not available in serverless environments like Cloudflare Pages, Netlify, or Vercel. These + platforms don't provide access to the underlying system resources. +

+
+

+ Why is this disabled? +
+ Serverless platforms execute your code in isolated environments without access to the server's operating + system metrics like CPU, memory, and disk usage. +

+

+ System monitoring features will be available when running in: +

    +
  • Local development environment
  • +
  • Virtual Machines (VMs)
  • +
  • Dedicated servers
  • +
  • Docker containers (with proper permissions)
  • +
+

+
+ + {/* Testing controls - only shown in development */} + {isDevelopment && ( +
+

Testing Controls

+

+ These controls are only visible in development mode +

+ +
+ )} +
+ ); + } + + return ( +
+ {/* Summary Header */} +
+
+
CPU
+
+ {(metricsHistory.cpu[metricsHistory.cpu.length - 1] || 0).toFixed(1)}% +
+
+
+
Memory
+
+ {Math.round(metrics.systemMemory?.percentage || 0)}% +
+
+
+
Disk
+
0 + ? metrics.disks.reduce((total, disk) => total + disk.percentage, 0) / metrics.disks.length + : 0, + ), + )} + > + {metrics.disks && metrics.disks.length > 0 + ? Math.round(metrics.disks.reduce((total, disk) => total + disk.percentage, 0) / metrics.disks.length) + : 0} + % +
+
+
+
Network
+
{metrics.network.downlink.toFixed(1)} Mbps
+
+
+ + {/* Memory Usage */} +
+

Memory Usage

+
+ {/* System Physical Memory */} +
+
+
+ System Memory +
+
+
+ Shows your system's physical memory (RAM) usage. +
+
+
+ + {Math.round(metrics.systemMemory?.percentage || 0)}% + +
+ {renderUsageGraph(metricsHistory.memory, 'Memory', '#2563eb', memoryChartRef)} +
+ Used: {formatBytes(metrics.systemMemory?.used || 0)} / {formatBytes(metrics.systemMemory?.total || 0)} +
+
+ Free: {formatBytes(metrics.systemMemory?.free || 0)} +
+
+ + {/* Swap Memory */} + {metrics.systemMemory?.swap && ( +
+
+
+ Swap Memory +
+
+
+ Virtual memory used when physical RAM is full. +
+
+
+ + {Math.round(metrics.systemMemory.swap.percentage)}% + +
+
+
+
+
+ Used: {formatBytes(metrics.systemMemory.swap.used)} / {formatBytes(metrics.systemMemory.swap.total)} +
+
+ Free: {formatBytes(metrics.systemMemory.swap.free)} +
+
+ )} +
+
+ + {/* Disk Usage */} +
+

Disk Usage

+ {metrics.disks && metrics.disks.length > 0 ? ( +
+
+ System Disk + + {(metricsHistory.disk[metricsHistory.disk.length - 1] || 0).toFixed(1)}% + +
+ {renderUsageGraph(metricsHistory.disk, 'Disk', '#8b5cf6', diskChartRef)} + + {/* Show only the main system disk (usually the first one) */} + {metrics.disks[0] && ( + <> +
+
+
+
+
Used: {formatBytes(metrics.disks[0].used)}
+
Free: {formatBytes(metrics.disks[0].available)}
+
Total: {formatBytes(metrics.disks[0].size)}
+
+ + )} +
+ ) : ( +
+
+

Disk information is not available

+

+ This feature may not be supported in your environment +

+
+ )} +
+ + {/* Process Information */} +
+
+

Process Information

+ +
+
+ {metrics.processes && metrics.processes.length > 0 ? ( + <> + {/* CPU Usage Summary */} + {metrics.processes[0].name !== 'Unknown' && ( +
+
+ CPU Usage + + {(metricsHistory.cpu[metricsHistory.cpu.length - 1] || 0).toFixed(1)}% Total + +
+
+
+ {metrics.processes.map((process, index) => { + return ( +
+ ); + })} +
+
+
+
+ System:{' '} + {metrics.processes.reduce((total, proc) => total + (proc.cpu < 10 ? proc.cpu : 0), 0).toFixed(1)}% +
+
+ User:{' '} + {metrics.processes.reduce((total, proc) => total + (proc.cpu >= 10 ? proc.cpu : 0), 0).toFixed(1)} + % +
+
+ Idle: {(100 - (metricsHistory.cpu[metricsHistory.cpu.length - 1] || 0)).toFixed(1)}% +
+
+
+ )} + +
+ + + + + + + + + + + {getSortedProcesses().map((process, index) => ( + + + + + + + ))} + +
handleSort('name')} + > + Process {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('pid')} + > + PID {sortField === 'pid' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('cpu')} + > + CPU % {sortField === 'cpu' && (sortDirection === 'asc' ? '↑' : '↓')} + handleSort('memory')} + > + Memory {sortField === 'memory' && (sortDirection === 'asc' ? '↑' : '↓')} +
+ {process.name} + {process.pid} +
+
+
+
+ {process.cpu.toFixed(1)}% +
+
+
+
+
+
+ {/* Calculate approximate MB based on percentage and total system memory */} + {metrics.systemMemory + ? `${formatBytes(metrics.systemMemory.total * (process.memory / 100))}` + : `${process.memory.toFixed(1)}%`} +
+
+
+
+ {metrics.processes[0].error ? ( + +
+ Error retrieving process information: {metrics.processes[0].error} + + ) : metrics.processes[0].name === 'Browser' ? ( + +
+ Showing browser process information. System process information is not available in this + environment. + + ) : ( + Showing top {metrics.processes.length} processes by memory usage + )} +
+ + ) : ( +
+
+

Process information is not available

+

+ This feature may not be supported in your environment +

+ +
+ )} +
+
+ + {/* CPU Usage Graph */} +
+

CPU Usage History

+
+
+ System CPU + + {(metricsHistory.cpu[metricsHistory.cpu.length - 1] || 0).toFixed(1)}% + +
+ {renderUsageGraph(metricsHistory.cpu, 'CPU', '#ef4444', cpuChartRef)} +
+ Average: {(metricsHistory.cpu.reduce((a, b) => a + b, 0) / metricsHistory.cpu.length || 0).toFixed(1)}% +
+
+ Peak: {Math.max(...metricsHistory.cpu).toFixed(1)}% +
+
+
+ + {/* Network */} +
+

Network

+
+
+
+ Connection + + {metrics.network.downlink.toFixed(1)} Mbps + +
+ {renderUsageGraph(metricsHistory.network, 'Network', '#f59e0b', networkChartRef)} +
+ Type: {metrics.network.type} + {metrics.network.effectiveType && ` (${metrics.network.effectiveType})`} +
+
+ Latency: {Math.round(metrics.network.latency.current)}ms + + (avg: {Math.round(metrics.network.latency.average)}ms) + +
+
+ Min: {Math.round(metrics.network.latency.min)}ms / Max: {Math.round(metrics.network.latency.max)}ms +
+ {metrics.network.uplink && ( +
+ Uplink: {metrics.network.uplink.toFixed(1)} Mbps +
+ )} +
+
+
+ + {/* Battery */} + {metrics.battery && ( +
+

Battery

+
+
+
+ Status +
+ {metrics.battery.charging &&
} + 20 ? 'text-upage-elements-textPrimary' : 'text-red-500', + )} + > + {Math.round(metrics.battery.level)}% + +
+
+ {renderUsageGraph(metricsHistory.battery, 'Battery', '#22c55e', batteryChartRef)} + {metrics.battery.timeRemaining && metrics.battery.timeRemaining !== Infinity && ( +
+ {metrics.battery.charging ? 'Time to full: ' : 'Time remaining: '} + {formatTime(metrics.battery.timeRemaining)} +
+ )} +
+
+
+ )} + + {/* Performance */} +
+

Performance

+
+
+
+ Page Load: {(metrics.performance.pageLoad / 1000).toFixed(2)}s +
+
+ DOM Ready: {(metrics.performance.domReady / 1000).toFixed(2)}s +
+
+ TTFB: {(metrics.performance.timing.ttfb / 1000).toFixed(2)}s +
+
+ Resources: {metrics.performance.resources.total} ({formatBytes(metrics.performance.resources.size)}) +
+
+
+
+ + {/* Alerts */} + {alerts.length > 0 && ( +
+
+ Recent Alerts + +
+
+ {alerts.slice(-5).map((alert, index) => ( +
+
+ {alert.message} + + {new Date(alert.timestamp).toLocaleTimeString()} + +
+ ))} +
+
+ )} +
+ ); +}; + +export default React.memo(TaskManagerTab); + +// Helper function to format bytes +const formatBytes = (bytes: number): string => { + if (bytes === 0) { + return '0 B'; + } + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const value = bytes / Math.pow(k, i); + + // Format with 2 decimal places for MB and larger units + const formattedValue = i >= 2 ? value.toFixed(2) : value.toFixed(0); + + return `${formattedValue} ${sizes[i]}`; +}; + +// Helper function to format time +const formatTime = (seconds: number): string => { + if (!isFinite(seconds) || seconds === 0) { + return 'Unknown'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + + return `${minutes}m`; +}; diff --git a/app/components/@settings/utils/animations.ts b/app/components/@settings/utils/animations.ts new file mode 100644 index 0000000..48d27e8 --- /dev/null +++ b/app/components/@settings/utils/animations.ts @@ -0,0 +1,41 @@ +import type { Variants } from 'framer-motion'; + +export const fadeIn: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export const slideIn: Variants = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, +}; + +export const scaleIn: Variants = { + initial: { opacity: 0, scale: 0.8 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.8 }, +}; + +export const tabAnimation: Variants = { + initial: { opacity: 0, scale: 0.8, y: 20 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.8, y: -20 }, +}; + +export const overlayAnimation: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export const modalAnimation: Variants = { + initial: { opacity: 0, scale: 0.95, y: 20 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.95, y: 20 }, +}; + +export const transition = { + duration: 0.2, +}; diff --git a/app/components/@settings/utils/tab-helpers.ts b/app/components/@settings/utils/tab-helpers.ts new file mode 100644 index 0000000..cec852e --- /dev/null +++ b/app/components/@settings/utils/tab-helpers.ts @@ -0,0 +1,89 @@ +import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants'; +import type { TabType, TabVisibilityConfig } from '~/components/@settings/core/types'; + +export const getVisibleTabs = ( + tabConfiguration: { userTabs: TabVisibilityConfig[]; developerTabs?: TabVisibilityConfig[] }, + isDeveloperMode: boolean, + notificationsEnabled: boolean, +): TabVisibilityConfig[] => { + if (!tabConfiguration?.userTabs || !Array.isArray(tabConfiguration.userTabs)) { + console.warn('Invalid tab configuration, using defaults'); + return DEFAULT_TAB_CONFIG as TabVisibilityConfig[]; + } + + // In developer mode, show ALL tabs without restrictions + if (isDeveloperMode) { + // Combine all unique tabs from both user and developer configurations + const allTabs = new Set([ + ...DEFAULT_TAB_CONFIG.map((tab) => tab.id), + ...tabConfiguration.userTabs.map((tab) => tab.id), + ...(tabConfiguration.developerTabs || []).map((tab) => tab.id), + 'task-manager' as TabType, // Always include task-manager in developer mode + ]); + + // Create a complete tab list with all tabs visible + const devTabs = Array.from(allTabs).map((tabId) => { + // Try to find existing configuration for this tab + const existingTab = + tabConfiguration.developerTabs?.find((t) => t.id === tabId) || + tabConfiguration.userTabs?.find((t) => t.id === tabId) || + DEFAULT_TAB_CONFIG.find((t) => t.id === tabId); + + return { + id: tabId as TabType, + visible: true, + window: 'developer' as const, + order: existingTab?.order || DEFAULT_TAB_CONFIG.findIndex((t) => t.id === tabId), + } as TabVisibilityConfig; + }); + + return devTabs.sort((a, b) => a.order - b.order); + } + + // In user mode, only show visible user tabs + return tabConfiguration.userTabs + .filter((tab) => { + if (!tab || typeof tab.id !== 'string') { + console.warn('Invalid tab entry:', tab); + return false; + } + + // Hide notifications tab if notifications are disabled + if (tab.id === 'notifications' && !notificationsEnabled) { + return false; + } + + // Always show task-manager in user mode if it's configured as visible + if (tab.id === 'task-manager') { + return tab.visible; + } + + // Only show tabs that are explicitly visible and assigned to the user window + return tab.visible && tab.window === 'user'; + }) + .sort((a, b) => a.order - b.order); +}; + +export const reorderTabs = ( + tabs: TabVisibilityConfig[], + startIndex: number, + endIndex: number, +): TabVisibilityConfig[] => { + const result = Array.from(tabs); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + // Update order property + return result.map((tab, index) => ({ + ...tab, + order: index, + })); +}; + +export const resetToDefaultConfig = (isDeveloperMode: boolean): TabVisibilityConfig[] => { + return DEFAULT_TAB_CONFIG.map((tab) => ({ + ...tab, + visible: isDeveloperMode ? true : tab.window === 'user', + window: isDeveloperMode ? 'developer' : tab.window, + })) as TabVisibilityConfig[]; +}; diff --git a/app/components/AuthErrorToast.client.tsx b/app/components/AuthErrorToast.client.tsx new file mode 100644 index 0000000..790aff7 --- /dev/null +++ b/app/components/AuthErrorToast.client.tsx @@ -0,0 +1,37 @@ +import { useFetcher } from '@remix-run/react'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +// 定义错误响应的类型 +interface AuthErrorResponse { + errorMessage?: string; +} + +/** + * 认证错误提示组件 + * + * 这个组件检测认证过程中的错误,并使用 toast 显示错误消息 + */ +export function AuthErrorToast() { + // 使用 fetcher 从服务器获取错误信息 + const fetcher = useFetcher(); + const [hasChecked, setHasChecked] = useState(false); + + useEffect(() => { + // 只在组件首次加载时检查一次错误 + if (!hasChecked) { + fetcher.load('/api/auth/check-error'); + setHasChecked(true); + } + }, [fetcher, hasChecked]); + + useEffect(() => { + // 当 fetcher 获取到数据时,如果有错误信息则显示 + if (fetcher.data?.errorMessage) { + toast.error(fetcher.data.errorMessage); + } + }, [fetcher.data]); + + // 这是一个无形的组件,不渲染任何内容 + return null; +} diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx new file mode 100644 index 0000000..3c59c6f --- /dev/null +++ b/app/components/ErrorBoundary.tsx @@ -0,0 +1,68 @@ +import type { ErrorInfo, ReactNode } from 'react'; +import { Component } from 'react'; +import { toast } from 'sonner'; +import { logger } from '~/utils/logger'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface State { + hasError: boolean; + error?: Error; +} + +/** + * 错误边界组件 + */ +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + // 更新状态,下次渲染时显示降级 UI + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // 记录错误信息 + logger.error('组件错误边界捕获到错误:', { error, errorInfo }); + + // 显示错误提示 + toast.error(`组件发生错误: ${error.message}`); + + // 调用可选的 onError 回调 + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + } + + render(): ReactNode { + if (this.state.hasError) { + // 如果提供了自定义的降级 UI,则使用它 + if (this.props.fallback) { + return this.props.fallback; + } + + // 默认的降级 UI + return ( +
+

组件加载失败

+

{this.state.error?.message || '发生了未知错误'}

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/app/components/auth/AuthButtons.tsx b/app/components/auth/AuthButtons.tsx new file mode 100644 index 0000000..c062c9b --- /dev/null +++ b/app/components/auth/AuthButtons.tsx @@ -0,0 +1,70 @@ +import { useNavigate } from '@remix-run/react'; +import { useEffect, useState } from 'react'; +import { Button } from '~/components/ui/Button'; +import { useAuth } from '~/lib/hooks/useAuth'; + +export function SignInButton({ className, children = '登录' }: { className?: string; children?: React.ReactNode }) { + const { signIn, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (isAuthenticated) { + navigate('/'); + } + }, [isAuthenticated, navigate]); + + const handleSignIn = () => { + signIn('/api/auth/callback'); + }; + + return ( + + ); +} + +export function SignOutButton({ className, children = '登出' }: { className?: string; children?: React.ReactNode }) { + const { signOut, isAuthenticated } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + + const handleSignOut = async () => { + if (!isAuthenticated) { + return; + } + + setIsLoading(true); + + try { + await signOut(); + } catch (error) { + console.error('登出失败:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} + +export function UserAuthButton({ + className, + signInText = '登录', + signOutText = '登出', +}: { + className?: string; + signInText?: React.ReactNode; + signOutText?: React.ReactNode; +}) { + const { isAuthenticated } = useAuth(); + + return isAuthenticated ? ( + {signOutText} + ) : ( + {signInText} + ); +} diff --git a/app/components/auth/UserProfile.tsx b/app/components/auth/UserProfile.tsx new file mode 100644 index 0000000..bf0d6d7 --- /dev/null +++ b/app/components/auth/UserProfile.tsx @@ -0,0 +1,31 @@ +import { useAuth } from '~/lib/hooks/useAuth'; + +export function UserProfile({ className }: { className?: string }) { + const { isAuthenticated, userInfo, isLoading } = useAuth(); + + if (!isAuthenticated) { + return
未登录
; + } + + if (isLoading) { + return
加载中...
; + } + + if (!userInfo) { + return
无法获取用户信息
; + } + + return ( +
+ {userInfo.picture && ( + {userInfo.name + )} +

{userInfo.name || userInfo.username || '用户'}

+ {userInfo.email &&

{userInfo.email}

} +
+ ); +} diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx new file mode 100644 index 0000000..8e98574 --- /dev/null +++ b/app/components/chat/Artifact.tsx @@ -0,0 +1,227 @@ +import { useStore } from '@nanostores/react'; +import classNames from 'classnames'; +import { AnimatePresence, motion } from 'framer-motion'; +import { computed } from 'nanostores'; +import { memo, useEffect, useRef, useState } from 'react'; +import { type BundledLanguage, type BundledTheme, createHighlighter, type HighlighterGeneric } from 'shiki'; +import type { ActionState } from '~/lib/runtime/action-runner'; +import { webBuilderStore } from '~/lib/stores/web-builder'; +import { cubicEasingFn } from '~/utils/easings'; + +const highlighterOptions = { + langs: ['shell'], + themes: ['light-plus', 'dark-plus'], +}; + +const shellHighlighter: HighlighterGeneric = + import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions)); + +if (import.meta.hot) { + import.meta.hot.data.shellHighlighter = shellHighlighter; +} + +interface ArtifactProps { + messageId: string; +} + +export const Artifact = memo(({ messageId }: ArtifactProps) => { + const userToggledActions = useRef(false); + const [showActions, setShowActions] = useState(false); + const [allActionFinished, setAllActionFinished] = useState(false); + + const artifacts = useStore(webBuilderStore.chatStore.artifacts); + const artifact = artifacts[messageId]; + + const actions = useStore( + computed(artifact.runner.actions, (actions) => { + return Object.values(actions); + }), + ); + + const toggleActions = () => { + userToggledActions.current = true; + setShowActions(!showActions); + }; + + useEffect(() => { + const actionsMap = artifact.runner.actions.get(); + + Object.entries(actionsMap).forEach(([actionId, action]) => { + if (action.status === 'running' || action.status === 'pending') { + artifact.runner.actions.setKey(actionId, { + ...action, + status: 'aborted', + }); + } + }); + }, []); + + useEffect(() => { + if (actions.length && !showActions && !userToggledActions.current) { + setShowActions(true); + } + + if (actions.length !== 0 && artifact.type === 'bundled') { + const finished = !actions.find((action) => action.status !== 'complete'); + + if (allActionFinished !== finished) { + setAllActionFinished(finished); + } + } + }, [actions]); + + return ( +
+
+ +
+ + {actions.length && artifact.type !== 'bundled' && ( + +
+
+
+
+ )} +
+
+ + {artifact.type !== 'bundled' && showActions && actions.length > 0 && ( + +
+ +
+ +
+ + )} + +
+ ); +}); + +interface ActionListProps { + actions: ActionState[]; +} + +const actionVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, +}; + +function openArtifactInWebBuilder(pageName: string, rootDomId: string) { + if (webBuilderStore.currentView.get() !== 'code') { + webBuilderStore.currentView.set('code'); + } + webBuilderStore.setSelectedPage(pageName); + webBuilderStore.editorStore.scrollToElement(rootDomId); +} + +const ActionList = memo(({ actions }: ActionListProps) => { + return ( + +
    + {actions.map((action, index) => { + const { status } = action; + + return ( + +
    +
    + {status === 'running' ? ( +
    + ) : status === 'pending' ? ( +
    + ) : status === 'complete' ? ( +
    + ) : status === 'failed' || status === 'aborted' ? ( +
    + ) : null} +
    +
    + {action.action === 'add' ? 'Create' : action.action === 'update' ? 'Update' : 'Delete'}{' '} + openArtifactInWebBuilder(action.pageName, action.rootDomId)} + > + {action.id} + +
    +
    +
    + ); + })} +
+
+ ); +}); + +function getIconColor(status: ActionState['status']) { + switch (status) { + case 'pending': { + return 'text-upage-elements-textTertiary'; + } + case 'running': { + return 'text-upage-elements-loader-progress'; + } + case 'complete': { + return 'text-upage-elements-icon-success'; + } + case 'aborted': { + return 'text-upage-elements-textSecondary'; + } + case 'failed': { + return 'text-upage-elements-icon-error'; + } + default: { + return undefined; + } + } +} diff --git a/app/components/chat/AssistantMessage.tsx b/app/components/chat/AssistantMessage.tsx new file mode 100644 index 0000000..269b0b9 --- /dev/null +++ b/app/components/chat/AssistantMessage.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react'; +import Popover from '~/components/ui/Popover'; +import Tooltip from '~/components/ui/Tooltip'; +import type { ParsedUIMessage } from '~/lib/stores/ai-state'; +import { Markdown } from './Markdown'; + +export const AssistantMessage = memo(({ message }: { message: ParsedUIMessage }) => { + return ( +
+ {message.parts.map((part) => { + if (part.type === 'data-summary') { + return ( +
+ {part.data.summary && ( + +
+ + } + > + {part.data.summary && ( +
+
+
+

+ + 摘要 +

+
+ {part.data.summary} +
+
+
+
+ )} +
+
+
+
+ )} +
+ ); + } + })} + {message.content && {message.content}} +
+ ); +}); diff --git a/app/components/chat/BaseChat.module.scss b/app/components/chat/BaseChat.module.scss new file mode 100644 index 0000000..ba35b13 --- /dev/null +++ b/app/components/chat/BaseChat.module.scss @@ -0,0 +1,55 @@ +.BaseChat { + &[data-chat-visible='false'] { + --workbench-inner-width: 100%; + --workbench-left: 0; + + .Chat { + --at-apply: upage-ease-cubic-bezier; + transition-property: transform, opacity; + transition-duration: 0.3s; + will-change: transform, opacity; + transform: translateX(-50%); + opacity: 0; + } + } +} + +.Chat { + opacity: 1; +} + +.PromptEffectContainer { + --prompt-container-offset: 50px; + --prompt-line-stroke-width: 2px; + position: absolute; + pointer-events: none; + inset: calc(var(--prompt-container-offset) / -2); + width: calc(100% + var(--prompt-container-offset)); + height: calc(100% + var(--prompt-container-offset)); + overflow: visible; + z-index: 0; +} + +.PromptEffectLine { + width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width)); + height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width)); + x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2); + y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2); + rx: 10px; + fill: transparent; + stroke-width: var(--prompt-line-stroke-width); + stroke: url(#line-gradient); + stroke-dasharray: 20 30; + stroke-dashoffset: 0; + animation: borderRotate 18s linear infinite; + filter: drop-shadow(0 0 5px rgba(180, 74, 255, 0.7)); +} + +@keyframes borderRotate { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -500; + } +} diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx new file mode 100644 index 0000000..5907f67 --- /dev/null +++ b/app/components/chat/Chat.client.tsx @@ -0,0 +1,158 @@ +import { useStore } from '@nanostores/react'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useLoaderData } from '@remix-run/react'; +import classNames from 'classnames'; +import { useAnimate } from 'framer-motion'; +import { useEffect, useState } from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { useShortcuts, useSnapScroll } from '~/lib/hooks'; +import { useChatMessage } from '~/lib/hooks/useChatMessage'; +import { aiState, setChatId, setChatStarted } from '~/lib/stores/ai-state'; +import { webBuilderStore } from '~/lib/stores/web-builder'; +import type { ChatMessage, ChatWithMessages } from '~/types/chat'; +import { renderLogger } from '~/utils/logger'; +import { Menu } from '../sidebar/Menu.client'; +import { WebBuilder } from '../webbuilder/WebBuilder.client'; +import styles from './BaseChat.module.scss'; +import ChatAlert from './ChatAlert'; +import { ChatTextarea } from './ChatTextarea'; +import { ExamplePrompts } from './ExamplePrompts'; +import FilePreview from './FilePreview'; +import { Messages } from './Messages.client'; +import ProgressCompilation from './ProgressCompilation'; +import { ScreenshotStateManager } from './ScreenshotStateManager'; + +export type ImageData = { + file: File; + base64?: string; +}; + +export function Chat() { + renderLogger.trace('Chat'); + const { id, chat } = useLoaderData<{ id?: string; chat: ChatWithMessages }>(); + + const { showChat, chatStarted } = useStore(aiState); + const actionAlert = useStore(webBuilderStore.chatStore.alert); + useShortcuts(); + const [animationScope] = useAnimate(); + const [scrollRef] = useSnapScroll(); + const { progressAnnotations, abort, sendChatMessage } = useChatMessage({ + initialId: id, + initialMessages: chat?.messages as unknown as ChatMessage[], + }); + const [uploadFiles, setUploadFiles] = useState([]); + + useEffect(() => { + if (id) { + setChatId(id); + } + }, [id]); + + useEffect(() => { + if (!chat) { + return; + } + const { messages } = chat; + if (messages.length > 0) { + setChatStarted(true); + } + webBuilderStore.chatStore.setReloadedMessages(messages.map((m) => m.id)); + }, [chat]); + + const handleSendMessage = (messageInput?: string) => { + if (!messageInput) { + return; + } + sendChatMessage({ messageContent: messageInput, files: uploadFiles }); + }; + + return ( + <> + { + +
+ {() => } +
+
+ {!chatStarted && ( +
+

+ 使用 UPage 构建网站 +

+

+ 将想法快速转变成现实,并通过可视化实时呈现。 +

+
+ )} +
+ + {() => { + return chatStarted ? ( + + ) : null; + }} + +
+
+ {actionAlert && ( + { + handleSendMessage?.(message); + }} + /> + )} +
+ {progressAnnotations && } +
+ { + setUploadFiles?.(uploadFiles.filter((_, i) => i !== index)); + }} + /> + + {() => } + + +
+
+
+
+ {!chatStarted && + ExamplePrompts((_event, messageInput) => { + handleSendMessage?.(messageInput); + })} +
+
+ {() => } +
+
+
+ } + + ); +} diff --git a/app/components/chat/ChatAlert.tsx b/app/components/chat/ChatAlert.tsx new file mode 100644 index 0000000..8c02a2b --- /dev/null +++ b/app/components/chat/ChatAlert.tsx @@ -0,0 +1,112 @@ +import { useStore } from '@nanostores/react'; +import classNames from 'classnames'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useCallback, useMemo } from 'react'; +import { webBuilderStore } from '~/lib/stores/web-builder'; + +interface Props { + postMessage: (message: string) => void; +} + +export default function ChatAlert({ postMessage }: Props) { + const actionAlert = useStore(webBuilderStore.chatStore.alert); + + const { description, content } = useMemo(() => actionAlert ?? { description: '', content: '' }, [actionAlert]); + + const handlePostMessage = useCallback( + (message: string) => { + postMessage(message); + handleClearAlert(); + }, + [postMessage], + ); + + const handleClearAlert = useCallback(() => { + webBuilderStore.chatStore.clearAlert(); + }, [webBuilderStore]); + + return ( + + +
+ {/* Icon */} + +
+
+ {/* Content */} +
+ + 预览错误 + + +

我们遇到了预览错误。是否想让 UPage 分析并帮助解决这个问题?

+ {description && ( +
+ Error: {description} +
+ )} +
+ + {/* Actions */} + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/app/components/chat/ChatTextarea.tsx b/app/components/chat/ChatTextarea.tsx new file mode 100644 index 0000000..4548532 --- /dev/null +++ b/app/components/chat/ChatTextarea.tsx @@ -0,0 +1,260 @@ +import { useStore } from '@nanostores/react'; +import classNames from 'classnames'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { useAuth, usePromptEnhancer } from '~/lib/hooks'; +import { aiState } from '~/lib/stores/ai-state'; +import { IconButton } from '../ui/IconButton'; +import { SendButton } from './SendButton.client'; + +interface ChatTextareaProps { + uploadFiles: File[]; + setUploadFiles: (files: File[]) => void; + onSendMessage: (message: string) => void; + onStopMessage: () => void; +} + +const TEXTAREA_MIN_HEIGHT = 76; + +export const ChatTextarea = ({ uploadFiles, setUploadFiles, onSendMessage, onStopMessage }: ChatTextareaProps) => { + const { isAuthenticated, signIn } = useAuth(); + const { chatStarted, isStreaming } = useStore(aiState); + const { enhancedInput, isLoading, enhancePrompt, resetEnhancer } = usePromptEnhancer(); + + const [input, setInput] = useState(''); + const textareaRef = useRef(null); + + // 检测当前 URL 是否包含登录回调参数 + useEffect(() => { + if (typeof window !== 'undefined') { + const savedMessage = localStorage.getItem('pendingChatMessage'); + + // 如果是从登录页面回调回来的,检查 localStorage 中是否有待发送的消息 + if (savedMessage && isAuthenticated) { + try { + const msgData = JSON.parse(savedMessage); + requestAnimationFrame(() => { + if (msgData.messageInput) { + setInput(msgData.messageInput); + sendMessage(); + } + }); + } catch (e) { + console.error('Error parsing saved message:', e); + } finally { + localStorage.removeItem('pendingChatMessage'); + } + } + } + }, [isAuthenticated]); + + useEffect(() => { + setInput(enhancedInput); + scrollTextArea(); + }, [enhancedInput]); + + const TEXTAREA_MAX_HEIGHT = useMemo(() => { + return chatStarted ? 400 : 200; + }, [chatStarted]); + + const scrollTextArea = useCallback(() => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.scrollTop = textarea.scrollHeight; + } + }, [textareaRef]); + + const handleEnhancePrompt = useCallback(async () => { + try { + await enhancePrompt(input); + } catch (error) { + console.error('Error enhancing prompt:', error); + } + }, [input]); + + const sendMessage = async () => { + if (!input?.trim()) { + return; + } + onSendMessage(input); + setInput(''); + setUploadFiles([]); + resetEnhancer(); + textareaRef.current?.blur(); + }; + + useEffect(() => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.style.height = 'auto'; + + const scrollHeight = textarea.scrollHeight; + + textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; + textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; + } + }, [input, textareaRef]); + + const handleSendMessage = () => { + if (!isAuthenticated) { + if (input) { + const savedMsg = { + messageInput: input, + timestamp: new Date().getTime(), + }; + localStorage.setItem('pendingChatMessage', JSON.stringify(savedMsg)); + signIn(); + return; + } + } + + if (sendMessage) { + sendMessage(); + } + }; + + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + handleFileReader(files); + }; + + const handleFileUpload = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + + input.onchange = async (e) => { + const files = (e.target as HTMLInputElement).files; + handleFileReader(files ? Array.from(files) : []); + }; + + input.click(); + }, [uploadFiles]); + + const handleFileReader = (files: File[]) => { + files.forEach((file) => { + setUploadFiles?.([...uploadFiles, file]); + }); + }; + + return ( +
+