Repository: chatboxai/chatbox Branch: main Commit: 1577f1eda83d Files: 617 Total size: 4.0 MB Directory structure: gitextract_f36dbven/ ├── .erb/ │ ├── .vscode/ │ │ └── settings.json │ ├── configs/ │ │ ├── .eslintrc │ │ ├── webpack.config.base.ts │ │ ├── webpack.config.eslint.ts │ │ ├── webpack.config.main.prod.ts │ │ ├── webpack.config.preload.dev.ts │ │ ├── webpack.config.renderer.dev.dll.ts │ │ ├── webpack.config.renderer.dev.ts │ │ ├── webpack.config.renderer.prod.ts │ │ └── webpack.paths.ts │ ├── mocks/ │ │ └── fileMock.js │ └── scripts/ │ ├── .eslintrc │ ├── check-build-exists.ts │ ├── check-native-dep.cjs │ ├── check-native-dep.js │ ├── check-node-env.js │ ├── check-port-in-use.js │ ├── clean.js │ ├── delete-source-maps.js │ ├── electron-rebuild.cjs │ ├── electron-rebuild.js │ ├── link-modules.cjs │ ├── link-modules.ts │ ├── notarize.js │ ├── patch-libsql.cjs │ └── postinstall.cjs ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── custom.md │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── config.yml │ ├── dependabot.yml │ └── stale.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierrc ├── ERROR_HANDLING.md ├── LICENSE ├── README.md ├── assets/ │ ├── assets.d.ts │ ├── entitlements.mac.plist │ ├── icon.icns │ └── installer.nsh ├── biome.json ├── doc/ │ ├── FAQ-CN.md │ ├── FAQ.md │ └── README-CN.md ├── docs/ │ ├── adding-new-provider.md │ ├── adding-provider.md │ ├── dependency-reorg.md │ ├── new-session-mechanism.md │ ├── rag.md │ ├── session-module-split-plan.md │ ├── storage.md │ ├── testing.md │ └── token-estimation.md ├── electron-builder.yml ├── electron.vite.config.ts ├── i18next-parser.config.mjs ├── package.json ├── patches/ │ ├── libsql@0.5.22.patch │ └── mdast-util-gfm-autolink-literal@2.0.1.patch ├── pnpm-workspace.yaml ├── postcss.config.js ├── release/ │ └── app/ │ └── package.json ├── script/ │ └── translate.mjs ├── scripts/ │ └── ralph/ │ ├── prompt-opencode.md │ └── ralph.sh ├── src/ │ ├── __tests__/ │ │ └── App.test.tsx.bk │ ├── main/ │ │ ├── adapters/ │ │ │ ├── index.ts │ │ │ └── sentry.ts │ │ ├── analystic-node.ts │ │ ├── app-updater.ts │ │ ├── autoLauncher.ts │ │ ├── cache.ts │ │ ├── deeplinks.ts │ │ ├── file-parser.ts │ │ ├── knowledge-base/ │ │ │ ├── db.ts │ │ │ ├── file-loaders.ts │ │ │ ├── index.ts │ │ │ ├── ipc-handlers.ts │ │ │ ├── model-providers.ts │ │ │ ├── parsers/ │ │ │ │ ├── chatbox-parser.ts │ │ │ │ ├── index.ts │ │ │ │ ├── local-parser.ts │ │ │ │ ├── mineru-parser.ts │ │ │ │ └── types.ts │ │ │ └── remote-file-parser.ts │ │ ├── locales.ts │ │ ├── main.ts │ │ ├── mcp/ │ │ │ ├── ipc-stdio-transport.ts │ │ │ ├── shell-env.cjs │ │ │ └── shell-env.d.ts │ │ ├── menu.ts │ │ ├── proxy.ts │ │ ├── readability.ts │ │ ├── store-node.ts │ │ ├── util.ts │ │ └── window_state.ts │ ├── preload/ │ │ └── index.ts │ ├── renderer/ │ │ ├── Sidebar.tsx │ │ ├── adapters/ │ │ │ ├── index.ts │ │ │ └── sentry.ts │ │ ├── components/ │ │ │ ├── Accordion.tsx │ │ │ ├── ActionMenu.tsx │ │ │ ├── AdaptiveSelect.tsx │ │ │ ├── Artifact.tsx │ │ │ ├── CustomProviderIcon.tsx │ │ │ ├── EditableAvatar.tsx │ │ │ ├── ErrorTestPannel.tsx │ │ │ ├── FileIcon.tsx │ │ │ ├── Image.tsx │ │ │ ├── ImageCountSlider.tsx │ │ │ ├── ImageModelSelect.tsx │ │ │ ├── ImageStyleSelect.tsx │ │ │ ├── InputBox/ │ │ │ │ ├── Attachments.tsx │ │ │ │ ├── ImageUploadButton.tsx │ │ │ │ ├── ImageUploadInput.tsx │ │ │ │ ├── InputBox.tsx │ │ │ │ ├── SessionSettingsButton.tsx │ │ │ │ ├── TokenCountMenu.tsx │ │ │ │ ├── WebBrowsingButton.tsx │ │ │ │ ├── actionIconStyles.ts │ │ │ │ ├── index.ts │ │ │ │ └── preprocessState.ts │ │ │ ├── Markdown.tsx │ │ │ ├── Mermaid.tsx │ │ │ ├── ModelList.tsx │ │ │ ├── ModelSelector/ │ │ │ │ ├── DesktopModelSelector.tsx │ │ │ │ ├── MobileModelSelector.tsx │ │ │ │ ├── ProviderHeader.tsx │ │ │ │ ├── SimplePreview.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── shared.tsx │ │ │ ├── Shortcut.tsx │ │ │ ├── SortableItem.tsx │ │ │ ├── SponsorChip.tsx │ │ │ ├── StyledMenu.tsx │ │ │ ├── UpdateAvailableButton.tsx │ │ │ ├── chat/ │ │ │ │ ├── CompactionStatus.tsx │ │ │ │ ├── Message.tsx │ │ │ │ ├── MessageAttachmentGrid.tsx │ │ │ │ ├── MessageErrTips.tsx │ │ │ │ ├── MessageList.tsx │ │ │ │ ├── MessageLoading.tsx │ │ │ │ ├── MessageNavigation.tsx │ │ │ │ └── SummaryMessage.tsx │ │ │ ├── common/ │ │ │ │ ├── AdaptiveModal.tsx │ │ │ │ ├── Avatar.tsx │ │ │ │ ├── CompressionModal.tsx │ │ │ │ ├── ConfirmDeleteButton.tsx │ │ │ │ ├── CreatableSelect.tsx │ │ │ │ ├── Divider.tsx │ │ │ │ ├── ErrorBoundary.tsx │ │ │ │ ├── LazyNumberInput.tsx │ │ │ │ ├── LazySlider.tsx │ │ │ │ ├── Link.tsx │ │ │ │ ├── Mark.tsx │ │ │ │ ├── MaxContextMessageCountSlider.tsx │ │ │ │ ├── MiniButton.tsx │ │ │ │ ├── PasswordTextField.tsx │ │ │ │ ├── PopoverConfirm.tsx │ │ │ │ ├── ScalableIcon.tsx │ │ │ │ ├── SegmentedControl.tsx │ │ │ │ ├── SliderWithInput.tsx │ │ │ │ ├── TemperatureSlider.tsx │ │ │ │ ├── TextFieldReset.tsx │ │ │ │ ├── Toasts.tsx │ │ │ │ └── TopPSlider.tsx │ │ │ ├── dev/ │ │ │ │ ├── DevHeader.tsx │ │ │ │ └── ThemeSwitchButton.tsx │ │ │ ├── icons/ │ │ │ │ ├── ArrowRightIcon.tsx │ │ │ │ ├── BrandGithub.tsx │ │ │ │ ├── BrandRedNote.tsx │ │ │ │ ├── BrandWechat.tsx │ │ │ │ ├── BrandX.tsx │ │ │ │ ├── Broom.tsx │ │ │ │ ├── Dart.tsx │ │ │ │ ├── FullscreenIcon.tsx │ │ │ │ ├── HomepageIcon.tsx │ │ │ │ ├── Java.tsx │ │ │ │ ├── LayoutExpand.tsx │ │ │ │ ├── LayoutShrink.tsx │ │ │ │ ├── Loading.tsx │ │ │ │ ├── ModelIcon.tsx │ │ │ │ ├── ProviderIcon.tsx │ │ │ │ ├── ProviderImageIcon.tsx │ │ │ │ └── Robot.tsx │ │ │ ├── knowledge-base/ │ │ │ │ ├── ChunksPreviewModal.tsx │ │ │ │ ├── KnowledgeBase.tsx │ │ │ │ ├── KnowledgeBaseDocuments.tsx │ │ │ │ ├── KnowledgeBaseForm.tsx │ │ │ │ ├── KnowledgeBaseMenu.tsx │ │ │ │ └── RemoteRetryModal.tsx │ │ │ ├── layout/ │ │ │ │ ├── ExitFullscreenButton.tsx │ │ │ │ ├── Header.tsx │ │ │ │ ├── Overlay.tsx │ │ │ │ ├── Page.tsx │ │ │ │ ├── Toolbar.tsx │ │ │ │ └── WindowControls.tsx │ │ │ ├── mcp/ │ │ │ │ ├── MCPMenu.tsx │ │ │ │ └── MCPStatus.tsx │ │ │ ├── message-parts/ │ │ │ │ └── ToolCallPartUI.tsx │ │ │ ├── session/ │ │ │ │ ├── SessionItem.tsx │ │ │ │ ├── SessionList.tsx │ │ │ │ └── ThreadHistoryDrawer.tsx │ │ │ ├── settings/ │ │ │ │ ├── DocumentParserSettings.tsx │ │ │ │ ├── mcp/ │ │ │ │ │ ├── BuiltinServersSection.tsx │ │ │ │ │ ├── ConfigModal.tsx │ │ │ │ │ ├── CustomServersSection.tsx │ │ │ │ │ ├── ServerRegistrySpotlight.tsx │ │ │ │ │ ├── registries.ts │ │ │ │ │ └── utils.ts │ │ │ │ └── provider/ │ │ │ │ ├── AddProviderModal.tsx │ │ │ │ ├── ImportProviderModal.tsx │ │ │ │ └── ProviderList.tsx │ │ │ └── ui/ │ │ │ ├── command.tsx │ │ │ └── dialog.tsx │ │ ├── dev/ │ │ │ └── devToolsConfig.ts │ │ ├── hooks/ │ │ │ ├── dom.ts │ │ │ ├── knowledge-base.ts │ │ │ ├── mcp.ts │ │ │ ├── useAppTheme.ts │ │ │ ├── useChatboxAIModels.ts │ │ │ ├── useChunksPreview.ts │ │ │ ├── useCopied.ts │ │ │ ├── useCopilots.ts │ │ │ ├── useDefaultSystemLanguage.ts │ │ │ ├── useI18nEffect.ts │ │ │ ├── useInputBoxHistory.ts │ │ │ ├── useKnowledgeBase.ts │ │ │ ├── useMessageInput.ts │ │ │ ├── useNeedRoomForWinControls.ts │ │ │ ├── useProviderImport.ts │ │ │ ├── useProviders.ts │ │ │ ├── useScreenChange.ts │ │ │ ├── useShortcut.tsx │ │ │ ├── useThinkingTimer.ts │ │ │ ├── useVersion.ts │ │ │ └── useWindowMaximized.ts │ │ ├── i18n/ │ │ │ ├── changelogs/ │ │ │ │ ├── changelog_en.ts │ │ │ │ ├── changelog_zh_Hans.ts │ │ │ │ └── changelog_zh_Hant.ts │ │ │ ├── for-key-scan.ts │ │ │ ├── index.ts │ │ │ ├── locales/ │ │ │ │ ├── ar/ │ │ │ │ │ └── translation.json │ │ │ │ ├── de/ │ │ │ │ │ └── translation.json │ │ │ │ ├── en/ │ │ │ │ │ └── translation.json │ │ │ │ ├── es/ │ │ │ │ │ └── translation.json │ │ │ │ ├── fr/ │ │ │ │ │ └── translation.json │ │ │ │ ├── it-IT/ │ │ │ │ │ └── translation.json │ │ │ │ ├── ja/ │ │ │ │ │ └── translation.json │ │ │ │ ├── ko/ │ │ │ │ │ └── translation.json │ │ │ │ ├── nb-NO/ │ │ │ │ │ └── translation.json │ │ │ │ ├── pt-PT/ │ │ │ │ │ └── translation.json │ │ │ │ ├── ru/ │ │ │ │ │ └── translation.json │ │ │ │ ├── sv/ │ │ │ │ │ └── translation.json │ │ │ │ ├── zh-Hans/ │ │ │ │ │ └── translation.json │ │ │ │ └── zh-Hant/ │ │ │ │ └── translation.json │ │ │ ├── locales.ts │ │ │ └── parser.ts │ │ ├── index.ejs │ │ ├── index.html │ │ ├── index.tsx │ │ ├── index.web.ejs │ │ ├── lib/ │ │ │ ├── format-chat.tsx │ │ │ └── utils.ts │ │ ├── modals/ │ │ │ ├── AppStoreRating.tsx │ │ │ ├── ArtifactPreview.tsx │ │ │ ├── AttachLink.tsx │ │ │ ├── ClearSessionList.tsx │ │ │ ├── ContentViewer.tsx │ │ │ ├── EdgeOneDeploySuccess.tsx │ │ │ ├── ExportChat.tsx │ │ │ ├── FileParseError.tsx │ │ │ ├── JsonViewer.tsx │ │ │ ├── MessageEdit.tsx │ │ │ ├── ModelEdit.tsx │ │ │ ├── ReportContent.tsx │ │ │ ├── SessionSettings.tsx │ │ │ ├── Settings.tsx │ │ │ ├── ThreadNameEdit.tsx │ │ │ ├── Welcome.tsx │ │ │ └── index.tsx │ │ ├── native/ │ │ │ └── stream-http.ts │ │ ├── packages/ │ │ │ ├── apple_app_store.ts │ │ │ ├── base64.test.ts │ │ │ ├── base64.ts │ │ │ ├── codeblock_state_recorder.ts │ │ │ ├── context-management/ │ │ │ │ ├── attachment-payload.test.ts │ │ │ │ ├── attachment-payload.ts │ │ │ │ ├── compaction-detector.test.ts │ │ │ │ ├── compaction-detector.ts │ │ │ │ ├── compaction.ts │ │ │ │ ├── context-builder.test.ts │ │ │ │ ├── context-builder.ts │ │ │ │ ├── context-tokens.hook.test.ts │ │ │ │ ├── context-tokens.integration.test.ts │ │ │ │ ├── context-tokens.ts │ │ │ │ ├── context-tokens.unit.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── summary-generator.ts │ │ │ │ ├── tool-cleanup.test.ts │ │ │ │ └── tool-cleanup.ts │ │ │ ├── edgeone.ts │ │ │ ├── event.ts │ │ │ ├── filetype.ts │ │ │ ├── initial_data.ts │ │ │ ├── keypairs.ts │ │ │ ├── latex.test.ts │ │ │ ├── latex.ts │ │ │ ├── lemonsqueezy.ts │ │ │ ├── local-parser.ts │ │ │ ├── mcp/ │ │ │ │ ├── builtin.ts │ │ │ │ ├── controller.ts │ │ │ │ ├── ipc-stdio-transport.ts │ │ │ │ └── types.ts │ │ │ ├── model-calls/ │ │ │ │ ├── generate-image.ts │ │ │ │ ├── index.ts │ │ │ │ ├── message-utils.ts │ │ │ │ ├── preprocess.ts │ │ │ │ ├── stream-text.ts │ │ │ │ ├── tools.ts │ │ │ │ └── toolsets/ │ │ │ │ ├── file.ts │ │ │ │ ├── knowledge-base.ts │ │ │ │ └── web-search.ts │ │ │ ├── model-context/ │ │ │ │ ├── builtin-data.ts │ │ │ │ └── index.ts │ │ │ ├── model-setting-utils/ │ │ │ │ ├── base-config.ts │ │ │ │ ├── custom-provider-setting-util.ts │ │ │ │ ├── index.ts │ │ │ │ ├── interface.ts │ │ │ │ ├── registry-setting-util.ts │ │ │ │ └── util.ts │ │ │ ├── navigator.ts │ │ │ ├── pic_utils.ts │ │ │ ├── prompts.ts │ │ │ ├── remote.ts │ │ │ ├── token-estimation/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── analyzer.test.ts │ │ │ │ │ ├── cache-keys.test.ts │ │ │ │ │ ├── computation-queue.test.ts │ │ │ │ │ ├── result-persister.test.ts │ │ │ │ │ ├── task-executor.test.ts │ │ │ │ │ ├── tokenizer.test.ts │ │ │ │ │ └── useTokenEstimation.test.ts │ │ │ │ ├── analyzer.ts │ │ │ │ ├── cache-keys.ts │ │ │ │ ├── computation-queue.ts │ │ │ │ ├── hooks/ │ │ │ │ │ └── useTokenEstimation.ts │ │ │ │ ├── index.ts │ │ │ │ ├── result-persister.ts │ │ │ │ ├── task-executor.ts │ │ │ │ ├── tokenizer.ts │ │ │ │ └── types.ts │ │ │ ├── token.test.ts │ │ │ ├── token.tsx │ │ │ ├── token_config.ts │ │ │ ├── tools/ │ │ │ │ └── index.ts │ │ │ ├── web-search/ │ │ │ │ ├── base.ts │ │ │ │ ├── bing-news.ts │ │ │ │ ├── bing.ts │ │ │ │ ├── chatbox-search.ts │ │ │ │ ├── duckduckgo.ts │ │ │ │ ├── index.ts │ │ │ │ └── tavily.ts │ │ │ └── word-count.ts │ │ ├── pages/ │ │ │ ├── PictureDialog.tsx │ │ │ ├── RemoteDialogWindow.tsx │ │ │ ├── SearchDialog.tsx │ │ │ └── SettingDialog/ │ │ │ └── AdvancedSettingTab.tsx │ │ ├── platform/ │ │ │ ├── desktop_platform.ts │ │ │ ├── index.ts │ │ │ ├── interfaces.ts │ │ │ ├── knowledge-base/ │ │ │ │ ├── desktop-controller.ts │ │ │ │ └── interface.ts │ │ │ ├── storages.ts │ │ │ ├── test_platform.ts │ │ │ ├── web_exporter.ts │ │ │ ├── web_logger.ts │ │ │ ├── web_platform.ts │ │ │ └── web_platform_utils.ts │ │ ├── preload.d.ts │ │ ├── reportWebVitals.ts │ │ ├── router.tsx │ │ ├── routes/ │ │ │ ├── __root.tsx │ │ │ ├── about.tsx │ │ │ ├── copilots.tsx │ │ │ ├── dev/ │ │ │ │ ├── context-generator.tsx │ │ │ │ ├── css-var.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── model-selector.tsx │ │ │ │ ├── route.tsx │ │ │ │ └── storage.tsx │ │ │ ├── image-creator/ │ │ │ │ ├── -components/ │ │ │ │ │ ├── EmptyState.tsx │ │ │ │ │ ├── GeneratedImagesGallery.tsx │ │ │ │ │ ├── HistoryItem.tsx │ │ │ │ │ ├── HistoryPanel.tsx │ │ │ │ │ ├── ImageGenerationErrorTips.tsx │ │ │ │ │ ├── MobileDrawers.tsx │ │ │ │ │ ├── PromptDisplay.tsx │ │ │ │ │ ├── ReferenceImagesPreview.tsx │ │ │ │ │ ├── Shimmer.tsx │ │ │ │ │ └── constants.ts │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── session/ │ │ │ │ └── $sessionId.tsx │ │ │ └── settings/ │ │ │ ├── chat.tsx │ │ │ ├── chatbox-ai.tsx │ │ │ ├── default-models.tsx │ │ │ ├── document-parser.tsx │ │ │ ├── general.tsx │ │ │ ├── hotkeys.tsx │ │ │ ├── index.tsx │ │ │ ├── knowledge-base.tsx │ │ │ ├── mcp.tsx │ │ │ ├── provider/ │ │ │ │ ├── $providerId.tsx │ │ │ │ ├── chatbox-ai/ │ │ │ │ │ ├── -components/ │ │ │ │ │ │ ├── LicenseDetailCard.tsx │ │ │ │ │ │ ├── LicenseKeyView.tsx │ │ │ │ │ │ ├── LicenseSelectionModal.tsx │ │ │ │ │ │ ├── LoggedInView.tsx │ │ │ │ │ │ ├── LoginView.tsx │ │ │ │ │ │ ├── ModelManagement.tsx │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── useAuthTokens.ts │ │ │ │ │ │ ├── useLicenseActivation.ts │ │ │ │ │ │ ├── useLogin.ts │ │ │ │ │ │ └── useUserLicenses.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── route.tsx │ │ │ ├── route.tsx │ │ │ └── web-search.tsx │ │ ├── setup/ │ │ │ ├── ga_init.ts │ │ │ ├── global_error_handler.ts │ │ │ ├── init_data.ts │ │ │ ├── load_polyfill.ts │ │ │ ├── mcp_bootstrap.ts │ │ │ ├── mobile_safe_area.ts │ │ │ ├── protect.ts │ │ │ ├── sentry_init.ts │ │ │ ├── storage_clear.ts │ │ │ └── token_estimation_init.ts │ │ ├── setupTests.ts │ │ ├── static/ │ │ │ ├── Block.css │ │ │ ├── _headers │ │ │ ├── globals.css │ │ │ └── index.css │ │ ├── storage/ │ │ │ ├── BaseStorage.ts │ │ │ ├── ImageGenerationStorage.ts │ │ │ ├── SQLiteImageGenerationStorage.ts │ │ │ ├── StoreStorage.ts │ │ │ └── index.ts │ │ ├── stores/ │ │ │ ├── atoms/ │ │ │ │ ├── compactionAtoms.ts │ │ │ │ ├── configAtoms.ts │ │ │ │ ├── index.ts │ │ │ │ ├── sessionAtoms.ts │ │ │ │ ├── settingsAtoms.ts │ │ │ │ ├── throttleWriteSessionAtom.ts │ │ │ │ ├── uiAtoms.ts │ │ │ │ └── utilAtoms.ts │ │ │ ├── authInfoStore.ts │ │ │ ├── chatStore.ts │ │ │ ├── imageGenerationActions.ts │ │ │ ├── imageGenerationStore.ts │ │ │ ├── lastUsedModelStore.ts │ │ │ ├── migration.test.ts │ │ │ ├── migration.ts │ │ │ ├── premiumActions.ts │ │ │ ├── queryClient.ts │ │ │ ├── safeStorage.ts │ │ │ ├── scrollActions.ts │ │ │ ├── session/ │ │ │ │ ├── crud.ts │ │ │ │ ├── export.ts │ │ │ │ ├── forks.ts │ │ │ │ ├── generation.ts │ │ │ │ ├── index.ts │ │ │ │ ├── messages.ts │ │ │ │ ├── naming.ts │ │ │ │ ├── state.ts │ │ │ │ ├── threads.ts │ │ │ │ └── types.ts │ │ │ ├── sessionActions.test.ts │ │ │ ├── sessionActions.ts │ │ │ ├── sessionHelpers.ts │ │ │ ├── settingActions.ts │ │ │ ├── settingsStore.ts │ │ │ ├── toastActions.ts │ │ │ ├── uiStore.ts │ │ │ ├── updateQueue.test.ts │ │ │ └── updateQueue.ts │ │ ├── test.tsx │ │ ├── utils/ │ │ │ ├── base64.ts │ │ │ ├── error-testing.ts │ │ │ ├── feature-flags.ts │ │ │ ├── format.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── message.test.ts │ │ │ ├── message.ts │ │ │ ├── mobile-request.ts │ │ │ ├── model-tester.ts │ │ │ ├── modelLogo.tsx │ │ │ ├── provider-config.test.ts │ │ │ ├── provider-config.ts │ │ │ ├── request.ts │ │ │ ├── session-utils.ts │ │ │ └── track.ts │ │ └── variables.ts │ └── shared/ │ ├── constants.ts │ ├── defaults.ts │ ├── electron-types.ts │ ├── file-extensions.ts │ ├── models/ │ │ ├── abstract-ai-sdk.ts │ │ ├── errors.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── openai-compatible.ts │ │ ├── rerank.ts │ │ ├── types.ts │ │ └── utils/ │ │ └── fetch-proxy.ts │ ├── providers/ │ │ ├── definitions/ │ │ │ ├── azure.ts │ │ │ ├── chatboxai.ts │ │ │ ├── chatglm.ts │ │ │ ├── claude.ts │ │ │ ├── deepseek.ts │ │ │ ├── gemini.ts │ │ │ ├── groq.ts │ │ │ ├── lmstudio.ts │ │ │ ├── mistral-ai.ts │ │ │ ├── models/ │ │ │ │ ├── azure.ts │ │ │ │ ├── chatboxai.ts │ │ │ │ ├── chatglm.ts │ │ │ │ ├── claude.ts │ │ │ │ ├── custom-claude.ts │ │ │ │ ├── custom-gemini.ts │ │ │ │ ├── custom-openai-responses.ts │ │ │ │ ├── custom-openai.ts │ │ │ │ ├── deepseek.ts │ │ │ │ ├── gemini.ts │ │ │ │ ├── groq.ts │ │ │ │ ├── lmstudio.ts │ │ │ │ ├── mistral-ai.ts │ │ │ │ ├── ollama.ts │ │ │ │ ├── openai-responses.ts │ │ │ │ ├── openai.test.ts │ │ │ │ ├── openai.ts │ │ │ │ ├── openrouter.ts │ │ │ │ ├── perplexity.ts │ │ │ │ ├── siliconflow.ts │ │ │ │ ├── volcengine.ts │ │ │ │ └── xai.ts │ │ │ ├── ollama.ts │ │ │ ├── openai-responses.ts │ │ │ ├── openai.ts │ │ │ ├── openrouter.ts │ │ │ ├── perplexity.ts │ │ │ ├── siliconflow.ts │ │ │ ├── volcengine.ts │ │ │ └── xai.ts │ │ ├── index.ts │ │ ├── registry.test.ts │ │ ├── registry.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── request/ │ │ ├── chatboxai_pool.ts │ │ └── request.ts │ ├── types/ │ │ ├── adapters.ts │ │ ├── image-generation.ts │ │ ├── mcp.ts │ │ ├── provider.ts │ │ ├── session.ts │ │ └── settings.ts │ ├── types.test.ts │ ├── types.ts │ └── utils/ │ ├── cache.ts │ ├── index.ts │ ├── json_utils.ts │ ├── knowledge-base-model-parser.ts │ ├── llm_utils.test.ts │ ├── llm_utils.ts │ ├── message.ts │ ├── model_settings.ts │ ├── network_utils.ts │ ├── sentry_adapter.ts │ └── word_count.ts ├── tailwind.config.js ├── tasks/ │ ├── prd-code-organization-optimization.md │ ├── prd-compaction-ux-improvement.md │ ├── prd-context-management.md │ └── prd-provider-system-refactor.md ├── team-sharing/ │ ├── Caddyfile │ ├── Dockerfile │ ├── README-CN.md │ ├── README.md │ └── main.sh ├── test/ │ ├── cases/ │ │ ├── file-conversation/ │ │ │ ├── sample-large.txt │ │ │ ├── sample.json │ │ │ ├── sample.md │ │ │ ├── sample.ts │ │ │ └── sample.txt │ │ └── provider-config-import-manual-test.md │ └── integration/ │ ├── context-management/ │ │ ├── context-management.test.ts │ │ └── setup.ts │ ├── file-conversation/ │ │ ├── file-conversation.test.ts │ │ ├── setup.ts │ │ └── test-harness.ts │ ├── mocks/ │ │ ├── model-dependencies.ts │ │ └── sentry.ts │ └── model-provider/ │ └── model-provider.test.ts ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .erb/.vscode/settings.json ================================================ { "cssVariables.lookupFiles": [ "**/*.css", "**/*.scss", "**/*.sass", "**/*.less", "node_modules/@mantine/core/styles.css" ] } ================================================ FILE: .erb/configs/.eslintrc ================================================ { "rules": { "no-console": "off", "global-require": "off", "import/no-dynamic-require": "off" } } ================================================ FILE: .erb/configs/webpack.config.base.ts ================================================ /** * Base webpack config used across other specific configs */ import webpack from 'webpack' import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin' import webpackPaths from './webpack.paths' import { dependencies as externals } from '../../release/app/package.json' const configuration: webpack.Configuration = { externals: [...Object.keys(externals || {})], stats: 'errors-only', module: { rules: [ { test: /\.[jt]sx?$/, exclude: [/node_modules/, /\.d\.ts$/], use: { loader: 'ts-loader', options: { // Remove this line to enable type checking in webpack builds transpileOnly: true, compilerOptions: { module: 'esnext', }, }, }, }, // Special rule for mermaid to transpile static blocks { test: /\.m?js$/, include: /node_modules\/mermaid/, use: { loader: 'babel-loader', options: { presets: [ ['@babel/preset-env', { targets: { chrome: '58', firefox: '60', safari: '11', edge: '16', ios: '11', android: '67' } }] ], plugins: [ '@babel/plugin-transform-class-static-block' ] } } }, ], }, output: { path: webpackPaths.srcPath, // https://github.com/webpack/webpack/issues/1114 library: { type: 'commonjs2', }, }, /** * Determine the array of extensions that should be used to resolve modules. */ resolve: { extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], modules: [webpackPaths.srcPath, 'node_modules'], // There is no need to add aliases here, the paths in tsconfig get mirrored plugins: [new TsconfigPathsPlugins()], }, plugins: [ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', CHATBOX_BUILD_TARGET: 'unknown', CHATBOX_BUILD_PLATFORM: 'unknown', USE_LOCAL_API: '', USE_BETA_API: '', USE_LOCAL_CHATBOX: '', USE_BETA_CHATBOX: '', }), ], } export default configuration ================================================ FILE: .erb/configs/webpack.config.eslint.ts ================================================ /* eslint import/no-unresolved: off, import/no-self-import: off */ module.exports = require('./webpack.config.renderer.dev').default ================================================ FILE: .erb/configs/webpack.config.main.prod.ts ================================================ /** * Webpack config for production electron main process */ import path from 'path' import TerserPlugin from 'terser-webpack-plugin' import webpack from 'webpack' import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer' import { merge } from 'webpack-merge' import JavaScriptObfuscator from 'webpack-obfuscator' import checkNodeEnv from '../scripts/check-node-env' import baseConfig from './webpack.config.base' import webpackPaths from './webpack.paths' checkNodeEnv('production') const configuration: webpack.Configuration = { devtool: false, mode: 'production', target: 'electron-main', entry: { main: path.join(webpackPaths.srcMainPath, 'main.ts'), preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), }, output: { path: webpackPaths.distMainPath, filename: '[name].js', library: { type: 'umd', }, }, optimization: { minimizer: [ new TerserPlugin({ parallel: true, }), ], }, plugins: [ new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', analyzerPort: 8888, }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks */ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', DEBUG_PROD: false, START_MINIMIZED: false, }), new webpack.DefinePlugin({ 'process.type': '"browser"', }), new JavaScriptObfuscator({ target: 'node', optionsPreset: 'default', // 默认的变量名混淆,可能被误报为恶意代码 identifierNamesGenerator: 'mangled-shuffled', }), ], /** * Disables webpack processing of __dirname and __filename. * If you run the bundle in node.js it falls back to these values of node.js. * https://github.com/webpack/webpack/issues/2010 */ node: { __dirname: false, __filename: false, }, } export default merge(baseConfig, configuration) ================================================ FILE: .erb/configs/webpack.config.preload.dev.ts ================================================ import path from 'path' import webpack from 'webpack' import { merge } from 'webpack-merge' import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer' import baseConfig from './webpack.config.base' import webpackPaths from './webpack.paths' import checkNodeEnv from '../scripts/check-node-env' // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's // at the dev webpack config is not accidentally run in a production environment if (process.env.NODE_ENV === 'production') { checkNodeEnv('development') } const configuration: webpack.Configuration = { devtool: 'inline-source-map', mode: 'development', target: 'electron-preload', entry: path.join(webpackPaths.srcMainPath, 'preload.ts'), output: { path: webpackPaths.dllPath, filename: 'preload.js', library: { type: 'umd', }, }, plugins: [ new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks * * By default, use 'development' as NODE_ENV. This can be overriden with * 'staging', for example, by changing the ENV variables in the npm scripts */ new webpack.EnvironmentPlugin({ NODE_ENV: 'development', }), new webpack.LoaderOptionsPlugin({ debug: true, }), ], /** * Disables webpack processing of __dirname and __filename. * If you run the bundle in node.js it falls back to these values of node.js. * https://github.com/webpack/webpack/issues/2010 */ node: { __dirname: false, __filename: false, }, watch: true, } export default merge(baseConfig, configuration) ================================================ FILE: .erb/configs/webpack.config.renderer.dev.dll.ts ================================================ /** * Builds the DLL for development electron renderer process */ import webpack from 'webpack' import path from 'path' import { merge } from 'webpack-merge' import baseConfig from './webpack.config.base' import webpackPaths from './webpack.paths' import { dependencies } from '../../package.json' import checkNodeEnv from '../scripts/check-node-env' checkNodeEnv('development') const EXCLUDE_MODULES = new Set([ '@modelcontextprotocol/sdk', // avoid `Package path . is not exported from package` error '@mastra/core', '@mastra/rag', '@libsql/client', 'capacitor-stream-http', // local file dependency ]) const dist = webpackPaths.dllPath const configuration: webpack.Configuration = { context: webpackPaths.rootPath, devtool: 'eval', mode: 'development', target: 'electron-renderer', externals: ['fsevents', 'crypto-browserify'], /** * Use `module` from `webpack.config.renderer.dev.js` */ module: require('./webpack.config.renderer.dev').default.module, entry: { renderer: Object.keys(dependencies || {}).filter((dependency) => !EXCLUDE_MODULES.has(dependency)), }, output: { path: dist, filename: '[name].dev.dll.js', library: { name: 'renderer', type: 'var', }, }, plugins: [ new webpack.DllPlugin({ path: path.join(dist, '[name].json'), name: '[name]', }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks */ new webpack.EnvironmentPlugin({ NODE_ENV: 'development', }), new webpack.LoaderOptionsPlugin({ debug: true, options: { context: webpackPaths.srcPath, output: { path: webpackPaths.dllPath, }, }, }), ], } export default merge(baseConfig, configuration) ================================================ FILE: .erb/configs/webpack.config.renderer.dev.ts ================================================ import 'webpack-dev-server' import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin' import { TanStackRouterWebpack } from '@tanstack/router-plugin/webpack' import chalk from 'chalk' import { execSync, spawn } from 'child_process' import fs from 'fs' import HtmlWebpackPlugin from 'html-webpack-plugin' import path from 'path' import webpack from 'webpack' import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer' import { merge } from 'webpack-merge' import checkNodeEnv from '../scripts/check-node-env' import baseConfig from './webpack.config.base' import webpackPaths from './webpack.paths' // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's // at the dev webpack config is not accidentally run in a production environment if (process.env.NODE_ENV === 'production') { checkNodeEnv('development') } const port = process.env.PORT || 1212 const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json') const skipDLLs = module.parent?.filename.includes('webpack.config.renderer.dev.dll') || module.parent?.filename.includes('webpack.config.eslint') const DEV_WEB_ONLY = process.env.DEV_WEB_ONLY === 'true' /** * Warn if the DLL is not built */ if (!skipDLLs && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) { console.log( chalk.black.bgYellow.bold( 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"' ) ) execSync('npm run postinstall') } const configuration: webpack.Configuration = { devtool: 'inline-source-map', mode: 'development', target: ['web', 'electron-renderer'], entry: [ `webpack-dev-server/client?http://localhost:${port}/dist`, 'webpack/hot/only-dev-server', path.join(webpackPaths.srcRendererPath, 'index.tsx'), ], output: { path: webpackPaths.distRendererPath, publicPath: '/', filename: 'renderer.dev.js', library: { type: 'umd', }, }, module: { rules: [ { test: /\.s?(c|a)ss$/, use: [ 'style-loader', { loader: 'css-loader', options: { modules: true, sourceMap: true, importLoaders: 1, }, }, 'sass-loader', ], include: /\.module\.s?(c|a)ss$/, }, { test: /\.s?css$/, use: ['style-loader', 'css-loader', 'sass-loader', 'postcss-loader', { loader: 'string-replace-loader', options: { search: /(\d+)dvh/g, replace: '$1vh', }, }], exclude: /\.module\.s?(c|a)ss$/, sideEffects: true, }, // Fonts { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: 'asset/resource', }, // Images { test: /\.(png|jpg|jpeg|gif)$/i, type: 'asset/resource', }, // SVG { test: /\.svg$/, use: [ { loader: '@svgr/webpack', options: { prettier: false, svgo: false, svgoConfig: { plugins: [{ removeViewBox: false }], }, titleProp: true, ref: true, }, }, 'file-loader', ], }, ], }, plugins: [ ...(skipDLLs ? [] : [ new webpack.DllReferencePlugin({ context: webpackPaths.dllPath, manifest: require(manifest), sourceType: 'var', }), ]), new webpack.NoEmitOnErrorsPlugin(), TanStackRouterWebpack({ target: 'react', autoCodeSplitting: true, routesDirectory: './src/renderer/routes', generatedRouteTree: './src/renderer/routeTree.gen.ts', }), /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks * * By default, use 'development' as NODE_ENV. This can be overriden with * 'staging', for example, by changing the ENV variables in the npm scripts */ new webpack.EnvironmentPlugin({ NODE_ENV: 'development', }), new webpack.LoaderOptionsPlugin({ debug: true, }), new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', analyzerPort: 8889, }), new ReactRefreshWebpackPlugin(), new HtmlWebpackPlugin({ filename: path.join('index.html'), template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), minify: { collapseWhitespace: true, removeAttributeQuotes: true, removeComments: true, }, isBrowser: false, env: process.env.NODE_ENV, isDevelopment: process.env.NODE_ENV !== 'production', nodeModules: webpackPaths.appNodeModulesPath, favicon: path.join(webpackPaths.srcRendererPath, 'favicon.ico'), }), ], node: { __dirname: false, __filename: false, }, devServer: { port, compress: true, hot: true, headers: { 'Access-Control-Allow-Origin': '*' }, static: { publicPath: '/', }, historyApiFallback: { verbose: true, }, setupMiddlewares(middlewares) { console.log('Starting preload.js builder...') const preloadProcess = spawn('npm', ['run', 'start:preload'], { shell: true, stdio: 'inherit', }) .on('close', (code: number) => process.exit(code!)) .on('error', (spawnError) => console.error(spawnError)) if (!DEV_WEB_ONLY) { console.log('Starting Main Process...') let args = ['run', 'start:main'] if (process.env.MAIN_ARGS) { args = args.concat(['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat()) } spawn('npm', args, { shell: true, stdio: 'inherit', }) .on('close', (code: number) => { preloadProcess.kill() process.exit(code!) }) .on('error', (spawnError) => console.error(spawnError)) } return middlewares }, }, } export default merge(baseConfig, configuration) ================================================ FILE: .erb/configs/webpack.config.renderer.prod.ts ================================================ /** * Build config for electron renderer process */ import { sentryWebpackPlugin } from '@sentry/webpack-plugin' import { TanStackRouterWebpack } from '@tanstack/router-plugin/webpack' import CssMinimizerPlugin from 'css-minimizer-webpack-plugin' import HtmlWebpackPlugin from 'html-webpack-plugin' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import path from 'path' import TerserPlugin from 'terser-webpack-plugin' import webpack from 'webpack' import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer' import { merge } from 'webpack-merge' import packageJson from '../../release/app/package.json' import checkNodeEnv from '../scripts/check-node-env' import baseConfig from './webpack.config.base' import webpackPaths from './webpack.paths' checkNodeEnv('production') const inferredRelease = process.env.SENTRY_RELEASE || packageJson.version const inferredDist = process.env.SENTRY_DIST || undefined // Ensure downstream tooling sees consistent release/dist values process.env.SENTRY_RELEASE = inferredRelease if (inferredDist) { process.env.SENTRY_DIST = inferredDist } const configuration: webpack.Configuration = { devtool: 'source-map', mode: 'production', target: ['web', 'electron-renderer'], entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], output: { path: webpackPaths.distRendererPath, publicPath: process.env.CHATBOX_BUILD_PLATFORM === 'web' ? '/' : './', filename: 'assets/js/[name].[contenthash].js', // JS文件放在assets/js目录下 library: { type: 'umd', }, }, module: { rules: [ { test: /\.s?(a|c)ss$/, use: [ MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { modules: true, sourceMap: true, importLoaders: 1, }, }, 'sass-loader', ], include: /\.module\.s?(c|a)ss$/, }, { test: /\.s?(a|c)ss$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader', 'postcss-loader', { loader: 'string-replace-loader', options: { search: /(\d+)dvh/g, replace: '$1vh', }, }], exclude: /\.module\.s?(c|a)ss$/, sideEffects: true, }, // Fonts { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: 'asset/resource', generator: { filename: 'assets/fonts/[name].[hash][ext]', // 字体资源放在assets/fonts目录下 }, }, // Images { test: /\.(png|jpg|jpeg|gif)$/i, type: 'asset/resource', generator: { filename: 'assets/images/[name].[hash][ext]', // 图片资源放在assets/images目录下 }, }, // SVG { test: /\.svg$/, use: [ { loader: '@svgr/webpack', options: { prettier: false, svgo: false, svgoConfig: { plugins: [{ removeViewBox: false }], }, titleProp: true, ref: true, }, }, 'file-loader', ], }, ], }, optimization: { minimize: true, minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], }, plugins: [ /** * Create global constants which can be configured at compile time. * * Useful for allowing different behaviour between development builds and * release builds * * NODE_ENV should be production so that modules do not perform certain * development checks */ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', DEBUG_PROD: false, }), TanStackRouterWebpack({ target: 'react', autoCodeSplitting: process.env.CHATBOX_BUILD_PLATFORM === 'web' ? true : false, routesDirectory: './src/renderer/routes', generatedRouteTree: './src/renderer/routeTree.gen.ts', }), new MiniCssExtractPlugin({ filename: '[name].[contenthash].css', // CSS文件放在assets/css目录下 - 又不放了,因为这样会导致非web端的字体文件引用路径出错 }), new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', analyzerPort: 8889, }), new HtmlWebpackPlugin({ filename: 'index.html', template: path.join( webpackPaths.srcRendererPath, process.env.CHATBOX_BUILD_PLATFORM === 'web' ? 'index.web.ejs' : 'index.ejs' ), minify: { collapseWhitespace: true, removeAttributeQuotes: true, removeComments: true, }, isBrowser: false, isDevelopment: false, favicon: path.join(webpackPaths.srcRendererPath, 'favicon.ico'), }), new webpack.DefinePlugin({ 'process.type': '"renderer"', }), // 禁用混淆,加快构建速度 // new JavaScriptObfuscator({ // optionsPreset: 'default', // // 太卡了 // // controlFlowFlattening: true, // // controlFlowFlatteningThreshold: 0.1, // // 默认的变量名混淆,可能被误报为恶意代码 // identifierNamesGenerator: 'mangled-shuffled', // // 这些静态字符串混淆后,很可能被误报为恶意代码 // exclude: ['initial_data.ts', 'initial_data.js'], // numbersToExpressions: true, // // 保护前端代码不被偷到其他地方部署 // // 迁移过程中,暂时关闭保护 // // domainLock: ['localhost', ".chatboxai.app", ".chatboxai.com", ".chatboxapp.xyz", "chatbox-pro.pages.dev"], // // domainLockRedirectUrl: 'https://chatboxai.app', // sourceMap: true, // }), process.env.SENTRY_AUTH_TOKEN && sentryWebpackPlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: 'sentry', project: 'chatbox', url: 'https://sentry.midway.run/', release: { name: inferredRelease, ...(inferredDist ? { dist: inferredDist } : {}), }, }), ], } export default merge(baseConfig, configuration) ================================================ FILE: .erb/configs/webpack.paths.ts ================================================ import path from 'path' const rootPath = path.join(__dirname, '../..') const dllPath = path.join(__dirname, '../dll') const srcPath = path.join(rootPath, 'src') const srcMainPath = path.join(srcPath, 'main') const srcRendererPath = path.join(srcPath, 'renderer') const releasePath = path.join(rootPath, 'release') const appPath = path.join(releasePath, 'app') const appPackagePath = path.join(appPath, 'package.json') const appNodeModulesPath = path.join(appPath, 'node_modules') const srcNodeModulesPath = path.join(srcPath, 'node_modules') const distPath = path.join(appPath, 'dist') const distMainPath = path.join(distPath, 'main') const distRendererPath = path.join(distPath, 'renderer') const buildPath = path.join(releasePath, 'build') export default { rootPath, dllPath, srcPath, srcMainPath, srcRendererPath, releasePath, appPath, appPackagePath, appNodeModulesPath, srcNodeModulesPath, distPath, distMainPath, distRendererPath, buildPath, } ================================================ FILE: .erb/mocks/fileMock.js ================================================ export default 'test-file-stub' ================================================ FILE: .erb/scripts/.eslintrc ================================================ { "rules": { "no-console": "off", "global-require": "off", "import/no-dynamic-require": "off", "import/no-extraneous-dependencies": "off" } } ================================================ FILE: .erb/scripts/check-build-exists.ts ================================================ // Check if the renderer and main bundles are built import path from 'path' import chalk from 'chalk' import fs from 'fs' import webpackPaths from '../configs/webpack.paths' const mainPath = path.join(webpackPaths.distMainPath, 'main.js') const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js') if (!fs.existsSync(mainPath)) { throw new Error( chalk.whiteBright.bgRed.bold('The main process is not built yet. Build it by running "npm run build:main"') ) } if (!fs.existsSync(rendererPath)) { throw new Error( chalk.whiteBright.bgRed.bold( 'The renderer process is not built yet. Build it by running "npm run build:renderer"' ) ) } ================================================ FILE: .erb/scripts/check-native-dep.cjs ================================================ const { execSync } = require('child_process') const fs = require('fs') const path = require('path') const { dependencies } = require('../../package.json') // Simple color helpers (chalk is ESM-only in newer versions) const colors = { blue: (text) => `\x1b[34m${text}\x1b[0m`, gray: (text) => `\x1b[90m${text}\x1b[0m`, bold: (text) => `\x1b[1m${text}\x1b[0m`, bgYellow: (text) => `\x1b[43m\x1b[97m${text}\x1b[0m`, bgGreen: (text) => `\x1b[42m\x1b[97m${text}\x1b[0m`, bgRed: (text) => `\x1b[41m\x1b[97m${text}\x1b[0m`, } // Helper function to recursively find .node files in a directory function findNodeFiles(dir) { const nodeFiles = [] try { const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { // Only search in common subdirectories to avoid performance issues if (['build', 'prebuilds', 'lib', 'bin'].includes(entry.name)) { nodeFiles.push(...findNodeFiles(fullPath)) } } else if (entry.isFile() && entry.name.endsWith('.node')) { nodeFiles.push(fullPath) } } } catch (e) { // Ignore permission errors or missing directories } return nodeFiles } // Helper function to get all packages including scoped ones (@scope/pkg) function getAllPackages(nodeModulesDir) { const packages = [] try { const entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory()) continue if (entry.name.startsWith('@')) { // Scoped package - read children const scopePath = path.join(nodeModulesDir, entry.name) try { const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true }) for (const scopedEntry of scopedEntries) { if (scopedEntry.isDirectory()) { packages.push(`${entry.name}/${scopedEntry.name}`) } } } catch (e) { // Ignore errors reading scoped directory } } else { packages.push(entry.name) } } } catch (e) { // Ignore errors reading node_modules } return packages } if (dependencies) { const dependenciesKeys = Object.keys(dependencies) // Packages to exclude from native dependency check: // These packages have transitive native dependencies but are correctly handled by // electron-vite (externalized for main process) and electron-builder (bundled from // release/app/node_modules). This check is designed for webpack bundling issues // which don't apply to electron-vite's architecture. // // - capacitor-stream-http: Capacitor plugin, not an Electron native dep // - epub: Optional zipfile dep, used in renderer for parsing // - @libsql/client, @mastra/libsql: Native bindings for SQLite, externalized by electron-vite // - @mastra/core, @mastra/rag: Type imports in shared + runtime in main, externalized // - officeparser: Uses pdfjs-dist with native canvas, externalized by electron-vite const excludePackages = [ 'capacitor-stream-http', 'epub', '@libsql/client', '@mastra/libsql', '@mastra/core', '@mastra/rag', 'officeparser', ] // Get all packages including scoped ones (@scope/pkg) const allPackages = getAllPackages('node_modules') // Check for packages with binding.gyp (source-based native modules) const nativeDepsByBindingGyp = allPackages.filter((pkg) => { if (excludePackages.includes(pkg)) return false return fs.existsSync(`node_modules/${pkg}/binding.gyp`) }) // Check for packages with .node files (precompiled native modules) const nativeDepsByNodeFiles = allPackages.filter((pkg) => { if (excludePackages.includes(pkg)) return false const nodeFiles = findNodeFiles(`node_modules/${pkg}`) return nodeFiles.length > 0 }) // Combine both types of native dependencies const allNativeDeps = [...new Set([...nativeDepsByBindingGyp, ...nativeDepsByNodeFiles])] if (allNativeDeps.length === 0) { process.exit(0) } console.debug(colors.blue(`Found native dependencies: ${allNativeDeps.join(', ')}`)) console.debug(colors.gray(`- With binding.gyp: ${nativeDepsByBindingGyp.join(', ') || 'none'}`)) console.debug(colors.gray(`- With .node files: ${nativeDepsByNodeFiles.join(', ') || 'none'}`)) try { // Find the reason for why the dependency is installed. If it is installed // because of a devDependency then that is okay. Warn when it is installed // because of a dependency // Note: pnpm ls --json returns an array (one entry per workspace package) const lsResult = JSON.parse( execSync(`pnpm ls ${allNativeDeps.join(' ')} --json`).toString() ) const rootResult = Array.isArray(lsResult) ? lsResult.find((item) => item.path === process.cwd()) ?? lsResult[0] : lsResult const dependenciesObject = rootResult?.dependencies ?? {} const rootDependencies = Object.keys(dependenciesObject) const filteredRootDependencies = rootDependencies.filter((rootDependency) => dependenciesKeys.includes(rootDependency) && !excludePackages.includes(rootDependency) ) if (filteredRootDependencies.length > 0) { const plural = filteredRootDependencies.length > 1 console.log(` ${colors.bgYellow(colors.bold('Webpack does not work with native dependencies.'))} ${colors.bold(filteredRootDependencies.join(', '))} ${ plural ? 'are native dependencies' : 'is a native dependency' } and should be installed inside of the "./release/app" folder. First, uninstall the packages from "./package.json": ${colors.bgGreen(colors.bold('pnpm remove your-package'))} ${colors.bold('Then, instead of installing the package to the root "./package.json":')} ${colors.bgRed(colors.bold('pnpm add your-package'))} ${colors.bold('Install the package to "./release/app/package.json"')} ${colors.bgGreen(colors.bold('cd ./release/app && pnpm add your-package'))} Read more about native dependencies at: ${colors.bold('https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure')} `) process.exit(1) } } catch (e) { console.log('Native dependencies could not be checked:', e.message) } } ================================================ FILE: .erb/scripts/check-native-dep.js ================================================ import chalk from 'chalk' import { execSync } from 'child_process' import fs from 'fs' import path from 'path' import { dependencies } from '../../package.json' // Helper function to recursively find .node files in a directory function findNodeFiles(dir) { const nodeFiles = [] try { const entries = fs.readdirSync(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { // Only search in common subdirectories to avoid performance issues if (['build', 'prebuilds', 'lib', 'bin'].includes(entry.name)) { nodeFiles.push(...findNodeFiles(fullPath)) } } else if (entry.isFile() && entry.name.endsWith('.node')) { nodeFiles.push(fullPath) } } } catch (e) { // Ignore permission errors or missing directories } return nodeFiles } // Helper function to get all packages including scoped ones (@scope/pkg) function getAllPackages(nodeModulesDir) { const packages = [] try { const entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory()) continue if (entry.name.startsWith('@')) { // Scoped package - read children const scopePath = path.join(nodeModulesDir, entry.name) try { const scopedEntries = fs.readdirSync(scopePath, { withFileTypes: true }) for (const scopedEntry of scopedEntries) { if (scopedEntry.isDirectory()) { packages.push(`${entry.name}/${scopedEntry.name}`) } } } catch (e) { // Ignore errors reading scoped directory } } else { packages.push(entry.name) } } } catch (e) { // Ignore errors reading node_modules } return packages } if (dependencies) { const dependenciesKeys = Object.keys(dependencies) // Packages to exclude from native dependency check: // These packages have transitive native dependencies but are correctly handled by // electron-vite (externalized for main process) and electron-builder (bundled from // release/app/node_modules). This check is designed for webpack bundling issues // which don't apply to electron-vite's architecture. // // - capacitor-stream-http: Capacitor plugin, not an Electron native dep // - epub: Optional zipfile dep, used in renderer for parsing // - @libsql/client, @mastra/libsql: Native bindings for SQLite, externalized by electron-vite // - @mastra/core, @mastra/rag: Type imports in shared + runtime in main, externalized // - officeparser: Uses pdfjs-dist with native canvas, externalized by electron-vite const excludePackages = [ 'capacitor-stream-http', 'epub', '@libsql/client', '@mastra/libsql', '@mastra/core', '@mastra/rag', 'officeparser', ] // Get all packages including scoped ones (@scope/pkg) const allPackages = getAllPackages('node_modules') // Check for packages with binding.gyp (source-based native modules) const nativeDepsByBindingGyp = allPackages.filter((pkg) => { if (excludePackages.includes(pkg)) return false return fs.existsSync(`node_modules/${pkg}/binding.gyp`) }) // Check for packages with .node files (precompiled native modules) const nativeDepsByNodeFiles = allPackages.filter((pkg) => { if (excludePackages.includes(pkg)) return false const nodeFiles = findNodeFiles(`node_modules/${pkg}`) return nodeFiles.length > 0 }) // Combine both types of native dependencies const allNativeDeps = [...new Set([...nativeDepsByBindingGyp, ...nativeDepsByNodeFiles])] if (allNativeDeps.length === 0) { process.exit(0) } console.debug(chalk.blue(`Found native dependencies: ${allNativeDeps.join(', ')}`)) console.debug(chalk.gray(`- With binding.gyp: ${nativeDepsByBindingGyp.join(', ') || 'none'}`)) console.debug(chalk.gray(`- With .node files: ${nativeDepsByNodeFiles.join(', ') || 'none'}`)) try { // Find the reason for why the dependency is installed. If it is installed // because of a devDependency then that is okay. Warn when it is installed // because of a dependency // Note: pnpm ls --json returns an array (one entry per workspace package) const lsResult = JSON.parse( execSync(`pnpm ls ${allNativeDeps.join(' ')} --json`).toString() ) const rootResult = Array.isArray(lsResult) ? lsResult.find((item) => item.path === process.cwd()) ?? lsResult[0] : lsResult const dependenciesObject = rootResult?.dependencies ?? {} const rootDependencies = Object.keys(dependenciesObject) const filteredRootDependencies = rootDependencies.filter((rootDependency) => dependenciesKeys.includes(rootDependency) && !excludePackages.includes(rootDependency) ) if (filteredRootDependencies.length > 0) { const plural = filteredRootDependencies.length > 1 console.log(` ${chalk.whiteBright.bgYellow.bold('Webpack does not work with native dependencies.')} ${chalk.bold(filteredRootDependencies.join(', '))} ${ plural ? 'are native dependencies' : 'is a native dependency' } and should be installed inside of the "./release/app" folder. First, uninstall the packages from "./package.json": ${chalk.whiteBright.bgGreen.bold('pnpm remove your-package')} ${chalk.bold('Then, instead of installing the package to the root "./package.json":')} ${chalk.whiteBright.bgRed.bold('pnpm add your-package')} ${chalk.bold('Install the package to "./release/app/package.json"')} ${chalk.whiteBright.bgGreen.bold('cd ./release/app && pnpm add your-package')} Read more about native dependencies at: ${chalk.bold('https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure')} `) process.exit(1) } } catch (e) { console.log('Native dependencies could not be checked:', e.message) } } ================================================ FILE: .erb/scripts/check-node-env.js ================================================ import chalk from 'chalk' export default function checkNodeEnv(expectedEnv) { if (!expectedEnv) { throw new Error('"expectedEnv" not set') } if (process.env.NODE_ENV !== expectedEnv) { console.log( chalk.whiteBright.bgRed.bold(`"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`) ) process.exit(2) } } ================================================ FILE: .erb/scripts/check-port-in-use.js ================================================ import chalk from 'chalk' import detectPort from 'detect-port' const port = process.env.PORT || '1212' detectPort(port, (err, availablePort) => { if (port !== String(availablePort)) { throw new Error( chalk.whiteBright.bgRed.bold( `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start` ) ) } else { process.exit(0) } }) ================================================ FILE: .erb/scripts/clean.js ================================================ import { rimrafSync } from 'rimraf' import fs from 'fs' import webpackPaths from '../configs/webpack.paths' const foldersToRemove = [webpackPaths.distPath, webpackPaths.appNodeModulesPath, webpackPaths.buildPath, webpackPaths.dllPath] foldersToRemove.forEach((folder) => { if (fs.existsSync(folder)) rimrafSync(folder) }) ================================================ FILE: .erb/scripts/delete-source-maps.js ================================================ import fs from 'fs' import path from 'path' import { rimrafSync } from 'rimraf' import webpackPaths from '../configs/webpack.paths' export default function deleteSourceMaps() { if (fs.existsSync(webpackPaths.distMainPath)) rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), { glob: true, }) if (fs.existsSync(webpackPaths.distRendererPath)) rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), { glob: true, }) } ================================================ FILE: .erb/scripts/electron-rebuild.cjs ================================================ const { execSync } = require('child_process') const fs = require('fs') const path = require('path') // Inline the paths instead of importing from webpack.paths.ts const rootPath = path.join(__dirname, '../..') const appPath = path.join(rootPath, 'release/app') const appNodeModulesPath = path.join(appPath, 'node_modules') // Read dependencies from release/app/package.json const appPackageJson = require('../../release/app/package.json') const dependencies = appPackageJson.dependencies || {} if (Object.keys(dependencies).length > 0 && fs.existsSync(appNodeModulesPath)) { const electronRebuildCmd = '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .' const cmd = process.platform === 'win32' ? electronRebuildCmd.replace(/\//g, '\\') : electronRebuildCmd execSync(cmd, { cwd: appPath, stdio: 'inherit', }) } ================================================ FILE: .erb/scripts/electron-rebuild.js ================================================ import { execSync } from 'child_process' import fs from 'fs' import { dependencies } from '../../release/app/package.json' import webpackPaths from '../configs/webpack.paths' if (Object.keys(dependencies || {}).length > 0 && fs.existsSync(webpackPaths.appNodeModulesPath)) { const electronRebuildCmd = '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .' const cmd = process.platform === 'win32' ? electronRebuildCmd.replace(/\//g, '\\') : electronRebuildCmd execSync(cmd, { cwd: webpackPaths.appPath, stdio: 'inherit', }) } ================================================ FILE: .erb/scripts/link-modules.cjs ================================================ const fs = require('fs') const path = require('path') // Inline the paths const rootPath = path.join(__dirname, '../..') const srcNodeModulesPath = path.join(rootPath, 'src/node_modules') const appNodeModulesPath = path.join(rootPath, 'release/app/node_modules') if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction') } ================================================ FILE: .erb/scripts/link-modules.ts ================================================ import fs from 'fs' import webpackPaths from '../configs/webpack.paths' const { srcNodeModulesPath } = webpackPaths const { appNodeModulesPath } = webpackPaths if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction') } ================================================ FILE: .erb/scripts/notarize.js ================================================ const { notarize } = require('@electron/notarize') exports.default = async function notarizeMacos(context) { const { electronPlatformName, appOutDir } = context if (electronPlatformName !== 'darwin') { return } if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env && 'APPLE_TEAM_ID' in process.env)) { console.warn('Skipping notarizing step. APPLE_ID, APPLE_ID_PASS and APPLE_TEAM_ID env variables must be set') return } const appName = context.packager.appInfo.productFilename console.log('[Notarize] start macOS notarization: notarize.js running with notarytool') await notarize({ tool: 'notarytool', appBundleId: 'xyz.chatboxapp.app', appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLE_ID, appleIdPassword: process.env.APPLE_ID_PASS, teamId: process.env.APPLE_TEAM_ID, }) } ================================================ FILE: .erb/scripts/patch-libsql.cjs ================================================ const fs = require('fs') const path = require('path') const muslTargetBlock = ` // @neon-rs/load doesn't detect arm musl if (target === "linux-arm-gnueabihf" && familySync() == MUSL) { target = "linux-arm-musleabihf"; } ` const winArm64GuardBlock = ` if (target === "win32-arm64-msvc") { console.log("[libsql] Windows ARM64 detected - native module not available"); return {}; } ` const directRequireBlock = ` return require(\`@libsql/\${target}\`); ` const tryCatchRequireBlock = ` try { return require(\`@libsql/\${target}\`); } catch (e) { const isMissingTarget = e?.code === "MODULE_NOT_FOUND" && typeof e?.message === "string" && (e.message.includes(\`@libsql/\${target}\`) || e.message.includes(\`@libsql\\\\\${target}\`)); if (!isMissingTarget) throw e; console.error(\`[libsql] Native module @libsql/\${target} not found\`); return {}; } ` const oldIncludeLine = ' e.message.includes(`@libsql/${target}`);' const newIncludeLine = ' (e.message.includes(`@libsql/${target}`) || e.message.includes(`@libsql\\${target}`));' function patchLibsqlFile(filePath) { if (!fs.existsSync(filePath)) { return 'missing' } const source = fs.readFileSync(filePath, 'utf8') let patched = source if (patched.includes(oldIncludeLine)) { patched = patched.replaceAll(oldIncludeLine, newIncludeLine) } if (!patched.includes('if (target === "win32-arm64-msvc") {') && patched.includes(muslTargetBlock)) { patched = patched.replace(muslTargetBlock, `${muslTargetBlock}${winArm64GuardBlock}`) } if (patched.includes(directRequireBlock)) { patched = patched.replace(directRequireBlock, tryCatchRequireBlock) } const isFullyPatched = patched.includes('if (target === "win32-arm64-msvc") {') && patched.includes('Native module @libsql/${target} not found') && patched.includes('e.message.includes(`@libsql\\\\${target}`)') if (!isFullyPatched) { return 'skip-unknown' } if (patched === source) { return 'already-patched' } fs.writeFileSync(filePath, patched, 'utf8') return 'patched' } function getCandidateLibsqlDirs(context) { const candidates = [] const appDir = context.appDir || context.packager?.appDir || (context.packager?.projectDir && path.join(context.packager.projectDir, 'release', 'app')) if (appDir) { candidates.push(path.join(appDir, 'node_modules', 'libsql')) } if (context.appOutDir) { const productFilename = context.packager?.appInfo?.productFilename if (productFilename) { candidates.push( path.join( context.appOutDir, `${productFilename}.app`, 'Contents', 'Resources', 'app.asar.unpacked', 'node_modules', 'libsql', ), ) } candidates.push(path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules', 'libsql')) } return [...new Set(candidates)] } exports.default = async function patchLibsql(context) { let touched = false for (const libsqlDir of getCandidateLibsqlDirs(context)) { for (const file of ['index.js', 'promise.js']) { const filePath = path.join(libsqlDir, file) const state = patchLibsqlFile(filePath) if (state === 'patched') { touched = true console.log(`[patch-libsql] patched ${filePath}`) } else if (state === 'already-patched') { touched = true console.log(`[patch-libsql] already patched ${filePath}`) } else if (state === 'skip-unknown') { console.warn(`[patch-libsql] skip unknown structure: ${filePath}`) } } } if (!touched) { console.warn('[patch-libsql] no libsql files patched') } } ================================================ FILE: .erb/scripts/postinstall.cjs ================================================ /** * Root postinstall script. * * NOTE: We intentionally do NOT run electron-builder install-app-deps here. * With pnpm workspaces, electron-builder install-app-deps corrupts the shared * node_modules by running pnpm install --production in release/app. * * Native module rebuilding is handled by: * 1. release/app/postinstall runs electron-rebuild for native deps in release/app * 2. The build process handles the rest */ const { execSync } = require('child_process') const fs = require('fs') const path = require('path') // Run native dependency check try { require('./check-native-dep.cjs') } catch (e) { if (e.code === 'MODULE_NOT_FOUND') { console.log('Native dependency check skipped: module not found') } else { throw e } } console.log('Postinstall complete (skipping electron-builder install-app-deps for pnpm compatibility)') ================================================ FILE: .eslintignore ================================================ # 暂时关掉 eslint * # Logs logs *.log # Runtime data pids *.pid *.seed # Coverage directory used by tools like istanbul coverage .eslintcache # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules # OSX .DS_Store release/app/dist release/build .erb/dll .idea npm-debug.log.* *.css.d.ts *.sass.d.ts *.scss.d.ts # eslint ignores hidden directories by default: # https://github.com/eslint/eslint/issues/8429 !.erb ================================================ FILE: .eslintrc.js ================================================ module.exports = { extends: 'erb', plugins: ['@typescript-eslint'], rules: { // A temporary hack related to IDE not resolving correct package.json 'import/no-extraneous-dependencies': 'off', 'react/react-in-jsx-scope': 'off', 'react/jsx-filename-extension': 'off', 'import/extensions': 'off', 'import/no-unresolved': 'off', 'import/no-import-module-exports': 'off', 'no-shadow': 'off', '@typescript-eslint/no-shadow': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'error', }, parserOptions: { ecmaVersion: 2020, sourceType: 'module', project: './tsconfig.json', tsconfigRootDir: __dirname, createDefaultProgram: true, }, settings: { 'import/resolver': { // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below node: {}, webpack: { config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), }, typescript: {}, }, 'import/parsers': { '@typescript-eslint/parser': ['.ts', '.tsx'], }, }, } ================================================ FILE: .gitattributes ================================================ * text eol=lf *.exe binary *.png binary *.jpg binary *.jpeg binary *.ico binary *.icns binary *.eot binary *.otf binary *.ttf binary *.woff binary *.woff2 binary ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: Bin-Huang patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve / BUG 反馈(提交前请搜索是否存在重复issues) title: '[BUG]' labels: '' assignees: '' --- **Bug Description** Please provide a clear and concise description of what the bug is. **Steps to Reproduce** Please provide the steps to reproduce the bug: 1. Go to "..." 2. Click on "..." 3. Scroll down to "..." 4. Observe the bug. **Expected Results** Please provide a clear and concise description of what you expected to happen. **Actual Results** Please provide a clear and concise description of what actually happened. **Screenshots** If possible, please add screenshots to help explain the issue. **Desktop (please complete the following information):** - Operating System: [e.g. macOS] - Application Version: [e.g. 2.0.1] **Additional Context** Please provide any additional context about the issue, such as interactions with other software or applications. --- **Bug 描述** 清晰简洁地描述这个 bug 是什么。 **重现步骤** 请提供能够让我们重现这个 bug 的步骤: 1. 前往 "......" 2. 点击 "......" 3. 滚动到 "......" 4. 发现了这个 bug。 **期望结果** 请清晰简洁地描述预期的行为。 **实际结果** 请清晰简洁地描述实际的行为。 **截图** 如果可行,添加截图以帮助解释问题。 **桌面端(请填写以下信息):** - 操作系统:[例如 macOS] - 应用程序版本:[例如 2.0.1] **其他上下文** 在这里提供关于问题的任何其他上下文,例如与其他软件或应用程序的交互等。 ================================================ FILE: .github/ISSUE_TEMPLATE/custom.md ================================================ --- name: Custom issue template about: Describe this issue template's purpose here. / 其他建设性意见与讨论 title: '[Other]' labels: '' assignees: '' --- ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project / 新功能新特性的想法(提交前请检查是否有重复 issues) title: '[Feature]' labels: '' assignees: '' --- **Problem Description** Please describe the issue or difficulty you are experiencing and why it makes using the software difficult or frustrating. **Proposed Solution** Please provide a clear and concise description of what you would like to see in terms of a function or solution. **Additional Context** Please provide any additional context or information that would help better understanding your feature request, such as screenshots, examples, or use cases. --- **问题描述** 请描述您遇到的问题或难题,以及为什么这使得使用软件变得困难或令人沮丧。 **解决思路** 请提供一个清晰、简洁的描述,说明您希望看到的功能或解决方案。 **附加上下文** 请提供任何其他上下文或信息,以便更好地理解您的功能请求,例如截图、示例或用例。 ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### Description [Please provide a detailed description of your contribution, including the main changes and their purpose] ### Additional Notes [If you have any additional comments or notes, please add them here] ### Screenshots [Optional: Include screenshots that help explain your PR] ### Contributor Agreement By submitting this Pull Request, I confirm that I have read and agree to the following terms: - I agree to contribute all code submitted in this PR to the open-source community edition licensed under GPLv3 and the proprietary official edition without compensation. - I grant the official edition development team the rights to freely use, modify, and distribute this code, including for commercial purposes. - I confirm that this code is my original work, or I have obtained the appropriate authorization from the copyright holder to submit this code under these terms. - I understand that the submitted code will be publicly released under the GPLv3 license, and may also be used in the proprietary official edition. **Please check the box below to confirm:** [ ] I have read and agree with the above statement. ================================================ FILE: .github/config.yml ================================================ requiredHeaders: - Prerequisites - Expected Behavior - Current Behavior - Possible Solution - Your Environment ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 60 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - discussion - security # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock .DS_Store # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # next.js build output .next # nuxt.js build output .nuxt # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # Webpack .webpack/ # Electron-Forge out/ publish.sh build.sh build-web.sh # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage /test/output # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # local env files .env*.local # IDE / Editor .idea .dir-locals.el # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts tmp # Logs logs *.log # Runtime data pids *.pid *.seed # Coverage directory used by tools like istanbul coverage .eslintcache # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules # OSX .DS_Store release/app/dist release/build .erb/dll .idea npm-debug.log.* *.css.d.ts *.sass.d.ts *.scss.d.ts dist electron-builder.env # Share VS Code settings # .vscode **/routeTree.gen.ts .env.sentry-build-plugin .sisyphus/ ================================================ FILE: .node-version ================================================ v22.7.0 ================================================ FILE: .npmrc ================================================ # Electron compatibility - use flat node_modules node-linker=hoisted # Auto-install peer dependencies auto-install-peers=true # Preserved from original npm config node-options=--max-old-space-size=4096 update-notifier=false engine-strict=true ================================================ FILE: .prettierrc ================================================ { "tabWidth": 2, "singleQuote": true, "printWidth": 120, "semi": false, "overrides": [ { "files": [ ".prettierrc", ".eslintrc" ], "options": { "parser": "json" } } ] } ================================================ FILE: ERROR_HANDLING.md ================================================ # Error Handling Improvements This document describes the error handling improvements made to Chatbox to address the issue where some users experienced "something went wrong!" errors with "cannot read properties of undefined" that were not being reported to Sentry. ## Changes Made ### 1. React Error Boundary (`src/renderer/components/ErrorBoundary.tsx`) Created a comprehensive React Error Boundary component that: - Catches React component rendering errors - Automatically reports errors to Sentry with detailed context - Displays a user-friendly error UI with retry options - Shows detailed error information when requested - Provides both custom and Sentry-wrapped error boundary variants ### 2. Global Error Handlers (`src/renderer/setup/global_error_handler.ts`) Added global error handlers for: - **Window errors**: Catches unhandled JavaScript errors - **Unhandled promise rejections**: Catches async errors that weren't handled - **Console error interception**: Monitors console errors for specific patterns like "cannot read properties of undefined" ### 3. Application Integration Updated the main application files: - `src/renderer/index.tsx`: Wrapped both initialization and main app with ErrorBoundary - `src/renderer/routes/__root.tsx`: Added error boundary at the route level - Added global error handler initialization ### 4. Error Testing Utilities (`src/renderer/utils/error-testing.ts`) Created testing utilities for development mode: - Test React error boundaries - Test global error handlers - Test unhandled promise rejections - Test Sentry integration - Available at `window.errorTestingUtils` in development ## Error Catching Strategy The solution implements a multi-layered error catching approach: 1. **React Error Boundaries**: Catch component rendering errors 2. **Global Window Handlers**: Catch unhandled JavaScript errors 3. **Promise Rejection Handlers**: Catch unhandled async errors 4. **Console Error Monitoring**: Detect specific error patterns 5. **Existing Try-Catch Blocks**: Already handling API and model errors ## Testing In development mode, you can test error handling using: ```javascript // Test React error boundary window.errorTestingUtils.triggerReactError() // Test global error handler window.errorTestingUtils.triggerGlobalError() // Test unhandled promise rejection window.errorTestingUtils.triggerUnhandledRejection() // Test property access error window.errorTestingUtils.triggerPropertyError() // Test Sentry integration window.errorTestingUtils.testSentryCapture() // Test console error interception window.errorTestingUtils.triggerConsoleError() ``` ## User Experience When errors occur, users will see: - A clean error UI instead of broken components - Options to retry or reload the application - Ability to view error details if needed - Automatic error reporting to Sentry for debugging ## Benefits 1. **Better Error Reporting**: All errors are now captured and sent to Sentry 2. **Improved User Experience**: Users see helpful error messages instead of broken UI 3. **Easier Debugging**: Detailed error context is provided to developers 4. **Graceful Recovery**: Users can retry operations or reload the app 5. **Comprehensive Coverage**: Multiple layers catch different types of errors ## Sentry Integration All caught errors are reported to Sentry with: - Error type tags (React, global, promise rejection, etc.) - Detailed context about the error - Component stack traces (for React errors) - Browser and application information - User session data This ensures that developers can identify and fix issues that users encounter in production. ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

English | 简体中文

This is the repository for the Chatbox Community Edition, open-sourced under the GPLv3 license. [Chatbox is going open-source Again!](https://github.com/chatboxai/chatbox/issues/2266) We regularly sync code from the pro repo to this repo, and vice versa. ### Download for Desktop
Windows MacOS Linux

Setup.exe

Intel

Apple Silicon

AppImage
### Download for iOS/Android .APK For more information: [chatboxai.app](https://chatboxai.app/) ---
Warp sponsorship ### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/chatbox) [Available for MacOS, Linux, & Windows](https://go.warp.dev/chatbox)

Chatbox (Community Edition)

Your Ultimate AI Copilot on the Desktop.
Chatbox is a desktop client for ChatGPT, Claude and other LLMs, available on Windows, Mac, Linux

macOS Windows Linux Downloads

Chatbox - Better UI & Desktop App for ChatGPT, Claude and other LLMs. | Product Hunt ## Features - **Local Data Storage** :floppy_disk: Your data remains on your device, ensuring it never gets lost and maintains your privacy. - **No-Deployment Installation Packages** :package: Get started quickly with downloadable installation packages. No complex setup necessary! - **Support for Multiple LLM Providers** :gear: Seamlessly integrate with a variety of cutting-edge language models: - OpenAI (ChatGPT) - Azure OpenAI - Claude - Google Gemini Pro - Ollama (enable access to local models like llama2, Mistral, Mixtral, codellama, vicuna, yi, and solar) - ChatGLM-6B - **Image Generation with Dall-E-3** :art: Create the images of your imagination with Dall-E-3. - **Enhanced Prompting** :speech_balloon: Advanced prompting features to refine and focus your queries for better responses. - **Keyboard Shortcuts** :keyboard: Stay productive with shortcuts that speed up your workflow. - **Markdown, Latex & Code Highlighting** :scroll: Generate messages with the full power of Markdown and Latex formatting, coupled with syntax highlighting for various programming languages, enhancing readability and presentation. - **Prompt Library & Message Quoting** :books: Save and organize prompts for reuse, and quote messages for context in discussions. - **Streaming Reply** :arrow_forward: Provide rapid responses to your interactions with immediate, progressive replies. - **Ergonomic UI & Dark Theme** :new_moon: A user-friendly interface with a night mode option for reduced eye strain during extended use. - **Team Collaboration** :busts_in_silhouette: Collaborate with ease and share OpenAI API resources among your team. [Learn More](./team-sharing/README.md) - **Cross-Platform Availability** :computer: Chatbox is ready for Windows, Mac, Linux users. - **Access Anywhere with the Web Version** :globe_with_meridians: Use the web application on any device with a browser, anywhere. - **iOS & Android** :phone: Use the mobile applications that will bring this power to your fingertips on the go. - **Multilingual Support** :earth_americas: Catering to a global audience by offering support in multiple languages: - English - 简体中文 (Simplified Chinese) - 繁體中文 (Traditional Chinese) - 日本語 (Japanese) - 한국어 (Korean) - Français (French) - Deutsch (German) - Русский (Russian) - Español (Spanish) - **And More...** :sparkles: Constantly enhancing the experience with new features! ## FAQ - [Frequently Asked Questions](./doc/FAQ.md) ## Why I made Chatbox? I developed Chatbox initially because I was debugging some prompts and found myself in need of a simple and easy-to-use prompt and API debugging tool. I thought there might be more people who needed such a tool, so I open-sourced it. At first, I didn't know that it would be so popular. I listened to the feedback from the open-source community and continued to develop and improve it. Now, it has become a very useful AI desktop application. There are many users who love Chatbox, and they not only use it for developing and debugging prompts, but also for daily chatting, and even to do some more interesting things like using well-designed prompts to make AI play various professional roles to assist them in everyday work... ## How to Contribute Any form of contribution is welcome, including but not limited to: - Submitting issues - Submitting pull requests - Submitting feature requests - Submitting bug reports - Submitting documentation revisions - Submitting translations - Submitting any other forms of contribution ## Prerequisites - Node.js (v20.x – v22.x) - npm (required – pnpm is not supported) ## Build Instructions 1. Clone the repository from Github ```bash git clone https://github.com/chatboxai/chatbox.git ``` 2. Install the required dependencies ```bash npm install ``` 3. Start the application (in development mode) ```bash npm run dev ``` 4. Build the application, package the installer for current platform ```bash npm run package ``` 5. Build the application, package the installer for all platforms ```bash npm run package:all ``` ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=chatboxai/chatbox&type=Date)](https://star-history.com/#chatboxai/chatbox&Date) ## Contact [Twitter](https://x.com/ChatboxAI_HQ) | [Email](mailto:hi@chatboxai.com) ## License [LICENSE](./LICENSE) ================================================ FILE: assets/assets.d.ts ================================================ type Styles = Record declare module '*.svg' { import React = require('react') export const ReactComponent: React.FC> const content: string export default content } declare module '*.png' { const content: string export default content } declare module '*.jpg' { const content: string export default content } declare module '*.scss' { const content: Styles export default content } declare module '*.sass' { const content: Styles export default content } declare module '*.css' { const content: Styles export default content } ================================================ FILE: assets/entitlements.mac.plist ================================================ com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-jit ================================================ FILE: assets/installer.nsh ================================================ !include LogicLib.nsh !macro customInit ; Check for x64 VC++ Redistributable (skip ARM64 check for now) ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" ${If} $0 != "1" MessageBox MB_YESNO|MB_ICONQUESTION "\ ${PRODUCT_NAME} requires Microsoft Visual C++ Redistributable 2015-2022 (x64).$\r$\n$\r$\n\ Would you like to download and install it now?" IDYES InstallVCRedist IDNO SkipVCRedist InstallVCRedist: ; Download using inetc plugin with visual progress inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" Pop $1 ${If} $1 != "OK" MessageBox MB_OK|MB_ICONSTOP "Failed to download Visual C++ Redistributable.$\r$\n$\r$\nPlease install it manually from:$\r$\nhttps://aka.ms/vs/17/release/vc_redist.x64.exe" Abort ${EndIf} ; Install VC++ Redistributable DetailPrint "Installing Microsoft Visual C++ Redistributable..." ExecWait '"$TEMP\vc_redist.x64.exe" /install /quiet /norestart' $2 ; Clean up Delete "$TEMP\vc_redist.x64.exe" ; Check if installation was successful ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" ${If} $0 != "1" MessageBox MB_OK|MB_ICONSTOP "Failed to install Visual C++ Redistributable.$\r$\n$\r$\nThe installation cannot continue." Abort ${EndIf} DetailPrint "Visual C++ Redistributable installed successfully!" Goto Done SkipVCRedist: MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ Redistributable is required for ${PRODUCT_NAME} to run properly.$\r$\n$\r$\nPlease install it manually from:$\r$\nhttps://aka.ms/vs/17/release/vc_redist.x64.exe" Abort ${EndIf} Done: !macroend ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": true }, "formatter": { "enabled": true, "formatWithErrors": false, "indentStyle": "space", "indentWidth": 2, "lineEnding": "lf", "lineWidth": 120, "attributePosition": "auto", "bracketSameLine": false, "bracketSpacing": true, "expand": "auto", "useEditorconfig": true, "includes": [ "src/**", "test/integration/**/*.ts", "!src/main/mcp/shell-env.cjs", "!src/renderer/components/icons/**", "biome.json", "*.config.js", "*.config.ts", "*.config.mjs", "!erb/**" ] }, "linter": { "enabled": true, "rules": { "recommended": true, "a11y": "off", "correctness": { "useExhaustiveDependencies": "warn" }, "suspicious": { "useAwait": "warn", "noArrayIndexKey": "warn" }, "nursery": { "noFloatingPromises": "warn" } }, "includes": [ "src/**", "!src/main/mcp/shell-env.cjs", "!src/renderer/components/icons/**", "biome.json", "*.config.js", "*.config.ts", "*.config.mjs", "!erb/**" ] }, "javascript": { "formatter": { "jsxQuoteStyle": "double", "quoteProperties": "asNeeded", "trailingCommas": "es5", "semicolons": "asNeeded", "arrowParentheses": "always", "bracketSameLine": false, "quoteStyle": "single", "attributePosition": "auto", "bracketSpacing": true } }, "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } } } ================================================ FILE: doc/FAQ-CN.md ================================================ # 常见问题

English | 中文

这里列举了一些最常见的问题和解决方案。如果你依然没有找到答案,也可以提交一个 [Issue](https://github.com/Bin-Huang/chatbox/issues/new/choose)。 ### 1001 #### 消息发送失败,提示 `Failed to fetch`? 这是因为 Chatbox 无法连接到你设置的 AI 模型服务器,请检查你当前的网络环境,确保可以正常连接到 AI 模型服务器。 对于 OpenAI API 的用户,如果你选择了 OpenAI API 作为 AI 模型提供方(即设置页的 AI Provider 中选择了 `OpenAI API`),那么一般是 Chatbox 无法访问设置的 `API HOST`。在默认设置下,Chatbox 会使用 `https://api.openai.com` 作为 API HOST,请确保你的当前网络可以访问这个服务。注意,在某些国家和地区是无法直接访问的。 ### 1002 #### 以前用的好好的,突然报错 `{"error":{"message":"You exceeded your current quota, please check your plan and billing details.`? 如果你以前使用一切正常,某天之后突然无法使用过,并且每次发送消息都报错: ``` {"error":{"message":"You exceeded your current quota, please check your plan and billing details.","type":"insufficient_quota","param":null,"code":null}} ``` 请注意,这个问题和 Chatbox 没有任何关系。这个情况中往往是因为你正在使用自己的 OpenAI API 账户,而你账户中的免费额度已经全部用完或过期了(一般都是因为过期导致的)。你需要自行登录 OpenAI 账户的控制台,绑定一张海外信用卡才能继续使用。OpenAI API 账户对信用卡有很多要求,如果你的信用卡不符合要求,那么你需要自行解决(非常折腾)。 **更推荐使用 `Chatbox AI`:** 如果你不想折腾这些问题,也可以使用 Chatbox 内置的 `Chatbox AI` 服务。这个服务可以让你无需折腾、什么都不用管、轻松使用 AI 能力。前往配置页,将 AI Provider 设置为 `Chatbox AI`,你将看到相应的设置。 ### 1003 #### 无法使用 GPT-4? 如果你选择 GPT-4,然后发送消息时得到类似的报错: ``` {"error":{"message":"The model: gpt-4-32k does not exist","type":"invalid_request_error","param":null,"code":"model_not_found"}} ``` 这个情况往往是因为你正在使用自己的 OpenAI 账户,你在模型中选择了 GPT-4,但 OpenAI API 账户不支持 GPT-4。截止到 2023 年 07 月 04 日,所有 OpenAI API 账户都需要向 OpenAI 填写申请后才能使用 GPT-4 模型。这里是申请链接: https://openai.com/waitlist/gpt-4-api 。请注意,即使你是 ChatGPT Plus 用户,你也需要申请后才能使用 GPT-4 的 API 模型。 ================================================ FILE: doc/FAQ.md ================================================ # Frequently Asked Questions

English | 中文

If you still haven't found the answer you're looking for, feel free to submit an [Issue](https://github.com/Bin-Huang/chatbox/issues/new/choose) as well. ### 1001 #### Message sending failed, showing `Failed to fetch`? This issue occurs when Chatbox cannot connect to the AI model server you've set up. Please check your current network environment and make sure it can connect properly to the AI model server. For OpenAI API users, if you've chosen OpenAI API as the AI model provider (meaning you've selected `OpenAI API` in the AI Provider settings), it's typically because Chatbox cannot access the `API HOST` you've set. By default, Chatbox uses `https://api.openai.com` as the API HOST. Please make sure your current network can access this service. ### 1002 #### Everything was working fine before, but now I keep getting an error: `{"error":{"message":"You exceeded your current quota, please check your plan and billing details`? If everything was working fine before and now you're unable to use the service, with each message sending attempt resulting in the following error: ``` {"error":{"message":"You exceeded your current quota, please check your plan and billing details.","type":"insufficient_quota","param":null,"code":null}} ``` Please note that this issue is not related to Chatbox. In this situation, it's likely that you're using your own OpenAI API account and your free quota has either been used up or expired (usually due to expiration). You need to log in to your OpenAI account's dashboard and link a credit card to continue using the service. The OpenAI API account has many requirements for credit cards. If your card doesn't meet these requirements, you'll need to resolve this issue yourself (it can be quite frustrating). **Consider using `Chatbox AI`:** If you don't want to deal with these issues, you can also use Chatbox's built-in `Chatbox AI` service. This service allows you to enjoy AI capabilities without any hassle. Go to the settings page and set the AI Provider to `Chatbox AI`, and you'll see the corresponding options. ### 1003 #### Unable to use GPT-4? If you select GPT-4 and receive a similar error message when sending messages: ``` {"error":{"message":"The model: gpt-4-32k does not exist","type":"invalid_request_error","param":null,"code":"model_not_found"}} ``` This issue often occurs when you're using your own OpenAI account and have selected the GPT-4 model, but your OpenAI API account does not support GPT-4. As of July 4, 2023, all OpenAI API accounts require a request to be submitted to OpenAI before the GPT-4 model can be used. Here's the application link: https://openai.com/waitlist/gpt-4-api. Please note that even if you're a ChatGPT Plus user, you still need to apply for access to use the GPT-4 API model. ================================================ FILE: doc/README-CN.md ================================================

English | 简体中文

这里是 Chatbox 社区版的代码仓库,以 GPLv3 许可证开源。 [Chatbox 再次开源!](https://github.com/chatboxai/chatbox/issues/2266) 我们定期从专业版仓库同步代码到这个仓库,反之亦然。 ### 下载电脑端
Windows MacOS Linux

Setup.exe

Intel

Apple Silicon

AppImage
### 下载移动端 .APK 更多信息请访问: [chatboxai.app](https://chatboxai.app/) ---

Chatbox (Community Edition)

Chatbox 是一个 AI 模型桌面客户端,支持 ChatGPT、Claude、Google Gemini、Ollama 等主流模型,适用于 Windows、Mac、Linux、Web、Android 和 iOS 全平台

macOS Windows Linux 下载量 Twitter

Chatbox - Better UI & Desktop App for ChatGPT, Claude and other LLMs. | Product Hunt 应用截图 应用截图 ## 特性 - **本地数据存储** :floppy_disk: 您的数据保留在您的设备上,确保数据永不丢失并保护您的隐私。 - **无需部署、直接安装的安装包** :package: 通过可下载的安装包快速开始使用。无需复杂设置! - **支持多个 LLM 提供商** :gear: 无缝集成多种 AI 模型: - OpenAI (ChatGPT) - Azure OpenAI - Claude - Google Gemini Pro - Ollama (启用对本地模型的访问,如 llama2、Mistral、Mixtral、codellama、vicuna、yi 和 solar) - ChatGLM-6B - **使用 Dall-E-3 生成图像** :art: 使用 Dall-E-3 创建您想象中的图像。 - **增强提示** :speech_balloon: 高级提示功能,精炼并聚焦您的查询以获得更好的响应。 - **键盘快捷键** :keyboard: 使用加速您工作流程的快捷键保持高效。 - **Markdown、Latex 和代码高亮** :scroll: 使用 Markdown 和 Latex 的全部功能生成消息,并结合各种编程语言的语法高亮,提高可读性和呈现效果。 - **提示库和消息引用** :books: 保存和组织提示以供重复使用,并引用消息以在讨论中提供上下文。 - **流式回复** :arrow_forward: 通过即时、渐进式回复快速响应您的互动。 - **人体工程学 UI 和深色主题** :new_moon: 用户友好的界面,带有夜间模式选项,减少长时间使用时的眼睛疲劳。 - **团队协作** :busts_in_silhouette: 轻松协作并在团队中共享 OpenAI API 资源。[了解更多](../team-sharing/README.md) - **跨平台可用性** :computer: 聊天盒已为 Windows、Mac、Linux 用户准备就绪。 - **通过 Web 版本随处访问** :globe_with_meridians: 在任何设备上使用带有浏览器的 Web 应用程序,随时随地。 - **iOS 和 Android** :phone: 使用移动应用程序,随时随地在您的指尖上带来这种能力。 - **多语言支持** :earth_americas: 通过提供多种语言的支持,迎合全球受众: - English - 简体中文 (Simplified Chinese) - 繁體中文 (Traditional Chinese) - 日本語 (Japanese) - 한국어 (Korean) - Français (French) - Deutsch (German) - Русский (Russian) - **更多...** :sparkles: 不断增强体验,加入新功能! ## 常见问题解答 - [常见问题](./FAQ-CN.md) ## 如何贡献 欢迎任何形式的贡献,包括但不限于: - 提交问题 - 提交拉取请求 - 提交功能请求 - 提交错误报告 - 提交文档修订 - 提交翻译 - 提交任何其他形式的贡献 ## 构建指南 1. 从 Github 克隆仓库 ```bash git clone https://github.com/chatboxai/chatbox.git ``` 2. 安装所需的依赖 ```bash npm install ``` 3. 启动应用程序(开发模式) ```bash npm run dev ``` 4. 构建应用程序,为当前平台打包安装程序 ```bash npm run package ``` 5. 构建应用程序,为所有平台打包安装程序 ```bash npm run package:all ``` ## Star History [![星星历史图表](https://api.star-history.com/svg?repos=chatboxai/chatbox&type=Date)](https://star-history.com/#chatboxai/chatbox&Date) ## 联系方式 [Twitter](https://x.com/ChatboxAI_HQ) | [电子邮件](mailto:hi@chatboxai.com) ================================================ FILE: docs/adding-new-provider.md ================================================ # Adding a New Provider (Registry Architecture) This guide documents how to add a new AI provider to Chatbox using the **registry-based architecture**. ## Overview The provider system uses a centralized registry. Adding a new provider requires: 1. **One definition file** - Registers the provider with `defineProvider()` 2. **One model class file** - Implements the AI SDK interface 3. **One enum entry** - Adds the provider ID to `ModelProviderEnum` 4. **One import** - Side-effect import in `providers/index.ts` That's it. No more scattered switch statements or setting-util files. ## Step-by-Step Guide ### Step 1: Add Provider to Enum **File:** `src/shared/types.ts` Add your provider to `ModelProviderEnum`: ```typescript export enum ModelProviderEnum { // ... existing providers YourProvider = 'your-provider', } ``` ### Step 2: Create the Model Class **File:** `src/shared/providers/definitions/models/your-provider.ts` For **OpenAI-compatible APIs**, extend `OpenAICompatible`: ```typescript import type { ModelDependencies } from '@shared/types/adapters' import type { ProviderModelInfo } from '@shared/types' import { OpenAICompatible } from '@shared/models/openai-compatible' export interface YourProviderConfig { apiKey: string model: ProviderModelInfo temperature: number topP: number maxOutputTokens: number | undefined stream: boolean | undefined } export default class YourProvider extends OpenAICompatible { public name = 'YourProvider' constructor(options: YourProviderConfig, dependencies: ModelDependencies) { super( { apiKey: options.apiKey, apiHost: 'https://api.yourprovider.com/v1', // Your API base URL model: options.model, temperature: options.temperature, topP: options.topP, maxOutputTokens: options.maxOutputTokens, stream: options.stream, }, dependencies ) } } ``` For **custom APIs** (non-OpenAI compatible), extend `AbstractAISDKModel` and implement: - `streamText()` - Streaming chat completion - `callChatCompletion()` - Non-streaming chat completion - Optionally: `isSupportToolUse()`, `isSupportVision()`, `isSupportReasoning()` See `definitions/models/claude.ts` or `definitions/models/gemini.ts` for examples. ### Step 3: Create the Provider Definition **File:** `src/shared/providers/definitions/your-provider.ts` ```typescript import { ModelProviderEnum, ModelProviderType } from '../../types' import { defineProvider } from '../registry' import YourProvider from './models/your-provider' export const yourProviderProvider = defineProvider({ // Required: Unique ID from ModelProviderEnum id: ModelProviderEnum.YourProvider, // Required: Display name shown in UI name: 'Your Provider', // Required: API type (affects model class behavior) type: ModelProviderType.OpenAI, // OpenAI | Claude | Gemini // Optional: Description for UI description: 'Your provider description', // Optional: Related URLs for settings page urls: { website: 'https://yourprovider.com', apiKey: 'https://yourprovider.com/api-keys', docs: 'https://yourprovider.com/docs', }, // Required: Default configuration defaultSettings: { apiHost: 'https://api.yourprovider.com', models: [ { modelId: 'your-model-v1', contextWindow: 128_000, maxOutput: 4_096, capabilities: ['vision', 'tool_use'], // Optional: vision, tool_use, reasoning }, { modelId: 'your-model-v2', contextWindow: 200_000, maxOutput: 8_192, }, ], }, // Required: Factory function to create model instances createModel: (config) => { return new YourProvider( { apiKey: config.providerSetting.apiKey || '', model: config.model, temperature: config.settings.temperature, topP: config.settings.topP, maxOutputTokens: config.settings.maxTokens, stream: config.settings.stream, }, config.dependencies ) }, // Optional: Custom display name for message headers getDisplayName: (modelId, providerSettings) => { const nickname = providerSettings?.models?.find((m) => m.modelId === modelId)?.nickname return `Your Provider (${nickname || modelId})` }, }) ``` ### Step 4: Register the Provider **File:** `src/shared/providers/index.ts` Add a side-effect import at the top of the file: ```typescript import './definitions/your-provider' ``` This import triggers `defineProvider()` which registers the provider in the registry. ### Step 5: Add Provider Icon (Optional but Recommended) **File:** `src/renderer/components/icons/ProviderIcon.tsx` Add an SVG icon case: ```typescript case ModelProviderEnum.YourProvider: return ( {/* Your SVG path data */} ) ``` ## ProviderDefinition Field Reference | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | `string` | Yes | Unique identifier from `ModelProviderEnum` | | `name` | `string` | Yes | Display name in UI | | `type` | `ModelProviderType` | Yes | API type: `OpenAI`, `Claude`, or `Gemini` | | `description` | `string` | No | Provider description for UI | | `urls` | `object` | No | Related URLs (website, apiKey, docs, models) | | `defaultSettings` | `ProviderSettings` | No | Default apiHost and models list | | `createModel` | `function` | Yes | Factory function that creates model instances | | `getDisplayName` | `function` | No | Custom display name for message headers | ### CreateModelConfig (passed to createModel) | Field | Type | Description | |-------|------|-------------| | `settings` | `SessionSettings` | Session-level settings (temperature, topP, etc.) | | `globalSettings` | `Settings` | Global application settings | | `config` | `Config` | App configuration (uuid, etc.) | | `dependencies` | `ModelDependencies` | Platform dependencies (fetch, etc.) | | `providerSetting` | `ProviderSettings` | Provider-specific settings (apiKey, apiHost, models) | | `formattedApiHost` | `string` | Pre-formatted API host URL | | `model` | `ProviderModelInfo` | Selected model configuration | ### Model Capabilities In `defaultSettings.models[].capabilities`, you can specify: | Capability | Description | |------------|-------------| | `vision` | Model supports image inputs | | `tool_use` | Model supports function/tool calling | | `reasoning` | Model is a reasoning/thinking model (o1, o3, etc.) | ## Complete Example: Groq Provider **File:** `src/shared/providers/definitions/groq.ts` ```typescript import { ModelProviderEnum, ModelProviderType } from '../../types' import { defineProvider } from '../registry' import Groq from './models/groq' export const groqProvider = defineProvider({ id: ModelProviderEnum.Groq, name: 'Groq', type: ModelProviderType.OpenAI, urls: { website: 'https://groq.com/', }, defaultSettings: { apiHost: 'https://api.groq.com/openai', models: [ { modelId: 'llama-3.3-70b-versatile', contextWindow: 131_072, maxOutput: 32_768, capabilities: ['tool_use'], }, ], }, createModel: (config) => { return new Groq( { apiKey: config.providerSetting.apiKey || '', model: config.model, temperature: config.settings.temperature, topP: config.settings.topP, maxOutputTokens: config.settings.maxTokens, stream: config.settings.stream, }, config.dependencies ) }, getDisplayName: (modelId, providerSettings) => { return `Groq API (${providerSettings?.models?.find((m) => m.modelId === modelId)?.nickname || modelId})` }, }) ``` ## Testing Your Implementation 1. **TypeScript check:** ```bash npm run check ``` 2. **Lint check:** ```bash npm run lint ``` 3. **Run development mode:** ```bash npm run dev ``` 4. **Verify in the app:** - Provider appears in Settings > Provider - API key can be configured - Models are listed in model selector - Chat functionality works ## Migration Notes The registry-based architecture replaces the previous scattered approach: | Old Location | New Location | |--------------|--------------| | `src/shared/models/your-provider.ts` | `src/shared/providers/definitions/models/your-provider.ts` | | `src/shared/models/index.ts` switch case | `defineProvider()` in definition file | | `src/shared/defaults.ts` SystemProviders entry | `defaultSettings` in definition file | | `src/renderer/packages/model-setting-utils/*-setting-util.ts` | `getDisplayName` in definition file | All provider information is now consolidated in a single `defineProvider()` call. ================================================ FILE: docs/adding-provider.md ================================================ # Adding a New Provider to Chatbox This guide documents all the steps and files that need to be modified when adding a new AI provider to the Chatbox application. ## Overview Adding a new provider involves modifying approximately 7-8 files across the codebase. The implementation follows a consistent pattern, making it straightforward to add support for new AI models. ## Step-by-Step Implementation ### 1. Add Provider to Enum **File:** `/src/shared/types.ts` Add your provider to the `ModelProviderEnum`: ```typescript export enum ModelProviderEnum { // ... existing providers YourProvider = 'your-provider', } ``` ### 2. Create Provider Implementation **File:** `/src/shared/models/your-provider.ts` Create a new file implementing your provider's API. Most providers can extend the base OpenAI-compatible class: ```typescript import { OpenAICompatible } from './openai-compatible' export class YourProvider extends OpenAICompatible { name = 'YourProvider' constructor(apiKey: string, apiHost: string) { super(apiKey, apiHost) } } ``` For providers with custom APIs, extend `AbstractAISdk` directly and implement required methods. ### 3. Register Provider in Factory **File:** `/src/shared/models/index.ts` Add three entries: 1. Import your provider: ```typescript import { YourProvider } from './your-provider' ``` 2. Add case in `getModel()` function: ```typescript case ModelProviderEnum.YourProvider: return new YourProvider(apiKey, apiHost) ``` 3. Add to `aiProviderNameHash`: ```typescript export const aiProviderNameHash = { // ... existing entries [ModelProviderEnum.YourProvider]: 'Your Provider Name', } ``` 4. (Optional) Add to `AIModelProviderMenuOptionList` if it should appear in selection menus: ```typescript export const AIModelProviderMenuOptionList = [ // ... existing entries { value: ModelProviderEnum.YourProvider, label: 'Your Provider' }, ] ``` ### 4. Configure Default Settings **File:** `/src/shared/defaults.ts` Add your provider configuration to the `SystemProviders` array: ```typescript { id: ModelProviderEnum.YourProvider, name: 'Your Provider', type: ModelProviderType.OpenAI, // OpenAI | Gemini | Claude defaultSettings: { apiHost: 'https://api.yourprovider.com', models: [ { modelId: 'model-1', capabilities: ['vision', 'tool_use'], // optional contextWindow: 128_000, // optional }, ], }, } ``` **Note:** See existing providers in `defaults.ts` for more examples. ### 5. Create Settings Utility **File:** `/src/renderer/packages/model-setting-utils/your-provider-setting-util.ts` Create a settings utility class: ```typescript import { BaseModelSettingUtil } from './base-model-setting-util' import { ModelProviderEnum } from '@/shared/types' export class YourProviderSettingUtil extends BaseModelSettingUtil { provider = ModelProviderEnum.YourProvider // Add any provider-specific validation or configuration methods } ``` ### 6. Register Settings Utility **File:** `/src/renderer/packages/model-setting-utils/index.ts` Add your utility to the `getModelSettingUtil()` function: ```typescript import { YourProviderSettingUtil } from './your-provider-setting-util' export function getModelSettingUtil(provider: ModelProviderEnum): BaseModelSettingUtil { const hash = { // ... existing entries [ModelProviderEnum.YourProvider]: new YourProviderSettingUtil(), } return hash[provider] || new BaseModelSettingUtil() } ``` ### 7. Add Provider Icons **SVG Icon - File:** `/src/renderer/components/icons/ProviderIcon.tsx` Add an SVG icon component in the switch statement: ```typescript case ModelProviderEnum.YourProvider: return ( {/* Your SVG path data */} ) ``` **PNG Icon - File:** `/src/renderer/static/icons/providers/your-provider.png` Add a 36x36 PNG icon for the provider list display. ## Optional Steps ### Translations If your provider requires custom UI text, add translations to the appropriate locale files in `/src/renderer/i18n/locales/`. ### Testing Create test files for your provider implementation: - `/src/shared/models/your-provider.test.ts` - `/src/renderer/packages/model-setting-utils/your-provider-setting-util.test.ts` ### Custom Settings UI If your provider needs custom settings beyond API key and host, you may need to create a custom component in `/src/renderer/routes/settings/provider/`. However, most providers work with the default settings page. ## Example: Recent VolcEngine Implementation The VolcEngine provider was recently added following this pattern: 1. Added enum value in `types.ts` 2. Created `/src/shared/models/volcengine.ts` extending OpenAICompatible 3. Added entries in `/src/shared/models/index.ts` 4. Added configuration in `defaults.ts` with chat models 5. Created settings utility 6. Registered utility in index ## Testing Your Implementation After implementing: 1. Run `npm run lint:fix` to ensure code style consistency 2. Run `npm run check` for TypeScript validation 3. Test the provider in development mode: `npm run dev` 4. Verify: - Provider appears in settings - API key/host can be configured - Models are selectable - Chat functionality works ## Common Patterns - Most providers extend `OpenAICompatible` if they follow OpenAI's API format - Use `supportContinuous: true` for streaming support - Set `functionCall: true` on models that support function calling - The `apiHost` in defaults should not include `/v1` suffix (added automatically) ================================================ FILE: docs/dependency-reorg.md ================================================ # Dependency split for Electron Vite 依据 electron-vite 的建议,本次调整将所有仅用于 renderer(前端打包)的依赖移动到 `devDependencies`,仅保留主进程(`src/main`)和 preload(`src/preload`)在运行时需要的依赖在 `dependencies` 中。 ## Runtime dependencies(保留在 `dependencies`) - @libsql/client - @mastra/libsql - @mastra/rag - @modelcontextprotocol/sdk - @mozilla/readability - @sentry/node - ai - auto-launch - chardet - cohere-ai - electron - electron-debug - electron-devtools-installer - electron-log - electron-store - electron-updater - epub - fs-extra - iconv-lite - linkedom - lodash - ofetch - officeparser - sanitize-filename - uuid ## 主要变动 - 新增 `@libsql/client` 到 `dependencies`(主进程知识库类型定义及运行时需求)。 - 将 `electron`、`electron-debug`、`electron-devtools-installer` 从 `devDependencies` 挪到 `dependencies`(主进程运行时直接使用)。 - 其余原本在 `dependencies` 中、仅被 renderer 使用的依赖全部移动到 `devDependencies`,以符合 electron-vite 关于依赖归类的最佳实践。 ## 后续操作 - 运行 `npm install` 以更新本地安装目录和锁文件。 - 如需验证,可执行 `npm run build`/`npm start` 确认依赖拆分未影响构建与运行。 ================================================ FILE: docs/new-session-mechanism.md ================================================ # 首页新会话机制文档 ## 概述 Chatbox 的首页(`/`路由)是用户创建新对话的入口。本文档详细说明了新会话的创建机制,特别是临时状态的管理和转移过程。 ## 核心概念 ### 1. 临时会话 ID:"new" 在用户真正发送第一条消息之前,首页使用一个特殊的会话 ID `"new"` 来标识这是一个尚未创建的临时会话。 ```typescript const [session, setSession] = useState({ id: 'new', ...initEmptyChatSession(), }) ``` ### 2. 临时状态管理:newSessionStateAtom 为了管理新会话的临时状态(如知识库选择、网页浏览模式等),系统使用了专门的 atom: ```typescript // src/renderer/stores/atoms/uiAtoms.ts export const newSessionStateAtom = atom<{ knowledgeBase?: Pick webBrowsing?: boolean }>({}) ``` 这个 atom 专门存储用户在发送第一条消息前的各种选择和设置。 ## 工作流程 ### 1. 用户交互阶段 当用户在首页进行以下操作时,状态都保存在临时存储中: - **选择知识库**:存储在 `newSessionStateAtom.knowledgeBase` - **选择模型**:存储在组件的 `session` state 中 - **选择 Copilot**:同样存储在组件的 `session` state 中 ### 2. InputBox 组件的智能处理 InputBox 组件会根据 sessionId 智能选择存储位置: ```typescript const isNewSession = currentSessionId === 'new' const knowledgeBase = isNewSession ? newSessionState.knowledgeBase : sessionKnowledgeBaseMap[currentSessionId] const setKnowledgeBase = useCallback((value) => { if (isNewSession) { setNewSessionState(prev => ({ ...prev, knowledgeBase: value })) } else { // 更新实际会话的知识库映射 setSessionKnowledgeBaseMap(prev => ({ ...prev, [currentSessionId]: value })) } }, [currentSessionId, isNewSession]) ``` ### 3. 会话创建和状态转移 当用户发送第一条消息时,`handleSubmit` 函数执行以下步骤: ```typescript const handleSubmit = async (payload: InputBoxPayload) => { // 1. 创建真正的会话 const newSession = await createSession({ name: session.name, type: 'chat', picUrl: session.picUrl, messages: session.messages, copilotId: session.copilotId, settings: session.settings, }) // 2. 转移临时状态到新会话 if (newSessionState.knowledgeBase) { setSessionKnowledgeBaseMap({ ...sessionKnowledgeBaseMap, [newSession.id]: newSessionState.knowledgeBase, }) // 清空临时状态 setNewSessionState({}) } // 3. 切换到新会话 sessionActions.switchCurrentSession(newSession.id) // 4. 发送消息 // ... } ``` ## 关键设计决策 ### 1. 为什么使用 "new" 作为临时 ID? - 简单明了,易于识别 - 避免与真实的 UUID 冲突 - 便于在代码中进行特殊处理 ### 2. 为什么需要 newSessionStateAtom? - **职责分离**:临时状态和持久状态分开管理 - **避免污染**:不会在 sessionKnowledgeBaseMap 中留下无效数据 - **易于扩展**:未来可以轻松添加更多临时状态字段 ### 3. 状态转移时机 状态转移发生在会话创建成功后、切换会话之前。这确保了: - 用户选择的设置不会丢失 - 新会话立即拥有正确的配置 - 避免了异步操作的竞态条件 ## 数据流图 ``` 用户操作 → newSessionStateAtom (临时存储) ↓ 发送消息 → 创建会话 ↓ 状态转移 → sessionKnowledgeBaseMap[newSessionId] (持久存储) ↓ 清空临时状态 → newSessionStateAtom = {} ``` ## 注意事项 1. **内存管理**:newSessionStateAtom 在会话创建后会被清空,避免内存泄漏 2. **并发安全**:状态转移是同步操作,避免了并发问题 3. **用户体验**:整个过程对用户透明,选择的设置会无缝延续到新会话 ## 相关文件 - `/src/renderer/routes/index.tsx` - 首页组件 - `/src/renderer/components/InputBox.tsx` - 输入框组件 - `/src/renderer/stores/atoms/uiAtoms.ts` - UI 状态定义 - `/src/renderer/stores/sessionActions.ts` - 会话相关操作 ================================================ FILE: docs/rag.md ================================================ 技术方案 ## 数据流 1. 上传文件到目录 2. 在数据库创建对每个文件预处理 task 3. 触发 worker 处理 task,work 处理完自动停止,直到下次触发 4. worker 根据后缀名或 mime type,找到 loader,如果找不到 loader 任务失败,loader 加载文件内容 5. 根据文件内容调用 embedding,写入 vector store。(vector store 因为需要操作 db 文件,需要在 electron main 层执行,在 renderer 层通过 ipc 调用) ## 文件读取 根据文件格式,采用不同的 loader - Mastra MDocument - 文本,markdown,html,json - officeparser(免费) - office 类 - unstructured api(付费用户可用) - 其他 ## embedding - vercel ai sdk ## rerank (TODO) - 接入 cohere, voyage, jina ## vector store - libsql ## UI 在设置页面添加知识库管理页面,可以列出和创建知识库,每个知识库中可以添加文件,添加后进入待处理、之后存在处理中、处理完或处理失败状态。 ## AI 调用知识库 提供一系列 tool 让 AI 来访问知识库,用户可以选中一个知识库,AI 可以使用 # 和目前的系统结合 - settings/provider 页面,对 ProviderModelInfo 类型,增加模型分类:`embedding`,之前的默认分类为 `chat`,只有 `chat` 模型可以选择 `capabilities` - 知识库页面可以设置使用的 embedding 和 reranker model,默认使用模型提供方设置里找到第一个可用的 - main 层的 rag 服务,需要使用 renderer 层的 provider 参数(baseURL 和 apikey、modelId),所以需要在 renderer 层通过 ipc 来初始化 ai sdk 的 model,每次进行知识库操作需要保证初始化已经进行过 ================================================ FILE: docs/session-module-split-plan.md ================================================ # Session Module Split Plan **Purpose**: Document the dependency analysis and proposed module split for `src/renderer/stores/sessionActions.ts` (1799 lines) to enable safe refactoring without circular imports. ## Current State The `sessionActions.ts` file has grown to 1799 lines and handles multiple responsibilities: - Session CRUD operations - Message operations - Thread/history management - Fork (message branching) operations - AI generation orchestration - Session/thread naming - Export functionality ## Module-Level State The file contains two shared state objects that must be moved to a central location: ```typescript // Line 1054-1055 const pendingNameGenerations = new Map>() const activeNameGenerations = new Set() ``` **Purpose**: Debounce and deduplicate name generation requests **Used by**: `scheduleGenerateNameAndThreadName`, `scheduleGenerateThreadName` **Strategy**: Move to `stores/session/state.ts` and import where needed ## Dependency Graph ### Call Chains (Critical Paths) ``` submitNewUserMessage └── insertMessage └── insertMessageAfter └── modifyMessage └── generate (internal) └── genMessageContext └── streamText (external) └── generateImage (external) └── modifyMessage └── trackGenerateEvent (internal) generateMore └── insertMessageAfter └── generate (internal) generateMoreInNewFork └── createNewFork └── generateMore regenerateInNewFork └── findMessageLocation (internal) └── createNewFork └── generateMore (or passed in runGenerateMore) └── generate (internal, fallback) scheduleGenerateNameAndThreadName └── generateNameAndThreadName (internal) └── _generateName (internal) └── modifyNameAndThreadName scheduleGenerateThreadName └── generateThreadName (internal) └── _generateName (internal) └── modifyThreadName createNewFork / switchFork / deleteFork / expandFork └── buildCreateForkPatch / buildSwitchForkPatch / buildDeleteForkPatch / buildExpandForkPatch (internal) └── applyForkTransform (internal) └── switchForkInMessages (internal, for switchFork only) └── computeNextMessageForksHash (internal) startNewThread └── refreshContextAndCreateNewThread moveThreadToConversations └── copySession (internal) └── removeThread └── switchCurrentSession moveCurrentThreadToConversations └── copySession (internal) └── removeCurrentThread └── switchCurrentSession ``` ### External Dependencies (imports) | Import | Used By | |--------|---------| | `@dnd-kit/sortable` (arrayMove) | reorderSessions | | `@sentry/react` | submitNewUserMessage, generate, _generateName | | `@shared/defaults` | refreshContextAndCreateNewThread, compressAndCreateThread | | `@shared/models` (getModel) | submitNewUserMessage, generate, _generateName | | `jotai` (getDefaultStore) | switchCurrentSession, switchToNext | | `lodash` (identity, omit, pickBy) | copySession, generate | | `uuid` (uuidv4) | refreshContextAndCreateNewThread, switchThread, compressAndCreateThread, fork operations | | `@/adapters` (createModelDependencies) | submitNewUserMessage, generate, _generateName | | `@/hooks/dom` | startNewThread, compressAndCreateThread | | `@/i18n/locales` | _generateName | | `@/packages/apple_app_store` | generate | | `@/packages/context-management` | submitNewUserMessage, genMessageContext | | `@/packages/model-calls` | generate, _generateName | | `@/packages/model-setting-utils` | submitNewUserMessage, generate | | `@/packages/token` | insertMessage, insertMessageAfter, modifyMessage, genMessageContext | | `@/router` | switchCurrentSession | | `@/storage/StoreStorage` | generate | | `@/utils/session-utils` | reorderSessions | | `@/utils/track` | trackGenerateEvent | | `@shared/models/errors` | submitNewUserMessage, generate | | `@shared/types` | Various (type imports) | | `@shared/utils/message` | Various message operations | | `../packages/prompts` | _generateName | | `../platform` | submitNewUserMessage, generate, _generateName | | `../storage` | generate, genMessageContext | | `./atoms` | switchCurrentSession, switchToNext | | `./chatStore` | Most operations | | `./scrollActions` | switchCurrentSession, startNewThread, compressAndCreateThread, switchThread | | `./sessionHelpers` | createEmpty, refreshContextAndCreateNewThread, compressAndCreateThread, exportSessionChat | | `./settingActions` | submitNewUserMessage | | `./settingsStore` | generate, _generateName | | `./uiStore` | getSessionWebBrowsing, generate | ## Proposed Module Assignments ### `stores/session/state.ts` Shared module state (no dependencies on other session modules): ```typescript export const pendingNameGenerations = new Map>() export const activeNameGenerations = new Set() ``` ### `stores/session/types.ts` Internal types used across modules: ```typescript export type MessageForkEntry = NonNullable[string] export type MessageLocation = { list: Message[]; index: number } ``` ### `stores/session/crud.ts` (~150 lines) Session lifecycle operations: - `createEmpty` - creates new chat/picture session - `copyAndSwitchSession` - duplicates session - `switchCurrentSession` - changes active session - `switchToIndex` - switch by index - `switchToNext` - switch to next/prev - `reorderSessions` - drag-drop reorder - `clearConversationList` - bulk delete sessions - `clear` - clear messages in session **Internal**: `create`, `copySession`, `clearSessionList` **Dependencies**: chatStore, atoms, scrollActions, router, sessionHelpers ### `stores/session/messages.ts` (~200 lines) Message CRUD operations: - `insertMessage` - add message to session - `insertMessageAfter` - insert after specific message - `modifyMessage` - update message - `removeMessage` - delete message - `submitNewUserMessage` - handle user input with AI response **Dependencies**: chatStore, settingActions, settingsStore, generation.ts (imports `generate`) **Note**: `submitNewUserMessage` calls `generate` - will need to import from generation.ts ### `stores/session/threads.ts` (~250 lines) Thread/history management: - `editThread` - rename thread - `removeThread` - delete thread - `switchThread` - change active thread - `refreshContextAndCreateNewThread` - archive current, start fresh - `startNewThread` - wrapper with scroll/focus - `removeCurrentThread` - delete current thread - `compressAndCreateThread` - compress with summary - `moveThreadToConversations` - promote thread to session - `moveCurrentThreadToConversations` - promote current thread **Dependencies**: chatStore, scrollActions, dom, sessionHelpers, crud.ts (for switchCurrentSession, copySession) ### `stores/session/forks.ts` (~400 lines) Message fork/branch operations: - `createNewFork` - create branch point - `switchFork` - navigate branches - `deleteFork` - remove current branch - `expandFork` - flatten all branches **Internal helpers**: - `buildCreateForkPatch` - `buildSwitchForkPatch` - `buildDeleteForkPatch` - `buildExpandForkPatch` - `switchForkInMessages` - `applyForkTransform` - `computeNextMessageForksHash` **Dependencies**: chatStore, types.ts ### `stores/session/generation.ts` (~450 lines) AI generation orchestration: - `generate` (internal, but used by messages.ts) - core generation logic - `generateMore` - continue generation - `generateMoreInNewFork` - new branch + generate - `regenerateInNewFork` - regenerate in new branch - `createLoadingPictures` - placeholder images - `genMessageContext` - build prompt context - `getMessageThreadContext` - get thread messages **Internal helpers**: - `trackGenerateEvent` - `getSessionWebBrowsing` - `findMessageLocation` **Dependencies**: chatStore, settingsStore, uiStore, platform, storage, model-calls, messages.ts (circular - see below) **Circular Dependency Issue**: - `generation.ts` exports `generate` which is called by `submitNewUserMessage` in `messages.ts` - `generate` doesn't call anything from messages.ts directly (it calls `modifyMessage` but that can be imported directly) - **Solution**: `messages.ts` imports `generate` from `generation.ts`. No circular dependency. ### `stores/session/naming.ts` (~150 lines) Session/thread naming: - `modifyNameAndThreadName` - update session + thread name - `modifyThreadName` - update thread name only - `scheduleGenerateNameAndThreadName` - debounced auto-naming - `scheduleGenerateThreadName` - debounced thread naming **Internal helpers**: - `_generateName` - core name generation - `generateNameAndThreadName` - wrapper - `generateThreadName` - wrapper **Dependencies**: chatStore, settingsStore, platform, state.ts, model-calls, prompts ### `stores/session/export.ts` (~20 lines) Export functionality: - `exportSessionChat` - export session to file **Dependencies**: chatStore, sessionHelpers ### `stores/session/index.ts` Re-exports all public functions (37 total): ```typescript // CRUD (7) export { createEmpty, copyAndSwitchSession, switchCurrentSession } from './crud' export { switchToIndex, switchToNext, reorderSessions, clearConversationList, clear } from './crud' // Messages (5) export { insertMessage, insertMessageAfter, modifyMessage, removeMessage } from './messages' export { submitNewUserMessage } from './messages' // Threads (9) export { editThread, removeThread, switchThread } from './threads' export { refreshContextAndCreateNewThread, startNewThread, removeCurrentThread } from './threads' export { compressAndCreateThread, moveThreadToConversations, moveCurrentThreadToConversations } from './threads' // Forks (4) export { createNewFork, switchFork, deleteFork, expandFork } from './forks' // Generation (6) export { generateMore, generateMoreInNewFork, regenerateInNewFork } from './generation' export { createLoadingPictures, genMessageContext, getMessageThreadContext } from './generation' // Naming (4) export { modifyNameAndThreadName, modifyThreadName } from './naming' export { scheduleGenerateNameAndThreadName, scheduleGenerateThreadName } from './naming' // Export (1) export { exportSessionChat } from './export' ``` **Total exported: 36 functions** (Note: `clear` is included in CRUD = 37) ## Shared State Handling Strategy **Decision: Centralized State Module** The `pendingNameGenerations` and `activeNameGenerations` Maps will be moved to `stores/session/state.ts` and imported by `naming.ts`. **Rationale**: 1. These are simple, isolated state containers 2. Only used by naming operations 3. No complex initialization or cleanup needed 4. Easy to import without circular dependencies **Alternative considered**: Zustand store - Rejected because the state is only used internally for debouncing - No need for reactivity or persistence ## Migration Order 1. **US-001**: Create directory structure + state.ts + types.ts 2. **US-002**: Extract crud.ts (no dependencies on other new modules) 3. **US-003**: Extract messages.ts (depends on generation.ts - stub import initially) 4. **US-004**: Extract threads.ts (depends on crud.ts) 5. **US-005**: Extract forks.ts (independent) 6. **US-006**: Extract generation.ts (provides `generate` to messages.ts) 7. **US-007**: Extract naming.ts (uses state.ts) 8. **US-008**: Extract export.ts (independent) 9. **US-009**: Clean up sessionActions.ts to be re-export facade 10. **US-010**: Finalize index.ts with all exports ## Verification Checklist - [ ] No circular dependencies (`npx madge --circular src/renderer/stores/`) - [ ] TypeScript compiles (`npm run check`) - [ ] All 37 exports accessible from sessionActions.ts - [ ] Internal helpers (prefixed with `_`) not exported - [ ] Module state properly isolated in state.ts ## File Size Targets | Module | Estimated Lines | |--------|-----------------| | state.ts | ~10 | | types.ts | ~20 | | crud.ts | ~150 | | messages.ts | ~200 | | threads.ts | ~250 | | forks.ts | ~400 | | generation.ts | ~450 | | naming.ts | ~150 | | export.ts | ~20 | | index.ts | ~50 | | **sessionActions.ts (facade)** | **<100** | ## Risk Mitigation 1. **Circular imports**: The main risk is between `messages.ts` and `generation.ts`. Analysis shows `generate` is called by `submitNewUserMessage`, but `generate` only calls `modifyMessage` which can be a direct chatStore call. No circular dependency. 2. **Missing exports**: Use TypeScript to ensure all 37 exports are available after split. 3. **Broken imports**: Update all imports in codebase to use `sessionActions.ts` facade (re-exports maintain compatibility). 4. **State synchronization**: pendingNameGenerations/activeNameGenerations are simple Maps/Sets - no sync issues expected. ================================================ FILE: docs/storage.md ================================================ # 存储架构文档 Chatbox 跨平台存储方案和版本迁移机制说明。 ## 跨平台存储方案 ### 存储类型 - **DESKTOP_FILE**: 桌面端文件存储(通过 IPC) - **INDEXEDDB**: IndexedDB(通过 localforage) - **LOCAL_STORAGE**: localStorage(已弃用) - **MOBILE_SQLITE**: SQLite 数据库(通过 Capacitor) ### 当前方案(v1.17.0) | 平台 | Settings/Configs | Sessions | 原因 | |------|-----------------|----------|------| | **Desktop** | 文件存储 | IndexedDB | 配置便于备份,会话需要大容量 | | **Mobile** | SQLite | SQLite | 统一存储,性能更好 | | **Web** | IndexedDB | IndexedDB | 大容量,异步访问 | ## 版本历史 | 版本 | Config Version | Desktop | Mobile | 主要变化 | |------|---------------|---------|--------|---------| | v1.9.8-v1.9.10 | 0-5 | 全部 File | localStorage | 初始版本 | | v1.9.11 | 6-7 | - | **→ SQLite** | Mobile 解决容量限制 | | v1.12.0 | 7-8 | - | - | 数据格式:sessions → session-list | | v1.13.1 | 9-10 | - | - | Provider/Session 设置重构 | | v1.16.1 | 11-12 | **Sessions → IndexedDB**
Configs 保持 File | **→ IndexedDB** | Desktop 分离存储
Mobile 统一到 IndexedDB | | **v1.17.0** | **12-13** | Sessions 保持 IndexedDB
Configs 保持 File | **→ SQLite** | Desktop 无变化
Mobile 性能优化 | **关键历史事实**: - Desktop 的 `configVersion`/`settings`/`configs` **从未** 存储在 IndexedDB 中 - Desktop 从 v1.16.1 开始只将 **sessions** 移到 IndexedDB - v1.16.1 → v1.17.0,Desktop 存储策略 **完全未变** - Mobile 的完整演进:localStorage → SQLite (v1.9.11) → IndexedDB (v1.16.1) → SQLite (v1.17.0) ## 迁移机制 ### 核心逻辑 ```typescript // 1. 找到最新的旧存储 const [oldConfigVersion, oldStorage] = await findNewestStorage(getOldVersionStorages()) // 2. 判断是否需要迁移数据 if ( (oldConfigVersion > configVersion || platform.type === 'desktop') && oldStorage && oldStorage.getStorageType() !== storage.getStorageType() // 存储类型不同 ) { await doMigrateStorage(oldStorage) // 迁移数据 } // 3. 增量升级数据格式 for (; configVersion < CurrentVersion; configVersion++) { await migrateFunctions[configVersion]?.(dataStore) await setConfigVersion(configVersion + 1) } ``` ### 迁移策略差异 | 平台 | 策略 | 说明 | |------|------|------| | **Mobile** | 复制所有 key | 所有数据在同一存储 | | **Desktop** | 只复制会话数据 | Settings/Configs 保留在文件中 | ## 关键设计决策 ### 1. 同类型存储共享数据源 **原则**: 旧存储和当前存储类型相同时,无需迁移数据。 **示例**: Mobile v1.9.11 (SQLite v7) → v1.17.0 (SQLite v13) - 都用 SQLite,数据已经在那里 - 只需升级数据格式,无需复制数据 ### 2. 多个旧存储选最新 **原则**: 存在多个旧存储时,选择 configVersion 最大的。 **示例**: localStorage v5 + IndexedDB v12 → 选择 IndexedDB v12 - 避免迁移过时数据 - 确保用户获得最新状态 ### 3. 桌面端混合存储 **原则**: 配置文件便于备份,会话数据用 IndexedDB。 **历史演进**: - v1.9.x: 所有数据在 config.json 文件中 - v1.16.1: 会话数据移到 IndexedDB,配置保持在文件中 - v1.17.0: 与 v1.16.1 完全相同(无变化) **关键事实**: - `configVersion`/`settings`/`configs` 从未在 IndexedDB 中存储过 - 只有会话数据(`chat-sessions-list`、`session:*`)在 IndexedDB **特殊处理**: - 迁移时只复制会话数据到 IndexedDB - Settings/Configs 保留在文件存储 ### 4. 增量数据格式升级 **原则**: 数据格式升级按版本逐步执行。 **优势**: - 从任意版本升级都能正确迁移 - 中断后可继续 - 便于维护 ## 测试要点 ### 覆盖场景 1. ✅ 首次运行(无旧数据) 2. ✅ 版本已是最新(跳过迁移) 3. ✅ 同类型存储(数据已可访问) 4. ✅ 跨存储迁移(File → IndexedDB, localStorage → SQLite) 5. ✅ 多个旧存储共存(选择最新版本) 6. ✅ 历史版本直接升级(跳过中间版本) ### 关键洞察 **1. 同类型存储共享数据源** ```typescript // 测试 mock 体现 if (type === 'MOBILE_SQLITE') { sqliteData = { ...data } // 共享同一数据容器 } ``` **2. 最新版本优先** ```typescript // localStorage v5 + IndexedDB v12 → 选择 v12 ``` **3. 桌面端部分迁移** ```typescript // 只迁移 sessions,不迁移 settings/configs ``` **4. 使用真实 Platform 实例** ```typescript beforeAll(async () => { const { default: DesktopPlatformClass } = await import('@/platform/desktop_platform') desktopPlatform = new DesktopPlatformClass(window.electronAPI) }) ``` ## 常见问题 **Q: 为什么回退到 SQLite?** A: IndexedDB 在某些 WebView 环境存在数据被清理问题,SQLite 更稳定。 **Q: 迁移失败会怎样?** A: 捕获异常并记录,应用仍可运行(初始化默认数据)。 **Q: 如何添加新版本?** A: 增加 `CurrentVersion`,在 `migrateFunctions` 添加迁移函数,更新文档。 ## 参考 - [Migration 源码](../src/renderer/stores/migration.ts) - [Migration 测试](../src/renderer/stores/migration.test.ts) - [测试文档](./testing.md) --- **最后更新**: 2025-10-25 | **当前版本**: v1.17.0 (Config Version 13) ================================================ FILE: docs/testing.md ================================================ # Testing Strategy and Implementation ## Current Testing Infrastructure ### Test Framework - **Vitest** - Modern, ESM-first test runner with excellent TypeScript support - **@ai-sdk/provider-utils/test** - Mock server utilities for AI provider testing - **Testing Library** - Component testing utilities ### Test Configuration ```typescript // vitest.config.ts export default defineConfig({ test: { globals: true, environment: 'node', env: { NODE_ENV: 'test', }, include: ['src/**/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'dist', 'release', '.erb'], } }) ``` ### Test Commands - `npm run test` - Run all tests once - `npm run test:watch` - Run tests in watch mode - `npm run test:ui` - Launch Vitest UI for interactive testing - `npm run test:coverage` - Run tests with coverage report ## Existing Test Coverage ### ✅ Completed Tests 1. **AI Provider Adapters** (`src/shared/models/`) - OpenAI streaming and tool calls - Error handling (rate limits, network errors) - Message format conversion - Stream parsing and response handling 2. **Utility Functions** (`src/shared/utils/`) - API URL normalization (`llm_utils.test.ts`) - Message sequencing and merging - ContentParts array handling 3. **Content Processing** (`src/renderer/`) - Base64 image parsing (`base64.test.ts`) - LaTeX rendering (`latex.test.ts`) - Provider configuration parsing (`provider-config.test.ts`) 4. **Message Handling** (`src/renderer/utils/`) - Message role sequencing - Empty message filtering - Multi-part content merging - Image content handling ## Testing Patterns and Best Practices ### Mock Server Pattern For AI provider testing, use `createTestServer` from `@ai-sdk/provider-utils/test`: ```typescript import { createTestServer } from '@ai-sdk/provider-utils/test' const server = createTestServer({ 'https://api.openai.com/v1/chat/completions': { headers: { 'Content-Type': 'text/event-stream' }, chunks: [ 'data: {"id":"1","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hello"}}]}\n\n', 'data: [DONE]\n\n', ] } }) ``` ### Handling Dynamic Responses Use `callNumber` parameter for different responses per call: ```typescript const server = createTestServer({ 'https://api.openai.com/v1/chat/completions': ({ callNumber }) => ({ chunks: callNumber === 0 ? ['data: {"choices":[{"delta":{"tool_calls":[...]}}]}\n\n'] : ['data: {"choices":[{"delta":{"content":"Result"}}]}\n\n'] }) }) ``` ### Environment-Aware Code Suppress console output in tests: ```typescript if (process.env.NODE_ENV !== 'test') { console.error('Error message') } ``` ## Test Coverage Goals ### High Priority (Core Functionality) - [x] AI provider adapters - Basic streaming and error handling - [x] Message processing core logic - [ ] Data storage layer (BaseStorage) - [ ] Session management - [ ] Settings management ### Medium Priority (Features) - [x] Content rendering (LaTeX, Markdown basics) - [x] Provider configuration - [ ] File processing and uploads - [ ] Knowledge base integration - [ ] MCP server communication ### Low Priority (Extensions) - [ ] UI component testing - [ ] Electron main process testing - [ ] Platform-specific features - [ ] Performance benchmarks ## Implementation Guidelines ### 1. Test Structure - Place test files alongside source files with `.test.ts` extension - Use descriptive test names that explain the expected behavior - Group related tests using `describe` blocks ### 2. Mock Strategy - Use real fetch with mock servers for API testing - Avoid mocking internal modules unless necessary - Create reusable test fixtures for common data ### 3. Async Testing - Always await async operations - Use proper cleanup in afterEach hooks - Handle streaming responses correctly ### 4. Type Safety - Never use `any` type in tests - Ensure all mocks match actual type signatures - Use type assertions sparingly and correctly ## Migration from Jest The project has been successfully migrated from Jest to Vitest for better ESM support and modern tooling: 1. **Key Changes** - Replaced Jest configuration with Vitest config - Updated test scripts in package.json - Fixed import issues with `@ai-sdk/provider-utils/test` - Updated test expectations for new data structures 2. **Benefits** - Native ESM support - Faster test execution - Better TypeScript integration - Interactive UI for test debugging ## Next Steps 1. **Immediate Actions** - Add tests for data storage layer - Test session lifecycle management - Verify settings persistence 2. **Short-term Goals** - Achieve 70% code coverage for core modules - Add integration tests for critical user flows - Set up automated test runs in CI/CD 3. **Long-term Vision** - Comprehensive E2E testing with Playwright - Performance regression testing - Cross-platform compatibility testing ## Resources - [Vitest Documentation](https://vitest.dev/) - [AI SDK Testing Guide](https://sdk.vercel.ai/docs/testing) - [Testing Library](https://testing-library.com/) ================================================ FILE: docs/token-estimation.md ================================================ # Token Estimation System Token 预估系统用于异步计算聊天消息和附件的 token 数量,在不阻塞 UI 的情况下提供实时的 token 统计。 ## 架构概览 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ React UI (InputBox, TokenCountMenu) │ │ └── useTokenEstimation hook │ │ ├── 返回: { totalTokens, isCalculating, breakdown } │ │ └── 订阅 computationQueue 状态变化 │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ analyzer.ts │ │ ├── 检查消息的 tokenCountMap 缓存 │ │ ├── 已缓存 → 直接返回 token 数 │ │ └── 未缓存 → 生成 pendingTasks │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ computation-queue.ts (Singleton) │ │ ├── 优先级队列 (priority: 0=当前输入, 10+=历史消息) │ │ ├── 任务去重 (by taskId) │ │ ├── 并发控制 (maxConcurrency=1) │ │ └── Session 级别取消 │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ task-executor.ts │ │ ├── 读取消息/附件内容 │ │ ├── 调用 tokenizer 计算 token │ │ └── 将结果发送到 resultPersister │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ result-persister.ts │ │ ├── 累积计算结果 │ │ ├── Throttle 机制 (1000ms) - 保证每秒至少 flush 一次 │ │ └── 调用 chatStore.updateMessages() 持久化 │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ chatStore.ts │ │ ├── 更新 storage (IndexedDB) │ │ └── setQueryData() 更新 React Query 缓存 → UI 重新渲染 │ └─────────────────────────────────────────────────────────────────────┘ ``` ## 文件结构 ```text src/renderer/packages/token-estimation/ ├── index.ts # 公共 API 导出 ├── types.ts # 类型定义 (ComputationTask, TaskResult, etc.) ├── hooks/ │ └── useTokenEstimation.ts # React Hook - UI 入口 ├── analyzer.ts # 分析哪些消息需要计算 ├── computation-queue.ts # 任务队列管理 ├── task-executor.ts # 任务执行逻辑 ├── result-persister.ts # 结果持久化 (throttle) ├── tokenizer.ts # Token 计算逻辑 (tiktoken/deepseek) ├── cache-keys.ts # 缓存 key 生成工具 └── __tests__/ # 单元测试 ``` ## 核心组件 ### 1. useTokenEstimation Hook **位置**: `hooks/useTokenEstimation.ts` React 组件的入口点,负责: - 调用 `analyzeTokenRequirements()` 分析需要计算的任务 - 将任务入队到 `computationQueue` - 订阅队列状态变化,返回 `isCalculating` - 当 session 切换时取消旧 session 的任务 ```typescript const { totalTokens, // 总 token 数 contextTokens, // 上下文消息 token 数 currentInputTokens, // 当前输入 token 数 isCalculating, // 是否正在计算 pendingTasks, // 待处理任务数 breakdown, // 详细分解 } = useTokenEstimation({ sessionId, constructedMessage, // 当前输入(未发送) contextMessages, // 历史消息 model, modelSupportToolUseForFile, }) ``` ### 2. Analyzer **位置**: `analyzer.ts` 分析消息列表,确定哪些需要计算: - 检查每条消息的 `tokenCountMap` 缓存 - 已缓存 → 直接累加到结果 - 未缓存 → 生成 `ComputationTask` ### 3. Computation Queue **位置**: `computation-queue.ts` 优先级任务队列,特性: - **优先级调度**: 当前输入 (0) > 附件 (1) > 历史消息 (10+) - **去重**: 通过 taskId 防止重复计算 - **并发控制**: 最多 1 个任务同时执行 - **Session 取消**: 切换会话时取消旧会话的任务 ```typescript // 优先级常量 PRIORITY = { CURRENT_INPUT_TEXT: 0, // 最高优先级 CURRENT_INPUT_ATTACHMENT: 1, CONTEXT_TEXT: 10, // 历史消息基础优先级 CONTEXT_ATTACHMENT: 11, } ``` ### 4. Task Executor **位置**: `task-executor.ts` 执行具体的 token 计算: - 读取消息文本或附件内容 - 调用 tokenizer 计算 token 数 - 将结果发送到 `resultPersister` ### 5. Result Persister **位置**: `result-persister.ts` 批量持久化计算结果,使用 **throttle** 机制: ```typescript // Throttle 而非 Debounce // - Debounce: 每次调用重置计时器,可能导致长时间不 flush // - Throttle: 保证每 1000ms 至少 flush 一次 private throttleMs = 1000 private lastFlushTime = 0 private scheduleFlush(): void { const now = Date.now() const timeSinceLastFlush = now - this.lastFlushTime if (timeSinceLastFlush >= this.throttleMs) { // 距离上次 flush 已超过 1s,立即 flush this.doFlush() } else if (!this.flushTimer) { // 安排在剩余时间后 flush this.flushTimer = setTimeout(() => { this.doFlush() }, this.throttleMs - timeSinceLastFlush) } // 如果已有计时器,不做任何事(throttle 行为) } ``` **为什么用 Throttle?** - 计算 100 条消息时,任务会连续完成 - Debounce 会不断重置计时器,直到所有任务完成才 flush - Throttle 保证用户每秒都能看到中间进度 ### 6. Tokenizer **位置**: `tokenizer.ts` 实际的 token 计算逻辑,支持: - **Tiktoken**: OpenAI 模型 (cl100k_base, o200k_base) - **DeepSeek**: DeepSeek 模型专用 tokenizer ## 缓存机制 Token 计算结果缓存在消息对象的 `tokenCountMap` 字段: ```typescript interface Message { // ... tokenCountMap?: { tiktoken?: number // 文本 token (tiktoken) tiktoken_preview?: number // 预览模式 token deepseek?: number // 文本 token (deepseek) deepseek_preview?: number // 预览模式 token } tokenCalculatedAt?: { tiktoken?: number // 计算时间戳 // ... } } ``` 附件也有类似的缓存结构: ```typescript interface MessageFile { // ... tokenCountMap?: TokenCountMap tokenCalculatedAt?: Record lineCount?: number byteLength?: number } ``` ## React Query 集成 系统通过 `chatStore` 与 React Query 集成: ```typescript // result-persister.ts await chatStore.updateMessages(sessionId, (messages) => { return messages.map((msg) => { const update = sessionUpdates.find((u) => u.messageId === msg.id) if (!update) return msg return applyUpdates(msg, update.updates) }) }) // chatStore.ts - updateMessages 内部 queryClient.setQueryData(QueryKeys.ChatSession(sessionId), updated) // ↑ 直接更新缓存,触发 UI 重新渲染 // 不使用 invalidateQueries,避免不必要的重新获取 ``` ## 初始化 系统在应用启动时初始化: ```typescript // src/renderer/setup/token_estimation_init.ts import { initializeExecutor, setResultPersister } from '@/packages/token-estimation/task-executor' import { resultPersister } from '@/packages/token-estimation/result-persister' import { computationQueue } from '@/packages/token-estimation/computation-queue' // 连接 persister 到 executor setResultPersister(resultPersister) // 初始化 executor (连接到 queue) initializeExecutor() // 启动定期清理 computationQueue.startCleanup() ``` ## 调试工具 开发环境下可通过 `window.__tokenEstimation` 访问: ```javascript // 查看队列状态 window.__tokenEstimation.getStatus() // { pending: 0, running: 0 } // 查看待处理任务 window.__tokenEstimation.getPendingTasks() // 手动触发 flush window.__tokenEstimation.flushNow() ``` ## 性能考虑 1. **并发限制**: 最多 1 个任务同时执行,防止 CPU 过载 2. **优先级调度**: 当前输入优先计算,用户体验更好 3. **Throttle 持久化**: 每秒最多写入一次,减少 I/O 4. **去重**: 相同任务不会重复计算 5. **Session 取消**: 切换会话时取消旧任务,节省资源 6. **内存清理**: 定期清理已完成任务 ID,防止内存泄漏 ## 常见问题 ### Q: 为什么 token 数显示为 0? 检查: 1. `initializeExecutor()` 是否被调用 2. `setResultPersister()` 是否被调用 3. 控制台是否有错误日志 ### Q: 为什么计算很慢? 可能原因: 1. 大量历史消息需要计算 2. 附件文件较大 3. 可以通过 `window.__tokenEstimation.getStatus()` 查看队列状态 ### Q: 如何添加新的 tokenizer? 1. 在 `tokenizer.ts` 添加新的计算逻辑 2. 在 `types.ts` 更新 `TokenizerType` 类型 3. 在 `cache-keys.ts` 更新缓存 key 生成逻辑 ### Q: 切换 session 后 isCalculating 状态不正确? **问题**(已修复):切换 session 后,InputBox 和 TokenCountMenu 仍显示上一个 session 的计算状态。 **原因**: 1. `InputBox` 使用 `key` 导致组件重新挂载,原有的 `prevSessionIdRef` 取消逻辑失效 2. `computationQueue.getStatus()` 返回全局队列状态,而非当前 session 的状态 **解决方案**: 1. 使用 effect cleanup function 在组件卸载时取消任务(替代 `useRef` 方案) 2. 添加 `getStatusForSession(sessionId)` 方法返回指定 session 的状态 3. `useTokenEstimation` hook 订阅当前 session 的状态变化 **相关代码**: - `computation-queue.ts`: `getStatusForSession()` - `useTokenEstimation.ts`: cleanup function + session-scoped status ================================================ FILE: electron-builder.yml ================================================ productName: Chatbox appId: xyz.chatboxapp.app asar: true asarUnpack: - "**\\*.{node,dll}" - "**/node_modules/libsql/**" files: - "dist" # - "!dist/**/*.map" # - "!dist/**/stats.html" - "node_modules" - "package.json" afterSign: .erb/scripts/notarize.js afterPack: .erb/scripts/patch-libsql.cjs # releaseInfo: # releaseNotes: See the changelog for details mac: notarize: false category: public.app-category.developer-tools target: target: default arch: - arm64 - x64 type: distribution hardenedRuntime: true entitlements: assets/entitlements.mac.plist entitlementsInherit: assets/entitlements.mac.plist gatekeeperAssess: false dmg: contents: - x: 130 y: 220 - x: 410 y: 220 type: link path: /Applications win: target: - target: nsis arch: - x64 - arm64 verifyUpdateCodeSignature: false artifactName: ${productName}-${version}-Setup.${ext} sign: ./custom_win_sign.js signingHashAlgorithms: - sha256 nsis: oneClick: false allowToChangeInstallationDirectory: true include: assets/installer.nsh linux: target: - target: AppImage arch: - x64 - arm64 - target: deb arch: - x64 - arm64 category: Development artifactName: ${productName}-${version}-${arch}.${ext} directories: app: release/app buildResources: assets output: release/build extraResources: - ./assets/** publish: - provider: s3 bucket: chatbox endpoint: https://208624959c9d215edea0720162a740c1.r2.cloudflarestorage.com path: /releases channel: ${env.UPDATE_CHANNEL} ================================================ FILE: electron.vite.config.ts ================================================ import { sentryVitePlugin } from '@sentry/vite-plugin' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' import react from '@vitejs/plugin-react' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import path, { resolve } from 'path' import { visualizer } from 'rollup-plugin-visualizer' import type { Plugin } from 'vite' import packageJson from './release/app/package.json' /** * Vite plugin to inject for web builds * This ensures relative paths resolve correctly for SPA routes like /session/xxx */ export function injectBaseTag(): Plugin { return { name: 'inject-base-tag', transformIndexHtml() { return [ { tag: 'base', attrs: { href: '/' }, injectTo: 'head-prepend', // Inject at the beginning of }, ] }, } } /** * Vite plugin to replace dvh units with vh units * This replaces the webpack string-replace-loader functionality */ export function dvhToVh(): Plugin { return { name: 'dvh-to-vh', transform(code, id) { if (id.endsWith('.css') || id.endsWith('.scss') || id.endsWith('.sass')) { return { code: code.replace(/(\d+)dvh/g, '$1vh'), map: null, } } return null }, } } const inferredRelease = process.env.SENTRY_RELEASE || packageJson.version const inferredDist = process.env.SENTRY_DIST || undefined process.env.SENTRY_RELEASE = inferredRelease if (inferredDist) { process.env.SENTRY_DIST = inferredDist } export default defineConfig(({ mode }) => { const isProduction = mode === 'production' const isWeb = process.env.CHATBOX_BUILD_PLATFORM === 'web' return { main: { plugins: [ ...(isProduction ? [ visualizer({ filename: 'release/app/dist/main/stats.html', open: false, title: 'Main Process Dependency Analysis', }), ] : [externalizeDepsPlugin()]), process.env.SENTRY_AUTH_TOKEN ? sentryVitePlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: 'sentry', project: 'chatbox', url: 'https://sentry.midway.run/', release: { name: inferredRelease, ...(inferredDist ? { dist: inferredDist } : {}), }, sourcemaps: { assets: isProduction ? 'release/app/dist/main/**' : 'output/main/**', }, telemetry: false, }) : undefined, ].filter(Boolean), build: { outDir: isProduction ? 'release/app/dist/main' : undefined, lib: { entry: resolve(__dirname, 'src/main/main.ts'), }, sourcemap: isProduction ? 'hidden' : true, minify: isProduction, rollupOptions: { external: Object.keys(packageJson.dependencies || {}), output: { entryFileNames: '[name].js', inlineDynamicImports: true, }, }, }, resolve: { alias: { '@': path.resolve(__dirname, './src/renderer'), 'src/shared': path.resolve(__dirname, './src/shared'), }, }, define: { 'process.type': '"browser"', 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.CHATBOX_BUILD_TARGET': JSON.stringify(process.env.CHATBOX_BUILD_TARGET || 'unknown'), 'process.env.CHATBOX_BUILD_PLATFORM': JSON.stringify(process.env.CHATBOX_BUILD_PLATFORM || 'unknown'), 'process.env.USE_LOCAL_API': JSON.stringify(process.env.USE_LOCAL_API || ''), 'process.env.USE_BETA_API': JSON.stringify(process.env.USE_BETA_API || ''), }, }, preload: { plugins: [ visualizer({ filename: 'release/app/dist/preload/stats.html', open: false, title: 'Preload Process Dependency Analysis', }), ], build: { outDir: isProduction ? 'release/app/dist/preload' : undefined, lib: { entry: resolve(__dirname, 'src/preload/index.ts'), }, sourcemap: isProduction ? 'hidden' : true, minify: isProduction, }, resolve: { alias: { '@': path.resolve(__dirname, './src/renderer'), 'src/shared': path.resolve(__dirname, './src/shared'), }, }, }, renderer: { resolve: { alias: { '@': path.resolve(__dirname, 'src/renderer'), '@shared': path.resolve(__dirname, 'src/shared'), }, }, plugins: [ TanStackRouterVite({ target: 'react', autoCodeSplitting: true, routesDirectory: './src/renderer/routes', generatedRouteTree: './src/renderer/routeTree.gen.ts', }), react({}), dvhToVh(), isWeb ? injectBaseTag() : undefined, visualizer({ filename: 'release/app/dist/renderer/stats.html', open: false, title: 'Renderer Process Dependency Analysis', }), process.env.SENTRY_AUTH_TOKEN ? sentryVitePlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: 'sentry', project: 'chatbox', url: 'https://sentry.midway.run/', release: { name: inferredRelease, ...(inferredDist ? { dist: inferredDist } : {}), }, sourcemaps: { assets: isProduction ? 'release/app/dist/renderer/**' : 'output/renderer/**', }, telemetry: false, }) : undefined, ].filter(Boolean), build: { outDir: isProduction ? 'release/app/dist/renderer' : undefined, target: 'es2020', // Avoid static initialization blocks for browser compatibility sourcemap: isProduction ? 'hidden' : true, minify: isProduction ? 'esbuild' : false, // Use esbuild for faster, less memory-intensive minification rollupOptions: { output: { entryFileNames: 'js/[name].[hash].js', chunkFileNames: 'js/[name].[hash].js', assetFileNames: (assetInfo) => { if (assetInfo.name?.endsWith('.css')) { return 'styles/[name].[hash][extname]' } if (/\.(woff|woff2|eot|ttf|otf)$/i.test(assetInfo.name || '')) { return 'fonts/[name].[hash][extname]' } if (/\.(png|jpg|jpeg|gif|svg|webp|ico)$/i.test(assetInfo.name || '')) { return 'images/[name].[hash][extname]' } return 'assets/[name].[hash][extname]' }, // Optimize chunk splitting to reduce memory usage during build manualChunks(id) { if (id.includes('node_modules')) { // Split large vendor chunks if (id.includes('@ai-sdk') || id.includes('ai/')) { return 'vendor-ai' } if (id.includes('@mantine') || id.includes('@tabler')) { return 'vendor-ui' } if (id.includes('mermaid') || id.includes('d3')) { return 'vendor-charts' } } }, }, }, }, css: { modules: { generateScopedName: '[name]__[local]___[hash:base64:5]', }, postcss: './postcss.config.cjs', }, server: { port: 1212, strictPort: true, }, define: { 'process.type': '"renderer"', 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.CHATBOX_BUILD_TARGET': JSON.stringify(process.env.CHATBOX_BUILD_TARGET || 'unknown'), 'process.env.CHATBOX_BUILD_PLATFORM': JSON.stringify(process.env.CHATBOX_BUILD_PLATFORM || 'unknown'), 'process.env.USE_LOCAL_API': JSON.stringify(process.env.USE_LOCAL_API || ''), 'process.env.USE_BETA_API': JSON.stringify(process.env.USE_BETA_API || ''), }, optimizeDeps: { include: ['mermaid'], esbuildOptions: { target: 'es2015', }, }, }, } }) ================================================ FILE: i18next-parser.config.mjs ================================================ export default { input: ['src/renderer/**/*.{js,jsx,ts,tsx}'], output: 'src/renderer/i18n/locales/$LOCALE/$NAMESPACE.json', locales: ['en', 'ar', 'de', 'es', 'fr', 'it-IT', 'ja', 'ko', 'nb-NO', 'pt-PT', 'ru', 'sv', 'zh-Hans', 'zh-Hant'], createOldCatalogs: false, keepRemoved: true, pluralSeparator: false, keySeparator: false, namespaceSeparator: false, sort: true, } ================================================ FILE: package.json ================================================ { "name": "xyz.chatboxapp.ce", "productName": "xyz.chatboxapp.ce", "version": "0.0.1", "engines": { "node": ">=20.0.0 <23.0.0", "pnpm": ">=10.0.0" }, "private": true, "description": "A desktop client for multiple cutting-edge AI models", "main": "out/main/main.js", "scripts": { "build": "electron-vite build", "build:main": "electron-vite build", "build:preload": "electron-vite build", "build:renderer": "electron-vite build", "build:web": "cross-env CHATBOX_BUILD_PLATFORM=web electron-vite build && pnpm run delete-sourcemaps", "delete-sourcemaps": "ts-node ./.erb/scripts/delete-source-maps-runner.js", "postinstall": "node .erb/scripts/postinstall.cjs", "package": "ts-node ./.erb/scripts/clean.js && pnpm run build && electron-builder build --publish never", "package:all": "ts-node ./.erb/scripts/clean.js && pnpm run build && electron-builder build --publish never --win --mac --linux", "release:web": "bash release-web.sh", "release:mac": "bash release-mac.sh", "release:linux": "bash release-linux.sh", "release:win": "bash release-win.sh", "electron:publish-mac": "pnpm run build && electron-builder build --publish always --mac", "electron:publish-linux": "pnpm run build && electron-builder build --publish always --linux", "electron:publish-win": "pnpm run build && electron-builder build --publish always --win", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "dev": "pnpm start", "dev:local": "cross-env USE_LOCAL_API=true pnpm start", "dev:web": "cross-env DEV_WEB_ONLY=true CHATBOX_BUILD_PLATFORM=web pnpm start", "dev:debug": "cross-env MAIN_ARGS=\"--inspect=5858\" pnpm start", "start": "electron-vite dev", "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .", "start:preload": "electron-vite build --preload --watch", "start:renderer": "electron-vite", "serve:web": "npx serve ./release/app/dist/renderer", "test": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:integration": "vitest run test/integration --testTimeout=300000", "test:file-conversation": "vitest run test/integration/file-conversation --testTimeout=120000", "test:model-provider": "vitest run test/integration/model-provider --testTimeout=120000", "lint": "biome lint .", "lint:fix": "biome lint . --write", "check": "npx tsc --noEmit", "format": "biome format --write", "check:biome": "biome check", "check:ci": "biome ci", "mobile:sync": "pnpm run mobile:sync:ios && pnpm run mobile:sync:android", "mobile:sync:ios": "cross-env CHATBOX_BUILD_TARGET=mobile_app CHATBOX_BUILD_PLATFORM=ios electron-vite build && pnpm run delete-sourcemaps && npx cap sync ios", "mobile:sync:android": "cross-env CHATBOX_BUILD_TARGET=mobile_app CHATBOX_BUILD_PLATFORM=android electron-vite build && pnpm run delete-sourcemaps && npx cap sync android", "mobile:ios": "pnpm run mobile:sync:ios && npx cap open ios", "mobile:android": "pnpm run mobile:sync:android && npx cap open android", "mobile:assets": "npx capacitor-assets generate --ios --android", "prepare": "node -e \"try { require('husky') } catch(e) { process.exit(0) }\" && husky || true", "translate": "i18next && node script/translate.mjs" }, "repository": { "type": "git", "url": "https://github.com/chatboxai/chatbox.git" }, "keywords": [], "author": { "name": "bennhuang", "email": "tohuangbin@gmail.com" }, "devDependencies": { "@ai-sdk/anthropic": "^3.0.6", "@ai-sdk/azure": "^3.0.4", "@ai-sdk/deepseek": "^2.0.3", "@ai-sdk/google": "^3.0.3", "@ai-sdk/mcp": "^1.0.3", "@ai-sdk/mistral": "^3.0.4", "@ai-sdk/openai": "^3.0.4", "@ai-sdk/openai-compatible": "^2.0.3", "@ai-sdk/perplexity": "^3.0.3", "@ai-sdk/provider": "^3.0.1", "@babel/core": "^7.28.0", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/preset-env": "^7.28.0", "@biomejs/biome": "2.0.0", "@braintree/sanitize-url": "^6.0.4", "@capacitor-community/sqlite": "^7.0.2", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", "@capacitor/assets": "^3.0.5", "@capacitor/browser": "^7.0.2", "@capacitor/cli": "^7.0.0", "@capacitor/core": "^7.0.0", "@capacitor/device": "^7.0.2", "@capacitor/filesystem": "^7.0.0", "@capacitor/ios": "^7.0.0", "@capacitor/keyboard": "^7.0.0", "@capacitor/share": "^7.0.0", "@capacitor/splash-screen": "^7.0.0", "@capacitor/toast": "^7.0.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@ebay/nice-modal-react": "^1.2.13", "@electron/notarize": "^2.0.0", "@electron/rebuild": "^3.2.13", "@emotion/babel-plugin": "^11.13.5", "@emotion/babel-preset-css-prop": "^11.12.0", "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@epic-web/cachified": "^5.5.1", "@faker-js/faker": "^8.0.2", "@mantine/core": "^7.17.7", "@mantine/form": "^7.17.7", "@mantine/hooks": "^7.17.7", "@mantine/modals": "^7.17.7", "@mantine/spotlight": "^7.17.7", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.11", "@openrouter/ai-sdk-provider": "^2.0.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@radix-ui/react-dialog": "^1.0.5", "@sentry/react": "^10.12.0", "@sentry/vite-plugin": "^4.6.1", "@sentry/webpack-plugin": "^4.3.0", "@svgr/webpack": "^8.0.1", "@tabler/icons-react": "^3.31.0", "@tanstack/react-query": "^5.74.4", "@tanstack/react-router": "^1.114.23", "@tanstack/router-devtools": "^1.114.23", "@tanstack/router-plugin": "^1.120.15", "@tanstack/zod-adapter": "^1.127.3", "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", "@testing-library/react": "^14.0.0", "@types/auto-launch": "^5.0.5", "@types/autosize": "^4.0.3", "@types/big.js": "^6.2.2", "@types/canvas-confetti": "^1.9.0", "canvas-confetti": "^1.9.4", "@types/d3": "^7.4.3", "@types/epub": "^0.0.11", "@types/gtag.js": "^0.0.13", "@types/highlight.js": "^10.1.0", "@types/katex": "^0.16.2", "@types/lodash": "^4.14.197", "@types/mark.js": "^8.11.12", "@types/markdown-it": "^12.2.3", "@types/markdown-it-link-attributes": "^3.0.1", "@types/node": "20.2.5", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", "@types/react-swipeable-views": "^0.13.6", "@types/react-syntax-highlighter": "^15.5.9", "@types/react-test-renderer": "^18.0.0", "@types/shell-quote": "^1.7.5", "@types/store": "^2.0.2", "@types/terser-webpack-plugin": "^5.0.4", "@types/uuid": "^9.0.1", "@types/webpack-bundle-analyzer": "^4.6.0", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", "ai": "^6.0.11", "ai-retry": "^1.0.1", "autoprefixer": "^10.4.14", "autosize": "^6.0.1", "axios": "^1.3.4", "babel-loader": "^10.0.0", "big.js": "^7.0.1", "browserslist-config-erb": "^0.0.3", "capacitor-plugin-safe-area": "^4.0.0", "capacitor-stream-http": "^0.1.0", "chalk": "^4.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", "compare-versions": "^6.1.1", "concurrently": "^8.1.0", "copy-to-clipboard": "^3.3.3", "core-js": "^3.34.0", "cross-env": "^7.0.3", "css-loader": "^6.8.1", "css-minimizer-webpack-plugin": "^5.0.0", "dayjs": "^1.11.13", "deepmerge": "^4.3.1", "detect-port": "^1.5.1", "dotenv": "^16.3.1", "electron": "^26.6.10", "electron-builder": "^24.2.1", "electron-vite": "^4.0.1", "electronmon": "^2.0.2", "emittery": "^1.1.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^7.2.13", "form-data": "^4.0.0", "highlight.js": "^11.7.0", "html-webpack-plugin": "^5.5.1", "husky": "^9.0.11", "i18next": "^22.4.13", "i18next-parser": "^9.3.0", "identity-obj-proxy": "^3.0.0", "immer": "^10.1.1", "javascript-obfuscator": "^4.0.2", "jotai": "^2.1.0", "jotai-immer": "^0.4.1", "jotai-optics": "^0.3.0", "js-base64": "^3.7.7", "js-tiktoken": "^1.0.7", "jsdom": "^26.1.0", "lint-staged": "^16.1.2", "localforage": "^1.10.0", "lucide-react": "^0.419.0", "mark.js": "^8.11.1", "material-ui-popup-state": "^5.0.4", "mermaid": "^11.4.0", "mini-css-extract-plugin": "^2.7.6", "msw": "^2.10.5", "node-loader": "^2.0.0", "optics-ts": "^2.4.0", "p-map": "^7.0.3", "p-timeout": "^6.1.4", "photoswipe": "^5.4.4", "postcss": "^8.5.3", "postcss-loader": "^7.3.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "14.2.9", "react-hotkeys-hook": "^4.6.1", "react-i18next": "^12.2.0", "react-markdown": "^9.0.0", "react-photoswipe-gallery": "^3.1.1", "react-refresh": "^0.14.0", "react-router-dom": "^6.11.2", "react-swipeable-views": "^0.14.1", "react-syntax-highlighter": "^15.5.0", "react-test-renderer": "^18.2.0", "react-virtuoso": "^4.10.4", "react-zoom-pan-pinch": "^3.4.4", "rehype-katex": "^7.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "rimraf": "^5.0.1", "rollup-plugin-visualizer": "^6.0.5", "sass": "^1.62.1", "sass-loader": "^13.3.1", "shell-env": "^4.0.1", "shell-quote": "^1.8.2", "sonner": "^2.0.3", "store": "^2.0.12", "string-replace-loader": "^3.2.0", "style-loader": "^3.3.3", "swr": "^2.1.5", "tailwind-merge": "^1.14.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.0", "tailwindcss-animate": "^1.0.7", "terser-webpack-plugin": "^5.3.9", "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths-webpack-plugin": "^4.0.1", "typescript": "^5.8.3", "unist-util-visit": "^5.0.0", "url-loader": "^4.1.1", "vaul": "^1.1.2", "vite": "^7.2.6", "vite-plugin-babel": "^1.3.2", "vite-plugin-svgr": "^4.5.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.0.16", "web-vitals": "^2.1.4", "webpack": "^5.85.0", "webpack-bundle-analyzer": "^4.9.0", "webpack-cli": "^5.1.1", "webpack-dev-server": "^4.15.0", "webpack-merge": "^5.9.0", "webpack-obfuscator": "^3.5.1", "zod": "^4.0.17", "zustand": "^5.0.6" }, "dependencies": { "@libsql/client": "^0.15.6", "@lobehub/icons": "^4.0.2", "@mastra/core": "^0.13.2", "@mastra/libsql": "^0.13.2", "@mastra/rag": "^1.0.8", "@modelcontextprotocol/sdk": "^1.15.1", "@mozilla/readability": "^0.5.0", "@sentry/node": "^9.28.1", "auto-launch": "^5.0.6", "chardet": "^2.1.0", "cohere-ai": "^7.17.1", "electron-debug": "^3.2.0", "electron-devtools-installer": "^3.2.0", "electron-log": "^5.3.4", "electron-store": "^8.1.0", "electron-updater": "^6.3.9", "epub": "^1.3.0", "es-toolkit": "^1.43.0", "fs-extra": "^11.1.1", "iconv-lite": "^0.6.3", "linkedom": "^0.18.5", "lodash": "^4.17.21", "ofetch": "^1.0.1", "officeparser": "5.0.0", "sanitize-filename": "^1.6.3", "uuid": "^9.0.0" }, "browserslist": [], "electronmon": { "patterns": [ "!**/**", "src/main/**" ], "logLevel": "quiet" }, "lint-staged": { "*.{js,jsx,ts,tsx}": "biome format --write" }, "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", "@sentry/cli", "core-js", "core-js-pure", "electron", "esbuild", "msw", "protobufjs", "sharp", "zipfile" ], "overrides": { "@tanstack/router-generator": "1.120.15", "@tanstack/router-plugin": "1.120.15", "@mastra/libsql": "0.13.2", "@mastra/rag": "1.0.8" }, "patchedDependencies": { "libsql@0.5.22": "patches/libsql@0.5.22.patch", "mdast-util-gfm-autolink-literal@2.0.1": "patches/mdast-util-gfm-autolink-literal@2.0.1.patch" } }, "packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67" } ================================================ FILE: patches/libsql@0.5.22.patch ================================================ diff --git a/index.js b/index.js index e24987954ec427320f51fd8037f9754b60ffa363..6c73d898462a0ecd2b0a34070bc6da9424f34350 100644 --- a/index.js +++ b/index.js @@ -23,7 +23,17 @@ function requireNative() { if (target === "linux-arm-gnueabihf" && familySync() == MUSL) { target = "linux-arm-musleabihf"; } - return require(`@libsql/${target}`); + try { + return require(`@libsql/${target}`); + } catch (e) { + const isMissingTarget = + e?.code === "MODULE_NOT_FOUND" && + typeof e?.message === "string" && + e.message.includes(`@libsql/${target}`); + if (!isMissingTarget) throw e; + console.error(`[libsql] Native module @libsql/${target} not found`); + return {}; + } } const { diff --git a/promise.js b/promise.js index 111d8257015acd8c9870433d9e863d1bc675b7d6..981082fbfea4d5ea52ad78a48e046b862ef3b003 100644 --- a/promise.js +++ b/promise.js @@ -38,7 +38,21 @@ function requireNative() { if (target === "linux-arm-gnueabihf" && familySync() == MUSL) { target = "linux-arm-musleabihf"; } - return require(`@libsql/${target}`); + if (target === "win32-arm64-msvc") { + console.log("[libsql] Windows ARM64 detected - native module not available"); + return {}; + } + try { + return require(`@libsql/${target}`); + } catch (e) { + const isMissingTarget = + e?.code === "MODULE_NOT_FOUND" && + typeof e?.message === "string" && + e.message.includes(`@libsql/${target}`); + if (!isMissingTarget) throw e; + console.error(`[libsql] Native module @libsql/${target} not found`); + return {}; + } } const { ================================================ FILE: patches/mdast-util-gfm-autolink-literal@2.0.1.patch ================================================ diff --git a/lib/index.js b/lib/index.js index c5ca771c24dd914e342f791716a822431ee32b3a..541065ab36b44b9b1d98036aae3b96183a2a7761 100644 --- a/lib/index.js +++ b/lib/index.js @@ -126,13 +126,24 @@ function exitLiteralAutolink(token) { this.exit(token) } +/** + * Email regex with lookbehind for browsers that support it (Safari 16.4+), + * fallback without lookbehind for older browsers (Safari 16.0-16.3). + */ +let defined_emailRegex +try { + defined_emailRegex = new RegExp('(?<=^|\\s|\\p{P}|\\p{S})([-.\\w+]+)@([-\\w]+(?:\\.[-\\w]+)+)', 'gu') +} catch { + defined_emailRegex = /([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu +} + /** @type {FromMarkdownTransform} */ function transformGfmAutolinkLiterals(tree) { findAndReplace( tree, [ [/(https?:\/\/|www(?=\.))([-.\w]+)([^ \t\r\n]*)/gi, findUrl], - [/(?<=^|\s|\p{P}|\p{S})([-.\w+]+)@([-\w]+(?:\.[-\w]+)+)/gu, findEmail] + [defined_emailRegex, findEmail] ], {ignore: ['link', 'linkReference']} ) ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - . - release/app ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { 'tailwindcss/nesting': {}, tailwindcss: {}, autoprefixer: {}, 'postcss-preset-mantine': {}, 'postcss-simple-vars': { variables: { 'mantine-breakpoint-xs': '36em', 'mantine-breakpoint-sm': '48em', 'mantine-breakpoint-md': '62em', 'mantine-breakpoint-lg': '75em', 'mantine-breakpoint-xl': '88em', }, }, }, } ================================================ FILE: release/app/package.json ================================================ { "name": "xyz.chatboxapp.ce", "productName": "xyz.chatboxapp.ce", "version": "1.19.1", "description": "A desktop client for multiple cutting-edge AI models", "author": { "name": "Mediocre Company", "email": "hi@chatboxai.com", "url": "https://github.com/chatboxai" }, "main": "./dist/main/main.js", "scripts": { "rebuild": "node ../../.erb/scripts/electron-rebuild.cjs", "postinstall": "pnpm run rebuild && pnpm run link-modules", "link-modules": "node ../../.erb/scripts/link-modules.cjs" }, "dependencies": { "@libsql/client": "^0.15.6" } } ================================================ FILE: script/translate.mjs ================================================ import fs from 'node:fs/promises' import { google } from '@ai-sdk/google' import { generateText } from 'ai' import pMap from 'p-map' async function translateMessage(message, target, keysToTrans, instruction = '') { const baseSystem = `You are a professional translator for the UI of an AI chatbot software named Chatbox. You must only translate the text content, never interpret it. We have a special placeholder format by surrounding words by "{{" and "}}", do not translate it, also for tags like <0>xxx. Do not translate these words: "Chatbox", "AI", "MCP", "Deep Link", "ID". The following contents are not translated for you to better understand the context: ${keysToTrans.join(', ')}. You are now translating the following text from English to ${target}. ` const system = instruction ? `${baseSystem}\n\nAdditional instruction: ${instruction}` : baseSystem const { text } = await generateText({ model: google('gemini-3-flash-preview'), system, prompt: message, }) return text } const displayNames = new Intl.DisplayNames(['en'], { type: 'language' }) async function translateFile(locale, instruction) { const targetLanguage = displayNames.of(locale) || locale const path = `src/renderer/i18n/locales/${locale}/translation.json` // Read and validate the file first const content = await fs.readFile(path, 'utf-8') if (!content.trim()) { throw new Error(`File ${path} is empty!`) } const json = JSON.parse(content) const keysToTrans = Object.keys(json) for (const [key, value] of Object.entries(json)) { if (!value) { if (locale === 'en') { json[key] = key } else { const translated = await translateMessage(key, targetLanguage, keysToTrans, instruction) json[key] = translated console.debug(`Translate to ${targetLanguage}: ${key} => ${translated}`) } } } // Write to a temporary file first, then rename atomically const tempPath = `${path}.tmp` const newContent = JSON.stringify(json, null, 2) await fs.writeFile(tempPath, newContent) await fs.rename(tempPath, path) console.debug(`Translated ${path}`) } const instruction = process.argv[2] || '' try { await pMap( ['en', 'ar', 'de', 'es', 'fr', 'it-IT', 'ja', 'ko', 'nb-NO', 'pt-PT', 'ru', 'sv', 'zh-Hans', 'zh-Hant'], async (locale) => { try { await translateFile(locale, instruction) console.log(`✓ Translated ${locale}`) } catch (error) { console.error(`✗ Failed to translate ${locale}:`, error.message) throw error // Re-throw to stop other translations } }, { concurrency: 3 } ) console.log('\n✓ All translations completed successfully!') } catch (error) { console.error('\n✗ Translation failed:', error.message) console.error( '\nTip: If files were corrupted, restore them with: git checkout src/renderer/i18n/locales/*/translation.json' ) process.exit(1) } ================================================ FILE: scripts/ralph/prompt-opencode.md ================================================ # Ralph Agent Instructions You are an autonomous coding agent working on a software project. ## Your Task 1. Read the PRD at `prd.json` (in the same directory as this file) 2. Read the progress log at `progress.txt` (check Codebase Patterns section first) 3. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main. 4. Pick the **highest priority** user story where `passes: false` 5. Implement that single user story 6. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires) 7. Update AGENTS.md files if you discover reusable patterns (see below) 8. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]` 9. Update the PRD to set `passes: true` for the completed story 10. Append your progress to `progress.txt` ## Progress Report Format APPEND to progress.txt (never replace, always append): ``` ## [Date/Time] - [Story ID] [Session: https://opncd.ai/s/[share-id]] - What was implemented - Files changed - **Learnings for future iterations:** - Patterns discovered (e.g., "this codebase uses X for Y") - Gotchas encountered (e.g., "don't forget to update Z when changing W") - Useful context (e.g., "the evaluation panel is in component X") --- ``` Note: Include the share URL (if session was shared) so future iterations can reference previous work. The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better. ## Consolidate Patterns If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings: ``` ## Codebase Patterns - Example: Use `sql` template for aggregations - Example: Always use `IF NOT EXISTS` for migrations - Example: Export types from actions.ts for UI components ``` Only add patterns that are **general and reusable**, not story-specific details. ## Update AGENTS.md Files Before committing, check if any edited files have learnings worth preserving in nearby AGENTS.md files: 1. **Identify directories with edited files** - Look at which directories you modified 2. **Check for existing AGENTS.md** - Look for AGENTS.md in those directories or parent directories 3. **Add valuable learnings** - If you discovered something future developers/agents should know: - API patterns or conventions specific to that module - Gotchas or non-obvious requirements - Dependencies between files - Testing approaches for that area - Configuration or environment requirements **Examples of good AGENTS.md additions:** - "When modifying X, also update Y to keep them in sync" - "This module uses pattern Z for all API calls" - "Tests require the dev server running on PORT 3000" - "Field names must match the template exactly" **Do NOT add:** - Story-specific implementation details - Temporary debugging notes - Information already in progress.txt Only update AGENTS.md if you have **genuinely reusable knowledge** that would help future work in that directory. ## Quality Requirements - ALL commits must pass your project's quality checks (typecheck, lint, test) - Do NOT commit broken code - Keep changes focused and minimal - Follow existing code patterns ## Browser Testing (Required for Frontend Stories) For any story that changes UI, you MUST verify it works in the browser: 1. **Preflight Check**: Look for `chrome-devtools-mcp` in your opencode.json MCP servers config 2. If NOT configured, print to console: ``` ⚠️ ChromeDevTools MCP not configured. Frontend testing skipped. Configure chrome-devtools-mcp for browser testing: https://github.com/ChromeDevTools/chrome-devtools-mcp/ ``` Then continue without browser verification. 3. If configured, use MCP browser tools to navigate and verify UI changes 4. Take a screenshot if helpful for the progress log A frontend story is NOT complete until browser verification passes (or MCP not available). ## Stop Condition After completing a user story, check if ALL stories have `passes: true`. If ALL stories are complete and passing, reply with: COMPLETE If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story). ## Important - Work on ONE story per iteration - Commit frequently - Keep CI green - Read the Codebase Patterns section in progress.txt before starting ================================================ FILE: scripts/ralph/ralph.sh ================================================ #!/bin/bash # Ralph Wiggum - Long-running AI agent loop # Usage: ./ralph.sh [max_iterations] [cli_tool] [model] [share] # cli_tool: amp (default) or opencode # model: opencode model ID or amp mode (smart/rush) # share: true/false (default: false) - share session for opencode set -e MAX_ITERATIONS=${1:-10} CLI_TOOL=${2:-amp} MODEL=${3:-} SHARE=${4:-false} SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROMPT_FILE="$SCRIPT_DIR/prompt-$CLI_TOOL.md" # Set opencode permissions via environment variable (equivalent to --dangerously-allow-all) if [ "$CLI_TOOL" = "opencode" ]; then export OPENCODE_PERMISSION='{"*": "allow"}' export OPENCODE_DISABLE_AUTOCOMPACT=true fi PRD_FILE="$SCRIPT_DIR/prd.json" PROGRESS_FILE="$SCRIPT_DIR/progress.txt" ARCHIVE_DIR="$SCRIPT_DIR/archive" LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch" # Archive previous run if branch changed if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "") LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "") if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then # Archive the previous run DATE=$(date +%Y-%m-%d) # Strip "ralph/" prefix from branch name for folder FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||') ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME" echo "Archiving previous run: $LAST_BRANCH" mkdir -p "$ARCHIVE_FOLDER" [ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/" [ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/" echo " Archived to: $ARCHIVE_FOLDER" # Reset progress file for new run echo "# Ralph Progress Log" >"$PROGRESS_FILE" echo "Started: $(date)" >>"$PROGRESS_FILE" echo "---" >>"$PROGRESS_FILE" fi fi # Track current branch if [ -f "$PRD_FILE" ]; then CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "") if [ -n "$CURRENT_BRANCH" ]; then echo "$CURRENT_BRANCH" >"$LAST_BRANCH_FILE" fi fi # Initialize progress file if it doesn't exist if [ ! -f "$PROGRESS_FILE" ]; then echo "# Ralph Progress Log" >"$PROGRESS_FILE" echo "Started: $(date)" >>"$PROGRESS_FILE" echo "---" >>"$PROGRESS_FILE" fi echo "Starting Ralph - Max iterations: $MAX_ITERATIONS" if [ -n "$MODEL" ]; then echo "Using CLI: $CLI_TOOL (model: $MODEL)" else echo "Using CLI: $CLI_TOOL (default model)" fi if [ "$CLI_TOOL" = "opencode" ]; then echo "Share session: $SHARE" fi for i in $(seq 1 $MAX_ITERATIONS); do echo "" echo "═══════════════════════════════════════════════════════" echo " Ralph Iteration $i of $MAX_ITERATIONS" echo "═══════════════════════════════════════════════════════" # Run amp or opencode with the ralph prompt if [ "$CLI_TOOL" = "opencode" ]; then OPENCODE_MODEL=${MODEL:-github-copilot/claude-opus-4.5} if [ "$SHARE" = "true" ]; then OUTPUT=$(cat "$PROMPT_FILE" | opencode run -m "$OPENCODE_MODEL" --variant high --agent Sisyphus --share - 2>&1 | tee /dev/stderr) || true else OUTPUT=$(cat "$PROMPT_FILE" | opencode run -m "$OPENCODE_MODEL" --variant high --agent Sisyphus - 2>&1 | tee /dev/stderr) || true fi else if [ -n "$MODEL" ]; then OUTPUT=$(cat "$PROMPT_FILE" | amp --dangerously-allow-all --mode "$MODEL" 2>&1 | tee /dev/stderr) || true else OUTPUT=$(cat "$PROMPT_FILE" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true fi fi # Check for completion signal if echo "$OUTPUT" | grep -q "COMPLETE"; then echo "" echo "Ralph completed all tasks!" echo "Completed at iteration $i of $MAX_ITERATIONS" exit 0 fi echo "Iteration $i complete. Continuing..." sleep 2 done echo "" echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks." echo "Check $PROGRESS_FILE for status." exit 1 ================================================ FILE: src/__tests__/App.test.tsx.bk ================================================ import '@testing-library/jest-dom' import { render } from '@testing-library/react' import App from '../renderer/App' describe('App', () => { it('should render', () => { expect(render()).toBeTruthy() }) }) ================================================ FILE: src/main/adapters/index.ts ================================================ import { app } from 'electron' import fs from 'fs' import os from 'os' import path from 'path' import { createAfetch } from '../../shared/request/request' import type { ApiRequestOptions, ModelDependencies } from '../../shared/types/adapters' import { sentry } from './sentry' export async function createModelDependencies(): Promise { // Main层的平台信息 const platformInfo = { type: 'desktop', platform: process.platform, os: os.platform(), version: app.getVersion(), } const afetch = createAfetch(platformInfo) return { storage: { async saveImage(folder: string, dataUrl: string): Promise { // 将图片写入 /tmp 目录下的临时文件 const fileName = `chatbox_${folder}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}.img` const filePath = path.join(os.tmpdir(), fileName) // 支持 data URL 或纯 base64 let base64Data = dataUrl if (base64Data.startsWith('data:')) { base64Data = base64Data.substring(base64Data.indexOf(',') + 1) } await fs.promises.writeFile(filePath, base64Data, 'base64') return filePath }, async getImage(storageKey: string): Promise { // 读取临时文件内容并返回 data URL const base64Data = await fs.promises.readFile(storageKey, 'base64') // 尝试推断 mimeType,默认 image/png const mimeType = 'image/png' return `data:${mimeType};base64,${base64Data}` }, }, request: { fetchWithOptions: async ( url: string, init?: RequestInit, options?: { retry?: number; parseChatboxRemoteError?: boolean } ): Promise => { return afetch(url, init, options) }, async apiRequest(options: ApiRequestOptions) { const response = await fetch(options.url, { method: options.method || 'GET', headers: options.headers, body: options.body, signal: options.signal, }) return response }, }, sentry, getRemoteConfig: () => { // Main层的远程配置,暂时不需要用到 throw new Error('Not implemented') }, } } ================================================ FILE: src/main/adapters/sentry.ts ================================================ import * as Sentry from '@sentry/node' import { app } from 'electron' import type { SentryAdapter, SentryScope } from '../../shared/utils/sentry_adapter' import { getSettings } from '../store-node' function initSentry() { const settings = getSettings() if (!settings.allowReportingAndTracking) { return } const version = app.getVersion() Sentry.init({ dsn: 'https://eca691c5e01ebfa05958fca1fcb487a9@sentry.midway.run/697', integrations: [], environment: process.env.NODE_ENV || 'development', // Performance Monitoring - set to 1.0 since we control sampling in beforeSend sampleRate: 1.0, tracesSampler(samplingContext) { // For traces related to knowledge-base operations, always sample const isKnowledgeBaseTrace = samplingContext.tags?.component === 'knowledge-base-file' || samplingContext.tags?.component === 'knowledge-base-db' || samplingContext.tags?.component === 'knowledge-base' if (isKnowledgeBaseTrace) { return 1.0 // 100% sampling for knowledge-base traces } return 0.1 // 10% sampling for other traces }, release: version, // 设置全局标签 initialScope: { tags: { platform: 'desktop', app_version: version, }, }, }) } initSentry() /** * 主进程的 Sentry 适配器实现 * 使用 @sentry/node 进行错误上报 */ export class MainSentryAdapter implements SentryAdapter { captureException(error: any): void { Sentry.captureException(error) } withScope(callback: (scope: SentryScope) => void): void { Sentry.withScope((sentryScope) => { const scope: SentryScope = { setTag(key: string, value: string): void { sentryScope.setTag(key, value) }, setExtra(key: string, value: any): void { sentryScope.setExtra(key, value) }, } callback(scope) }) } } export const sentry = new MainSentryAdapter() ================================================ FILE: src/main/analystic-node.ts ================================================ import * as store from './store-node' import { app } from 'electron' import { ofetch } from 'ofetch' // Measurement Protocol 参考文档 // https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?hl=zh-cn&client_type=gtag // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag&hl=zh-cn#required_parameters // 事件名、参数名,必须是字母、数字、下划线的组合 const measurement_id = `G-B365F44W6E` const api_secret = `pRnsvLo-REWLVzV_PbKvWg` export async function event(name: string, params: any = {}) { const clientId = store.getConfig().uuid const res = await ofetch( `https://www.google-analytics.com/mp/collect?measurement_id=${measurement_id}&api_secret=${api_secret}`, { method: 'POST', body: { user_id: clientId, client_id: clientId, events: [ { name: name, params: { app_name: 'chatbox', app_version: app.getVersion(), chatbox_platform_type: 'desktop', chatbox_platform: 'desktop', app_platform: process.platform, ...params, }, }, ], }, } ) return res } ================================================ FILE: src/main/app-updater.ts ================================================ import { autoUpdater } from 'electron-updater' import { getSettings } from './store-node' import { getLogger } from './util' const log = getLogger('app-updater') export class AppUpdater { constructor(onUpdateDownloaded: () => void) { log.transports.file.level = 'info' autoUpdater.logger = log autoUpdater.once('update-downloaded', (event) => { // Notify renderer process about the update onUpdateDownloaded() }) const settings = getSettings() if (settings.autoUpdate) { // 立即检查一次更新 this.tryUpdate() // 设置定时器,每小时检查一次更新 setInterval( () => { this.tryUpdate() }, 1000 * 60 * 60 ) // 每小时检查一次 log.info('Update timer started, checking every hour') } } async tryUpdate() { const feedUrls = [ 'https://chatboxai.app/api/auto_upgrade', 'https://api.chatboxai.app/api/auto_upgrade', 'https://api.ai-chatbox.com/api/auto_upgrade', 'https://api.chatboxapp.xyz/api/auto_upgrade', 'https://api.chatboxai.com/api/auto_upgrade', ] for (const url of feedUrls) { try { autoUpdater.setFeedURL(url) const settings = getSettings() if (settings.betaUpdate) { autoUpdater.channel = 'beta' autoUpdater.allowDowngrade = false } const result = await autoUpdater.checkForUpdatesAndNotify() if (result) { return result } } catch (e) { log.error(`auto_updater: attempt failed: ${url}. `, e) } } return null } } ================================================ FILE: src/main/autoLauncher.ts ================================================ import AutoLaunch from 'auto-launch' import { getSettings } from './store-node' // 开机自启动 let _autoLaunch: AutoLaunch | null = null export function get() { if (!_autoLaunch) { _autoLaunch = new AutoLaunch({ name: 'Chatbox' }) } return _autoLaunch } export async function sync() { const autoLaunch = get() const settings = getSettings() const isEnabled = await autoLaunch.isEnabled() if (!isEnabled && settings.autoLaunch) { await autoLaunch.enable() return } if (isEnabled && !settings.autoLaunch) { await autoLaunch.disable() return } } export async function ensure(enable: boolean) { const autoLaunch = get() const isEnabled = await autoLaunch.isEnabled() if (!isEnabled && enable) { await autoLaunch.enable() return } if (isEnabled && !enable) { await autoLaunch.disable() return } } ================================================ FILE: src/main/cache.ts ================================================ export interface CacheItem { value: T expireAt: number } // In-memory cache store const memoryCache = new Map>() export async function cache( key: string, getter: () => Promise, options: { ttl: number // 缓存过期时间,单位为毫秒 refreshFallbackToCache?: boolean // 如果刷新时获取新值失败,是否从缓存中继续使用过期的旧值 } ): Promise { let cache = memoryCache.get(key) as CacheItem | undefined if (cache && cache.expireAt > Date.now()) { return cache.value } try { const newValue = await getter() cache = { value: newValue, expireAt: Date.now() + options.ttl, } memoryCache.set(key, cache) return newValue } catch (e) { if (options.refreshFallbackToCache && cache) { return cache.value } throw e } } ================================================ FILE: src/main/deeplinks.ts ================================================ import type { BrowserWindow } from 'electron' import log from 'electron-log/main' export function handleDeepLink(mainWindow: BrowserWindow, link: string) { const normalizedLink = link.replace(/^chatbox-dev:\/\//, 'chatbox://') const url = new URL(normalizedLink) console.log('🔗 Parsed URL:', { hostname: url.hostname, pathname: url.pathname, params: url.searchParams.toString() }) // handle `chatbox://mcp/install?server=` if (url.hostname === 'mcp' && url.pathname === '/install') { const encodedConfig = url.searchParams.get('server') || '' mainWindow.webContents.send('navigate-to', `/settings/mcp?install=${encodeURIComponent(encodedConfig)}`) } // handle `chatbox://provider/import?config=` if (url.hostname === 'provider' && url.pathname === '/import') { const encodedConfig = url.searchParams.get('config') || '' mainWindow.webContents.send('navigate-to', `/settings/provider?import=${encodeURIComponent(encodedConfig)}`) } // handle `chatbox://auth/callback?ticket_id=xxx&status=success` // // 不需要,实际跳回到 app 后业务hooks useLogin 会处理后续动作 // if (url.hostname === 'auth' && url.pathname === '/callback') { // const ticketId = url.searchParams.get('ticket_id') || '' // const status = url.searchParams.get('status') || '' // log.info('✅ Auth callback received:', { ticketId, status }) // mainWindow.webContents.send('navigate-to', `/settings/provider/chatbox-ai?ticket_id=${ticketId}&status=${status}`) // } } ================================================ FILE: src/main/file-parser.ts ================================================ import * as chardet from 'chardet' import Epub from 'epub' import * as fs from 'fs-extra' import * as iconv from 'iconv-lite' import { isEpubFilePath, isOfficeFilePath } from '../shared/file-extensions' import { getLogger } from './util' const log = getLogger('file-parser') // Helper function to decode HTML entities function decodeHtmlEntities(text: string): string { // Handle hexadecimal entities like 此 text = text.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => { try { return String.fromCharCode(parseInt(hex, 16)) } catch (e) { return match // Return original if conversion fails } }) // Handle decimal entities like { text = text.replace(/&#(\d+);/g, (match, dec) => { try { return String.fromCharCode(parseInt(dec, 10)) } catch (e) { return match // Return original if conversion fails } }) // Handle named entities return text .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") } // Simple concurrent map implementation using native Promise.allSettled async function concurrentMap( items: T[], mapper: (item: T, index: number) => Promise, concurrency: number = 8 ): Promise { const results: R[] = [] for (let i = 0; i < items.length; i += concurrency) { const batch = items.slice(i, i + concurrency) const batchNumber = Math.floor(i / concurrency) + 1 const totalBatches = Math.ceil(items.length / concurrency) log.debug(`Processing batch ${batchNumber}/${totalBatches} with ${batch.length} items`) const batchResults = await Promise.allSettled(batch.map((item, batchIndex) => mapper(item, i + batchIndex))) // Extract successful results for (const result of batchResults) { if (result.status === 'fulfilled') { results.push(result.value) } } } return results } export async function parseFile(filePath: string) { if (isOfficeFilePath(filePath)) { try { const officeParser = await import('officeparser') const data = await officeParser.default.parseOfficeAsync(filePath) return data } catch (error) { log.error(error) throw error } } if (isEpubFilePath(filePath)) { try { const data = await parseEpub(filePath) return data } catch (error) { log.error(error) throw error } } // Read first 4KB for encoding detection to avoid memory issues with large files const stats = await fs.stat(filePath) const sampleSize = Math.min(4096, stats.size) // Read sample using createReadStream for partial file reading const sampleBuffer = new Uint8Array(sampleSize) const fd = await fs.promises.open(filePath, 'r') await fd.read(sampleBuffer, 0, sampleSize, 0) await fd.close() // Detect encoding from sample const detectedEncoding = chardet.detect(sampleBuffer) const encoding = detectedEncoding || 'utf8' log.debug(`Detected encoding for ${filePath}: ${encoding}`) // Read full file as buffer and convert with detected encoding const fileBuffer = await fs.readFile(filePath) const data = iconv.decode(fileBuffer, encoding) return data } export async function parseEpub(filePath: string): Promise { return new Promise((resolve, reject) => { const epub = new Epub(filePath) epub.on('error', (error) => { log.error('EPUB parsing error:', error) reject(error) }) epub.on('end', async () => { try { const metadata = epub.metadata as { title?: string; creator?: string; language?: string } log.info('EPUB metadata:', { title: metadata.title, creator: metadata.creator, language: metadata.language, chapters: epub.flow.length, }) // Helper function to process a single chapter const processChapter = async (chapter: { id: string }): Promise => { try { const chapterText = await new Promise((resolveChapter, rejectChapter) => { epub.getChapter(chapter.id, (error, text) => { if (error) { log.error(`Error reading chapter ${chapter.id}:`, error) rejectChapter(error) } else { resolveChapter(text || '') } }) }) // Remove HTML tags and extract plain text let plainText = chapterText.replace(/<[^>]*>/g, '') // Remove HTML tags // Decode HTML entities (including hex) plainText = decodeHtmlEntities(plainText) .replace(/\s+/g, ' ') // Replace multiple whitespaces with single space .trim() return plainText || null } catch (chapterError) { log.warn(`Failed to read chapter ${chapter.id}, skipping:`, chapterError) return null // Return null for failed chapters to continue processing } } // Extract text from all chapters using concurrent processing log.info(`Starting concurrent processing of ${epub.flow.length} chapters with concurrency: 8`) const chapterResults = await concurrentMap(epub.flow as { id: string }[], processChapter, 8) const chapterTexts = chapterResults.filter((text: string | null) => text !== null) as string[] log.info(`Successfully processed ${chapterTexts.length}/${epub.flow.length} chapters`) const fullText = chapterTexts.join('\n\n') if (!fullText) { throw new Error('No readable text content found in EPUB file') } log.info(`Successfully extracted ${fullText.length} characters from ${chapterTexts.length} chapters`) resolve(fullText) } catch (error) { log.error('Error extracting EPUB content:', error) reject(error) } }) epub.parse() }) } ================================================ FILE: src/main/knowledge-base/db.ts ================================================ import fs from 'node:fs' import path from 'node:path' import type { Client } from '@libsql/client' import { LibSQLVector } from '@mastra/libsql' import { app } from 'electron' import { sentry } from '../adapters/sentry' import { getLogger } from '../util' const log = getLogger('knowledge-base:db') // Database file path const dbPath = path.join(app.getPath('userData'), 'databases', 'chatbox_kb.db') // Ensure database directory exists const dbDir = path.dirname(dbPath) if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }) } // Polyfill for mastra if (typeof global.crypto === 'undefined' || !('subtle' in global.crypto)) { global.crypto = require('node:crypto') } let db: Client let vectorStore: LibSQLVector async function initDB(db: Client) { try { await db.batch([ `CREATE TABLE IF NOT EXISTS knowledge_base ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, embedding_model TEXT NOT NULL, rerank_model TEXT, vision_model TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS kb_file ( id INTEGER PRIMARY KEY AUTOINCREMENT, kb_id INTEGER NOT NULL, filename TEXT NOT NULL, filepath TEXT NOT NULL, mime_type TEXT NOT NULL, file_size INTEGER DEFAULT 0, chunk_count INTEGER DEFAULT 0, total_chunks INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', error TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, processing_started_at DATETIME, FOREIGN KEY (kb_id) REFERENCES knowledge_base(id) )`, ]) // Add total_chunks column if it doesn't exist (for existing databases) await db.batch([`ALTER TABLE kb_file ADD COLUMN total_chunks INTEGER DEFAULT 0`]).catch((error) => { if (error instanceof Error && !error.message.includes('duplicate column name')) { log.error('[DB] Failed to add total_chunks column', error) } else { // Ignore error if column already exists log.info('[DB] Database initialized (total_chunks column already exists)') } }) // Add use_remote_parsing column if it doesn't exist (for remote parsing feature) await db.batch([`ALTER TABLE kb_file ADD COLUMN use_remote_parsing INTEGER DEFAULT 0`]).catch((error) => { if (error instanceof Error && !error.message.includes('duplicate column name')) { log.error('[DB] Failed to add use_remote_parsing column', error) } }) // Add parsed_remotely column to track which parsing method was used (for UI display) await db.batch([`ALTER TABLE kb_file ADD COLUMN parsed_remotely INTEGER DEFAULT 0`]).catch((error) => { if (error instanceof Error && !error.message.includes('duplicate column name')) { log.error('[DB] Failed to add parsed_remotely column', error) } }) // Add document_parser column to knowledge_base table (JSON format, NULL means use global config) await db.batch([`ALTER TABLE knowledge_base ADD COLUMN document_parser TEXT DEFAULT NULL`]).catch((error) => { if (error instanceof Error && !error.message.includes('duplicate column name')) { log.error('[DB] Failed to add document_parser column', error) } }) // Add parser_type column to kb_file table to record which parser was used await db.batch([`ALTER TABLE kb_file ADD COLUMN parser_type TEXT DEFAULT 'local'`]).catch((error) => { if (error instanceof Error && !error.message.includes('duplicate column name')) { log.error('[DB] Failed to add parser_type column', error) } }) // Add provider_mode column to knowledge_base table to store user's provider mode selection await db.batch([`ALTER TABLE knowledge_base ADD COLUMN provider_mode TEXT DEFAULT NULL`]).catch((error) => { if (error instanceof Error && !error.message.includes('duplicate column name')) { log.error('[DB] Failed to add provider_mode column', error) } }) log.info('[DB] Database initialized') } catch (error) { log.error('[DB] Failed to initialize database:', error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'database_initialization') scope.setExtra('dbPath', dbPath) sentry.captureException(error) }) throw error } } export async function initializeDatabase() { try { vectorStore = new LibSQLVector({ connectionUrl: `file:${dbPath}`, }) // 这里不再创建新的 client,因为多个 client 同时操作一个 db 文件会导致数据损坏 // biome-ignore lint/suspicious/noExplicitAny: access internal property db = (vectorStore as any).turso await initDB(db) // Clean up any processing files left from previous session await cleanupProcessingFiles() } catch (error) { log.error('[DB] Failed to initialize database system:', error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'vector_store_initialization') scope.setExtra('dbPath', dbPath) sentry.captureException(error) }) throw error } } export function getDatabase(): Client { if (!db) { const error = new Error('Database not initialized') log.error('[DB] Database not initialized') sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'database_access') sentry.captureException(error) }) throw error } return db } export function getVectorStore(): LibSQLVector { if (!vectorStore) { const error = new Error('Vector store not initialized') log.error('[DB] Vector store not initialized') sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'vector_store_access') sentry.captureException(error) }) throw error } return vectorStore } // Helper function to parse SQLite timestamp correctly export function parseSQLiteTimestamp(sqliteTimestamp: string): number { try { // SQLite CURRENT_TIMESTAMP returns UTC time in format: 'YYYY-MM-DD HH:MM:SS' // We need to explicitly tell JavaScript this is UTC time const utcDate = new Date(`${sqliteTimestamp} UTC`) const timestamp = utcDate.getTime() if (Number.isNaN(timestamp)) { throw new Error(`Invalid timestamp format: ${sqliteTimestamp}`) } return timestamp } catch (error) { log.error(`[DB] Failed to parse SQLite timestamp: ${sqliteTimestamp}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'timestamp_parsing') scope.setExtra('sqliteTimestamp', sqliteTimestamp) sentry.captureException(error) }) // Return current timestamp as fallback return Date.now() } } // Transaction wrapper - ensures atomicity of database operations export async function withTransaction(operation: () => Promise): Promise { const db = getDatabase() const transactionId = Math.random().toString(36).slice(2, 10) try { log.debug(`[DB] Starting transaction ${transactionId}`) await db.execute('BEGIN TRANSACTION') const result = await operation() await db.execute('COMMIT') log.debug(`[DB] Transaction ${transactionId} committed successfully`) return result } catch (error) { log.error(`[DB] Transaction ${transactionId} failed:`, error) try { await db.execute('ROLLBACK') log.debug(`[DB] Transaction ${transactionId} rolled back`) } catch (rollbackError) { log.error(`[DB] Failed to rollback transaction ${transactionId}:`, rollbackError) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'transaction_rollback') scope.setExtra('transactionId', transactionId) sentry.captureException(rollbackError) }) } // Report transaction failures to Sentry for critical operations sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'transaction_failure') scope.setExtra('transactionId', transactionId) sentry.captureException(error) }) throw error } } // Cleanup processing files that may have been left from previous session async function cleanupProcessingFiles() { try { log.debug('[DB] Cleaning up processing files from previous session...') const result = await db.execute({ sql: 'UPDATE kb_file SET status = ?, processing_started_at = NULL WHERE status = ?', args: ['paused', 'processing'], }) const affectedRows = result.rowsAffected || 0 if (affectedRows > 0) { log.debug(`[DB] Set ${affectedRows} interrupted processing files to paused status (manual resume required)`) } } catch (err) { log.error('[DB] Failed to cleanup processing files:', err) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-db') scope.setTag('operation', 'cleanup_processing_files') sentry.captureException(err) }) } } // Check for timed out processing files and mark them as failed export async function checkProcessingTimeouts() { try { // Files processing for more than 5 minutes should be marked as failed const timeoutMinutes = 5 const timeoutThreshold = new Date(Date.now() - timeoutMinutes * 60 * 1000).toISOString() const db = getDatabase() // Find processing files that started before the timeout threshold const rs = await db.execute({ sql: `SELECT id, filename FROM kb_file WHERE status = 'processing' AND processing_started_at IS NOT NULL AND datetime(processing_started_at) < datetime(?)`, args: [timeoutThreshold], }) if (rs.rows.length > 0) { log.debug(`[DB] Found ${rs.rows.length} timed out processing files`) // Mark them as failed for (const file of rs.rows) { await db.execute({ sql: 'UPDATE kb_file SET status = ?, error = ?, processing_started_at = NULL WHERE id = ?', args: ['failed', `Processing timeout after ${timeoutMinutes} minutes`, file.id], }) log.debug(`[DB] Marked file as failed due to timeout: ${file.filename} (id=${file.id})`) } } } catch (err) { log.error('[DB] Failed to check processing timeouts:', err) } } ================================================ FILE: src/main/knowledge-base/file-loaders.ts ================================================ import { setTimeout } from 'node:timers/promises' import { MDocument } from '@mastra/rag' import { embedMany } from 'ai' import { ChatboxAIAPIError } from '../../shared/models/errors' import type { DocumentParserConfig } from '../../shared/types/settings' import { rerank } from '../../shared/models/rerank' import { sentry } from '../adapters/sentry' import { getLogger } from '../util' import { checkProcessingTimeouts, getDatabase, getVectorStore } from './db' import { getEmbeddingProvider, getRerankProvider } from './model-providers' import { getEffectiveParserConfig, parseFileWithRouter, type ParserFileMeta } from './parsers' const log = getLogger('knowledge-base:file-loaders') /** * Parse error message to extract user-friendly message * Handles JSON error responses from Chatbox AI API * Uses i18nKey from ChatboxAIAPIError.codeNameMap for known error codes */ function parseErrorMessage(errorMessage: string): string { // Try to extract error code from JSON error response // Format: "Status Code 500, {"error":{"code":"system_error","detail":"Server error...","status":500,"title":"Server Error"}}" try { // Find JSON part in the message const jsonMatch = errorMessage.match(/\{[\s\S]*\}/) if (jsonMatch) { const jsonStr = jsonMatch[0] const parsed = JSON.parse(jsonStr) const errorCode = parsed.error?.code // Try to get i18nKey from ChatboxAIAPIError.codeNameMap if (errorCode && ChatboxAIAPIError.codeNameMap[errorCode]) { return ChatboxAIAPIError.codeNameMap[errorCode].i18nKey } // Fallback to detail or title if (parsed.error?.detail) { return parsed.error.detail } if (parsed.error?.title) { return parsed.error.title } } } catch { // JSON parsing failed, return original message } return errorMessage } // Parse file to MDocument using the parser router async function parseFileToDocumentWithRouter( filePath: string, fileMeta: ParserFileMeta, kbId: number, parserConfig: DocumentParserConfig ): Promise<{ document: MDocument; parserUsed: string }> { log.info(`[FILE] Parsing ${fileMeta.filename} with ${parserConfig.type} parser`) const result = await parseFileWithRouter(filePath, fileMeta, parserConfig, kbId) log.info(`[FILE] Parse completed for ${fileMeta.filename}, parser used: ${result.parserUsed}`) // Convert content to MDocument based on content type const document = MDocument.fromText(result.content) return { document, parserUsed: result.parserUsed } } // Use mastra to parse, chunk, embed, and store files export async function processFileWithMastra( filePath: string, fileMeta: { fileId: number; filename: string; mimeType: string }, kbId: number, parserConfig: DocumentParserConfig ) { const startTime = Date.now() log.debug( `[FILE] Starting file processing: ${fileMeta.filename} (id=${fileMeta.fileId}, parser=${parserConfig.type})` ) try { const db = getDatabase() // Check current processing status and get processed chunk count const fileRecord = await db.execute('SELECT chunk_count, total_chunks, status FROM kb_file WHERE id = ?', [ fileMeta.fileId, ]) const currentChunkCount = (fileRecord.rows[0]?.chunk_count as number) || 0 const currentTotalChunks = (fileRecord.rows[0]?.total_chunks as number) || 0 // 1. Parse file using the parser router const parseResult = await parseFileToDocumentWithRouter(filePath, fileMeta, kbId, parserConfig) const doc = parseResult.document const parserUsed = parseResult.parserUsed // Update parser_type in database await db.execute({ sql: 'UPDATE kb_file SET parser_type = ? WHERE id = ?', args: [parserUsed, fileMeta.fileId], }) // 2. Chunking const allChunks = await doc.chunk({ strategy: 'recursive', size: 512, overlap: 50, }) if (!allChunks || allChunks.length === 0) { // Cloud parsing (chatbox-ai, mineru) resulted in 0 chunks - mark as done (truly empty file) // Local parsing resulted in 0 chunks - mark as failed so user can retry with server parsing if (parserConfig.type === 'chatbox-ai' || parserConfig.type === 'mineru') { await db.execute({ sql: 'UPDATE kb_file SET chunk_count = 0, status = ? WHERE id = ?', args: ['done', fileMeta.fileId], }) } else { throw new Error('No content extracted from file') } return } // Record total chunks if not already recorded if (currentTotalChunks === 0 || currentTotalChunks !== allChunks.length) { await db.execute({ sql: 'UPDATE kb_file SET total_chunks = ? WHERE id = ?', args: [allChunks.length, fileMeta.fileId], }) log.debug(`[FILE] Recorded total chunks: ${allChunks.length} for file ${fileMeta.fileId}`) } log.debug(`[FILE] Processing progress: ${currentChunkCount}/${allChunks.length} chunks already processed`) // 3. Check if processing is already complete if (currentChunkCount >= allChunks.length) { log.info(`[FILE] File already fully processed: ${fileMeta.filename} (id=${fileMeta.fileId})`) return } // 4. Get remaining chunks to process const remainingChunks = allChunks.slice(currentChunkCount) log.debug(`[FILE] Processing remaining ${remainingChunks.length} chunks from index ${currentChunkCount}`) // 5. If no remaining chunks, processing is complete if (remainingChunks.length === 0) { log.info(`[FILE] File processing already complete: ${fileMeta.filename} (id=${fileMeta.fileId})`) return } // 6. Process remaining chunks in batches const embeddingInstance = await getEmbeddingProvider(kbId) const vectorStore = getVectorStore() const indexName = `kb_${kbId}` const BATCH_SIZE = 50 // Process chunks in batches of 50 // Ensure vector index exists by getting dimension from first remaining chunk const firstEmbedding = await embedMany({ model: embeddingInstance, values: [`filename: ${fileMeta.filename}\nchunk:\n${remainingChunks[0].text}`], }) await vectorStore.createIndex({ indexName, dimension: firstEmbedding.embeddings[0].length }) for (let i = 0; i < remainingChunks.length; i += BATCH_SIZE) { // Check if file has been paused before processing each batch const statusCheck = await db.execute('SELECT status FROM kb_file WHERE id = ?', [fileMeta.fileId]) const currentStatus = statusCheck.rows[0]?.status as string if (currentStatus === 'paused') { log.info(`[FILE] File processing paused by user: ${fileMeta.filename} (id=${fileMeta.fileId})`) return } const batchChunks = remainingChunks.slice(i, i + BATCH_SIZE) const batchTexts = batchChunks.map((chunk: any) => `filename: ${fileMeta.filename}\nchunk:\n${chunk.text}`) const batchNumber = Math.floor(i / BATCH_SIZE) + 1 const totalBatches = Math.ceil(remainingChunks.length / BATCH_SIZE) log.debug(`[FILE] Processing batch ${batchNumber}/${totalBatches}, chunks: ${batchTexts.length}`) // Generate embeddings for this batch const embeddingResult = await embedMany({ model: embeddingInstance, values: batchTexts, }) if (!embeddingResult.embeddings || embeddingResult.embeddings.length !== batchTexts.length) { throw new Error( `Embedding batch failed: expected ${batchTexts.length}, got ${embeddingResult.embeddings?.length || 0}` ) } // Store vectors for this batch log.debug(`[FILE] Storing batch ${batchNumber}/${totalBatches} to vector store`) await vectorStore.upsert({ indexName, vectors: embeddingResult.embeddings, metadata: batchChunks.map((chunk: any, chunkIndex: number) => ({ text: chunk.text, fileId: fileMeta.fileId, filename: fileMeta.filename, mimeType: fileMeta.mimeType, chunkIndex: currentChunkCount + i + chunkIndex, // Use absolute chunk index })), }) // Update processed chunk count in database const newChunkCount = currentChunkCount + i + batchChunks.length await db.execute({ sql: 'UPDATE kb_file SET chunk_count = ? WHERE id = ?', args: [newChunkCount, fileMeta.fileId], }) log.debug(`[FILE] Updated chunk count to ${newChunkCount} for file ${fileMeta.fileId}`) // Small delay between batches to avoid overwhelming the API if (i + BATCH_SIZE < remainingChunks.length) { await setTimeout(100) // 100ms delay between batches } } const duration = Date.now() - startTime log.info( `[FILE] File processed successfully: ${fileMeta.filename} (id=${fileMeta.fileId}), total chunks: ${allChunks.length}, duration: ${duration}ms` ) // Mark as done and clear processing timestamp await db.execute({ sql: 'UPDATE kb_file SET status = ?, processing_started_at = NULL WHERE id = ?', args: ['done', fileMeta.fileId], }) } catch (error: any) { const duration = Date.now() - startTime log.error(`[FILE] File processing failed after ${duration}ms: ${fileMeta.filename} (id=${fileMeta.fileId})`, error) // Determine the operation type based on error message for better debugging let operation = 'file_processing' if (error.message.includes('parse')) { operation = 'file_parsing' } else if (error.message.includes('chunk')) { operation = 'document_chunking' } else if (error.message.includes('embedding')) { operation = 'generate_embeddings' } else if (error.message.includes('store') || error.message.includes('vector')) { operation = 'vector_storage' } else if (error.message.includes('vision') || error.message.includes('OCR') || error.message.includes('image')) { operation = 'image_ocr_processing' } // Report processing failures to Sentry with unified context sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', operation) scope.setExtra('fileId', fileMeta.fileId) scope.setExtra('filename', fileMeta.filename) scope.setExtra('mimeType', fileMeta.mimeType) scope.setExtra('kbId', kbId) scope.setExtra('duration', duration) scope.setExtra('filePath', filePath) sentry.captureException(error) }) throw error } } async function processPendingFiles() { try { // First check for timed out processing files await checkProcessingTimeouts() const db = getDatabase() // Query pending files with their KB's parser config const rs = await db.execute( ` SELECT f.*, kb.document_parser as kb_document_parser FROM kb_file f JOIN knowledge_base kb ON f.kb_id = kb.id WHERE f.status = ? `, ['pending'] ) if (rs.rows.length === 0) { return } log.debug(`[FILE] Processing ${rs.rows.length} pending files`) for (const file of rs.rows) { const useRemoteParsing = Boolean(file.use_remote_parsing) // Parse KB parser config let kbParserConfig: DocumentParserConfig | undefined if (file.kb_document_parser) { try { kbParserConfig = JSON.parse(file.kb_document_parser as string) } catch { log.warn(`[FILE] Failed to parse KB document_parser config for file ${file.id}`) } } // Get effective parser config // When useRemoteParsing is true (user clicked "Retry with server parsing"), force use Chatbox AI parser // This overrides the KB's configured parser to ensure server parsing is used const effectiveParserConfig: DocumentParserConfig = useRemoteParsing ? { type: 'chatbox-ai' } : getEffectiveParserConfig(kbParserConfig) try { log.debug( `[FILE] Processing file: ${file.filename} (id=${file.id}, parser=${effectiveParserConfig.type}, useRemoteParsing=${useRemoteParsing})` ) // Mark as processing, record the processing start time, save parsing method and parser_type, and clear the use_remote_parsing flag // We set parser_type here at the start so that if parsing fails, the error message will correctly show which parser was used await db.execute({ sql: 'UPDATE kb_file SET status = ?, processing_started_at = CURRENT_TIMESTAMP, use_remote_parsing = 0, parsed_remotely = ?, parser_type = ? WHERE id = ?', args: ['processing', useRemoteParsing ? 1 : 0, effectiveParserConfig.type, file.id], }) // Use mastra to parse, chunk, embed, and store (supports resuming from chunk_count) await processFileWithMastra( file.filepath as string, { fileId: file.id as number, filename: file.filename as string, mimeType: file.mime_type as string }, file.kb_id as number, effectiveParserConfig ) } catch (err: any) { log.error(`[FILE] File processing failed: ${file.filename} (id=${file.id})`, err) // Mark as failed - parse error message to extract user-friendly message const rawErrorMessage = err instanceof Error ? err.message : String(err) const errorMessage = parseErrorMessage(rawErrorMessage) await db.execute({ sql: 'UPDATE kb_file SET status = ?, error = ?, processing_started_at = NULL WHERE id = ?', args: ['failed', errorMessage, file.id], }) // Report individual file processing failures sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', 'individual_file_processing') scope.setExtra('fileId', file.id) scope.setExtra('filename', file.filename) scope.setExtra('kbId', file.kb_id) scope.setExtra('parserType', effectiveParserConfig.type) sentry.captureException(err) }) } } } catch (error: any) { log.error('[FILE] Failed to process pending files:', error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', 'process_pending_files') sentry.captureException(error) }) } } // Periodic polling export async function startWorkerLoop() { log.info('[FILE] Starting worker loop') while (true) { try { await processPendingFiles() } catch (e: any) { log.error('[FILE] Worker loop error:', e) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', 'worker_loop') sentry.captureException(e) }) // Wait before retrying to prevent rapid error loops await setTimeout(10000) // 10 seconds } await setTimeout(3000) // Poll every 3 seconds } } // Search interface, embeddingProvider parameter is required export async function searchKnowledgeBase(kbId: number, query: string) { try { log.debug(`[FILE] Searching knowledge base: kbId=${kbId}, query=${query}`) const embeddingInstance = await getEmbeddingProvider(kbId) const embedding = await embedMany({ model: embeddingInstance, values: [query], }) const vectorStore = getVectorStore() const indexName = `kb_${kbId}` const results = await vectorStore.query({ indexName, queryVector: embedding.embeddings[0], topK: 20, }) try { const rerankInstance = await getRerankProvider(kbId) if (rerankInstance) { const rerankedResults = await rerank(results, query, rerankInstance, { topK: 5, }) return rerankedResults.map((r) => ({ id: r.result.id, score: r.result.score, ...r.result.metadata, })) } return results.map((r) => ({ id: r.id, score: r.score, ...r.metadata, })) } catch (e) { log.error(`[FILE] Failed to rerank: kbId=${kbId}, query=${query}`, e) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', 'rerank') scope.setExtra('kbId', kbId) scope.setExtra('query', query) sentry.captureException(e) }) return results.map((r) => ({ id: r.id, score: r.score, ...r.metadata, })) } } catch (e) { log.error(`[FILE] Failed to search: kbId=${kbId}, query=${query}`, e) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', 'search_knowledge_base') scope.setExtra('kbId', kbId) scope.setExtra('query', query) sentry.captureException(e) }) // TODO: user friendly error message throw e } } // Read chunks from vector store export async function readChunks(kbId: number, chunks: { fileId: number; chunkIndex: number }[]) { try { log.debug(`[FILE] Reading chunks: kbId=${kbId}, chunks=${chunks.length}`) if (!chunks || chunks.length === 0) { return [] } const indexName = `kb_${kbId}` const results: any[] = [] // Use single SQL query to get all chunks at once log.debug(`[FILE] Using single SQL query via vectorStore.turso for ${chunks.length} chunks`) const vectorStore = getVectorStore() // Build composite IN condition to avoid SQLite's 999 variable limit const valuePlaceholders = chunks.map(() => '(?,?)').join(',') const condition = `(json_extract(metadata, '$.fileId'), json_extract(metadata, '$.chunkIndex')) IN (${valuePlaceholders})` // Flatten chunk parameters for the query const args = chunks.flatMap((c) => [c.fileId, c.chunkIndex]) const sql = `SELECT metadata FROM ${indexName} WHERE ${condition}` log.debug(`[FILE] Executing SQL: ${sql}`) log.debug(`[FILE] With args:`, args) const queryResult = await (vectorStore as any).turso.execute({ sql, args, }) log.debug(`[FILE] Single SQL query returned ${queryResult.rows.length} results`) // Parse results and maintain the order requested by chunks array const foundChunks = queryResult.rows.map((row: any) => { const metadata = JSON.parse(row.metadata as string) return { fileId: metadata.fileId, filename: metadata.filename, chunkIndex: metadata.chunkIndex, text: metadata.text, } }) // Maintain the order of the requested chunks for (const chunk of chunks) { const found = foundChunks.find( (fc: any) => Number(fc.fileId) === Number(chunk.fileId) && Number(fc.chunkIndex) === Number(chunk.chunkIndex) ) if (found) { results.push(found) } } return results } catch (sqlErr: any) { log.error(`[FILE] Single SQL query failed:`, sqlErr) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-file') scope.setTag('operation', 'read_chunks') scope.setExtra('kbId', kbId) scope.setExtra('chunkCount', chunks.length) sentry.captureException(sqlErr) }) throw sqlErr } } ================================================ FILE: src/main/knowledge-base/index.ts ================================================ import { sentry } from '../adapters/sentry' import { getLogger } from '../util' import { initializeDatabase } from './db' import { startWorkerLoop } from './file-loaders' import { registerKnowledgeBaseHandlers } from './ipc-handlers' const log = getLogger('knowledge-base:index') let initPromise: Promise | null = null async function initializeKnowledgeBase() { const startTime = Date.now() log.info('[KB] Initializing knowledge base system...') try { // Register IPC handlers registerKnowledgeBaseHandlers() log.debug('[KB] IPC handlers registered') // Initialize database and vector store await initializeDatabase() log.debug('[KB] Database initialized') // Start background file processing worker startWorkerLoop() log.debug('[KB] Worker loop started') const duration = Date.now() - startTime log.info(`[KB] Knowledge base system initialized successfully in ${duration}ms`) } catch (error) { const duration = Date.now() - startTime log.error(`[KB] Failed to initialize knowledge base system after ${duration}ms:`, error) // Report critical initialization errors to Sentry sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base') scope.setTag('operation', 'initialization') scope.setExtra('duration', duration) scope.setExtra('error_type', 'initialization_failure') sentry.captureException(error) }) throw error } } export function getInitPromise() { if (!initPromise) { initPromise = initializeKnowledgeBase() } return initPromise } // Auto-initialize when module is imported with error handling getInitPromise().catch((error) => { log.error('[KB] Knowledge base auto-initialization failed:', error) // Don't rethrow here to avoid unhandled promise rejection }) // Re-export public APIs for external use export { getDatabase, getVectorStore, parseSQLiteTimestamp, withTransaction } from './db' export { readChunks, searchKnowledgeBase } from './file-loaders' export { getEmbeddingProvider, getRerankProvider, getVisionProvider } from './model-providers' ================================================ FILE: src/main/knowledge-base/ipc-handlers.ts ================================================ import { ipcMain } from 'electron' import type { FileMeta } from 'src/shared/types' import { sentry } from '../adapters/sentry' import { getLogger } from '../util' import { getDatabase, getVectorStore, parseSQLiteTimestamp, withTransaction } from './db' import { readChunks, searchKnowledgeBase } from './file-loaders' import { MineruParser, testMineruConnection } from './parsers' const log = getLogger('knowledge-base:ipc-handlers') // Store active MinerU parsing tasks for cancellation support // Key: filePath, Value: AbortController const activeMineruParseTasks = new Map() // Register knowledge base related APIs export function registerKnowledgeBaseHandlers() { // Knowledge Base CRUD operations ipcMain.handle('kb:list', async () => { try { log.debug('ipcMain: kb:list') const db = getDatabase() const rs = await db.execute('SELECT * FROM knowledge_base') return rs.rows.map((row) => ({ id: row.id, name: row.name, embeddingModel: row.embedding_model, rerankModel: row.rerank_model, visionModel: row.vision_model, providerMode: row.provider_mode || undefined, documentParser: row.document_parser ? JSON.parse(row.document_parser as string) : undefined, createdAt: row.created_at, })) } catch (error: any) { log.error('ipcMain: kb:list failed', error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'kb_list') sentry.captureException(error) }) throw error } }) ipcMain.handle( 'kb:create', async ( _event, { name, embeddingModel, rerankModel, visionModel, documentParser, providerMode, }: { name: string embeddingModel: string rerankModel: string visionModel?: string documentParser?: { type: string; mineru?: { apiToken: string } } providerMode?: 'chatbox-ai' | 'custom' } ) => { try { log.info( `ipcMain: kb:create, name=${name}, embeddingModel=${embeddingModel}, rerankModel=${rerankModel}, visionModel=${visionModel}, documentParser=${documentParser?.type || 'default'}, providerMode=${providerMode || 'not specified'}` ) // Validate required fields if (!name || !name.trim()) { throw new Error('Knowledge base name is required') } if (!embeddingModel || !embeddingModel.trim()) { throw new Error('Embedding model is required') } const db = getDatabase() const documentParserJson = documentParser ? JSON.stringify(documentParser) : null const rs = await db.execute({ sql: 'INSERT INTO knowledge_base (name, embedding_model, rerank_model, vision_model, document_parser, provider_mode) VALUES (?, ?, ?, ?, ?, ?)', args: [ name.trim(), embeddingModel, rerankModel || null, visionModel || null, documentParserJson, providerMode || null, ], }) const id = rs.lastInsertRowid if (!id) { throw new Error('Failed to create knowledge base') } log.info(`[IPC] Knowledge base created successfully: id=${id}, name=${name}`) return { id, name: name.trim() } } catch (error: any) { log.error(`ipcMain: kb:create failed for name=${name}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'kb_create') scope.setExtra('name', name) scope.setExtra('embeddingModel', embeddingModel) scope.setExtra('rerankModel', rerankModel) scope.setExtra('visionModel', visionModel) scope.setExtra('documentParser', documentParser?.type) sentry.captureException(error) }) throw error } } ) ipcMain.handle( 'kb:update', async ( _event, { id, name, rerankModel, visionModel }: { id: number; name?: string; rerankModel?: string; visionModel?: string } ) => { try { log.info(`ipcMain: kb:update, id=${id}, name=${name}, rerankModel=${rerankModel}, visionModel=${visionModel}`) if (!id || id <= 0) { throw new Error('Invalid knowledge base ID') } if (!name && rerankModel === undefined && visionModel === undefined) { return 0 } const db = getDatabase() let sql = 'UPDATE knowledge_base SET ' const args: (string | number)[] = [] if (name !== undefined) { if (!name.trim()) { throw new Error('Knowledge base name cannot be empty') } sql += 'name = ?' args.push(name.trim()) } if (rerankModel !== undefined) { if (args.length > 0) sql += ', ' sql += 'rerank_model = ?' args.push(rerankModel ?? '') } if (visionModel !== undefined) { if (args.length > 0) sql += ', ' sql += 'vision_model = ?' args.push(visionModel ?? '') } sql += ' WHERE id = ?' args.push(id) const rs = await db.execute(sql, args) log.info(`[IPC] Knowledge base updated: id=${id}, affected rows=${rs.rowsAffected ?? 'unknown'}`) return rs.rowsAffected } catch (error: any) { log.error(`ipcMain: kb:update failed for id=${id}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'kb_update') scope.setExtra('kbId', id) scope.setExtra('name', name) scope.setExtra('rerankModel', rerankModel) scope.setExtra('visionModel', visionModel) sentry.captureException(error) }) throw error } } ) ipcMain.handle('kb:delete', async (_event, kbId: number): Promise<{ success: boolean; error?: string }> => { try { log.info(`ipcMain: kb:delete, kbId=${kbId}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } await withTransaction(async () => { const db = getDatabase() const vectorStore = getVectorStore() // Verify knowledge base exists before deletion const kbExists = await db.execute('SELECT id FROM knowledge_base WHERE id = ?', [kbId]) if (!kbExists.rows[0]) { throw new Error(`Knowledge base ${kbId} not found`) } // 1. Delete associated files from kb_file await db.execute({ sql: 'DELETE FROM kb_file WHERE kb_id = ?', args: [kbId], }) log.info(`[IPC] Deleted file records for kbId=${kbId}`) // 2. Delete the knowledge base entry await db.execute({ sql: 'DELETE FROM knowledge_base WHERE id = ?', args: [kbId], }) log.info(`[IPC] Deleted knowledge base record for kbId=${kbId}`) // 3. Delete vector index await vectorStore.deleteIndex({ indexName: `kb_${kbId}` }) log.info(`[IPC] Deleted vector index for kbId=${kbId}`) }) return { success: true } } catch (error: any) { log.error(`ipcMain: kb:delete failed for kbId=${kbId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'kb_delete') scope.setExtra('kbId', kbId) sentry.captureException(error) }) return { success: false, error: error.message } } }) // File management operations ipcMain.handle('kb:file:list', async (_event, kbId: number) => { try { log.debug(`ipcMain: kb:file:list, kbId=${kbId}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } const db = getDatabase() const rs = await db.execute({ sql: 'SELECT * FROM kb_file WHERE kb_id = ?', args: [kbId], }) return rs.rows.map((row) => ({ id: row.id, kb_id: row.kb_id, filename: row.filename, filepath: row.filepath, mime_type: row.mime_type, file_size: row.file_size || 0, chunk_count: row.chunk_count || 0, total_chunks: row.total_chunks || 0, status: row.status, error: row.error, createdAt: parseSQLiteTimestamp(row.created_at as string), parsed_remotely: row.parsed_remotely || 0, parser_type: row.parser_type || 'local', })) } catch (error: any) { log.error(`ipcMain: kb:file:list failed for kbId=${kbId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_list') scope.setExtra('kbId', kbId) sentry.captureException(error) }) throw error } }) ipcMain.handle('kb:file:count', async (_event, kbId: number) => { try { // log.debug(`ipcMain: kb:file:count, kbId=${kbId}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } const db = getDatabase() const rs = await db.execute({ sql: 'SELECT COUNT(*) as count FROM kb_file WHERE kb_id = ?', args: [kbId], }) return rs.rows[0].count as number } catch (error: any) { log.error(`ipcMain: kb:file:count failed for kbId=${kbId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_count') scope.setExtra('kbId', kbId) sentry.captureException(error) }) throw error } }) ipcMain.handle('kb:file:list-paginated', async (_event, kbId: number, offset = 0, limit = 20) => { try { // log.debug(`ipcMain: kb:file:list-paginated, kbId=${kbId}, offset=${offset}, limit=${limit}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } if (offset < 0 || limit <= 0 || limit > 100) { throw new Error('Invalid pagination parameters') } const db = getDatabase() const rs = await db.execute({ sql: 'SELECT * FROM kb_file WHERE kb_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', args: [kbId, limit, offset], }) return rs.rows.map((row) => ({ id: row.id, kb_id: row.kb_id, filename: row.filename, filepath: row.filepath, mime_type: row.mime_type, file_size: row.file_size || 0, chunk_count: row.chunk_count || 0, total_chunks: row.total_chunks || 0, status: row.status, error: row.error, createdAt: parseSQLiteTimestamp(row.created_at as string), parsed_remotely: row.parsed_remotely || 0, parser_type: row.parser_type || 'local', })) } catch (error: any) { log.error(`ipcMain: kb:file:list-paginated failed for kbId=${kbId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_list_paginated') scope.setExtra('kbId', kbId) scope.setExtra('offset', offset) scope.setExtra('limit', limit) sentry.captureException(error) }) throw error } }) ipcMain.handle('kb:file:get-metas', async (_event, kbId: number, fileIds: number[]) => { try { log.debug(`ipcMain: kb:file:get-metas, kbId=${kbId}, fileIds=${fileIds.join(',')}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } if (!fileIds || fileIds.length === 0) { return [] } if (fileIds.length > 100) { throw new Error('Too many file IDs requested (max 100)') } const db = getDatabase() const placeholders = fileIds.map(() => '?').join(',') const sql = `SELECT id, kb_id, filename, mime_type, file_size, chunk_count, total_chunks, status, created_at FROM kb_file WHERE kb_id = ? AND id IN (${placeholders})` const rs = await db.execute({ sql, args: [kbId, ...fileIds], }) return rs.rows.map((row) => ({ id: row.id, kbId: row.kb_id, filename: row.filename, mimeType: row.mime_type, fileSize: row.file_size || 0, chunkCount: row.chunk_count || 0, totalChunks: row.total_chunks || 0, status: row.status, createdAt: parseSQLiteTimestamp(row.created_at as string), })) } catch (error: any) { log.error(`ipcMain: kb:file:get-metas failed for kbId=${kbId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_get_metas') scope.setExtra('kbId', kbId) scope.setExtra('fileIdsCount', fileIds?.length || 0) sentry.captureException(error) }) throw error } }) ipcMain.handle( 'kb:file:read-chunks', async (_event, kbId: number, chunks: { fileId: number; chunkIndex: number }[]) => { try { log.debug(`ipcMain: kb:file:read-chunks, kbId=${kbId}, chunks=${chunks.length}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } if (!chunks || !Array.isArray(chunks)) { throw new Error('Invalid chunks parameter') } if (chunks.length > 200) { throw new Error('Too many chunks requested (max 200)') } return await readChunks(kbId, chunks) } catch (error: any) { log.error(`ipcMain: kb:file:read-chunks failed for kbId=${kbId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_read_chunks') scope.setExtra('kbId', kbId) scope.setExtra('chunksCount', chunks?.length || 0) sentry.captureException(error) }) throw error } } ) // File upload and create task, embeddingProvider parameter is required ipcMain.handle('kb:file:upload', async (_event, kbId: number, file: FileMeta): Promise<{ id: number }> => { try { log.debug(`ipcMain: kb:file:upload, kbId=${kbId}, file=${JSON.stringify(file)}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } if (!file || !file.name || !file.path || !file.type) { throw new Error('Invalid file metadata') } if (file.size < 0 || file.size > 100 * 1024 * 1024) { // 100MB limit throw new Error('Invalid file size') } const db = getDatabase() // Verify knowledge base exists const kbExists = await db.execute('SELECT id FROM knowledge_base WHERE id = ?', [kbId]) if (!kbExists.rows[0]) { throw new Error(`Knowledge base ${kbId} not found`) } // 1. Create file record in database (status: pending) log.info( `[IPC] Creating file record: kbId=${kbId}, filename=${file.name}, filepath=${file.path}, mimeType=${file.type}, size=${file.size}` ) const rs = await db.execute({ sql: 'INSERT INTO kb_file (kb_id, filename, filepath, mime_type, file_size) VALUES (?, ?, ?, ?, ?)', args: [kbId, file.name, file.path, file.type, file.size], }) const id = rs.lastInsertRowid if (!id) { throw new Error('File upload failed - no ID returned') } log.info(`[IPC] File created: id=${id}, kbId=${kbId}, filename=${file.name}`) return { id: Number(id), } } catch (error: any) { log.error(`ipcMain: kb:file:upload failed for kbId=${kbId}, filename=${file?.name}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_upload') scope.setExtra('kbId', kbId) scope.setExtra('filename', file?.name) scope.setExtra('fileSize', file?.size) scope.setExtra('mimeType', file?.type) sentry.captureException(error) }) throw error } }) // Search interface, embeddingProvider parameter is required ipcMain.handle('kb:search', async (_event, kbId: number, query: string) => { try { log.debug(`ipcMain: kb:search, kbId=${kbId}, query=${query}`) if (!kbId || kbId <= 0) { throw new Error('Invalid knowledge base ID') } if (!query || !query.trim()) { throw new Error('Search query is required') } if (query.length > 1000) { throw new Error('Search query too long (max 1000 characters)') } return await searchKnowledgeBase(kbId, query.trim()) } catch (error: any) { log.error(`ipcMain: kb:search failed for kbId=${kbId}, query=${query}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'search') scope.setExtra('kbId', kbId) scope.setExtra('queryLength', query?.length || 0) sentry.captureException(error) }) throw error } }) // Retry failed files ipcMain.handle('kb:file:retry', async (_event, fileId: number, useRemoteParsing = false) => { try { log.debug(`ipcMain: kb:file:retry, fileId=${fileId}, useRemoteParsing=${useRemoteParsing}`) if (!fileId || fileId <= 0) { throw new Error('Invalid file ID') } const db = getDatabase() // Check if file exists and is in failed state const rs = await db.execute({ sql: 'SELECT * FROM kb_file WHERE id = ?', args: [fileId], }) const file = rs.rows[0] if (!file) { throw new Error('File not found') } if (file.status !== 'failed') { throw new Error('Only failed files can be retried') } // Reset file status to pending for reprocessing, also set use_remote_parsing flag await db.execute({ sql: 'UPDATE kb_file SET status = ?, error = NULL, chunk_count = 0, total_chunks = 0, processing_started_at = NULL, use_remote_parsing = ? WHERE id = ?', args: ['pending', useRemoteParsing ? 1 : 0, fileId], }) log.info( `[IPC] File retry request created: ${file.filename} (id=${fileId}, useRemoteParsing=${useRemoteParsing})` ) return { success: true } } catch (error: any) { log.error(`ipcMain: kb:file:retry failed for fileId=${fileId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_retry') scope.setExtra('fileId', fileId) scope.setExtra('useRemoteParsing', useRemoteParsing) sentry.captureException(error) }) throw error } }) // Pause processing file ipcMain.handle('kb:file:pause', async (_event, fileId: number) => { try { log.debug(`ipcMain: kb:file:pause, fileId=${fileId}`) if (!fileId || fileId <= 0) { throw new Error('Invalid file ID') } const db = getDatabase() // Check if file exists and is processing const rs = await db.execute({ sql: 'SELECT * FROM kb_file WHERE id = ?', args: [fileId], }) const file = rs.rows[0] if (!file) { throw new Error('File not found') } if (file.status !== 'processing') { throw new Error('Only processing files can be paused') } // Set file status to paused await db.execute({ sql: 'UPDATE kb_file SET status = ?, processing_started_at = NULL WHERE id = ?', args: ['paused', fileId], }) log.info(`[IPC] File paused: ${file.filename} (id=${fileId})`) return { success: true } } catch (error: any) { log.error(`ipcMain: kb:file:pause failed for fileId=${fileId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_pause') scope.setExtra('fileId', fileId) sentry.captureException(error) }) throw error } }) // Resume paused file ipcMain.handle('kb:file:resume', async (_event, fileId: number) => { try { log.debug(`ipcMain: kb:file:resume, fileId=${fileId}`) if (!fileId || fileId <= 0) { throw new Error('Invalid file ID') } const db = getDatabase() // Check if file exists and is paused const rs = await db.execute({ sql: 'SELECT * FROM kb_file WHERE id = ?', args: [fileId], }) const file = rs.rows[0] if (!file) { throw new Error('File not found') } if (file.status !== 'paused') { throw new Error('Only paused files can be resumed') } // Set file status to pending for processing await db.execute({ sql: 'UPDATE kb_file SET status = ?, error = NULL WHERE id = ?', args: ['pending', fileId], }) log.info(`[IPC] File resume request created: ${file.filename} (id=${fileId})`) return { success: true } } catch (error: any) { log.error(`ipcMain: kb:file:resume failed for fileId=${fileId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_resume') scope.setExtra('fileId', fileId) sentry.captureException(error) }) throw error } }) // Delete file and its embeddings ipcMain.handle('kb:file:delete', async (_event, fileId: number) => { try { log.debug(`ipcMain: kb:file:delete, fileId=${fileId}`) if (!fileId || fileId <= 0) { throw new Error('Invalid file ID') } return withTransaction(async () => { const db = getDatabase() const vectorStore = getVectorStore() // Find file information const rs = await db.execute({ sql: 'SELECT * FROM kb_file WHERE id = ?', args: [fileId], }) const file = rs.rows[0] if (!file) { throw new Error('File not found') } const indexName = `kb_${file.kb_id}` // Delete embedding data - use vectorStore.turso for direct operation log.info(`[IPC] Deleting vectors: fileId=${fileId}, indexName=${indexName}`) try { // First query the number of vectors to delete const countResult = await (vectorStore as any).turso.execute({ sql: `SELECT COUNT(*) as count FROM ${indexName} WHERE json_extract(metadata, '$.fileId') = ?`, args: [fileId], }) const vectorCount = Number(countResult.rows[0]?.count || 0) log.info(`[IPC] Found ${vectorCount} vectors to delete`) if (vectorCount > 0) { // Delete vector data const deleteResult = await (vectorStore as any).turso.execute({ sql: `DELETE FROM ${indexName} WHERE json_extract(metadata, '$.fileId') = ?`, args: [fileId], }) const rowsDeleted = Number(deleteResult.rowsAffected || 0) log.info(`[IPC] Deleted ${rowsDeleted} vectors`) } else { log.info(`[IPC] No vectors to delete`) } } catch (vectorDeleteErr: any) { log.error(`[IPC] Failed to delete vectors: fileId=${fileId}`, vectorDeleteErr) // Continue with file record deletion even if vector deletion fails sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_delete_vectors') scope.setExtra('fileId', fileId) scope.setExtra('indexName', indexName) sentry.captureException(vectorDeleteErr) }) } // Delete file record const res = await db.execute({ sql: 'DELETE FROM kb_file WHERE id = ?', args: [fileId], }) log.info(`[IPC] Deleted file record: fileId=${fileId}, affected rows=${res.rowsAffected ?? 'unknown'}`) return { success: true } }) } catch (error: any) { log.error(`ipcMain: kb:file:delete failed for fileId=${fileId}`, error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'file_delete') scope.setExtra('fileId', fileId) sentry.captureException(error) }) return { success: false, error: error.message } } }) // Parser-related handlers ipcMain.handle('parser:test-mineru', async (_event, apiToken: string) => { try { log.debug('ipcMain: parser:test-mineru') if (!apiToken || !apiToken.trim()) { return { success: false, error: 'API token is required' } } return await testMineruConnection(apiToken.trim()) } catch (error: any) { log.error('ipcMain: parser:test-mineru failed', error) return { success: false, error: error.message } } }) // Parse file with MinerU (for InputBox file attachments) ipcMain.handle( 'parser:parse-file-with-mineru', async ( _event, params: { filePath: string filename: string mimeType: string apiToken: string } ): Promise<{ success: boolean; content?: string; error?: string; cancelled?: boolean }> => { const { filePath, filename, mimeType, apiToken } = params try { log.info(`ipcMain: parser:parse-file-with-mineru, filename=${filename}, mimeType=${mimeType}`) if (!filePath || !filePath.trim()) { return { success: false, error: 'File path is required' } } if (!apiToken || !apiToken.trim()) { return { success: false, error: 'API token is required' } } // Create AbortController for this task const abortController = new AbortController() activeMineruParseTasks.set(filePath, abortController) try { // Create MinerU parser instance const parser = new MineruParser(apiToken.trim()) // Parse file (will poll for up to 5 minutes) const content = await parser.parse( filePath, { fileId: Date.now(), // Temporary ID for this parsing session filename, mimeType, }, abortController.signal ) log.info(`ipcMain: parser:parse-file-with-mineru completed, content length=${content.length}`) return { success: true, content } } finally { // Clean up the task from the map activeMineruParseTasks.delete(filePath) } } catch (error: any) { // Check if this was a cancellation if (error.code === 'CANCELLED' || error.name === 'AbortError') { log.info(`ipcMain: parser:parse-file-with-mineru cancelled, filename=${filename}`) return { success: false, cancelled: true, error: 'Operation cancelled' } } log.error('ipcMain: parser:parse-file-with-mineru failed', error) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-ipc') scope.setTag('operation', 'parse_file_with_mineru') scope.setExtra('filename', params?.filename) scope.setExtra('mimeType', params?.mimeType) sentry.captureException(error) }) return { success: false, error: error.message } } } ) // Cancel MinerU parsing task ipcMain.handle('parser:cancel-mineru-parse', async (_event, filePath: string) => { try { log.info(`ipcMain: parser:cancel-mineru-parse, filePath=${filePath}`) const controller = activeMineruParseTasks.get(filePath) if (controller) { controller.abort() activeMineruParseTasks.delete(filePath) log.info(`ipcMain: parser:cancel-mineru-parse succeeded, filePath=${filePath}`) return { success: true } } log.debug(`ipcMain: parser:cancel-mineru-parse - no active task found for filePath=${filePath}`) return { success: true } // No task to cancel is also success } catch (error: any) { log.error('ipcMain: parser:cancel-mineru-parse failed', error) return { success: false, error: error.message } } }) } ================================================ FILE: src/main/knowledge-base/model-providers.ts ================================================ import { CohereClient } from 'cohere-ai' import { getModel, getProviderSettings } from '../../shared/models' import { getChatboxAPIOrigin } from '../../shared/request/chatboxai_pool' import { SessionSettingsSchema } from '../../shared/types' import { parseKnowledgeBaseModelString } from '../../shared/utils/knowledge-base-model-parser' import { createModelDependencies } from '../adapters' import { sentry } from '../adapters/sentry' import { cache } from '../cache' import { getConfig, getSettings, store } from '../store-node' import { getLogger } from '../util' import { getDatabase } from './db' const log = getLogger('knowledge-base:model-providers') function getMergedSettings(providerId: string, modelId: string) { try { const globalSettings = getSettings() const providerEntry = Object.entries(globalSettings.providers ?? {}).find(([key, value]) => key === providerId) if (!providerEntry) { const error = new Error(`provider ${providerId} not set`) log.error(`[MODEL] Provider not configured: ${providerId}`) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'provider_configuration') scope.setExtra('providerId', providerId) scope.setExtra('modelId', modelId) sentry.captureException(error) }) throw error } // Build complete settings object for getModel return SessionSettingsSchema.parse({ ...globalSettings, provider: providerId, modelId, }) } catch (error: any) { log.error(`[MODEL] Failed to get merged settings for ${providerId}:${modelId}`, error) if (!error.message.includes('not set')) { sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_merged_settings') scope.setExtra('providerId', providerId) scope.setExtra('modelId', modelId) sentry.captureException(error) }) } throw error } } export async function getEmbeddingProvider(kbId: number) { return cache( `kb:embedding:${kbId}`, async () => { try { const db = getDatabase() const rs = await db.execute('SELECT * FROM knowledge_base WHERE id = ?', [kbId]) if (!rs.rows[0]) { const error = new Error(`Knowledge base ${kbId} not found`) log.error(`[MODEL] Knowledge base not found: ${kbId}`) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_embedding_provider') scope.setExtra('kbId', kbId) sentry.captureException(error) }) throw error } const embeddingModel = rs.rows[0].embedding_model as string if (!embeddingModel) { log.error(`kb:embedding:${kbId} embeddingModel not set`) const error = new Error('embeddingModel not set') sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_embedding_provider') scope.setExtra('kbId', kbId) scope.setExtra('error_type', 'missing_embedding_model') sentry.captureException(error) }) throw error } const parsed = parseKnowledgeBaseModelString(embeddingModel) if (!parsed) { const error = new Error(`Invalid embedding model format: ${embeddingModel}`) log.error(`[MODEL] Invalid embedding model format: ${embeddingModel}`) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_embedding_provider') scope.setExtra('kbId', kbId) scope.setExtra('embeddingModel', embeddingModel) sentry.captureException(error) }) throw error } const { providerId, modelId } = parsed const modelSettings = getMergedSettings(providerId, modelId) const model = getModel(modelSettings, getSettings(), getConfig(), await createModelDependencies()) // Force cast to AbstractAISDKModel to access getTextEmbeddingModel method return (model as any).getTextEmbeddingModel({}) } catch (error: any) { log.error(`[MODEL] Failed to get embedding provider for kb ${kbId}:`, error) // Only report unexpected errors to Sentry (not configuration errors) if (!error.message.includes('not set') && !error.message.includes('not found')) { sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_embedding_provider') scope.setExtra('kbId', kbId) sentry.captureException(error) }) } throw error } }, { ttl: 1000 * 60, // 1 minute } ) } // Return vision model and its dependencies, constructed with getModel export async function getVisionProvider(kbId: number) { return cache( `kb:vision:${kbId}`, async () => { try { const db = getDatabase() const rs = await db.execute('SELECT * FROM knowledge_base WHERE id = ?', [kbId]) if (!rs.rows[0]) { const error = new Error(`Knowledge base ${kbId} not found`) log.error(`[MODEL] Knowledge base not found: ${kbId}`) throw error } const visionModel = rs.rows[0].vision_model as string if (!visionModel) { return null } const parsed = parseKnowledgeBaseModelString(visionModel) if (!parsed) { const error = new Error(`Invalid vision model format: ${visionModel}`) log.error(`[MODEL] Invalid vision model format: ${visionModel}`) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_vision_provider') scope.setExtra('kbId', kbId) scope.setExtra('visionModel', visionModel) sentry.captureException(error) }) throw error } const { providerId, modelId } = parsed const settingsForModel = getMergedSettings(providerId, modelId) const dependencies = await createModelDependencies() const model = getModel(settingsForModel, getSettings(), getConfig(), dependencies) return { model, dependencies } } catch (error: any) { log.error(`[MODEL] Failed to get vision provider for kb ${kbId}:`, error) if (!error.message.includes('not set') && !error.message.includes('not found')) { sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_vision_provider') scope.setExtra('kbId', kbId) sentry.captureException(error) }) } throw error } }, { ttl: 1000 * 60 } ) } export async function getRerankProvider(kbId: number) { return cache( `kb:rerank:${kbId}`, async () => { try { const db = getDatabase() const rs = await db.execute('SELECT * FROM knowledge_base WHERE id = ?', [kbId]) if (!rs.rows[0]) { const error = new Error(`Knowledge base ${kbId} not found`) log.error(`[MODEL] Knowledge base not found: ${kbId}`) throw error } const rerankModel = rs.rows[0].rerank_model as string if (!rerankModel) { return null } const parsed = parseKnowledgeBaseModelString(rerankModel) if (!parsed) { const error = new Error(`Invalid rerank model format: ${rerankModel}`) log.error(`[MODEL] Invalid rerank model format: ${rerankModel}`) sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_rerank_provider') scope.setExtra('kbId', kbId) scope.setExtra('rerankModel', rerankModel) sentry.captureException(error) }) throw error } const { providerId, modelId } = parsed const sessionSettings = getMergedSettings(providerId, modelId) const { providerSetting, formattedApiHost } = getProviderSettings(sessionSettings, getSettings()) let apiHost = formattedApiHost let token = providerSetting.apiKey if (providerId === 'chatbox-ai') { apiHost = getChatboxAPIOrigin() token = store.get('settings.licenseKey') } const client = new CohereClient({ environment: apiHost, token, }) return { client, modelId } } catch (error: any) { log.error(`[MODEL] Failed to get rerank provider for kb ${kbId}:`, error) if (!error.message.includes('not set') && !error.message.includes('not found')) { sentry.withScope((scope) => { scope.setTag('component', 'knowledge-base-model') scope.setTag('operation', 'get_rerank_provider') scope.setExtra('kbId', kbId) sentry.captureException(error) }) } throw error } }, { ttl: 1000 * 60, // 1 minute } ) } ================================================ FILE: src/main/knowledge-base/parsers/chatbox-parser.ts ================================================ import type { DocumentParserType } from '../../../shared/types/settings' import { parseFileRemotely } from '../remote-file-parser' import type { DocumentParser, ParserFileMeta } from './types' /** * Chatbox AI document parser * Uses Chatbox AI backend for cloud-based document parsing * Requires user to be logged in (has valid license key) */ export class ChatboxParser implements DocumentParser { readonly type: DocumentParserType = 'chatbox-ai' async parse(filePath: string, meta: ParserFileMeta): Promise { // Use the existing remote file parser implementation return await parseFileRemotely(filePath, meta.filename, meta.mimeType) } } ================================================ FILE: src/main/knowledge-base/parsers/index.ts ================================================ import { isTextFilePath } from '../../../shared/file-extensions' import type { DocumentParserConfig, DocumentParserType } from '../../../shared/types/settings' import { getLogger } from '../../util' import { ChatboxParser } from './chatbox-parser' import { LocalParser } from './local-parser' import { MineruParser } from './mineru-parser' import type { DocumentParser, ParserFileMeta, ParserResult } from './types' const log = getLogger('knowledge-base:parser-router') export { MineruParser, testMineruConnection } from './mineru-parser' export * from './types' /** * Create a parser instance based on configuration * @param config - Parser configuration * @param kbId - Knowledge base ID (required for local parser's vision model) */ export function createParser(config: DocumentParserConfig, kbId?: number): DocumentParser { switch (config.type) { case 'local': return new LocalParser(kbId) case 'chatbox-ai': return new ChatboxParser() case 'mineru': if (!config.mineru?.apiToken) { throw new Error('MinerU API token is required') } return new MineruParser(config.mineru.apiToken) default: log.warn(`Unknown parser type: ${config.type}, falling back to local parser`) return new LocalParser(kbId) } } /** * Get effective parser configuration * Priority: KB config > Global config > Default (local) */ export function getEffectiveParserConfig( kbConfig?: DocumentParserConfig | null, globalConfig?: DocumentParserConfig | null ): DocumentParserConfig { if (kbConfig) { return kbConfig } if (globalConfig) { return globalConfig } return { type: 'local' } } /** * Parse a file using the appropriate parser * Text files always use local parsing for efficiency * * @param filePath - Path to the file * @param meta - File metadata * @param config - Parser configuration * @param kbId - Knowledge base ID (for vision model access) * @returns Parsed content and parser type used */ export async function parseFileWithRouter( filePath: string, meta: ParserFileMeta, config: DocumentParserConfig, kbId?: number ): Promise { // 文本文件始终使用本地解析 if (isTextFilePath(filePath)) { log.debug(`[ROUTER] Using local parser for text file: ${meta.filename}`) const localParser = new LocalParser(kbId) const content = await localParser.parse(filePath, meta) return { content, parserUsed: 'local' } } // 非文本文件使用配置的解析器 log.debug(`[ROUTER] Using ${config.type} parser for: ${meta.filename}`) const parser = createParser(config, kbId) const content = await parser.parse(filePath, meta) return { content, parserUsed: config.type } } /** * Get display name for parser type */ export function getParserDisplayName(type: DocumentParserType): string { switch (type) { case 'local': return 'Local' case 'chatbox-ai': return 'Chatbox AI' case 'mineru': return 'MinerU' default: return type } } ================================================ FILE: src/main/knowledge-base/parsers/local-parser.ts ================================================ import fs from 'node:fs' import type { ModelMessage } from 'ai' import { isEpubFilePath, isLegacyOfficeFilePath, isOfficeFilePath, isTextFilePath, } from '../../../shared/file-extensions' import type { DocumentParserType } from '../../../shared/types/settings' import { parseFile } from '../../file-parser' import { getVisionProvider } from '../model-providers' import type { DocumentParser, ParserFileMeta } from './types' /** * Local document parser * Uses built-in libraries for document parsing * Supports: Office files, images (via vision model), EPUB, text files */ export class LocalParser implements DocumentParser { readonly type: DocumentParserType = 'local' constructor(private kbId?: number) {} async parse(filePath: string, meta: ParserFileMeta): Promise { if (isLegacyOfficeFilePath(filePath)) { throw new Error( 'Legacy Office formats (.doc/.xls/.ppt) are not supported by local parser. Please convert to .docx/.xlsx/.pptx or switch document parser to Chatbox AI.' ) } if (isOfficeFilePath(filePath)) { return await parseFile(filePath) } if (meta.mimeType.startsWith('image/')) { return await this.parseImage(filePath, meta) } if (isEpubFilePath(filePath)) { return await parseFile(filePath) } if (isTextFilePath(filePath)) { return await parseFile(filePath) } throw new Error(`Unsupported file type: ${meta.mimeType}`) } /** * Parse image file using vision model (OCR) */ private async parseImage(filePath: string, meta: ParserFileMeta): Promise { if (!this.kbId) { throw new Error('Knowledge base ID required for image parsing') } const vision = await getVisionProvider(this.kbId) if (!vision) { throw new Error('Vision model not configured for this knowledge base') } const { model: visionModel } = vision // Read image as base64 const imageBase64 = fs.readFileSync(filePath, { encoding: 'base64' }) const dataUrl = `data:${meta.mimeType};base64,${imageBase64}` // Assemble chat message with image const msg: ModelMessage = { role: 'user', content: [ { type: 'text', text: 'OCR the following image into Markdown. Do not surround your output with triple backticks.', }, { type: 'image', image: dataUrl, mediaType: meta.mimeType }, ], } const chatResult = await visionModel.chat([msg], {}) const text = chatResult.contentParts .filter((p) => p.type === 'text') .map((p: { type: 'text'; text: string }) => p.text) .join('') return text } } ================================================ FILE: src/main/knowledge-base/parsers/mineru-parser.ts ================================================ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import AdmZip from 'adm-zip' import type { DocumentParserType } from '../../../shared/types/settings' import { getLogger } from '../../util' import type { DocumentParser, MineruBatchResultResponse, MineruBatchUploadResponse, MineruErrorCode, MineruExtractResult, ParserFileMeta, } from './types' import { MineruError } from './types' const log = getLogger('knowledge-base:mineru-parser') const MINERU_API_BASE = 'https://mineru.net/api/v4' const POLL_INTERVAL_MS = 10000 // 10 seconds const MAX_POLL_ATTEMPTS = 30 // 5 minutes total timeout const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB /** * Map MinerU API error codes to internal error codes */ function mapErrorCode(code: string | number): MineruErrorCode { const codeStr = String(code) if (codeStr === 'A0202' || codeStr === 'A0211') { return 'AUTH_FAILED' } if (codeStr === '-60005' || codeStr === '-60006') { return 'FILE_TOO_LARGE' } if (codeStr === '-60002') { return 'UNSUPPORTED_FORMAT' } if (codeStr === '-60010') { return 'PARSE_FAILED' } return 'NETWORK_ERROR' } /** * Sleep utility with abort support */ function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new MineruError('Operation cancelled', 'CANCELLED')) return } let timeoutId: NodeJS.Timeout | undefined // Define abort handler so we can remove it later const onAbort = () => { if (timeoutId) { clearTimeout(timeoutId) } reject(new MineruError('Operation cancelled', 'CANCELLED')) } timeoutId = setTimeout(() => { // Remove abort listener when sleep completes normally signal?.removeEventListener('abort', onAbort) resolve() }, ms) signal?.addEventListener('abort', onAbort, { once: true }) }) } /** * MinerU document parser implementation * Uses MinerU batch upload API for file parsing */ export class MineruParser implements DocumentParser { readonly type: DocumentParserType = 'mineru' constructor(private apiToken: string) {} async parse(filePath: string, meta: ParserFileMeta, signal?: AbortSignal): Promise { const dataId = `chatbox-${meta.fileId}-${Date.now()}` log.info(`[MINERU] Starting parse for ${meta.filename} (dataId=${dataId})`) // Check if already cancelled if (signal?.aborted) { throw new MineruError('Operation cancelled', 'CANCELLED') } // Check file size const stats = await fs.promises.stat(filePath) if (stats.size > MAX_FILE_SIZE) { throw new MineruError(`File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE})`, 'FILE_TOO_LARGE') } // 1. Get batch upload URL const { batchId, uploadUrl } = await this.getBatchUploadUrl(meta.filename, dataId) log.debug(`[MINERU] Got upload URL for ${meta.filename}, batchId=${batchId}`) if (signal?.aborted) { throw new MineruError('Operation cancelled', 'CANCELLED') } // 2. Upload file (no Content-Type needed) await this.uploadFile(filePath, uploadUrl) log.debug(`[MINERU] Uploaded file ${meta.filename}`) if (signal?.aborted) { throw new MineruError('Operation cancelled', 'CANCELLED') } // 3. Poll for result const result = await this.pollBatchResult(batchId, dataId, signal) log.debug(`[MINERU] Got result for ${meta.filename}, state=${result.state}`) // 4. Download and extract markdown if (!result.full_zip_url) { throw new MineruError('No result URL returned from MinerU', 'PARSE_FAILED') } const content = await this.downloadAndExtract(result.full_zip_url) log.info(`[MINERU] Parse completed for ${meta.filename}, content length=${content.length}`) return content } /** * Get batch upload URL from MinerU API */ private async getBatchUploadUrl(filename: string, dataId: string): Promise<{ batchId: string; uploadUrl: string }> { const response = await fetch(`${MINERU_API_BASE}/file-urls/batch`, { method: 'POST', headers: { Authorization: `Bearer ${this.apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ files: [{ name: filename, data_id: dataId }], model_version: 'vlm', enable_formula: true, enable_table: true, }), }) const data: MineruBatchUploadResponse = await response.json() if (data.code !== 0) { throw new MineruError(data.msg || 'Failed to get upload URL', mapErrorCode(data.code)) } return { batchId: data.data.batch_id, uploadUrl: data.data.file_urls[0], } } /** * Upload file to MinerU OSS */ private async uploadFile(filePath: string, uploadUrl: string): Promise { const fileBuffer = await fs.promises.readFile(filePath) const response = await fetch(uploadUrl, { method: 'PUT', body: fileBuffer, // Note: No Content-Type header needed per MinerU API docs }) if (!response.ok) { throw new MineruError(`File upload failed with status ${response.status}`, 'NETWORK_ERROR') } } /** * Poll for batch parsing result */ private async pollBatchResult(batchId: string, dataId: string, signal?: AbortSignal): Promise { for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) { // Check for cancellation before sleeping if (signal?.aborted) { throw new MineruError('Operation cancelled', 'CANCELLED') } await sleep(POLL_INTERVAL_MS, signal) // Check for cancellation after sleeping if (signal?.aborted) { throw new MineruError('Operation cancelled', 'CANCELLED') } const response = await fetch(`${MINERU_API_BASE}/extract-results/batch/${batchId}`, { headers: { Authorization: `Bearer ${this.apiToken}`, }, signal, // Pass signal to fetch for network cancellation }) const data: MineruBatchResultResponse = await response.json() if (data.code !== 0) { throw new MineruError(data.msg || 'Failed to get result', mapErrorCode(data.code)) } // Find our file result by data_id const result = data.data.extract_result.find((r) => r.data_id === dataId) if (!result) { log.debug(`[MINERU] Result not found yet for dataId=${dataId}, attempt ${i + 1}/${MAX_POLL_ATTEMPTS}`) continue } log.debug(`[MINERU] Polling status: ${result.state} for dataId=${dataId}`) if (result.state === 'done') { return result } if (result.state === 'failed') { throw new MineruError(result.err_msg || 'Parsing failed', 'PARSE_FAILED') } // Continue polling for other states: waiting-file, pending, running, converting } throw new MineruError(`Polling timeout after ${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000} seconds`, 'TIMEOUT') } /** * Download ZIP and extract markdown content */ private async downloadAndExtract(zipUrl: string): Promise { // Download ZIP file const response = await fetch(zipUrl) if (!response.ok) { throw new MineruError(`Failed to download result: ${response.status}`, 'NETWORK_ERROR') } const arrayBuffer = await response.arrayBuffer() const buffer = Buffer.from(arrayBuffer) // Create temp directory for extraction const tempDir = path.join(os.tmpdir(), `mineru-${Date.now()}`) await fs.promises.mkdir(tempDir, { recursive: true }) try { // Extract ZIP const zip = new AdmZip(buffer) zip.extractAllTo(tempDir, true) // Find markdown file const files = await this.findMarkdownFiles(tempDir) if (files.length === 0) { throw new MineruError('No markdown file found in result', 'PARSE_FAILED') } // Read the first markdown file const mdContent = await fs.promises.readFile(files[0], 'utf-8') return mdContent } finally { // Cleanup temp directory await fs.promises.rm(tempDir, { recursive: true, force: true }).catch((err) => { log.warn(`[MINERU] Failed to cleanup temp dir: ${err.message}`) }) } } /** * Recursively find markdown files in directory */ private async findMarkdownFiles(dir: string): Promise { const results: string[] = [] const entries = await fs.promises.readdir(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { const subResults = await this.findMarkdownFiles(fullPath) results.push(...subResults) } else if (entry.name.endsWith('.md')) { results.push(fullPath) } } return results } } /** * Test MinerU connection by validating the API token * Uses the single file extract API with an invalid URL to test token validity */ export async function testMineruConnection(apiToken: string): Promise<{ success: boolean; error?: string }> { try { // We use the batch upload API with an empty files array to validate the token // This won't create any actual tasks but will validate the token const response = await fetch(`${MINERU_API_BASE}/file-urls/batch`, { method: 'POST', headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ files: [], model_version: 'vlm', }), }) const data = await response.json() // Check for auth errors if (data.msgCode === 'A0202' || data.msgCode === 'A0211') { return { success: false, error: 'Token invalid or expired' } } // Any other response (including error for empty files) means token is valid return { success: true } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: `Network error: ${errorMessage}` } } } ================================================ FILE: src/main/knowledge-base/parsers/types.ts ================================================ import type { DocumentParserType } from '../../../shared/types/settings' /** * File metadata for parsing */ export interface ParserFileMeta { fileId: number filename: string mimeType: string } /** * 策略模式统一解析器接口 */ export interface DocumentParser { readonly type: DocumentParserType /** * Parse a file and return its text content * @param filePath - Path to the file to parse * @param meta - File metadata * @returns Parsed text content */ parse(filePath: string, meta: ParserFileMeta): Promise } /** * Parser result with metadata about the parsing process */ export interface ParserResult { content: string parserUsed: DocumentParserType } /** * MinerU-specific error codes */ export type MineruErrorCode = | 'AUTH_FAILED' | 'QUOTA_EXCEEDED' | 'TIMEOUT' | 'PARSE_FAILED' | 'NETWORK_ERROR' | 'FILE_TOO_LARGE' | 'UNSUPPORTED_FORMAT' | 'CANCELLED' /** * MinerU API error class */ export class MineruError extends Error { constructor( message: string, public code: MineruErrorCode ) { super(message) this.name = 'MineruError' } } /** * MinerU batch upload API response types */ export interface MineruBatchUploadResponse { code: number msg: string data: { batch_id: string file_urls: string[] } } export interface MineruExtractResult { file_name: string data_id?: string state: 'waiting-file' | 'pending' | 'running' | 'done' | 'failed' | 'converting' full_zip_url?: string err_msg?: string extract_progress?: { extracted_pages: number total_pages: number start_time: string } } export interface MineruBatchResultResponse { code: number msg: string data: { batch_id: string extract_result: MineruExtractResult[] } } ================================================ FILE: src/main/knowledge-base/remote-file-parser.ts ================================================ import fs from 'node:fs' import os from 'node:os' import { app } from 'electron' import { getChatboxAPIOrigin } from '../../shared/request/chatboxai_pool' import { createAfetch } from '../../shared/request/request' import { getSettings, store } from '../store-node' import { getLogger } from '../util' const log = getLogger('knowledge-base:remote-file-parser') // backend limit, error code 20010 const MAX_FILE_SIZE = 50 * 1024 * 1024 // Platform info for main process function getPlatformInfo() { return { type: 'desktop', platform: process.platform, os: os.platform(), version: app.getVersion(), } } // Create afetch instance for main process function getAfetch() { return createAfetch(getPlatformInfo()) } // Get Chatbox API headers function getChatboxHeaders() { const info = getPlatformInfo() return { 'CHATBOX-PLATFORM': info.platform, 'CHATBOX-PLATFORM-TYPE': info.type, 'CHATBOX-OS': info.os, 'CHATBOX-VERSION': info.version, } } /** * Get the license key from settings */ function getLicenseKey(): string | undefined { return store.get('settings.licenseKey') as string | undefined } /** * Generate upload URL for file */ async function generateUploadUrl(licenseKey: string, filename: string): Promise<{ url: string; filename: string }> { type Response = { data: { url: string filename: string } } const afetch = getAfetch() const res = await afetch( `${getChatboxAPIOrigin()}/api/files/generate-upload-url`, { method: 'POST', headers: { Authorization: licenseKey, 'Content-Type': 'application/json', ...getChatboxHeaders(), }, body: JSON.stringify({ licenseKey, filename }), }, { parseChatboxRemoteError: true } ) const json: Response = await res.json() return json.data } /** * Upload file from local path to COS using Node.js fetch * Unlike renderer's XMLHttpRequest, we use native fetch with Buffer */ async function uploadFileFromPath(filePath: string, uploadUrl: string, mimeType: string): Promise { const fileBuffer = await fs.promises.readFile(filePath) const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': mimeType || 'application/octet-stream', 'Content-Length': fileBuffer.length.toString(), }, body: fileBuffer, }) if (!response.ok) { throw new Error(`File upload failed with status ${response.status}`) } } /** * Create file record and get parsed content from backend */ async function createAndParseFile( licenseKey: string, filename: string, filetype: string ): Promise<{ uuid: string; content: string }> { type Response = { data: { uuid: string content: string } } const afetch = getAfetch() const res = await afetch( `${getChatboxAPIOrigin()}/api/files/create`, { method: 'POST', headers: { Authorization: licenseKey, 'Content-Type': 'application/json', ...getChatboxHeaders(), }, body: JSON.stringify({ licenseKey, filename, filetype, returnContent: true, }), }, { parseChatboxRemoteError: true } ) const json: Response = await res.json() return json.data } /** * Parse file remotely using Chatbox AI backend * This is the main entry point for remote file parsing * * @param filePath - Local file path * @param filename - Original filename * @param mimeType - File MIME type * @returns Parsed text content */ export async function parseFileRemotely(filePath: string, filename: string, mimeType: string): Promise { const licenseKey = getLicenseKey() if (!licenseKey) { throw new Error('License key not found for remote parsing') } const stats = await fs.promises.stat(filePath) if (stats.size > MAX_FILE_SIZE) { throw new Error(`File too large: ${stats.size} bytes (max: ${MAX_FILE_SIZE})`) } log.info(`[REMOTE] Starting remote parsing for: ${filename}`) // Step 1: Generate upload URL const { url: uploadUrl, filename: serverFilename } = await generateUploadUrl(licenseKey, filename) log.debug(`[REMOTE] Generated upload URL for: ${filename}`) // Step 2: Upload file to COS await uploadFileFromPath(filePath, uploadUrl, mimeType) log.debug(`[REMOTE] Uploaded file to COS: ${filename}`) // Step 3: Create file record and get parsed content const result = await createAndParseFile(licenseKey, serverFilename, mimeType) log.info(`[REMOTE] Remote parsing completed for: ${filename}, UUID: ${result.uuid}`) return result.content } ================================================ FILE: src/main/locales.ts ================================================ import { app } from 'electron' export default class Locale { locale: string = 'en' constructor() { try { this.locale = app.getLocale() } catch (e) { console.log(e) } } isCN(): boolean { return this.locale.startsWith('zh') } t(key: TranslationKey): string { return translations[key][this.isCN() ? 'zh' : 'en'] } } type TranslationKey = keyof typeof translations const translations = { 'Show/Hide': { en: 'Show/Hide', zh: '显示/隐藏', }, Exit: { en: 'Exit', zh: '退出', }, New_Version: { en: 'New Version', zh: '新版本', }, Restart: { en: 'Restart', zh: '重启', }, Later: { en: 'Later', zh: '稍后', }, App_Update: { en: 'App Update', zh: '应用更新', }, New_Version_Downloaded: { en: 'New version has been downloaded, restart the application to apply the update.', zh: '新版本已经下载好,重启应用以应用更新。', }, Copy: { en: 'Copy', zh: '复制', }, Cut: { en: 'Cut', zh: '剪切', }, Paste: { en: 'Paste', zh: '粘贴', }, PasteAsPlainText: { en: 'Paste as Plain Text', zh: '粘贴为文本', }, ReplaceWith: { en: 'Replace with', zh: '替换成', }, ResetZoom: { en: 'Reset Zoom', zh: '重置缩放', }, ZoomIn: { en: 'Zoom In', zh: '放大', }, ZoomOut: { en: 'Zoom Out', zh: '缩小', }, } ================================================ FILE: src/main/main.ts ================================================ /* eslint global-require: off, no-console: off, promise/always-return: off */ /** * This module executes inside of electron's main process. You can start * electron renderer process from here and communicate with the other processes * through IPC. * * When running `npm run build` or `npm run build:main`, this file is compiled to * `./src/main.js` using webpack. This gives us some performance wins. */ import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, Tray } from 'electron' import electronDebug from 'electron-debug' import log from 'electron-log/main' import { autoUpdater } from 'electron-updater' import os from 'os' import path from 'path' // @ts-expect-error - source-map-support doesn't have type definitions import * as sourceMapSupport from 'source-map-support' import type { ShortcutSetting } from 'src/shared/types' import * as analystic from './analystic-node' import * as autoLauncher from './autoLauncher' import { handleDeepLink } from './deeplinks' import { parseFile } from './file-parser' import Locale from './locales' import * as mcpIpc from './mcp/ipc-stdio-transport' import MenuBuilder from './menu' import * as proxy from './proxy' import { delStoreBlob, getConfig, getSettings, getStoreBlob, listStoreBlobKeys, setStoreBlob, store, } from './store-node' import * as windowState from './window_state' const knowledgeBaseInitPromise = import('./knowledge-base/index.js') .then((mod) => mod.getInitPromise()) .catch((error) => { log.error('[KB] Failed to initialize knowledge base during bootstrap:', error) }) // 这行代码是解决 Windows 通知的标题和图标不正确的问题,标题会错误显示成 electron.app.Chatbox // 参考:https://stackoverflow.com/questions/65859634/notification-from-electron-shows-electron-app-electron if (process.platform === 'win32') { app.setAppUserModelId(app.name) } const RESOURCES_PATH = app.isPackaged ? path.join(process.resourcesPath, 'assets') : path.join(__dirname, '../../assets') const getAssetPath = (...paths: string[]): string => { return path.join(RESOURCES_PATH, ...paths) } // 开发环境使用 chatbox-dev:// 协议,避免和正式版冲突 const PROTOCOL_SCHEME = process.defaultApp ? 'chatbox-dev' : 'chatbox' if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME, process.execPath, [path.resolve(process.argv[1])]) } } else { app.setAsDefaultProtocolClient(PROTOCOL_SCHEME) } console.log(`📱 URL Scheme registered: ${PROTOCOL_SCHEME}://`) // --------- 全局变量 --------- let mainWindow: BrowserWindow | null = null let tray: Tray | null = null // --------- 快捷键 --------- /** * 将渲染层的 shortcut 转化成 electron 支持的格式 * react-hotkeys-hook 的快捷键格式参考: https://react-hotkeys-hook.vercel.app/docs/documentation/useHotkeys/basic-usage#modifiers--special-keys * Electron 的快捷键格式参考: https://www.electronjs.org/docs/latest/api/accelerator */ function normalizeShortcut(shortcut: string) { if (!shortcut) { return '' } let keys = shortcut.split('+') keys = keys.map((key) => { switch (key) { case 'mod': return 'CommandOrControl' case 'option': return 'Alt' case 'backquote': return '`' default: return key } }) return keys.join('+') } /** * 检查快捷键是否有效 * @param shortcut 快捷键字符串 * @returns 是否为有效的快捷键 */ function isValidShortcut(shortcut: string): boolean { if (!shortcut) { return false } const keys = shortcut.split('+') // 检查是否至少包含一个非修饰键 const hasNonModifier = keys.some((key) => { const normalizedKey = key.trim().toLowerCase() return ![ 'mod', 'command', 'cmd', 'control', 'ctrl', 'commandorcontrol', 'option', 'alt', 'shift', 'super', ].includes(normalizedKey) }) return hasNonModifier } function registerShortcuts(shortcutSetting?: ShortcutSetting) { if (!shortcutSetting) { shortcutSetting = getSettings().shortcuts } if (!shortcutSetting) { return } try { const quickToggle = normalizeShortcut(shortcutSetting.quickToggle) if (isValidShortcut(quickToggle)) { globalShortcut.register(quickToggle, () => showOrHideWindow()) } } catch (error) { log.error('Failed to register shortcut [windowQuickToggle]:', error) } } function unregisterShortcuts() { return globalShortcut.unregisterAll() } // --------- Tray 图标 --------- function createTray() { const locale = new Locale() let iconPath = getAssetPath('icon.png') if (process.platform === 'darwin') { // 生成 iconTemplate.png 的命令 // gm convert -background none ./iconTemplateRawPreview.png -resize 130% -gravity center -extent 512x512 iconTemplateRaw.png // gm convert ./iconTemplateRaw.png -colorspace gray -negate -threshold 50% -resize 16x16 -units PixelsPerInch -density 72 iconTemplate.png // gm convert ./iconTemplateRaw.png -colorspace gray -negate -threshold 50% -resize 64x64 -units PixelsPerInch -density 144 iconTemplate@2x.png iconPath = getAssetPath('iconTemplate.png') } else if (process.platform === 'win32') { iconPath = getAssetPath('icon.ico') } tray = new Tray(iconPath) const contextMenu = Menu.buildFromTemplate([ { label: locale.t('Show/Hide'), click: showOrHideWindow, accelerator: getSettings().shortcuts.quickToggle, }, { label: locale.t('Exit'), click: () => app.quit(), accelerator: 'Command+Q', }, ]) tray.setToolTip('Chatbox') tray.setContextMenu(contextMenu) tray.on('double-click', showOrHideWindow) return tray } function ensureTray() { if (tray) { log.info('tray: already exists') return tray } try { createTray() log.info('tray: created') } catch (e) { log.error('tray: failed to create', e) } } function destroyTray() { if (!tray) { log.info('tray: skip destroy because it does not exist') return } try { tray.destroy() tray = null log.info('tray: destroyed') } catch (e) { log.error('tray: failed to destroy', e) } } // --------- 开发模式 --------- if (process.env.NODE_ENV === 'production') { sourceMapSupport.install() } const isDebug = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' if (isDebug) { electronDebug() } // const installExtensions = async () => { // const installer = require('electron-devtools-installer') // const forceDownload = !!process.env.UPGRADE_EXTENSIONS // const extensions = ['REACT_DEVELOPER_TOOLS'] // return installer // .default( // extensions.map((name) => installer[name]), // forceDownload // ) // .catch(console.log) // } // --------- 窗口管理 --------- async function createWindow() { if (isDebug) { // 不在安装 DEBUG 浏览器插件。可能不兼容,所以不如直接在网页里debug // await installExtensions() } const [state] = windowState.getState() mainWindow = new BrowserWindow({ show: false, // remove the default titlebar titleBarStyle: 'hidden', // expose window controlls in Windows/Linux frame: false, trafficLightPosition: { x: 10, y: 16 }, width: state.width, height: state.height, x: state.x, y: state.y, minWidth: windowState.minWidth, minHeight: windowState.minHeight, icon: getAssetPath('icon.png'), webPreferences: { spellcheck: true, webSecurity: false, // 其中一个作用是解决跨域问题 allowRunningInsecureContent: false, preload: app.isPackaged ? path.join(__dirname, '../preload/index.js') : path.join(__dirname, '../../out/preload/index.js'), }, }) // Load the local URL for development or the local // html file for production if (!app.isPackaged && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) } mainWindow.on('ready-to-show', () => { if (!mainWindow) { throw new Error('"mainWindow" is not defined') } if (process.env.START_MINIMIZED) { mainWindow.minimize() } else { if (state.mode === windowState.WindowMode.Maximized) { mainWindow.maximize() } if (state.mode === windowState.WindowMode.Fullscreen) { mainWindow.setFullScreen(true) } mainWindow.show() } }) // 窗口关闭时保存窗口大小与位置 mainWindow.on('close', () => { if (mainWindow) { windowState.saveState(mainWindow) } }) mainWindow.on('closed', () => { mainWindow = null }) // Send maximized state changes to renderer mainWindow.on('maximize', () => { mainWindow?.webContents.send('window:maximized-changed', true) }) mainWindow.on('unmaximize', () => { mainWindow?.webContents.send('window:maximized-changed', false) }) mainWindow.on('focus', () => { mainWindow?.webContents.send('window:focused') }) const menuBuilder = new MenuBuilder(mainWindow) menuBuilder.buildMenu() // Open urls in the user's browser mainWindow.webContents.setWindowOpenHandler((edata) => { shell.openExternal(edata.url) return { action: 'deny' } }) // 隐藏 Windows, Linux 应用顶部的菜单栏 // https://www.computerhope.com/jargon/m/menubar.htm mainWindow.setMenuBarVisibility(false) // 网络问题 session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, // 'Content-Security-Policy': ['default-src \'self\''] // 'Content-Security-Policy': ['*'], // 为了支持代理 }, }) }) // 监听系统主题更新 nativeTheme.on('updated', () => { mainWindow?.webContents.send('system-theme-updated') }) return mainWindow } async function showOrHideWindow() { if (!mainWindow) { await createWindow() return } if (mainWindow.isMinimized()) { mainWindow.restore() mainWindow.focus() mainWindow.webContents.send('window-show') } else if (mainWindow?.isFocused()) { // 解决MacOS全屏下隐藏将黑屏的问题 if (mainWindow.isFullScreen()) { mainWindow.setFullScreen(false) } mainWindow.hide() // mainWindow.minimize() } else { // 解决MacOS下无法聚焦的问题 mainWindow.hide() mainWindow.show() mainWindow.focus() // 解决MacOS全屏下无法聚焦的问题 mainWindow.webContents.send('window-show') } } // --------- 应用管理 --------- const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.quit() } else { app.on('second-instance', async (event, commandLine, workingDirectory) => { // on windows and linux, the deep link is passed in the command line const url = commandLine.find((arg) => arg.startsWith('chatbox://') || arg.startsWith('chatbox-dev://')) if (url) { // Deep Link 场景:总是显示并聚焦窗口 if (!mainWindow) { // 窗口未创建,立即创建 await createWindow() } if (mainWindow) { if (mainWindow.isMinimized()) { mainWindow.restore() } mainWindow.show() mainWindow.focus() // 确保窗口加载完成后再处理 Deep Link if (mainWindow.webContents.isLoading()) { mainWindow.webContents.once('did-finish-load', () => { if (mainWindow) { handleDeepLink(mainWindow, url) } }) } else { handleDeepLink(mainWindow, url) } } } else { // 非 Deep Link 场景:切换显示/隐藏 await showOrHideWindow() } }) app.on('window-all-closed', () => { // Respect the OSX convention of having the application in memory even // after all windows have been closed // if (process.platform !== 'darwin') { // app.quit() // } }) app .whenReady() .then(async () => { await knowledgeBaseInitPromise await createWindow() ensureTray() // Remove this if your app does not use auto updates // eslint-disable-next-line new AppUpdater(() => mainWindow?.webContents.send('update-downloaded', {})) // 处理启动时的 Deep Link (Windows/Linux) // macOS 会通过 open-url 事件处理,不需要在这里处理 if (process.platform !== 'darwin') { const url = process.argv.find((arg) => arg.startsWith('chatbox://') || arg.startsWith('chatbox-dev://')) if (url && mainWindow) { // 确保窗口加载完成后再处理 Deep Link if (mainWindow.webContents.isLoading()) { mainWindow.webContents.once('did-finish-load', () => { if (mainWindow) { handleDeepLink(mainWindow, url) } }) } else { handleDeepLink(mainWindow, url) } } } app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (mainWindow === null) { createWindow() } if (mainWindow && !mainWindow.isVisible()) { mainWindow.show() mainWindow.focus() } }) // 监听窗口大小位置变化的代码,很大程度参考了 VSCODE 的实现 /Users/benn/Documents/w/vscode/src/vs/platform/windows/electron-main/windowsStateHandler.ts // When a window looses focus, save all windows state. This allows to // prevent loss of window-state data when OS is restarted without properly // shutting down the application (https://github.com/microsoft/vscode/issues/87171) app.on('browser-window-blur', () => { if (mainWindow) { windowState.saveState(mainWindow) } }) registerShortcuts() proxy.init() app.on('will-quit', () => { try { unregisterShortcuts() } catch (e) { log.error('shortcut: failed to unregister', e) } mcpIpc.closeAllTransports() destroyTray() }) app.on('before-quit', () => { destroyTray() }) }) .catch(console.log) } // macos uses this event to handle deep links app.on('open-url', async (_event, url) => { if (!mainWindow) { // 窗口未创建,立即创建 await createWindow() } if (mainWindow) { if (mainWindow.isMinimized()) { mainWindow.restore() } mainWindow.show() mainWindow.focus() // 确保窗口加载完成后再处理 Deep Link if (mainWindow.webContents.isLoading()) { mainWindow.webContents.once('did-finish-load', () => { if (mainWindow) { handleDeepLink(mainWindow, url) } }) } else { handleDeepLink(mainWindow, url) } } }) // --------- IPC 监听 --------- ipcMain.handle('getStoreValue', (event, key) => { return store.get(key) }) ipcMain.handle('setStoreValue', (event, key, dataJson) => { // 仅在传输层用 JSON 序列化,存储层用原生数据,避免存储层 JSON 损坏后无法自动处理的情况 const data = JSON.parse(dataJson) return store.set(key, data) }) ipcMain.handle('delStoreValue', (event, key) => { return store.delete(key) }) ipcMain.handle('getAllStoreValues', (event) => { return JSON.stringify(store.store) }) ipcMain.handle('setAllStoreValues', (event, dataJson) => { const data = JSON.parse(dataJson) store.store = { ...store.store, ...data } }) ipcMain.handle('getStoreBlob', async (event, key) => { return getStoreBlob(key) }) ipcMain.handle('setStoreBlob', async (event, key, value: string) => { return setStoreBlob(key, value) }) ipcMain.handle('delStoreBlob', async (event, key) => { return delStoreBlob(key) }) ipcMain.handle('listStoreBlobKeys', async (event) => { return listStoreBlobKeys() }) ipcMain.handle('getVersion', () => { return app.getVersion() }) ipcMain.handle('getPlatform', () => { return process.platform }) ipcMain.handle('getArch', () => { return process.arch }) ipcMain.handle('getHostname', () => { return os.hostname() }) ipcMain.handle('getDeviceName', () => { if (process.platform === 'darwin') { try { const { execSync } = require('child_process') const computerName = execSync('scutil --get ComputerName', { encoding: 'utf8' }).trim() return computerName || os.hostname() } catch (error) { return os.hostname() } } else if (process.platform === 'win32') { return process.env.COMPUTERNAME || os.hostname() } else { return os.hostname() } }) ipcMain.handle('getLocale', () => { try { return app.getLocale() } catch (e: any) { return '' } }) ipcMain.handle('openLink', (event, link) => { return shell.openExternal(link) }) ipcMain.handle('ensureShortcutConfig', (event, json) => { const config: ShortcutSetting = JSON.parse(json) unregisterShortcuts() registerShortcuts(config) }) ipcMain.handle('shouldUseDarkColors', () => nativeTheme.shouldUseDarkColors) ipcMain.handle('ensureProxy', (event, json) => { const config: { proxy?: string } = JSON.parse(json) proxy.ensure(config.proxy) }) ipcMain.handle('relaunch', () => { app.relaunch() app.quit() }) ipcMain.handle('analysticTrackingEvent', (event, dataJson) => { const data = JSON.parse(dataJson) analystic.event(data.name, data.params).catch((e) => { log.error('analystic_tracking_event', e) }) }) ipcMain.handle('getConfig', (event) => { return getConfig() }) ipcMain.handle('getSettings', (event) => { return getSettings() }) ipcMain.handle('shouldShowAboutDialogWhenStartUp', (event) => { const currentVersion = app.getVersion() if (store.get('lastShownAboutDialogVersion', '') === currentVersion) { return false } store.set('lastShownAboutDialogVersion', currentVersion) return true }) ipcMain.handle('appLog', (event, dataJson) => { const data: { level: string; message: string } = JSON.parse(dataJson) data.message = 'APP_LOG: ' + data.message switch (data.level) { case 'info': log.info(data.message) break case 'error': log.error(data.message) break default: log.info(data.message) } }) ipcMain.handle('exportLogs', async () => { try { const fs = await import('fs/promises') const logPath = log.transports.file.getFile()?.path if (!logPath) { return '' } const content = await fs.readFile(logPath, 'utf-8') return content } catch (error) { log.error('Failed to export logs:', error) return '' } }) ipcMain.handle('clearLogs', async () => { try { const fs = await import('fs/promises') const logPath = log.transports.file.getFile()?.path if (logPath) { await fs.writeFile(logPath, '', 'utf-8') } } catch (error) { log.error('Failed to clear logs:', error) } }) ipcMain.handle('ensureAutoLaunch', (event, enable: boolean) => { if (isDebug) { log.info('ensureAutoLaunch: skip by debug mode') return } return autoLauncher.ensure(enable) }) ipcMain.handle('parseFileLocally', async (event, dataJSON: string) => { const params: { filePath: string } = JSON.parse(dataJSON) try { const data = await parseFile(params.filePath) return JSON.stringify({ text: data, isSupported: true }) } catch (e) { log.error(`parseFileLocally failed: "${params.filePath}"`, e) return JSON.stringify({ isSupported: false }) } }) ipcMain.handle('parseUrl', async (event, url: string) => { // const result = await readability(url, { maxLength: 1000 }) // const key = 'parseUrl-' + uuidv4() // await setStoreBlob(key, result.text) // return JSON.stringify({ key, title: result.title }) return JSON.stringify({ key: '', title: '' }) }) ipcMain.handle('isFullscreen', () => { return mainWindow?.isFullScreen() || false }) ipcMain.handle('setFullscreen', (event, enable: boolean) => { if (!mainWindow) { return } if (enable) { mainWindow.setFullScreen(true) } else { // 解决MacOS全屏下隐藏将黑屏的问题 if (mainWindow.isFullScreen()) { mainWindow.setFullScreen(false) } mainWindow.hide() } }) ipcMain.handle('install-update', () => { autoUpdater.quitAndInstall() }) ipcMain.handle('switch-theme', (event, theme: 'dark' | 'light') => { if (!mainWindow || process.platform !== 'darwin' || typeof mainWindow.setTitleBarOverlay !== 'function') { return } mainWindow.setTitleBarOverlay({ color: theme === 'dark' ? '#282828' : 'white', symbolColor: theme === 'dark' ? 'white' : 'black', }) }) ipcMain.handle('window:minimize', () => { mainWindow?.minimize() }) ipcMain.handle('window:maximize', () => { mainWindow?.maximize() }) ipcMain.handle('window:unmaximize', () => { mainWindow?.unmaximize() }) ipcMain.handle('window:close', () => { mainWindow?.close() }) ipcMain.handle('window:is-maximized', () => { return mainWindow?.isMaximized() }) ================================================ FILE: src/main/mcp/ipc-stdio-transport.ts ================================================ // 和 renderer/packages/mcp/ipc-stdio-transport.ts 配套的main进程ipc handler import { StdioClientTransport, type StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js' import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' import chardet from 'chardet' import { ipcMain } from 'electron' import iconv from 'iconv-lite' import { isEmpty } from 'lodash' import { v4 as uuidv4 } from 'uuid' import { getLogger } from '../util' import { shellEnv } from './shell-env' async function enhanceEnv(configEnv?: Record) { let env = await shellEnv().catch((err) => { logger.error('shell-env', err) return {} }) if (configEnv) { env = { ...env, ...configEnv } } return isEmpty(env) ? undefined : env } const logger = getLogger('mcp:stdio-transport') const transportMap = new Map() function getTransport(transportId: string) { const transport = transportMap.get(transportId) if (!transport) { throw new Error(`Transport ${transportId} not found`) } return transport } ipcMain.handle('mcp:stdio-transport:create', async (event, serverParams: StdioServerParameters) => { logger.info('create', serverParams) const postMessage = (channel: string, ...args: any[]) => { try { event.sender.send(channel, ...args) } catch (err) { logger.error('postMessage error', channel, err) } } const env = await enhanceEnv(serverParams.env) const transport = new StdioClientTransport({ command: serverParams.command, args: serverParams.args, env, stderr: 'pipe', }) let stderrMessage = '' transport.stderr?.addListener('data', (data: Buffer) => { const encoding = chardet.detect(new Uint8Array(data)) const text = iconv.decode(data, encoding || 'utf-8') logger.debug('mcp stderr', text) stderrMessage += text }) const transportId = uuidv4() transport.onclose = () => { logger.info('onclose', transportId) transport.stderr?.removeAllListeners() postMessage(`mcp:stdio-transport:${transportId}:onclose`, stderrMessage) transportMap.delete(transportId) } transport.onerror = (error) => { logger.error('onerror', transportId, error) postMessage(`mcp:stdio-transport:${transportId}:onerror`, error) } transport.onmessage = (message) => { logger.info('onmessage', transportId, message) postMessage(`mcp:stdio-transport:${transportId}:onmessage`, message) } transportMap.set(transportId, transport) return transportId }) ipcMain.handle('mcp:stdio-transport:start', async (_event, transportId: string) => { logger.info('start', transportId) const transport = getTransport(transportId) await transport.start() }) ipcMain.handle('mcp:stdio-transport:send', async (_event, transportId: string, message: JSONRPCMessage) => { logger.info('send', transportId, message) const transport = getTransport(transportId) await transport.send(message) }) ipcMain.handle('mcp:stdio-transport:close', async (_event, transportId: string) => { logger.info('close', transportId) const transport = getTransport(transportId) await transport.close() transportMap.delete(transportId) }) export function closeAllTransports() { for (const [id, transport] of transportMap.entries()) { transport.close().catch((err) => { logger.error('close stdio transport', id, err) }) } } ================================================ FILE: src/main/mcp/shell-env.cjs ================================================ var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __hasOwnProp = Object.prototype.hasOwnProperty; var __toESM = (mod, isNodeMode, target) => { target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: () => mod[key], enumerable: true }); return to; }; var __moduleCache = /* @__PURE__ */ new WeakMap; var __toCommonJS = (from) => { var entry = __moduleCache.get(from), desc; if (entry) return entry; entry = __defProp({}, "__esModule", { value: true }); if (from && typeof from === "object" || typeof from === "function") __getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable })); __moduleCache.set(from, entry); return entry; }; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true, configurable: true, set: (newValue) => all[name] = () => newValue }); }; // node_modules/isexe/windows.js var require_windows = __commonJS((exports2, module2) => { module2.exports = isexe; isexe.sync = sync; var fs = require("fs"); function checkPathExt(path, options) { var pathext = options.pathExt !== undefined ? options.pathExt : process.env.PATHEXT; if (!pathext) { return true; } pathext = pathext.split(";"); if (pathext.indexOf("") !== -1) { return true; } for (var i = 0;i < pathext.length; i++) { var p = pathext[i].toLowerCase(); if (p && path.substr(-p.length).toLowerCase() === p) { return true; } } return false; } function checkStat(stat, path, options) { if (!stat.isSymbolicLink() && !stat.isFile()) { return false; } return checkPathExt(path, options); } function isexe(path, options, cb) { fs.stat(path, function(er, stat) { cb(er, er ? false : checkStat(stat, path, options)); }); } function sync(path, options) { return checkStat(fs.statSync(path), path, options); } }); // node_modules/isexe/mode.js var require_mode = __commonJS((exports2, module2) => { module2.exports = isexe; isexe.sync = sync; var fs = require("fs"); function isexe(path, options, cb) { fs.stat(path, function(er, stat) { cb(er, er ? false : checkStat(stat, options)); }); } function sync(path, options) { return checkStat(fs.statSync(path), options); } function checkStat(stat, options) { return stat.isFile() && checkMode(stat, options); } function checkMode(stat, options) { var mod = stat.mode; var uid = stat.uid; var gid = stat.gid; var myUid = options.uid !== undefined ? options.uid : process.getuid && process.getuid(); var myGid = options.gid !== undefined ? options.gid : process.getgid && process.getgid(); var u = parseInt("100", 8); var g = parseInt("010", 8); var o = parseInt("001", 8); var ug = u | g; var ret = mod & o || mod & g && gid === myGid || mod & u && uid === myUid || mod & ug && myUid === 0; return ret; } }); // node_modules/isexe/index.js var require_isexe = __commonJS((exports2, module2) => { var fs = require("fs"); var core; if (process.platform === "win32" || global.TESTING_WINDOWS) { core = require_windows(); } else { core = require_mode(); } module2.exports = isexe; isexe.sync = sync; function isexe(path, options, cb) { if (typeof options === "function") { cb = options; options = {}; } if (!cb) { if (typeof Promise !== "function") { throw new TypeError("callback not provided"); } return new Promise(function(resolve, reject) { isexe(path, options || {}, function(er, is) { if (er) { reject(er); } else { resolve(is); } }); }); } core(path, options || {}, function(er, is) { if (er) { if (er.code === "EACCES" || options && options.ignoreErrors) { er = null; is = false; } } cb(er, is); }); } function sync(path, options) { try { return core.sync(path, options || {}); } catch (er) { if (options && options.ignoreErrors || er.code === "EACCES") { return false; } else { throw er; } } } }); // node_modules/which/which.js var require_which = __commonJS((exports2, module2) => { var isWindows = process.platform === "win32" || process.env.OSTYPE === "cygwin" || process.env.OSTYPE === "msys"; var path = require("path"); var COLON = isWindows ? ";" : ":"; var isexe = require_isexe(); var getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" }); var getPathInfo = (cmd, opt) => { const colon = opt.colon || COLON; const pathEnv = cmd.match(/\//) || isWindows && cmd.match(/\\/) ? [""] : [ ...isWindows ? [process.cwd()] : [], ...(opt.path || process.env.PATH || "").split(colon) ]; const pathExtExe = isWindows ? opt.pathExt || process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM" : ""; const pathExt = isWindows ? pathExtExe.split(colon) : [""]; if (isWindows) { if (cmd.indexOf(".") !== -1 && pathExt[0] !== "") pathExt.unshift(""); } return { pathEnv, pathExt, pathExtExe }; }; var which = (cmd, opt, cb) => { if (typeof opt === "function") { cb = opt; opt = {}; } if (!opt) opt = {}; const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt); const found = []; const step = (i) => new Promise((resolve, reject) => { if (i === pathEnv.length) return opt.all && found.length ? resolve(found) : reject(getNotFoundError(cmd)); const ppRaw = pathEnv[i]; const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; const pCmd = path.join(pathPart, cmd); const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; resolve(subStep(p, i, 0)); }); const subStep = (p, i, ii) => new Promise((resolve, reject) => { if (ii === pathExt.length) return resolve(step(i + 1)); const ext = pathExt[ii]; isexe(p + ext, { pathExt: pathExtExe }, (er, is) => { if (!er && is) { if (opt.all) found.push(p + ext); else return resolve(p + ext); } return resolve(subStep(p, i, ii + 1)); }); }); return cb ? step(0).then((res) => cb(null, res), cb) : step(0); }; var whichSync = (cmd, opt) => { opt = opt || {}; const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt); const found = []; for (let i = 0;i < pathEnv.length; i++) { const ppRaw = pathEnv[i]; const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; const pCmd = path.join(pathPart, cmd); const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; for (let j = 0;j < pathExt.length; j++) { const cur = p + pathExt[j]; try { const is = isexe.sync(cur, { pathExt: pathExtExe }); if (is) { if (opt.all) found.push(cur); else return cur; } } catch (ex) {} } } if (opt.all && found.length) return found; if (opt.nothrow) return null; throw getNotFoundError(cmd); }; module2.exports = which; which.sync = whichSync; }); // node_modules/path-key/index.js var require_path_key = __commonJS((exports2, module2) => { var pathKey = (options = {}) => { const environment = options.env || process.env; const platform = options.platform || process.platform; if (platform !== "win32") { return "PATH"; } return Object.keys(environment).reverse().find((key) => key.toUpperCase() === "PATH") || "Path"; }; module2.exports = pathKey; module2.exports.default = pathKey; }); // node_modules/cross-spawn/lib/util/resolveCommand.js var require_resolveCommand = __commonJS((exports2, module2) => { var path = require("path"); var which = require_which(); var getPathKey = require_path_key(); function resolveCommandAttempt(parsed, withoutPathExt) { const env = parsed.options.env || process.env; const cwd = process.cwd(); const hasCustomCwd = parsed.options.cwd != null; const shouldSwitchCwd = hasCustomCwd && process.chdir !== undefined && !process.chdir.disabled; if (shouldSwitchCwd) { try { process.chdir(parsed.options.cwd); } catch (err) {} } let resolved; try { resolved = which.sync(parsed.command, { path: env[getPathKey({ env })], pathExt: withoutPathExt ? path.delimiter : undefined }); } catch (e) {} finally { if (shouldSwitchCwd) { process.chdir(cwd); } } if (resolved) { resolved = path.resolve(hasCustomCwd ? parsed.options.cwd : "", resolved); } return resolved; } function resolveCommand(parsed) { return resolveCommandAttempt(parsed) || resolveCommandAttempt(parsed, true); } module2.exports = resolveCommand; }); // node_modules/cross-spawn/lib/util/escape.js var require_escape = __commonJS((exports2, module2) => { var metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g; function escapeCommand(arg) { arg = arg.replace(metaCharsRegExp, "^$1"); return arg; } function escapeArgument(arg, doubleEscapeMetaChars) { arg = `${arg}`; arg = arg.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\""); arg = arg.replace(/(?=(\\+?)?)\1$/, "$1$1"); arg = `"${arg}"`; arg = arg.replace(metaCharsRegExp, "^$1"); if (doubleEscapeMetaChars) { arg = arg.replace(metaCharsRegExp, "^$1"); } return arg; } module2.exports.command = escapeCommand; module2.exports.argument = escapeArgument; }); // node_modules/shebang-regex/index.js var require_shebang_regex = __commonJS((exports2, module2) => { module2.exports = /^#!(.*)/; }); // node_modules/shebang-command/index.js var require_shebang_command = __commonJS((exports2, module2) => { var shebangRegex = require_shebang_regex(); module2.exports = (string = "") => { const match = string.match(shebangRegex); if (!match) { return null; } const [path, argument] = match[0].replace(/#! ?/, "").split(" "); const binary = path.split("/").pop(); if (binary === "env") { return argument; } return argument ? `${binary} ${argument}` : binary; }; }); // node_modules/cross-spawn/lib/util/readShebang.js var require_readShebang = __commonJS((exports2, module2) => { var fs = require("fs"); var shebangCommand = require_shebang_command(); function readShebang(command) { const size = 150; const buffer = Buffer.alloc(size); let fd; try { fd = fs.openSync(command, "r"); fs.readSync(fd, buffer, 0, size, 0); fs.closeSync(fd); } catch (e) {} return shebangCommand(buffer.toString()); } module2.exports = readShebang; }); // node_modules/cross-spawn/lib/parse.js var require_parse = __commonJS((exports2, module2) => { var path = require("path"); var resolveCommand = require_resolveCommand(); var escape = require_escape(); var readShebang = require_readShebang(); var isWin = process.platform === "win32"; var isExecutableRegExp = /\.(?:com|exe)$/i; var isCmdShimRegExp = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i; function detectShebang(parsed) { parsed.file = resolveCommand(parsed); const shebang = parsed.file && readShebang(parsed.file); if (shebang) { parsed.args.unshift(parsed.file); parsed.command = shebang; return resolveCommand(parsed); } return parsed.file; } function parseNonShell(parsed) { if (!isWin) { return parsed; } const commandFile = detectShebang(parsed); const needsShell = !isExecutableRegExp.test(commandFile); if (parsed.options.forceShell || needsShell) { const needsDoubleEscapeMetaChars = isCmdShimRegExp.test(commandFile); parsed.command = path.normalize(parsed.command); parsed.command = escape.command(parsed.command); parsed.args = parsed.args.map((arg) => escape.argument(arg, needsDoubleEscapeMetaChars)); const shellCommand = [parsed.command].concat(parsed.args).join(" "); parsed.args = ["/d", "/s", "/c", `"${shellCommand}"`]; parsed.command = process.env.comspec || "cmd.exe"; parsed.options.windowsVerbatimArguments = true; } return parsed; } function parse(command, args, options) { if (args && !Array.isArray(args)) { options = args; args = null; } args = args ? args.slice(0) : []; options = Object.assign({}, options); const parsed = { command, args, options, file: undefined, original: { command, args } }; return options.shell ? parsed : parseNonShell(parsed); } module2.exports = parse; }); // node_modules/cross-spawn/lib/enoent.js var require_enoent = __commonJS((exports2, module2) => { var isWin = process.platform === "win32"; function notFoundError(original, syscall) { return Object.assign(new Error(`${syscall} ${original.command} ENOENT`), { code: "ENOENT", errno: "ENOENT", syscall: `${syscall} ${original.command}`, path: original.command, spawnargs: original.args }); } function hookChildProcess(cp, parsed) { if (!isWin) { return; } const originalEmit = cp.emit; cp.emit = function(name, arg1) { if (name === "exit") { const err = verifyENOENT(arg1, parsed); if (err) { return originalEmit.call(cp, "error", err); } } return originalEmit.apply(cp, arguments); }; } function verifyENOENT(status, parsed) { if (isWin && status === 1 && !parsed.file) { return notFoundError(parsed.original, "spawn"); } return null; } function verifyENOENTSync(status, parsed) { if (isWin && status === 1 && !parsed.file) { return notFoundError(parsed.original, "spawnSync"); } return null; } module2.exports = { hookChildProcess, verifyENOENT, verifyENOENTSync, notFoundError }; }); // node_modules/cross-spawn/index.js var require_cross_spawn = __commonJS((exports2, module2) => { var cp = require("child_process"); var parse = require_parse(); var enoent = require_enoent(); function spawn(command, args, options) { const parsed = parse(command, args, options); const spawned = cp.spawn(parsed.command, parsed.args, parsed.options); enoent.hookChildProcess(spawned, parsed); return spawned; } function spawnSync(command, args, options) { const parsed = parse(command, args, options); const result = cp.spawnSync(parsed.command, parsed.args, parsed.options); result.error = result.error || enoent.verifyENOENTSync(result.status, parsed); return result; } module2.exports = spawn; module2.exports.spawn = spawn; module2.exports.sync = spawnSync; module2.exports._parse = parse; module2.exports._enoent = enoent; }); // node_modules/strip-final-newline/index.js var require_strip_final_newline = __commonJS((exports2, module2) => { module2.exports = (input) => { const LF = typeof input === "string" ? ` ` : ` `.charCodeAt(); const CR = typeof input === "string" ? "\r" : "\r".charCodeAt(); if (input[input.length - 1] === LF) { input = input.slice(0, input.length - 1); } if (input[input.length - 1] === CR) { input = input.slice(0, input.length - 1); } return input; }; }); // node_modules/npm-run-path/index.js var require_npm_run_path = __commonJS((exports2, module2) => { var path = require("path"); var pathKey = require_path_key(); var npmRunPath = (options) => { options = { cwd: process.cwd(), path: process.env[pathKey()], execPath: process.execPath, ...options }; let previous; let cwdPath = path.resolve(options.cwd); const result = []; while (previous !== cwdPath) { result.push(path.join(cwdPath, "node_modules/.bin")); previous = cwdPath; cwdPath = path.resolve(cwdPath, ".."); } const execPathDir = path.resolve(options.cwd, options.execPath, ".."); result.push(execPathDir); return result.concat(options.path).join(path.delimiter); }; module2.exports = npmRunPath; module2.exports.default = npmRunPath; module2.exports.env = (options) => { options = { env: process.env, ...options }; const env = { ...options.env }; const path2 = pathKey({ env }); options.path = env[path2]; env[path2] = module2.exports(options); return env; }; }); // node_modules/mimic-fn/index.js var require_mimic_fn = __commonJS((exports2, module2) => { var mimicFn = (to, from) => { for (const prop of Reflect.ownKeys(from)) { Object.defineProperty(to, prop, Object.getOwnPropertyDescriptor(from, prop)); } return to; }; module2.exports = mimicFn; module2.exports.default = mimicFn; }); // node_modules/onetime/index.js var require_onetime = __commonJS((exports2, module2) => { var mimicFn = require_mimic_fn(); var calledFunctions = new WeakMap; var onetime = (function_, options = {}) => { if (typeof function_ !== "function") { throw new TypeError("Expected a function"); } let returnValue; let callCount = 0; const functionName = function_.displayName || function_.name || ""; const onetime2 = function(...arguments_) { calledFunctions.set(onetime2, ++callCount); if (callCount === 1) { returnValue = function_.apply(this, arguments_); function_ = null; } else if (options.throw === true) { throw new Error(`Function \`${functionName}\` can only be called once`); } return returnValue; }; mimicFn(onetime2, function_); calledFunctions.set(onetime2, callCount); return onetime2; }; module2.exports = onetime; module2.exports.default = onetime; module2.exports.callCount = (function_) => { if (!calledFunctions.has(function_)) { throw new Error(`The given function \`${function_.name}\` is not wrapped by the \`onetime\` package`); } return calledFunctions.get(function_); }; }); // node_modules/human-signals/build/src/core.js var require_core = __commonJS((exports2) => { Object.defineProperty(exports2, "__esModule", { value: true }); exports2.SIGNALS = undefined; var SIGNALS = [ { name: "SIGHUP", number: 1, action: "terminate", description: "Terminal closed", standard: "posix" }, { name: "SIGINT", number: 2, action: "terminate", description: "User interruption with CTRL-C", standard: "ansi" }, { name: "SIGQUIT", number: 3, action: "core", description: "User interruption with CTRL-\\", standard: "posix" }, { name: "SIGILL", number: 4, action: "core", description: "Invalid machine instruction", standard: "ansi" }, { name: "SIGTRAP", number: 5, action: "core", description: "Debugger breakpoint", standard: "posix" }, { name: "SIGABRT", number: 6, action: "core", description: "Aborted", standard: "ansi" }, { name: "SIGIOT", number: 6, action: "core", description: "Aborted", standard: "bsd" }, { name: "SIGBUS", number: 7, action: "core", description: "Bus error due to misaligned, non-existing address or paging error", standard: "bsd" }, { name: "SIGEMT", number: 7, action: "terminate", description: "Command should be emulated but is not implemented", standard: "other" }, { name: "SIGFPE", number: 8, action: "core", description: "Floating point arithmetic error", standard: "ansi" }, { name: "SIGKILL", number: 9, action: "terminate", description: "Forced termination", standard: "posix", forced: true }, { name: "SIGUSR1", number: 10, action: "terminate", description: "Application-specific signal", standard: "posix" }, { name: "SIGSEGV", number: 11, action: "core", description: "Segmentation fault", standard: "ansi" }, { name: "SIGUSR2", number: 12, action: "terminate", description: "Application-specific signal", standard: "posix" }, { name: "SIGPIPE", number: 13, action: "terminate", description: "Broken pipe or socket", standard: "posix" }, { name: "SIGALRM", number: 14, action: "terminate", description: "Timeout or timer", standard: "posix" }, { name: "SIGTERM", number: 15, action: "terminate", description: "Termination", standard: "ansi" }, { name: "SIGSTKFLT", number: 16, action: "terminate", description: "Stack is empty or overflowed", standard: "other" }, { name: "SIGCHLD", number: 17, action: "ignore", description: "Child process terminated, paused or unpaused", standard: "posix" }, { name: "SIGCLD", number: 17, action: "ignore", description: "Child process terminated, paused or unpaused", standard: "other" }, { name: "SIGCONT", number: 18, action: "unpause", description: "Unpaused", standard: "posix", forced: true }, { name: "SIGSTOP", number: 19, action: "pause", description: "Paused", standard: "posix", forced: true }, { name: "SIGTSTP", number: 20, action: "pause", description: 'Paused using CTRL-Z or "suspend"', standard: "posix" }, { name: "SIGTTIN", number: 21, action: "pause", description: "Background process cannot read terminal input", standard: "posix" }, { name: "SIGBREAK", number: 21, action: "terminate", description: "User interruption with CTRL-BREAK", standard: "other" }, { name: "SIGTTOU", number: 22, action: "pause", description: "Background process cannot write to terminal output", standard: "posix" }, { name: "SIGURG", number: 23, action: "ignore", description: "Socket received out-of-band data", standard: "bsd" }, { name: "SIGXCPU", number: 24, action: "core", description: "Process timed out", standard: "bsd" }, { name: "SIGXFSZ", number: 25, action: "core", description: "File too big", standard: "bsd" }, { name: "SIGVTALRM", number: 26, action: "terminate", description: "Timeout or timer", standard: "bsd" }, { name: "SIGPROF", number: 27, action: "terminate", description: "Timeout or timer", standard: "bsd" }, { name: "SIGWINCH", number: 28, action: "ignore", description: "Terminal window size changed", standard: "bsd" }, { name: "SIGIO", number: 29, action: "terminate", description: "I/O is available", standard: "other" }, { name: "SIGPOLL", number: 29, action: "terminate", description: "Watched event", standard: "other" }, { name: "SIGINFO", number: 29, action: "ignore", description: "Request for process information", standard: "other" }, { name: "SIGPWR", number: 30, action: "terminate", description: "Device running out of power", standard: "systemv" }, { name: "SIGSYS", number: 31, action: "core", description: "Invalid system call", standard: "other" }, { name: "SIGUNUSED", number: 31, action: "terminate", description: "Invalid system call", standard: "other" } ]; exports2.SIGNALS = SIGNALS; }); // node_modules/human-signals/build/src/realtime.js var require_realtime = __commonJS((exports2) => { Object.defineProperty(exports2, "__esModule", { value: true }); exports2.SIGRTMAX = exports2.getRealtimeSignals = undefined; var getRealtimeSignals = function() { const length = SIGRTMAX - SIGRTMIN + 1; return Array.from({ length }, getRealtimeSignal); }; exports2.getRealtimeSignals = getRealtimeSignals; var getRealtimeSignal = function(value, index) { return { name: `SIGRT${index + 1}`, number: SIGRTMIN + index, action: "terminate", description: "Application-specific signal (realtime)", standard: "posix" }; }; var SIGRTMIN = 34; var SIGRTMAX = 64; exports2.SIGRTMAX = SIGRTMAX; }); // node_modules/human-signals/build/src/signals.js var require_signals = __commonJS((exports2) => { Object.defineProperty(exports2, "__esModule", { value: true }); exports2.getSignals = undefined; var _os = require("os"); var _core = require_core(); var _realtime = require_realtime(); var getSignals = function() { const realtimeSignals = (0, _realtime.getRealtimeSignals)(); const signals = [..._core.SIGNALS, ...realtimeSignals].map(normalizeSignal); return signals; }; exports2.getSignals = getSignals; var normalizeSignal = function({ name, number: defaultNumber, description, action, forced = false, standard }) { const { signals: { [name]: constantSignal } } = _os.constants; const supported = constantSignal !== undefined; const number = supported ? constantSignal : defaultNumber; return { name, number, description, supported, action, forced, standard }; }; }); // node_modules/human-signals/build/src/main.js var require_main = __commonJS((exports2) => { Object.defineProperty(exports2, "__esModule", { value: true }); exports2.signalsByNumber = exports2.signalsByName = undefined; var _os = require("os"); var _signals = require_signals(); var _realtime = require_realtime(); var getSignalsByName = function() { const signals = (0, _signals.getSignals)(); return signals.reduce(getSignalByName, {}); }; var getSignalByName = function(signalByNameMemo, { name, number, description, supported, action, forced, standard }) { return { ...signalByNameMemo, [name]: { name, number, description, supported, action, forced, standard } }; }; var signalsByName = getSignalsByName(); exports2.signalsByName = signalsByName; var getSignalsByNumber = function() { const signals = (0, _signals.getSignals)(); const length = _realtime.SIGRTMAX + 1; const signalsA = Array.from({ length }, (value, number) => getSignalByNumber(number, signals)); return Object.assign({}, ...signalsA); }; var getSignalByNumber = function(number, signals) { const signal = findSignalByNumber(number, signals); if (signal === undefined) { return {}; } const { name, description, supported, action, forced, standard } = signal; return { [number]: { name, number, description, supported, action, forced, standard } }; }; var findSignalByNumber = function(number, signals) { const signal = signals.find(({ name }) => _os.constants.signals[name] === number); if (signal !== undefined) { return signal; } return signals.find((signalA) => signalA.number === number); }; var signalsByNumber = getSignalsByNumber(); exports2.signalsByNumber = signalsByNumber; }); // node_modules/execa/lib/error.js var require_error = __commonJS((exports2, module2) => { var { signalsByName } = require_main(); var getErrorPrefix = ({ timedOut, timeout, errorCode, signal, signalDescription, exitCode, isCanceled }) => { if (timedOut) { return `timed out after ${timeout} milliseconds`; } if (isCanceled) { return "was canceled"; } if (errorCode !== undefined) { return `failed with ${errorCode}`; } if (signal !== undefined) { return `was killed with ${signal} (${signalDescription})`; } if (exitCode !== undefined) { return `failed with exit code ${exitCode}`; } return "failed"; }; var makeError = ({ stdout, stderr, all, error, signal, exitCode, command, escapedCommand, timedOut, isCanceled, killed, parsed: { options: { timeout } } }) => { exitCode = exitCode === null ? undefined : exitCode; signal = signal === null ? undefined : signal; const signalDescription = signal === undefined ? undefined : signalsByName[signal].description; const errorCode = error && error.code; const prefix = getErrorPrefix({ timedOut, timeout, errorCode, signal, signalDescription, exitCode, isCanceled }); const execaMessage = `Command ${prefix}: ${command}`; const isError = Object.prototype.toString.call(error) === "[object Error]"; const shortMessage = isError ? `${execaMessage} ${error.message}` : execaMessage; const message = [shortMessage, stderr, stdout].filter(Boolean).join(` `); if (isError) { error.originalMessage = error.message; error.message = message; } else { error = new Error(message); } error.shortMessage = shortMessage; error.command = command; error.escapedCommand = escapedCommand; error.exitCode = exitCode; error.signal = signal; error.signalDescription = signalDescription; error.stdout = stdout; error.stderr = stderr; if (all !== undefined) { error.all = all; } if ("bufferedData" in error) { delete error.bufferedData; } error.failed = true; error.timedOut = Boolean(timedOut); error.isCanceled = isCanceled; error.killed = killed && !timedOut; return error; }; module2.exports = makeError; }); // node_modules/execa/lib/stdio.js var require_stdio = __commonJS((exports2, module2) => { var aliases = ["stdin", "stdout", "stderr"]; var hasAlias = (options) => aliases.some((alias) => options[alias] !== undefined); var normalizeStdio = (options) => { if (!options) { return; } const { stdio } = options; if (stdio === undefined) { return aliases.map((alias) => options[alias]); } if (hasAlias(options)) { throw new Error(`It's not possible to provide \`stdio\` in combination with one of ${aliases.map((alias) => `\`${alias}\``).join(", ")}`); } if (typeof stdio === "string") { return stdio; } if (!Array.isArray(stdio)) { throw new TypeError(`Expected \`stdio\` to be of type \`string\` or \`Array\`, got \`${typeof stdio}\``); } const length = Math.max(stdio.length, aliases.length); return Array.from({ length }, (value, index) => stdio[index]); }; module2.exports = normalizeStdio; module2.exports.node = (options) => { const stdio = normalizeStdio(options); if (stdio === "ipc") { return "ipc"; } if (stdio === undefined || typeof stdio === "string") { return [stdio, stdio, stdio, "ipc"]; } if (stdio.includes("ipc")) { return stdio; } return [...stdio, "ipc"]; }; }); // node_modules/signal-exit/signals.js var require_signals2 = __commonJS((exports2, module2) => { module2.exports = [ "SIGABRT", "SIGALRM", "SIGHUP", "SIGINT", "SIGTERM" ]; if (process.platform !== "win32") { module2.exports.push("SIGVTALRM", "SIGXCPU", "SIGXFSZ", "SIGUSR2", "SIGTRAP", "SIGSYS", "SIGQUIT", "SIGIOT"); } if (process.platform === "linux") { module2.exports.push("SIGIO", "SIGPOLL", "SIGPWR", "SIGSTKFLT", "SIGUNUSED"); } }); // node_modules/signal-exit/index.js var require_signal_exit = __commonJS((exports2, module2) => { var process2 = global.process; var processOk = function(process3) { return process3 && typeof process3 === "object" && typeof process3.removeListener === "function" && typeof process3.emit === "function" && typeof process3.reallyExit === "function" && typeof process3.listeners === "function" && typeof process3.kill === "function" && typeof process3.pid === "number" && typeof process3.on === "function"; }; if (!processOk(process2)) { module2.exports = function() { return function() {}; }; } else { assert = require("assert"); signals = require_signals2(); isWin = /^win/i.test(process2.platform); EE = require("events"); if (typeof EE !== "function") { EE = EE.EventEmitter; } if (process2.__signal_exit_emitter__) { emitter = process2.__signal_exit_emitter__; } else { emitter = process2.__signal_exit_emitter__ = new EE; emitter.count = 0; emitter.emitted = {}; } if (!emitter.infinite) { emitter.setMaxListeners(Infinity); emitter.infinite = true; } module2.exports = function(cb, opts) { if (!processOk(global.process)) { return function() {}; } assert.equal(typeof cb, "function", "a callback must be provided for exit handler"); if (loaded === false) { load(); } var ev = "exit"; if (opts && opts.alwaysLast) { ev = "afterexit"; } var remove = function() { emitter.removeListener(ev, cb); if (emitter.listeners("exit").length === 0 && emitter.listeners("afterexit").length === 0) { unload(); } }; emitter.on(ev, cb); return remove; }; unload = function unload() { if (!loaded || !processOk(global.process)) { return; } loaded = false; signals.forEach(function(sig) { try { process2.removeListener(sig, sigListeners[sig]); } catch (er) {} }); process2.emit = originalProcessEmit; process2.reallyExit = originalProcessReallyExit; emitter.count -= 1; }; module2.exports.unload = unload; emit = function emit(event, code, signal) { if (emitter.emitted[event]) { return; } emitter.emitted[event] = true; emitter.emit(event, code, signal); }; sigListeners = {}; signals.forEach(function(sig) { sigListeners[sig] = function listener() { if (!processOk(global.process)) { return; } var listeners = process2.listeners(sig); if (listeners.length === emitter.count) { unload(); emit("exit", null, sig); emit("afterexit", null, sig); if (isWin && sig === "SIGHUP") { sig = "SIGINT"; } process2.kill(process2.pid, sig); } }; }); module2.exports.signals = function() { return signals; }; loaded = false; load = function load() { if (loaded || !processOk(global.process)) { return; } loaded = true; emitter.count += 1; signals = signals.filter(function(sig) { try { process2.on(sig, sigListeners[sig]); return true; } catch (er) { return false; } }); process2.emit = processEmit; process2.reallyExit = processReallyExit; }; module2.exports.load = load; originalProcessReallyExit = process2.reallyExit; processReallyExit = function processReallyExit(code) { if (!processOk(global.process)) { return; } process2.exitCode = code || 0; emit("exit", process2.exitCode, null); emit("afterexit", process2.exitCode, null); originalProcessReallyExit.call(process2, process2.exitCode); }; originalProcessEmit = process2.emit; processEmit = function processEmit(ev, arg) { if (ev === "exit" && processOk(global.process)) { if (arg !== undefined) { process2.exitCode = arg; } var ret = originalProcessEmit.apply(this, arguments); emit("exit", process2.exitCode, null); emit("afterexit", process2.exitCode, null); return ret; } else { return originalProcessEmit.apply(this, arguments); } }; } var assert; var signals; var isWin; var EE; var emitter; var unload; var emit; var sigListeners; var loaded; var load; var originalProcessReallyExit; var processReallyExit; var originalProcessEmit; var processEmit; }); // node_modules/execa/lib/kill.js var require_kill = __commonJS((exports2, module2) => { var os = require("os"); var onExit = require_signal_exit(); var DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5; var spawnedKill = (kill, signal = "SIGTERM", options = {}) => { const killResult = kill(signal); setKillTimeout(kill, signal, options, killResult); return killResult; }; var setKillTimeout = (kill, signal, options, killResult) => { if (!shouldForceKill(signal, options, killResult)) { return; } const timeout = getForceKillAfterTimeout(options); const t = setTimeout(() => { kill("SIGKILL"); }, timeout); if (t.unref) { t.unref(); } }; var shouldForceKill = (signal, { forceKillAfterTimeout }, killResult) => { return isSigterm(signal) && forceKillAfterTimeout !== false && killResult; }; var isSigterm = (signal) => { return signal === os.constants.signals.SIGTERM || typeof signal === "string" && signal.toUpperCase() === "SIGTERM"; }; var getForceKillAfterTimeout = ({ forceKillAfterTimeout = true }) => { if (forceKillAfterTimeout === true) { return DEFAULT_FORCE_KILL_TIMEOUT; } if (!Number.isFinite(forceKillAfterTimeout) || forceKillAfterTimeout < 0) { throw new TypeError(`Expected the \`forceKillAfterTimeout\` option to be a non-negative integer, got \`${forceKillAfterTimeout}\` (${typeof forceKillAfterTimeout})`); } return forceKillAfterTimeout; }; var spawnedCancel = (spawned, context) => { const killResult = spawned.kill(); if (killResult) { context.isCanceled = true; } }; var timeoutKill = (spawned, signal, reject) => { spawned.kill(signal); reject(Object.assign(new Error("Timed out"), { timedOut: true, signal })); }; var setupTimeout = (spawned, { timeout, killSignal = "SIGTERM" }, spawnedPromise) => { if (timeout === 0 || timeout === undefined) { return spawnedPromise; } let timeoutId; const timeoutPromise = new Promise((resolve, reject) => { timeoutId = setTimeout(() => { timeoutKill(spawned, killSignal, reject); }, timeout); }); const safeSpawnedPromise = spawnedPromise.finally(() => { clearTimeout(timeoutId); }); return Promise.race([timeoutPromise, safeSpawnedPromise]); }; var validateTimeout = ({ timeout }) => { if (timeout !== undefined && (!Number.isFinite(timeout) || timeout < 0)) { throw new TypeError(`Expected the \`timeout\` option to be a non-negative integer, got \`${timeout}\` (${typeof timeout})`); } }; var setExitHandler = async (spawned, { cleanup, detached }, timedPromise) => { if (!cleanup || detached) { return timedPromise; } const removeExitHandler = onExit(() => { spawned.kill(); }); return timedPromise.finally(() => { removeExitHandler(); }); }; module2.exports = { spawnedKill, spawnedCancel, setupTimeout, validateTimeout, setExitHandler }; }); // node_modules/is-stream/index.js var require_is_stream = __commonJS((exports2, module2) => { var isStream = (stream) => stream !== null && typeof stream === "object" && typeof stream.pipe === "function"; isStream.writable = (stream) => isStream(stream) && stream.writable !== false && typeof stream._write === "function" && typeof stream._writableState === "object"; isStream.readable = (stream) => isStream(stream) && stream.readable !== false && typeof stream._read === "function" && typeof stream._readableState === "object"; isStream.duplex = (stream) => isStream.writable(stream) && isStream.readable(stream); isStream.transform = (stream) => isStream.duplex(stream) && typeof stream._transform === "function"; module2.exports = isStream; }); // node_modules/get-stream/buffer-stream.js var require_buffer_stream = __commonJS((exports2, module2) => { var { PassThrough: PassThroughStream } = require("stream"); module2.exports = (options) => { options = { ...options }; const { array } = options; let { encoding } = options; const isBuffer = encoding === "buffer"; let objectMode = false; if (array) { objectMode = !(encoding || isBuffer); } else { encoding = encoding || "utf8"; } if (isBuffer) { encoding = null; } const stream = new PassThroughStream({ objectMode }); if (encoding) { stream.setEncoding(encoding); } let length = 0; const chunks = []; stream.on("data", (chunk) => { chunks.push(chunk); if (objectMode) { length = chunks.length; } else { length += chunk.length; } }); stream.getBufferedValue = () => { if (array) { return chunks; } return isBuffer ? Buffer.concat(chunks, length) : chunks.join(""); }; stream.getBufferedLength = () => length; return stream; }; }); // node_modules/get-stream/index.js var require_get_stream = __commonJS((exports2, module2) => { var { constants: BufferConstants } = require("buffer"); var stream = require("stream"); var { promisify } = require("util"); var bufferStream = require_buffer_stream(); var streamPipelinePromisified = promisify(stream.pipeline); class MaxBufferError extends Error { constructor() { super("maxBuffer exceeded"); this.name = "MaxBufferError"; } } async function getStream(inputStream, options) { if (!inputStream) { throw new Error("Expected a stream"); } options = { maxBuffer: Infinity, ...options }; const { maxBuffer } = options; const stream2 = bufferStream(options); await new Promise((resolve, reject) => { const rejectPromise = (error) => { if (error && stream2.getBufferedLength() <= BufferConstants.MAX_LENGTH) { error.bufferedData = stream2.getBufferedValue(); } reject(error); }; (async () => { try { await streamPipelinePromisified(inputStream, stream2); resolve(); } catch (error) { rejectPromise(error); } })(); stream2.on("data", () => { if (stream2.getBufferedLength() > maxBuffer) { rejectPromise(new MaxBufferError); } }); }); return stream2.getBufferedValue(); } module2.exports = getStream; module2.exports.buffer = (stream2, options) => getStream(stream2, { ...options, encoding: "buffer" }); module2.exports.array = (stream2, options) => getStream(stream2, { ...options, array: true }); module2.exports.MaxBufferError = MaxBufferError; }); // node_modules/merge-stream/index.js var require_merge_stream = __commonJS((exports2, module2) => { var { PassThrough } = require("stream"); module2.exports = function() { var sources = []; var output = new PassThrough({ objectMode: true }); output.setMaxListeners(0); output.add = add; output.isEmpty = isEmpty; output.on("unpipe", remove); Array.prototype.slice.call(arguments).forEach(add); return output; function add(source) { if (Array.isArray(source)) { source.forEach(add); return this; } sources.push(source); source.once("end", remove.bind(null, source)); source.once("error", output.emit.bind(output, "error")); source.pipe(output, { end: false }); return this; } function isEmpty() { return sources.length == 0; } function remove(source) { sources = sources.filter(function(it) { return it !== source; }); if (!sources.length && output.readable) { output.end(); } } }; }); // node_modules/execa/lib/stream.js var require_stream = __commonJS((exports2, module2) => { var isStream = require_is_stream(); var getStream = require_get_stream(); var mergeStream = require_merge_stream(); var handleInput = (spawned, input) => { if (input === undefined || spawned.stdin === undefined) { return; } if (isStream(input)) { input.pipe(spawned.stdin); } else { spawned.stdin.end(input); } }; var makeAllStream = (spawned, { all }) => { if (!all || !spawned.stdout && !spawned.stderr) { return; } const mixed = mergeStream(); if (spawned.stdout) { mixed.add(spawned.stdout); } if (spawned.stderr) { mixed.add(spawned.stderr); } return mixed; }; var getBufferedData = async (stream, streamPromise) => { if (!stream) { return; } stream.destroy(); try { return await streamPromise; } catch (error) { return error.bufferedData; } }; var getStreamPromise = (stream, { encoding, buffer, maxBuffer }) => { if (!stream || !buffer) { return; } if (encoding) { return getStream(stream, { encoding, maxBuffer }); } return getStream.buffer(stream, { maxBuffer }); }; var getSpawnedResult = async ({ stdout, stderr, all }, { encoding, buffer, maxBuffer }, processDone) => { const stdoutPromise = getStreamPromise(stdout, { encoding, buffer, maxBuffer }); const stderrPromise = getStreamPromise(stderr, { encoding, buffer, maxBuffer }); const allPromise = getStreamPromise(all, { encoding, buffer, maxBuffer: maxBuffer * 2 }); try { return await Promise.all([processDone, stdoutPromise, stderrPromise, allPromise]); } catch (error) { return Promise.all([ { error, signal: error.signal, timedOut: error.timedOut }, getBufferedData(stdout, stdoutPromise), getBufferedData(stderr, stderrPromise), getBufferedData(all, allPromise) ]); } }; var validateInputSync = ({ input }) => { if (isStream(input)) { throw new TypeError("The `input` option cannot be a stream in sync mode"); } }; module2.exports = { handleInput, makeAllStream, getSpawnedResult, validateInputSync }; }); // node_modules/execa/lib/promise.js var require_promise = __commonJS((exports2, module2) => { var nativePromisePrototype = (async () => {})().constructor.prototype; var descriptors = ["then", "catch", "finally"].map((property) => [ property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property) ]); var mergePromise = (spawned, promise) => { for (const [property, descriptor] of descriptors) { const value = typeof promise === "function" ? (...args) => Reflect.apply(descriptor.value, promise(), args) : descriptor.value.bind(promise); Reflect.defineProperty(spawned, property, { ...descriptor, value }); } return spawned; }; var getSpawnedPromise = (spawned) => { return new Promise((resolve, reject) => { spawned.on("exit", (exitCode, signal) => { resolve({ exitCode, signal }); }); spawned.on("error", (error) => { reject(error); }); if (spawned.stdin) { spawned.stdin.on("error", (error) => { reject(error); }); } }); }; module2.exports = { mergePromise, getSpawnedPromise }; }); // node_modules/execa/lib/command.js var require_command = __commonJS((exports2, module2) => { var normalizeArgs = (file, args = []) => { if (!Array.isArray(args)) { return [file]; } return [file, ...args]; }; var NO_ESCAPE_REGEXP = /^[\w.-]+$/; var DOUBLE_QUOTES_REGEXP = /"/g; var escapeArg = (arg) => { if (typeof arg !== "string" || NO_ESCAPE_REGEXP.test(arg)) { return arg; } return `"${arg.replace(DOUBLE_QUOTES_REGEXP, "\\\"")}"`; }; var joinCommand = (file, args) => { return normalizeArgs(file, args).join(" "); }; var getEscapedCommand = (file, args) => { return normalizeArgs(file, args).map((arg) => escapeArg(arg)).join(" "); }; var SPACES_REGEXP = / +/g; var parseCommand = (command) => { const tokens = []; for (const token of command.trim().split(SPACES_REGEXP)) { const previousToken = tokens[tokens.length - 1]; if (previousToken && previousToken.endsWith("\\")) { tokens[tokens.length - 1] = `${previousToken.slice(0, -1)} ${token}`; } else { tokens.push(token); } } return tokens; }; module2.exports = { joinCommand, getEscapedCommand, parseCommand }; }); // node_modules/execa/index.js var require_execa = __commonJS((exports2, module2) => { var path = require("path"); var childProcess = require("child_process"); var crossSpawn = require_cross_spawn(); var stripFinalNewline = require_strip_final_newline(); var npmRunPath = require_npm_run_path(); var onetime = require_onetime(); var makeError = require_error(); var normalizeStdio = require_stdio(); var { spawnedKill, spawnedCancel, setupTimeout, validateTimeout, setExitHandler } = require_kill(); var { handleInput, getSpawnedResult, makeAllStream, validateInputSync } = require_stream(); var { mergePromise, getSpawnedPromise } = require_promise(); var { joinCommand, parseCommand, getEscapedCommand } = require_command(); var DEFAULT_MAX_BUFFER = 1000 * 1000 * 100; var getEnv = ({ env: envOption, extendEnv, preferLocal, localDir, execPath }) => { const env = extendEnv ? { ...process.env, ...envOption } : envOption; if (preferLocal) { return npmRunPath.env({ env, cwd: localDir, execPath }); } return env; }; var handleArguments = (file, args, options = {}) => { const parsed = crossSpawn._parse(file, args, options); file = parsed.command; args = parsed.args; options = parsed.options; options = { maxBuffer: DEFAULT_MAX_BUFFER, buffer: true, stripFinalNewline: true, extendEnv: true, preferLocal: false, localDir: options.cwd || process.cwd(), execPath: process.execPath, encoding: "utf8", reject: true, cleanup: true, all: false, windowsHide: true, ...options }; options.env = getEnv(options); options.stdio = normalizeStdio(options); if (process.platform === "win32" && path.basename(file, ".exe") === "cmd") { args.unshift("/q"); } return { file, args, options, parsed }; }; var handleOutput = (options, value, error) => { if (typeof value !== "string" && !Buffer.isBuffer(value)) { return error === undefined ? undefined : ""; } if (options.stripFinalNewline) { return stripFinalNewline(value); } return value; }; var execa = (file, args, options) => { const parsed = handleArguments(file, args, options); const command = joinCommand(file, args); const escapedCommand = getEscapedCommand(file, args); validateTimeout(parsed.options); let spawned; try { spawned = childProcess.spawn(parsed.file, parsed.args, parsed.options); } catch (error) { const dummySpawned = new childProcess.ChildProcess; const errorPromise = Promise.reject(makeError({ error, stdout: "", stderr: "", all: "", command, escapedCommand, parsed, timedOut: false, isCanceled: false, killed: false })); return mergePromise(dummySpawned, errorPromise); } const spawnedPromise = getSpawnedPromise(spawned); const timedPromise = setupTimeout(spawned, parsed.options, spawnedPromise); const processDone = setExitHandler(spawned, parsed.options, timedPromise); const context = { isCanceled: false }; spawned.kill = spawnedKill.bind(null, spawned.kill.bind(spawned)); spawned.cancel = spawnedCancel.bind(null, spawned, context); const handlePromise = async () => { const [{ error, exitCode, signal, timedOut }, stdoutResult, stderrResult, allResult] = await getSpawnedResult(spawned, parsed.options, processDone); const stdout = handleOutput(parsed.options, stdoutResult); const stderr = handleOutput(parsed.options, stderrResult); const all = handleOutput(parsed.options, allResult); if (error || exitCode !== 0 || signal !== null) { const returnedError = makeError({ error, exitCode, signal, stdout, stderr, all, command, escapedCommand, parsed, timedOut, isCanceled: context.isCanceled, killed: spawned.killed }); if (!parsed.options.reject) { return returnedError; } throw returnedError; } return { command, escapedCommand, exitCode: 0, stdout, stderr, all, failed: false, timedOut: false, isCanceled: false, killed: false }; }; const handlePromiseOnce = onetime(handlePromise); handleInput(spawned, parsed.options.input); spawned.all = makeAllStream(spawned, parsed.options); return mergePromise(spawned, handlePromiseOnce); }; module2.exports = execa; module2.exports.sync = (file, args, options) => { const parsed = handleArguments(file, args, options); const command = joinCommand(file, args); const escapedCommand = getEscapedCommand(file, args); validateInputSync(parsed.options); let result; try { result = childProcess.spawnSync(parsed.file, parsed.args, parsed.options); } catch (error) { throw makeError({ error, stdout: "", stderr: "", all: "", command, escapedCommand, parsed, timedOut: false, isCanceled: false, killed: false }); } const stdout = handleOutput(parsed.options, result.stdout, result.error); const stderr = handleOutput(parsed.options, result.stderr, result.error); if (result.error || result.status !== 0 || result.signal !== null) { const error = makeError({ stdout, stderr, error: result.error, signal: result.signal, exitCode: result.status, command, escapedCommand, parsed, timedOut: result.error && result.error.code === "ETIMEDOUT", isCanceled: false, killed: result.signal !== null }); if (!parsed.options.reject) { return error; } throw error; } return { command, escapedCommand, exitCode: 0, stdout, stderr, failed: false, timedOut: false, isCanceled: false, killed: false }; }; module2.exports.command = (command, options) => { const [file, ...args] = parseCommand(command); return execa(file, args, options); }; module2.exports.commandSync = (command, options) => { const [file, ...args] = parseCommand(command); return execa.sync(file, args, options); }; module2.exports.node = (scriptPath, args, options = {}) => { if (args && !Array.isArray(args) && typeof args === "object") { options = args; args = []; } const stdio = normalizeStdio.node(options); const defaultExecArgv = process.execArgv.filter((arg) => !arg.startsWith("--inspect")); const { nodePath = process.execPath, nodeOptions = defaultExecArgv } = options; return execa(nodePath, [ ...nodeOptions, scriptPath, ...Array.isArray(args) ? args : [] ], { ...options, stdin: undefined, stdout: undefined, stderr: undefined, stdio, shell: false }); }; }); // node_modules/shell-env/index.js var exports_shell_env = {}; __export(exports_shell_env, { shellEnvSync: () => shellEnvSync, shellEnv: () => shellEnv }); module.exports = __toCommonJS(exports_shell_env); var import_node_process2 = __toESM(require("node:process")); var import_execa = __toESM(require_execa()); // node_modules/shell-env/node_modules/ansi-regex/index.js function ansiRegex({ onlyFirst = false } = {}) { const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; const pattern = [ `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`, "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))" ].join("|"); return new RegExp(pattern, onlyFirst ? undefined : "g"); } // node_modules/shell-env/node_modules/strip-ansi/index.js var regex = ansiRegex(); function stripAnsi(string) { if (typeof string !== "string") { throw new TypeError(`Expected a \`string\`, got \`${typeof string}\``); } return string.replace(regex, ""); } // node_modules/default-shell/index.js var import_node_process = __toESM(require("node:process")); var import_node_os = require("node:os"); var detectDefaultShell = () => { const { env } = import_node_process.default; if (import_node_process.default.platform === "win32") { return env.COMSPEC || "cmd.exe"; } try { const { shell } = import_node_os.userInfo(); if (shell) { return shell; } } catch {} if (import_node_process.default.platform === "darwin") { return env.SHELL || "/bin/zsh"; } return env.SHELL || "/bin/sh"; }; var defaultShell = detectDefaultShell(); var default_shell_default = defaultShell; // node_modules/shell-env/index.js var args = [ "-ilc", 'echo -n "_SHELL_ENV_DELIMITER_"; env; echo -n "_SHELL_ENV_DELIMITER_"; exit' ]; var env = { DISABLE_AUTO_UPDATE: "true" }; var parseEnv = (env2) => { env2 = env2.split("_SHELL_ENV_DELIMITER_")[1]; const returnValue = {}; for (const line of stripAnsi(env2).split(` `).filter((line2) => Boolean(line2))) { const [key, ...values] = line.split("="); returnValue[key] = values.join("="); } return returnValue; }; async function shellEnv(shell) { if (import_node_process2.default.platform === "win32") { return import_node_process2.default.env; } try { const { stdout } = await import_execa.default(shell || default_shell_default, args, { env }); return parseEnv(stdout); } catch (error) { if (shell) { throw error; } else { return import_node_process2.default.env; } } } function shellEnvSync(shell) { if (import_node_process2.default.platform === "win32") { return import_node_process2.default.env; } try { const { stdout } = import_execa.default.sync(shell || default_shell_default, args, { env }); return parseEnv(stdout); } catch (error) { if (shell) { throw error; } else { return import_node_process2.default.env; } } } ================================================ FILE: src/main/mcp/shell-env.d.ts ================================================ export function shellEnv(): Promise>> ================================================ FILE: src/main/menu.ts ================================================ import { app, type BrowserWindow, Menu, MenuItem, type MenuItemConstructorOptions, shell } from 'electron' import Locale from './locales' interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { selector?: string submenu?: DarwinMenuItemConstructorOptions[] | Menu } export default class MenuBuilder { mainWindow: BrowserWindow constructor(mainWindow: BrowserWindow) { this.mainWindow = mainWindow } buildMenu(): Menu { const locale = new Locale() // 监听右键菜单 this.mainWindow.webContents.on('context-menu', (_, props) => { const items: (Electron.MenuItem | Electron.MenuItemConstructorOptions)[] = [ { role: 'copy', label: locale.t('Copy'), accelerator: 'CmdOrCtrl+C' }, { role: 'cut', label: locale.t('Cut'), accelerator: 'CmdOrCtrl+X' }, { role: 'paste', label: locale.t('Paste'), accelerator: 'CmdOrCtrl+V' }, { role: 'pasteAndMatchStyle', label: locale.t('PasteAsPlainText'), accelerator: 'CmdOrCtrl+Shift+V' }, // { type: 'separator' }, // { role: 'resetZoom', label: locale.t('ResetZoom'), accelerator: 'CmdOrCtrl+0' }, // { role: 'zoomIn', label: locale.t('ZoomIn'), accelerator: 'CmdOrCtrl+=' }, // { role: 'zoomOut', label: locale.t('ZoomOut'), accelerator: 'CmdOrCtrl+-' }, ] // Add each spelling suggestion for (const suggestion of props.dictionarySuggestions.slice(0, 3)) { items.push({ label: `${locale.t('ReplaceWith')} "${suggestion}"`, click: () => this.mainWindow.webContents.replaceMisspelling(suggestion), }) } if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') { items.push({ label: 'Inspect element', click: () => { this.mainWindow.webContents.inspectElement(x, y) }, }) } const { x, y } = props Menu.buildFromTemplate(items).popup({ window: this.mainWindow }) }) const template = process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate() const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) return menu } buildDarwinTemplate(): MenuItemConstructorOptions[] { const subMenuAbout: DarwinMenuItemConstructorOptions = { label: 'Chatbox', submenu: [ { label: 'About Chatbox', selector: 'orderFrontStandardAboutPanel:', }, { type: 'separator' }, { label: 'Services', submenu: [] }, { type: 'separator' }, { label: 'Hide Chatbox', accelerator: 'Command+H', selector: 'hide:', }, { label: 'Hide Others', accelerator: 'Command+Shift+H', selector: 'hideOtherApplications:', }, { label: 'Show All', selector: 'unhideAllApplications:' }, { type: 'separator' }, { label: 'Quit', accelerator: 'Command+Q', click: () => { app.quit() }, }, ], } const subMenuEdit: DarwinMenuItemConstructorOptions = { label: 'Edit', submenu: [ { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:', }, { type: 'separator' }, { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, { label: 'Paste', accelerator: 'Command+V', selector: 'paste:', }, { label: 'Paste and Match Style', accelerator: 'Command+Shift+V', role: 'pasteAndMatchStyle', }, { label: 'Select All', accelerator: 'Command+A', selector: 'selectAll:', }, ], } const subMenuViewDev: MenuItemConstructorOptions = { label: 'View', submenu: [ { label: 'Reload', accelerator: 'Command+R', click: () => { this.mainWindow.webContents.reload() }, }, { label: 'Toggle Full Screen', accelerator: 'Ctrl+Command+F', click: () => { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) }, }, // { // label: 'Reset Zoom', // accelerator: 'Command+0', // role: 'resetZoom', // }, // { // label: 'Zoom In', // accelerator: 'Command+=', // role: 'zoomIn', // }, // { // label: 'Zoom Out', // accelerator: 'Command+-', // role: 'zoomOut', // }, // { // label: 'Toggle Developer Tools', // accelerator: 'Alt+Command+I', // click: () => { // this.mainWindow.webContents.toggleDevTools(); // }, // }, ], } const subMenuViewProd: MenuItemConstructorOptions = { label: 'View', submenu: [ { label: 'Toggle Full Screen', accelerator: 'Ctrl+Command+F', click: () => { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) }, }, { label: 'Toggle Developer Tools', accelerator: 'Alt+Command+I', click: () => { this.mainWindow.webContents.toggleDevTools() }, }, ], } const subMenuWindow: DarwinMenuItemConstructorOptions = { label: 'Window', submenu: [ { label: 'Minimize', accelerator: 'Command+M', selector: 'performMiniaturize:', }, { label: 'Close', accelerator: 'Command+W', selector: 'performClose:', }, { type: 'separator' }, { label: 'Bring All to Front', selector: 'arrangeInFront:' }, ], } const subMenuHelp: MenuItemConstructorOptions = { label: 'Help', submenu: [ { label: 'Learn More', click() { shell.openExternal('https://chatboxai.app') }, }, { label: 'Github Repo', click() { shell.openExternal('https://github.com/chatboxai/chatbox') }, }, // { // label: 'Community Discussions', // click() { // shell.openExternal('https://www.electronjs.org/community'); // }, // }, { label: 'Search Issues', click() { shell.openExternal('https://github.com/chatboxai/chatbox/issues?q=is%3Aissue') }, }, ], } const subMenuView = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' ? subMenuViewDev : subMenuViewProd return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp] } buildDefaultTemplate() { const templateDefault = [ { label: '&File', submenu: [ { label: '&Open', accelerator: 'Ctrl+O', }, { label: '&Close', accelerator: 'Ctrl+W', click: () => { this.mainWindow.close() }, }, ], }, { label: '&View', submenu: process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' ? [ { label: '&Reload', accelerator: 'Ctrl+R', click: () => { this.mainWindow.webContents.reload() }, }, { label: 'Toggle &Full Screen', accelerator: 'F11', click: () => { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) }, }, // { // label: 'Toggle &Developer Tools', // accelerator: 'Alt+Ctrl+I', // click: () => { // this.mainWindow.webContents.toggleDevTools(); // }, // }, ] : [ { label: 'Toggle &Full Screen', accelerator: 'F11', click: () => { this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()) }, }, ], }, { label: 'Help', submenu: [ { label: 'Learn More', click() { shell.openExternal('https://chatboxai.app') }, }, { label: 'Github Repo', click() { shell.openExternal('https://github.com/chatboxai/chatbox') }, }, // { // label: 'Community Discussions', // click() { // shell.openExternal('https://www.electronjs.org/community'); // }, // }, { label: 'Search Issues', click() { shell.openExternal('https://github.com/chatboxai/chatbox/issues?q=is%3Aissue') }, }, ], }, ] return templateDefault } } ================================================ FILE: src/main/proxy.ts ================================================ import { session } from 'electron' import * as store from './store-node' export function init() { const { proxy } = store.getSettings() if (proxy) { ensure(proxy) } } export function ensure(proxy?: string) { if (proxy) { session.defaultSession.setProxy({ proxyRules: proxy }) } else { session.defaultSession.setProxy({}) } } ================================================ FILE: src/main/readability.ts ================================================ // import { Readability } from '@mozilla/readability' // import { parseHTML } from 'linkedom' // import { fetch } from 'ofetch' // import { sliceTextWithEllipsis } from './util' // // linkedom 只能在 Node.js 环境,且在网页中 fetch 其他 URL 很容易出现 CORS 问题 // export async function readability(url: string, options: { maxLength?: number } = {}) { // const userAgents = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36` // const documentString = await fetch(url, { // headers: { // 'User-Agent': userAgents, // }, // }).then((res) => res.text()) // const { document } = parseHTML(documentString) // const reader = new Readability(document, {}) // const title = document.querySelector('title')?.textContent || undefined // const result = reader.parse() // const ret = { // title, // text: (result?.textContent || '').trim(), // } // if (options.maxLength) { // ret.text = sliceTextWithEllipsis(ret.text, options.maxLength) // } // return ret // } ================================================ FILE: src/main/store-node.ts ================================================ import { app, powerMonitor } from 'electron' import Store from 'electron-store' import * as fs from 'fs-extra' import path from 'path' import sanitizeFilename from 'sanitize-filename' import * as defaults from '../shared/defaults' import type { Config, Settings } from '../shared/types' import { getLogger } from './util' const logger = getLogger('store-node') const configPath = path.resolve(app.getPath('userData'), 'config.json') // 1) 检查配置文件是否合法 // 如果配置文件不合法,则使用最新的备份文件 if (fs.existsSync(configPath) && !checkConfigValid(configPath)) { logger.error('config.json is invalid.') const backups = getBackups() if (backups.length > 0) { // 不断尝试使用最新的备份文件,直到成功 for (let i = backups.length - 1; i >= 0; i--) { const backup = backups[i] if (checkConfigValid(backup.filepath)) { fs.copySync(backup.filepath, configPath) logger.info('use backup:', backup.filepath) break } } } } // 2) 初始化store interface StoreType { configVersion: number settings: Settings configs: Config lastShownAboutDialogVersion: string // 上次启动时自动弹出关于对话框的应用版本 } export const store = new Store({ clearInvalidConfig: true, // 当配置JSON不合法时,清空配置 }) logger.info('init store, config path:', store.path) // 3) 启动自动备份,每10分钟备份一次,并自动清理多余的备份文件 autoBackup() let autoBackupTimer = setInterval(autoBackup, 10 * 60 * 1000) powerMonitor.on('resume', () => { clearInterval(autoBackupTimer) autoBackupTimer = setInterval(autoBackup, 10 * 60 * 1000) }) powerMonitor.on('suspend', () => { clearInterval(autoBackupTimer) }) async function autoBackup() { try { if (needBackup()) { const filename = await backup() if (filename) { logger.info('auto backup:', filename) } } await clearBackups() } catch (err) { logger.error('auto backup error:', err) } } export function getSettings(): Settings { const settings = store.get<'settings'>('settings', defaults.settings()) return settings } export function getConfig(): Config { let configs = store.get<'configs'>('configs') if (!configs) { configs = defaults.newConfigs() store.set<'configs'>('configs', configs) } return configs } /** * 备份配置文件 */ export async function backup() { if (!fs.existsSync(configPath)) { logger.error('skip backup because config.json does not exist.') return } if (!checkConfigValid(configPath)) { logger.error('skip backup because config.json is invalid.') return } const now = new Date().toISOString().replace(/:/g, '_') const backupPath = path.resolve(app.getPath('userData'), `config-backup-${now}.json`) try { await fs.copy(configPath, backupPath) } catch (err) { logger.error('Failed to backup config:', err) return } logger.info('backup config to:', backupPath) return backupPath } /** * 获取所有备份文件,并按照时间排序 * @returns 备份文件信息 */ export function getBackups() { const filenames = fs.readdirSync(app.getPath('userData')) const backupFilenames = filenames.filter((filename) => filename.startsWith('config-backup-')) if (backupFilenames.length === 0) { return [] } let backupFileInfos = backupFilenames.map((filename) => { let dateStr = filename.replace('config-backup-', '').replace('.json', '') dateStr = dateStr.replace(/_/g, ':') const date = new Date(dateStr) return { filename, filepath: path.resolve(app.getPath('userData'), filename), dateMs: date.getTime() || 0, } }) backupFileInfos = backupFileInfos.sort((a, b) => a.dateMs - b.dateMs) return backupFileInfos } /** * 检查是否需要备份 * @returns 是否需要备份 */ export function needBackup() { const backups = getBackups() if (backups.length === 0) { return true } const lastBackup = backups[backups.length - 1] return lastBackup.dateMs < Date.now() - 10 * 60 * 1000 // 10分钟备份一次 } /** * 清理备份文件,仅保留最近50个备份 */ export async function clearBackups() { const limit = 50 const backups = getBackups() if (backups.length < limit) { return } const now = new Date() const todayStartMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() const yesterdayStartMs = todayStartMs - 24 * 60 * 60 * 1000 const thirtyDaysAgoStartMs = todayStartMs - 30 * 24 * 60 * 60 * 1000 const backupsToDelete: { filename: string; filepath: string }[] = [] const keptHourlyBackups: { [hourKey: string]: { filename: string; filepath: string } } = {} // Key: YYYY-MM-DD-HH const keptDailyBackups: { [dateKey: string]: { filename: string; filepath: string } } = {} // Key: YYYY-MM-DD for (const backup of backups) { const backupDate = new Date(backup.dateMs) const dateKey = backupDate.toISOString().slice(0, 10) // YYYY-MM-DD const hourKey = `${dateKey}-${backupDate.toISOString().slice(11, 13)}` // YYYY-MM-DD-HH if (backup.dateMs < thirtyDaysAgoStartMs) { // Older than 30 days: mark for deletion backupsToDelete.push({ filename: backup.filename, filepath: backup.filepath }) } else if (backup.dateMs < yesterdayStartMs) { // Between 30 days ago and yesterday (exclusive): keep latest per day const existingKept = keptDailyBackups[dateKey] if (existingKept) { // A backup for this day was already kept; mark the older one for deletion backupsToDelete.push(existingKept) } // Keep the current one (it's the latest encountered for this day so far) keptDailyBackups[dateKey] = { filename: backup.filename, filepath: backup.filepath } } else { // Today or yesterday: keep latest per hour const existingKept = keptHourlyBackups[hourKey] if (existingKept) { // A backup for this hour was already kept; mark the older one for deletion backupsToDelete.push(existingKept) } // Keep the current one (it's the latest encountered for this hour so far) keptHourlyBackups[hourKey] = { filename: backup.filename, filepath: backup.filepath } } } // Perform the actual deletions if (backupsToDelete.length > 0) { logger.info(`Clearing ${backupsToDelete.length} old backup(s)...`) try { await Promise.all( backupsToDelete.map(async (backup) => { await fs.remove(backup.filepath) // logger.info('clear backup:', backup.filename) // Log per file might be too verbose }) ) logger.info('Finished clearing old backups.') } catch (err) { logger.error('Failed to clear some backups:', err) } } } /** * 检查配置文件是否是合法的JSON文件 * @returns 配置文件是否合法 */ function checkConfigValid(filepath: string) { try { JSON.parse(fs.readFileSync(filepath, 'utf8')) } catch (err) { return false } return true } export async function getStoreBlob(key: string) { const filename = path.resolve(app.getPath('userData'), 'chatbox-blobs', sanitizeFilename(key)) const exists = await fs.pathExists(filename) if (!exists) { return null } return fs.readFile(filename, { encoding: 'utf-8' }) } export async function setStoreBlob(key: string, value: string) { const filename = path.resolve(app.getPath('userData'), 'chatbox-blobs', sanitizeFilename(key)) await fs.ensureDir(path.dirname(filename)) return fs.writeFile(filename, value, { encoding: 'utf-8' }) } export async function delStoreBlob(key: string) { const filename = path.resolve(app.getPath('userData'), 'chatbox-blobs', sanitizeFilename(key)) const exists = await fs.pathExists(filename) if (!exists) { return } await fs.remove(filename) } export async function listStoreBlobKeys() { const dir = path.resolve(app.getPath('userData'), 'chatbox-blobs') const exists = await fs.pathExists(dir) if (!exists) { return [] } return fs.readdir(dir) } ================================================ FILE: src/main/util.ts ================================================ import log from 'electron-log/main' import path from 'path' import { URL } from 'url' export function resolveHtmlPath(htmlFileName: string) { if (process.env.NODE_ENV === 'development') { return process.env['ELECTRON_RENDERER_URL'] } return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}` } export function sliceTextWithEllipsis(text: string, maxLength: number) { if (text.length <= maxLength) { return text } // 这里添加了一些根据文本的随机性,避免内容被截断 const headLength = Math.floor(maxLength * 0.4) + Math.floor(text.length * 0.1) const tailLength = Math.floor(maxLength * 0.5) const head = text.slice(0, headLength) const tail = text.slice(-tailLength) return head + tail } // 初始化后,dev 模式可以收集到 renderer 层日志,但 electron 打包后无法正常工作 // log.initialize() export function getLogger(logId: string) { const logger = log.create({ logId }) logger.transports.console.format = '{h}:{i}:{s}.{ms} › [{logId}] › {text}' logger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{logId}] {text}' return logger } ================================================ FILE: src/main/window_state.ts ================================================ // 保持窗口大小位置变化的代码,很大程度参考了 VSCODE 的实现 // /Users/benn/Documents/w/vscode/src/vs/platform/windows/electron-main/windowImpl.ts import { screen, type Display, type Rectangle } from 'electron' import { store } from './store-node' export interface IWindowState { width?: number height?: number x?: number y?: number mode?: WindowMode readonly display?: number } export enum WindowMode { Maximized, Normal, Minimized, // not used anymore, but also cannot remove due to existing stored UI state (needs migration) Fullscreen, } // 需要限制窗口最小宽高,否则可能会出现缩小时(比如按最大窗口启动后,双击状态栏缩小)缩小到几像素大小的情况 export const minWidth = 280 export const minHeight = 450 export function defaultWindowState(mode = WindowMode.Normal): IWindowState { return { width: 1024, height: 768, mode, } } const storeKey = 'windowState' export function getState(): [IWindowState, boolean? /* has multiple displays */] { const state = getCache() return restoreWindowState(state) } export function saveState(win: Electron.BrowserWindow): void { let [x, y] = win.getPosition() let [width, height] = win.getSize() let mode = WindowMode.Normal if (win.isFullScreen()) { mode = WindowMode.Fullscreen // when we are in fullscreen, we want to persist the last non-fullscreen x/y position and width/height const [originalState] = getState() x = originalState.x ?? x y = originalState.y ?? y width = originalState.width ?? width height = originalState.height ?? height } else if (win.isMaximized()) { mode = WindowMode.Maximized } setCache({ width, height, x, y, mode, // mode?: WindowMode; // readonly display?: number; }) } function restoreWindowState(state?: IWindowState): [IWindowState, boolean? /* has multiple displays */] { let hasMultipleDisplays = false if (state) { try { const displays = screen.getAllDisplays() hasMultipleDisplays = displays.length > 1 state = validateWindowState(state, displays) } catch (err) { // this.logService.warn(`Unexpected error validating window state: ${err}\n${err.stack}`); // somehow display API can be picky about the state to validate } } return [state || defaultWindowState(), hasMultipleDisplays] } function validateWindowState(state: IWindowState, displays: Display[]): IWindowState | undefined { // this.logService.trace(`window#validateWindowState: validating window state on ${displays.length} display(s)`, state); if ( typeof state.x !== 'number' || typeof state.y !== 'number' || typeof state.width !== 'number' || typeof state.height !== 'number' ) { // this.logService.trace('window#validateWindowState: unexpected type of state values'); return undefined } if (state.width <= 0 || state.height <= 0) { // this.logService.trace('window#validateWindowState: unexpected negative values'); return undefined } // 防止过度缩小 if (state.width < minWidth) { state.width = minWidth } if (state.height < minHeight) { state.height = minHeight } // Single Monitor: be strict about x/y positioning // macOS & Linux: these OS seem to be pretty good in ensuring that a window is never outside of it's bounds. // Windows: it is possible to have a window with a size that makes it fall out of the window. our strategy // is to try as much as possible to keep the window in the monitor bounds. we are not as strict as // macOS and Linux and allow the window to exceed the monitor bounds as long as the window is still // some pixels (128) visible on the screen for the user to drag it back. if (displays.length === 1) { const displayWorkingArea = getWorkingArea(displays[0]) if (displayWorkingArea) { // this.logService.trace('window#validateWindowState: 1 monitor working area', displayWorkingArea); function ensureStateInDisplayWorkingArea(): void { if (!state || typeof state.x !== 'number' || typeof state.y !== 'number' || !displayWorkingArea) { return } if (state.x < displayWorkingArea.x) { // prevent window from falling out of the screen to the left state.x = displayWorkingArea.x } if (state.y < displayWorkingArea.y) { // prevent window from falling out of the screen to the top state.y = displayWorkingArea.y } } // ensure state is not outside display working area (top, left) ensureStateInDisplayWorkingArea() if (state.width > displayWorkingArea.width) { // prevent window from exceeding display bounds width state.width = displayWorkingArea.width } if (state.height > displayWorkingArea.height) { // prevent window from exceeding display bounds height state.height = displayWorkingArea.height } if (state.x > displayWorkingArea.x + displayWorkingArea.width - 128) { // prevent window from falling out of the screen to the right with // 128px margin by positioning the window to the far right edge of // the screen state.x = displayWorkingArea.x + displayWorkingArea.width - state.width } if (state.y > displayWorkingArea.y + displayWorkingArea.height - 128) { // prevent window from falling out of the screen to the bottom with // 128px margin by positioning the window to the far bottom edge of // the screen state.y = displayWorkingArea.y + displayWorkingArea.height - state.height } // again ensure state is not outside display working area // (it may have changed from the previous validation step) ensureStateInDisplayWorkingArea() } return state } // Multi Montior (fullscreen): try to find the previously used display if (state.display && state.mode === WindowMode.Fullscreen) { const display = displays.find((d) => d.id === state.display) if (display && typeof display.bounds?.x === 'number' && typeof display.bounds?.y === 'number') { // this.logService.trace('window#validateWindowState: restoring fullscreen to previous display'); const defaults = defaultWindowState(WindowMode.Fullscreen) // make sure we have good values when the user restores the window defaults.x = display.bounds.x // carefull to use displays x/y position so that the window ends up on the correct monitor defaults.y = display.bounds.y return defaults } } // Multi Monitor (non-fullscreen): ensure window is within display bounds let display: Display | undefined let displayWorkingArea: Rectangle | undefined try { display = screen.getDisplayMatching({ x: state.x, y: state.y, width: state.width, height: state.height }) displayWorkingArea = getWorkingArea(display) } catch (error) { // Electron has weird conditions under which it throws errors // e.g. https://github.com/microsoft/vscode/issues/100334 when // large numbers are passed in } if ( display && // we have a display matching the desired bounds displayWorkingArea && // we have valid working area bounds state.x + state.width > displayWorkingArea.x && // prevent window from falling out of the screen to the left state.y + state.height > displayWorkingArea.y && // prevent window from falling out of the screen to the top state.x < displayWorkingArea.x + displayWorkingArea.width && // prevent window from falling out of the screen to the right state.y < displayWorkingArea.y + displayWorkingArea.height // prevent window from falling out of the screen to the bottom ) { // this.logService.trace('window#validateWindowState: multi-monitor working area', displayWorkingArea); return state } return undefined } function getWorkingArea(display: Display): Rectangle | undefined { // Prefer the working area of the display to account for taskbars on the // desktop being positioned somewhere (https://github.com/microsoft/vscode/issues/50830). // // Linux X11 sessions sometimes report wrong display bounds, so we validate // the reported sizes are positive. if (display.workArea.width > 0 && display.workArea.height > 0) { return display.workArea } if (display.bounds.width > 0 && display.bounds.height > 0) { return display.bounds } return undefined } function setCache(state: IWindowState) { store.set(storeKey, state) } function getCache(): IWindowState { const state = store.get(storeKey) as IWindowState | undefined if (!state) { return defaultWindowState() } return state } ================================================ FILE: src/preload/index.ts ================================================ // Disable no-unused-vars, broken for spread args /* eslint no-unused-vars: off */ import { contextBridge, ipcRenderer } from 'electron' import type { ElectronIPC } from 'src/shared/electron-types' // export type Channels = 'ipc-example'; const electronHandler: ElectronIPC = { // ipcRenderer: { // sendMessage(channel: Channels, ...args: unknown[]) { // ipcRenderer.send(channel, ...args); // }, // on(channel: Channels, func: (...args: unknown[]) => void) { // const subscription = ( // _event: IpcRendererEvent, // ...args: unknown[] // ) => func(...args); // ipcRenderer.on(channel, subscription); // return () => { // ipcRenderer.removeListener(channel, subscription); // }; // }, // once(channel: Channels, func: (...args: unknown[]) => void) { // ipcRenderer.once(channel, (_event, ...args) => func(...args)); // }, // }, invoke: ipcRenderer.invoke, onSystemThemeChange: (callback: () => void) => { ipcRenderer.on('system-theme-updated', callback) return () => ipcRenderer.off('system-theme-updated', callback) }, onWindowMaximizedChanged: (callback: (_: Electron.IpcRendererEvent, windowMaximized: boolean) => void) => { ipcRenderer.on('window:maximized-changed', callback) return () => ipcRenderer.off('window:maximized-changed', callback) }, onWindowFocused: (callback: (_: Electron.IpcRendererEvent) => void) => { ipcRenderer.on('window:focused', callback) return () => ipcRenderer.off('window:focused', callback) }, onWindowShow: (callback: () => void) => { ipcRenderer.on('window-show', callback) return () => ipcRenderer.off('window-show', callback) }, onUpdateDownloaded: (callback: () => void) => { ipcRenderer.on('update-downloaded', callback) return () => ipcRenderer.off('update-downloaded', callback) }, addMcpStdioTransportEventListener: (transportId: string, event: string, callback?: (...args: any[]) => void) => { ipcRenderer.on(`mcp:stdio-transport:${transportId}:${event}`, (_event, ...args) => { callback?.(...args) }) }, onNavigate: (callback: (path: string) => void) => { const listener = (_event: unknown, path: string) => { callback(path) } ipcRenderer.on('navigate-to', listener) return () => ipcRenderer.off('navigate-to', listener) }, } contextBridge.exposeInMainWorld('electronAPI', electronHandler) ================================================ FILE: src/renderer/Sidebar.tsx ================================================ import { ActionIcon, Box, Button, Flex, Image, NavLink, Stack, Text, Tooltip } from '@mantine/core' import SwipeableDrawer from '@mui/material/SwipeableDrawer' import { IconCirclePlus, IconCode, IconInfoCircle, IconLayoutSidebarLeftCollapse, IconMessageChatbot, IconPhotoPlus, IconSettingsFilled, } from '@tabler/icons-react' import { useNavigate } from '@tanstack/react-router' import clsx from 'clsx' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from './components/common/Divider' import { ScalableIcon } from './components/common/ScalableIcon' import ThemeSwitchButton from './components/dev/ThemeSwitchButton' import SessionList from './components/session/SessionList' import { FORCE_ENABLE_DEV_PAGES } from './dev/devToolsConfig' import useNeedRoomForMacWinControls from './hooks/useNeedRoomForWinControls' import { useIsSmallScreen, useSidebarWidth } from './hooks/useScreenChange' import useVersion from './hooks/useVersion' import { navigateToSettings } from './modals/Settings' import { trackingEvent } from './packages/event' import platform from './platform' import icon from './static/icon.png' import { useLanguage } from './stores/settingsStore' import { useUIStore } from './stores/uiStore' import { CHATBOX_BUILD_PLATFORM } from './variables' export default function Sidebar() { const { t } = useTranslation() const versionHook = useVersion() const language = useLanguage() const navigate = useNavigate() const showSidebar = useUIStore((s) => s.showSidebar) const setShowSidebar = useUIStore((s) => s.setShowSidebar) const setSidebarWidth = useUIStore((s) => s.setSidebarWidth) const sessionListViewportRef = useRef(null) const sidebarWidth = useSidebarWidth() const isSmallScreen = useIsSmallScreen() const [isResizing, setIsResizing] = useState(false) const resizeStartX = useRef(0) const resizeStartWidth = useRef(0) const { needRoomForMacWindowControls } = useNeedRoomForMacWinControls() const handleCreateNewSession = useCallback(() => { navigate({ to: `/` }) if (isSmallScreen) { setShowSidebar(false) } trackingEvent('create_new_conversation', { event_category: 'user' }) }, [navigate, setShowSidebar, isSmallScreen]) const handleCreateNewPictureSession = useCallback(() => { navigate({ to: '/image-creator' }) if (isSmallScreen) { setShowSidebar(false) } trackingEvent('open_image_creator', { event_category: 'user' }) }, [isSmallScreen, setShowSidebar, navigate]) const handleResizeStart = useCallback( (e: React.MouseEvent) => { if (isSmallScreen) return e.preventDefault() e.stopPropagation() setIsResizing(true) resizeStartX.current = e.clientX resizeStartWidth.current = sidebarWidth }, [isSmallScreen, sidebarWidth] ) useEffect(() => { if (!isResizing) return const handleMouseMove = (e: MouseEvent) => { const isRTL = language === 'ar' const deltaX = isRTL ? resizeStartX.current - e.clientX : e.clientX - resizeStartX.current const newWidth = Math.max(200, Math.min(500, resizeStartWidth.current + deltaX)) setSidebarWidth(newWidth) } const handleMouseUp = () => { setIsResizing(false) } document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [isResizing, language, setSidebarWidth]) return ( setShowSidebar(false)} onOpen={() => setShowSidebar(true)} ModalProps={{ keepMounted: true, // Better open performance on mobile. }} sx={{ '& .MuiDrawer-paper': { backgroundImage: 'none', boxSizing: 'border-box', width: isSmallScreen ? '75vw' : sidebarWidth, maxWidth: '75vw', }, }} SlideProps={language === 'ar' ? { direction: 'left' } : undefined} PaperProps={ language === 'ar' ? { sx: { direction: 'rtl', overflowY: 'initial' } } : { sx: { overflowY: 'initial' } } } disableSwipeToOpen={CHATBOX_BUILD_PLATFORM !== 'ios'} // 只在iOS设备上启用SwipeToOpen disableEnforceFocus={true} // 关闭 focus trap,避免在侧边栏打开时弹出的 modal 中 input 无法点击 > {needRoomForMacWindowControls && } platform.openLink('https://chatboxai.app/')} style={{ cursor: 'pointer' }} > Chatbox {FORCE_ENABLE_DEV_PAGES && } setShowSidebar(false)}> } onClick={() => { navigate({ to: '/copilots', }) if (isSmallScreen) { setShowSidebar(false) } }} variant="light" p="xs" /> } onClick={() => { navigateToSettings() if (isSmallScreen) { setShowSidebar(false) } }} variant="light" p="xs" /> {FORCE_ENABLE_DEV_PAGES && ( } onClick={() => { navigate({ to: '/dev', }) if (isSmallScreen) { setShowSidebar(false) } }} variant="light" p="xs" /> )} {`${t('About')} ${/\d/.test(versionHook.version) ? `(${versionHook.version})` : ''}`} {CHATBOX_BUILD_PLATFORM === 'android' && versionHook.needCheckUpdate && ( )} } leftSection={} onClick={() => { navigate({ to: '/about', }) if (isSmallScreen) { setShowSidebar(false) } }} variant="light" p="xs" /> {!isSmallScreen && ( )} ) } ================================================ FILE: src/renderer/adapters/index.ts ================================================ import { createAfetch } from '@shared/request/request' import type { ApiRequestOptions, ModelDependencies } from '@shared/types/adapters' import { getOS } from '@/packages/navigator' import platform from '@/platform' import storage from '@/storage' import { StorageKeyGenerator } from '@/storage/StoreStorage' import * as settingActions from '@/stores/settingActions' import { apiRequest } from '@/utils/request' import { RendererSentryAdapter } from './sentry' export async function createModelDependencies(): Promise { // 获取平台信息 const platformInfo = { type: platform.type, platform: await platform.getPlatform(), os: getOS(), version: (await platform.getVersion()) || 'unknown', } const afetch = createAfetch(platformInfo) return { storage: { async saveImage(folder: string, dataUrl: string): Promise { const storageKey = StorageKeyGenerator.picture(folder) await storage.setBlob(storageKey, dataUrl) return storageKey }, async getImage(storageKey: string): Promise { const blob = await storage.getBlob(storageKey) if (!blob) return '' return blob.startsWith('data:') ? blob : `data:image/png;base64,${blob}` }, }, request: { fetchWithOptions: async ( url: string, init?: RequestInit, options?: { retry?: number; parseChatboxRemoteError?: boolean } ): Promise => { // 支持自定义选项的 fetch return afetch(url, init, options || {}) }, async apiRequest(options: ApiRequestOptions): Promise { if (options.method === 'POST') { return apiRequest.post(options.url, options.headers || {}, options.body, { signal: options.signal, retry: options.retry, useProxy: options.useProxy, }) } else { return apiRequest.get(options.url, options.headers || {}, { signal: options.signal, retry: options.retry, useProxy: options.useProxy, }) } }, }, sentry: new RendererSentryAdapter(), getRemoteConfig: settingActions.getRemoteConfig, } } ================================================ FILE: src/renderer/adapters/sentry.ts ================================================ import * as Sentry from '@sentry/react' import type { SentryAdapter, SentryScope } from '../../shared/utils/sentry_adapter' /** * 渲染进程的 Sentry 适配器实现 */ export class RendererSentryAdapter implements SentryAdapter { captureException(error: any): void { Sentry.captureException(error) } withScope(callback: (scope: SentryScope) => void): void { Sentry.withScope((sentryScope) => { const scope: SentryScope = { setTag(key: string, value: string): void { sentryScope.setTag(key, value) }, setExtra(key: string, value: any): void { sentryScope.setExtra(key, value) }, } callback(scope) }) } } ================================================ FILE: src/renderer/components/Accordion.tsx ================================================ import ArrowForwardIosSharpIcon from '@mui/icons-material/ArrowForwardIosSharp' import MuiAccordion, { type AccordionProps } from '@mui/material/Accordion' import MuiAccordionDetails from '@mui/material/AccordionDetails' import MuiAccordionSummary, { type AccordionSummaryProps } from '@mui/material/AccordionSummary' import { styled } from '@mui/material/styles' export const Accordion = styled((props: AccordionProps) => ( ))(({ theme }) => ({ border: `1px solid ${theme.palette.divider}`, '&:not(:last-child)': { // borderBottom: 0, }, '&:before': { display: 'none', }, })) export const AccordionSummary = styled((props: AccordionSummaryProps) => ( } {...props} /> ))(({ theme }) => ({ backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, .05)' : 'rgba(0, 0, 0, .01)', flexDirection: 'row-reverse', '& .MuiAccordionSummary-expandIconWrapper.Mui-expanded': { transform: 'rotate(90deg)', }, '& .MuiAccordionSummary-content': { marginLeft: theme.spacing(1), }, })) export const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({ padding: theme.spacing(2), // border: '1px solid rgba(0, 0, 0, .125)', })) ================================================ FILE: src/renderer/components/ActionMenu.tsx ================================================ import { Menu, type MenuItemProps, type MenuProps, Stack, Text, useMantineTheme } from '@mantine/core' import { IconCheck, type IconProps } from '@tabler/icons-react' import { type FC, type MouseEventHandler, type ReactElement, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Drawer } from 'vaul' import { useIsSmallScreen } from '@/hooks/useScreenChange' import { Divider } from './common/Divider' import { ScalableIcon } from './common/ScalableIcon' export type ActionMenuItemProps = | { divider?: false text: string icon?: React.ElementType color?: MenuItemProps['color'] onClick?: MouseEventHandler doubleCheck?: | boolean | { text?: string // 二次确认的文字,默认 t('Confirm?') icon?: React.ElementType color?: MenuItemProps['color'] timeout?: number // 二次确认的超时时间,默认 5000 毫秒 } // 点击时需要二次确认 } | { divider: true } export type ActionMenuProps = { children: ReactElement items: ActionMenuItemProps[] title?: string type?: 'desktop' | 'mobile' | 'auto' } & MenuProps export const ActionMenu: FC = ({ type = 'auto', ...props }) => { const isSmallScreen = useIsSmallScreen() if ((isSmallScreen && type === 'auto') || type === 'mobile') { return } return } const DesktopActionMenu: FC = ({ children, items, title, position = 'bottom-start', ...menuProps }) => { const theme = useMantineTheme() return ( {children} e.stopPropagation()}> {items.map((item, index) => item.divider ? ( ) : item.doubleCheck ? ( ) : ( : undefined} color={item.color || 'chatbox-primary'} style={{ color: theme.variantColorResolver({ color: item.color || 'chatbox-primary', theme, variant: 'light' }) .color, }} onClick={item.onClick} > {item.text} ) )} ) } const MobileActionMenu: FC = ({ children, items, title }) => { const [open, setOpen] = useState(false) const handleItemClick = (onClick?: MouseEventHandler) => { return (e: React.MouseEvent) => { if (onClick) { onClick(e) } setOpen(false) } } return ( {children}
{title && ( {title} )} {items.map((item, index) => item.divider ? ( ) : item.doubleCheck ? ( ) : ( ) )}
) } const MobileDoubleCheckMenuItem: FC<{ item: Extract onConfirm?: (e: React.MouseEvent) => void }> = ({ item, onConfirm }) => { const [confirmOpen, setConfirmOpen] = useState(false) const { t } = useTranslation() if (!item.doubleCheck) return null const doubleCheckConfig = item.doubleCheck === true ? {} : item.doubleCheck const doubleCheckText = doubleCheckConfig.text ?? t('Confirm?') const doubleCheckColor = doubleCheckConfig.color ?? item.color ?? 'chatbox-error' return (
) } export default ActionMenu const DoubleCheckMenuItem = ({ timeout = 5000, text, onClick, icon, doubleCheckText, doubleCheckIcon, doubleCheckColor, ...menuItemProps }: { timeout?: number text: string icon?: React.ElementType onClick?: MouseEventHandler doubleCheckText?: string doubleCheckIcon?: React.ElementType doubleCheckColor?: MenuItemProps['color'] } & MenuItemProps) => { const { t } = useTranslation() const [showConfirm, setShowConfirm] = useState(false) useEffect(() => { if (showConfirm) { const tid = setTimeout(() => { setShowConfirm(false) }, timeout) return () => clearTimeout(tid) } }, [showConfirm, timeout]) const theme = useMantineTheme() return !showConfirm ? ( : undefined} onClick={() => setShowConfirm(true)} {...menuItemProps} style={{ color: menuItemProps.color ? theme.variantColorResolver({ color: menuItemProps.color, theme, variant: 'light' }).color : undefined, }} > {text} ) : ( } onClick={onClick} {...menuItemProps} color={doubleCheckColor ?? menuItemProps.color} style={{ color: (doubleCheckColor ?? menuItemProps.color) ? theme.variantColorResolver({ color: doubleCheckColor ?? menuItemProps.color, theme, variant: 'light' }) .color : undefined, }} > {doubleCheckText ?? t('Confirm?')} ) } ================================================ FILE: src/renderer/components/AdaptiveSelect.tsx ================================================ import type { SelectProps as MantineSelectProps } from '@mantine/core' import { Button, Select, Stack, Text } from '@mantine/core' import { useState } from 'react' import { Drawer } from 'vaul' import { useIsSmallScreen } from '@/hooks/useScreenChange' export interface AdaptiveSelectProps extends Omit { onChange?: (value: string | null) => void } export function AdaptiveSelect(props: AdaptiveSelectProps) { const isSmallScreen = useIsSmallScreen() const [drawerOpened, setDrawerOpened] = useState(false) return isSmallScreen ? ( setDrawerOpened(open)} noBodyStyles> ) } ================================================ FILE: src/renderer/components/Artifact.tsx ================================================ import NiceModal from '@ebay/nice-modal-react' import ReplayOutlinedIcon from '@mui/icons-material/ReplayOutlined' import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined' import { ButtonGroup, IconButton } from '@mui/material' import type { Message } from '@shared/types/session' import { debounce } from 'lodash' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useIsSmallScreen } from '@/hooks/useScreenChange' import { cn } from '@/lib/utils' import { getMessageThreadContext } from '@/stores/sessionActions' import { getMessageText } from '../../shared/utils/message' import ArrowRightIcon from './icons/ArrowRightIcon' import FullscreenIcon from './icons/FullscreenIcon' const RENDERABLE_CODE_LANGUAGES = ['html'] as const export type RenderableCodeLanguage = (typeof RENDERABLE_CODE_LANGUAGES)[number] const CODE_BLOCK_LANGUAGES = [...RENDERABLE_CODE_LANGUAGES, 'js', 'javascript', 'css'] as const export type CodeBlockLanguage = (typeof CODE_BLOCK_LANGUAGES)[number] export function isContainRenderableCode(markdown: string): boolean { if (!markdown) { return false } return ( RENDERABLE_CODE_LANGUAGES.some((l) => markdown.includes('```' + l + '\n')) || RENDERABLE_CODE_LANGUAGES.some((l) => markdown.includes('```' + l.toUpperCase() + '\n')) ) } export function isRenderableCodeLanguage(language: string): boolean { return !!language && RENDERABLE_CODE_LANGUAGES.includes(language.toLowerCase() as RenderableCodeLanguage) } export function MessageArtifact(props: { sessionId: string messageId: string messageContent: string preview: boolean setPreview: (preview: boolean) => void }) { const { sessionId, messageId, messageContent, preview, setPreview } = props const [contextMessages, setContextMessages] = useState([]) useEffect(() => { async function fetchContextMessages(): Promise { if (!sessionId || !messageId) { return [] } const messageList = await getMessageThreadContext(sessionId, messageId) const index = messageList.findIndex((m) => m.id === messageId) return messageList.slice(0, index) } void fetchContextMessages().then((msgs) => { setContextMessages(msgs) }) }, [messageId, sessionId]) const htmlCode = useMemo(() => { return generateHtml([...contextMessages.map((m) => getMessageText(m)), messageContent]) }, [contextMessages, messageContent]) return } export function ArtifactWithButtons(props: { htmlCode: string preview: boolean setPreview: (preview: boolean) => void }) { const { htmlCode, preview, setPreview } = props const { t } = useTranslation() const [reloadSign, setReloadSign] = useState(0) const isSmallScreen = useIsSmallScreen() const onReplay = () => { setReloadSign(Math.random()) } const onPreview = () => { setPreview(true) setReloadSign(Math.random()) } const onStopPreview = () => { setPreview(false) } const onOpenFullscreen = async () => { await NiceModal.show('artifact-preview', { htmlCode, }) } if (!preview) { return (
{t('Preview')}
{ e.preventDefault() e.stopPropagation() onOpenFullscreen() }} /> setPreview(true)} />
) } return (
) } export function Artifact(props: { htmlCode: string; reloadSign?: number; className?: string }) { const { htmlCode, reloadSign, className } = props const ref = useRef(null) const iframeOrigin = 'https://artifact-preview.chatboxai.app/preview' const sendIframeMsg = (type: 'html', code: string) => { if (!ref.current) { return } ref.current.contentWindow?.postMessage({ type, code }, '*') } // 当 reloadSign 改变时,重新加载 iframe 内容 useEffect(() => { ;(async () => { sendIframeMsg('html', '') await new Promise((resolve) => setTimeout(resolve, 1500)) sendIframeMsg('html', htmlCode) })() }, [reloadSign]) // 当 htmlCode 改变时,防抖地刷新 iframe 内容 const updateIframe = debounce(() => { sendIframeMsg('html', htmlCode) }, 300) useEffect(() => { updateIframe() return () => updateIframe.cancel() }, [htmlCode]) return (