Compare commits

..

125 Commits

Author SHA1 Message Date
kangfenmao
fa9f59146e chore(version): 1.5.11 2025-09-12 16:20:22 +08:00
kangfenmao
c1d8bf38ef refactor: update styles and improve navbar handling
- Removed unnecessary margin-bottom style from bubble markdown.
- Adjusted margin in Prompt component for better layout.
- Enhanced useAppInit hook to include navbar position logic for background styling.
- Added alignment to ErrorBlock alert for improved visual consistency.
2025-09-12 16:20:07 +08:00
George·Dong
7217a7216e fix/miniapp-tab-cache (#10024)
* feat(minapps): add Tabs-mode webview pool and integrate page shell

* fix(minapp): position tabs pool below toolbar and preserve layout

* style(minapp): fix format issues

* style(minapps): optimize var name

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(minapps): stabilize tab webview lifecycle and mount logic

* refactor(minapps): improve webview detection and state handling

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
# Conflicts:
#	src/renderer/src/components/Tab/TabContainer.tsx
2025-09-12 15:54:16 +08:00
George·Dong
d35998bd74 fix(codetool): incorrect codetool workdir on macOS (#10056)
fix(codetool): run command and cd in same shell on macOS
2025-09-12 15:53:31 +08:00
359156687
dbd090377d fix: workaround for electron build issue on rpm package (#9986)
* fix: add workaround for electron build issue on rpm package

Signed-off-by: 33671 <error_z@yeah.net>

* fix format

Signed-off-by: 33671 <error_z@yeah.net>

---------

Signed-off-by: 33671 <error_z@yeah.net>
2025-09-12 15:51:56 +08:00
Pleasure1234
2ebcb43d50 fix: improve note sorting behavior for drag and drop operations (#9971)
* fix: improve note sorting behavior for drag and drop operations

- Skip automatic sorting when performing same-level drag reordering
- Preserve treePath during same-level moves to maintain manual ordering
- Return special indicator for manual reorder operations to prevent conflicts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: type safety issue

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-12 15:47:28 +08:00
Konv Suu
826b71deba fix(miniapp): title container background style align with sidebar (#9915) 2025-09-12 15:46:06 +08:00
George·Dong
7c0a800d9d refactor(miniapp): 适配顶部状态栏 (#9695)
* feat(minapp): add top-navbar fixed toolbar and layout adjustments

* refactor(minapps): optimize toolbar

* fix(minapps): hide redundant components

* feat(minapp): improve webview load handling and popup visibility

* feat(minapps): improve WebView load handling and clean up launchpad

* feat(minapp): 实现活跃小程序数量限制与关闭缓存清理

* fix(minapp): 修复WebView高度不正确的问题

* fix(minapp): show popup only for left navbar mode

* feat(minapps): add full-screen loading mask for webview

* fix: lint error

* feat(minapp): fix drawer sizing and layout when side navbar present

* refactor(minapp): 移除固定工具栏组件,优化弹窗容器布局

* feat(minapps): memoize app lookup to avoid unnecessary recompute

* chore(minapps): optimize comments

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(renderer): remove stray blank line in MinAppFullPageView

* refactor(minapps): remove top navbar opened minapps component

* refactor(tab): remove unused TopNavbarOpenedMinappTabs import

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-12 15:42:50 +08:00
beyondkmp
6f9906fe49 fix: update User-Agent handling in WebviewService to conditionally set based on URL (#9931)
# Conflicts:
#	yarn.lock
2025-09-12 15:22:59 +08:00
kangfenmao
ba7eec64b0 feat: add client ID generation and update user agent headers in AppUpdater
- Introduced a new method in ConfigManager to generate and retrieve a unique client ID.
- Updated AppUpdater to include the client ID in the request headers alongside the user agent.
2025-09-12 09:59:22 +08:00
Phantom
83d2403339 refactor(OGCard): replace static image with dynamic generated graph (#10115)
* refactor(OGCard): replace static image with dynamic generated graph

- Replace CherryLogo import with GeneratedGraph component for dynamic preview
- Extract image height to constant for consistency
- Use useCallback for GeneratedGraph to optimize performance

* chore: remove unused banner.png asset

* style(OGCard): change image height from pixels to rem units

Use rem units for better responsiveness and consistency with the design system
2025-09-12 09:56:14 +08:00
kangfenmao
bc17dcb911 chore(version): 1.5.10 2025-09-11 16:28:24 +08:00
Konv Suu
44e93671fa fix: 对齐模型设置中 avatar 的样式 (#9829)
* fix: 对齐模型设置中 avatar 的样式

* update

* update

* fix: 修复上传弹出两次文件夹的问题

* update
2025-09-11 15:21:27 +08:00
Phantom
a5bfd8f3db fix: handle multiple content source when pasting to translate input (#9919)
* fix(translate): 处理粘贴事件时增加处理中状态检查

* fix(translate): 修复粘贴文本时未阻止默认行为的问题

添加event.preventDefault()以防止粘贴文本时触发默认行为
同时优化粘贴逻辑,优先处理文本内容
2025-09-11 15:20:46 +08:00
LiuVaayne
07c3c33acc refactor(mcp): enhance MCPService logging and error handling (#9878) 2025-09-11 15:19:31 +08:00
Konv Suu
9ff4acf092 fix: regex pattern error when update manual blacklist (#9871) 2025-09-04 13:15:02 +08:00
Phantom
128b1fe9bc fix(translate): wrong copy button state (#9867) 2025-09-04 13:01:39 +08:00
Phantom
9a92372c3e refactor(mcp): use includes http to detect streamable http type mcp server (#9865)
refactor(mcp): 简化 McpServerTypeSchema 的类型校验逻辑

将联合类型替换为字符串校验并优化 http 相关类型的转换
2025-09-04 11:41:26 +08:00
Pleasure1234
0a36869b3c fix: NavigationService initialization timing issue and add tab drag reordering (#9700)
* fix: NavigationService initialization timing issue and add tab drag reordering

- Fix NavigationService timing issue in TabsService by adding fallback navigation with setTimeout
- Add drag and drop functionality for tab reordering with visual feedback
- Remove unused MessageSquareDiff icon from Navbar
- Add reorderTabs action to tabs store

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update tabs.ts

* Update TabContainer.tsx

* Update TabContainer.tsx

* fix(dnd): horizontal sortable (#9827)

* refactor(CodeViewer): improve props, aligned to CodeEditor (#9786)

* refactor(CodeViewer): improve props, aligned to CodeEditor

* refactor: simplify internal variables

* refactor: remove default lineNumbers

* fix: shiki theme container style

* revert: use ReactMarkdown for prompt editing

* fix: draggable list id type (#9809)

* refactor(dnd): rename idKey to itemKey for clarity

* refactor: key and id type for draggable lists

* chore: update yarn lock

* fix: type error

* refactor: improve getId fallbacks

* feat: integrate file selection and upload functionality in KnowledgeFiles component (#9815)

* feat: integrate file selection and upload functionality in KnowledgeFiles component

- Added useFiles hook to manage file selection.
- Updated handleAddFile to utilize the new file selection logic, allowing multiple file uploads.
- Improved user experience by handling file uploads asynchronously and logging the results.

* feat: enhance file upload interaction in KnowledgeFiles component

- Wrapped Dragger component in a div to allow for custom click handling.
- Prevented default click behavior to improve user experience when adding files.
- Maintained existing file upload functionality while enhancing the UI interaction.

* refactor(KnowledgeFiles): 提取文件处理逻辑到独立函数

将重复的文件上传和处理逻辑提取到独立的processFiles函数中,提高代码复用性和可维护性

---------

Co-authored-by: icarus <eurfelux@gmail.com>

* fix(Sortable): correct gap and horizontal style

* feat: make tabs sortable (example)

* refactor: improve sortable direction and gap

* refactor: update example

* fix: remove useless states

---------

Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: icarus <eurfelux@gmail.com>
Co-authored-by: Pleasure1234 <3196812536@qq.com>

* fix: syntax error

* refactor: remove useless styles

* fix: tabs overflow, add scrollbar

* fix: button gap

* fix: app region drag

* refactor: remove scrollbar, add space for app dragging

* Revert "refactor: remove scrollbar, add space for app dragging"

This reverts commit f6ebeb143e.

* refactor: update style

* refactor: add a scroll-to-right button

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: one <wangan.cs@gmail.com>
Co-authored-by: beyondkmp <beyondkmp@gmail.com>
Co-authored-by: icarus <eurfelux@gmail.com>
2025-09-04 02:42:42 +08:00
Phantom
a9a38f88bb fix: transform parameters when adding mcp by json (#9850)
* fix(mcp): 添加 streamable_http 类型并转换为 streamableHttp

为了兼容其他配置,添加 streamable_http 类型并在解析时自动转换为 streamableHttp

* feat(mcp): 根据URL自动推断服务器类型

当未显式指定服务器类型时,通过检查URL后缀自动设置合适的类型(mcp或sse)

* feat(mcp): 添加http类型支持并映射到streamableHttp
2025-09-03 21:30:26 +08:00
SuYao
aca1fcad18 feat: enhance RichEditor with logging and improve NotesPage editor synchronization (#9817)
* feat: enhance RichEditor with logging and improve NotesPage editor synchronization

- Added logging for enhanced link setting failures in RichEditor.
- Improved content synchronization logic in NotesPage to prevent unnecessary updates and ensure cleaner state management during file switches.
- Updated markdown conversion to handle task list structures more robustly, including support for div formats in task items.
- Added tests to verify task list structure preservation during HTML to Markdown conversions.

* feat: enhance Markdown preview interaction in AssistantPromptSettings

- Added double-click functionality to toggle preview mode in the Markdown container, preserving scroll position for a smoother user experience.
2025-09-03 20:02:04 +08:00
LiuVaayne
24bc878c27 fix: correct provider URL formatting in syncModelScopeServers function (#9852) 2025-09-03 19:34:48 +08:00
one
b1a9fbc6fd refactor: tooltip icons (#9841)
* refactor: add HelpTooltip, group tooltip icons

* refactor: add a tip for preview tools

* refactor: use HelpTooltip in SettingsTab
2025-09-03 18:02:53 +08:00
Pleasure1234
8a4c635c97 refactor: migrate showWorkspace setting from global settings to notes module (#9814)
* refactor: migrate showWorkspace setting from global settings to notes module

- Move showWorkspace state from settings store to notes store for better module cohesion
- Add useShowWorkspace hook in useNotesSettings for consistent access pattern
- Add smooth animation for workspace panel show/hide transition
- Relocate save to notes action to message toolbar for better accessibility
- Add migration v146 to handle state migration for existing users

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: cli lint error

* feat: add open outside menu

* fix: hooks error

* Update useShowWorkspace.ts

* fix: update icon import in NotesSidebarHeader component

- Replaced FilePlus icon with FilePlus2 in the NotesSidebarHeader for consistency with the latest icon set.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: suyao <sy20010504@gmail.com>
2025-09-03 17:02:24 +08:00
one
16d5f5c299 fix: capture animations and fonts in iframe (#9800)
* fix: capture animations in iframe

* fix: font urls

* fix: yarn lock

* refactor: inline fonts persistence
2025-09-03 15:11:13 +08:00
Ricardo
69a5a0434a fix: enhance Obsidian vault detection for multiple installation methods (#9821) 2025-09-03 15:08:59 +08:00
one
6d1f3a5729 fix(Markdown): regex for style (#9839) 2025-09-03 14:19:06 +08:00
co63oc
b725400428 fix typos (#9831) 2025-09-03 13:14:06 +08:00
beyondkmp
9f7d2be463 refactor(electron.vite.config.ts): streamline external dependencies and improve build configuration (#9835)
- Removed hardcoded external dependencies and replaced them with dynamic extraction from package.json.
- Cleaned up the configuration for better maintainability and flexibility in managing dependencies.
2025-09-03 12:45:33 +08:00
kangfenmao
fdee510c8c feat: add 'invalid_model' translation key across multiple languages
- Introduced a new translation key 'invalid_model' in English, Japanese, Russian, Chinese (Simplified and Traditional), Greek, Spanish, French, and Portuguese.
- Updated the SelectModelButton component to display an error tag when no valid provider is found, enhancing user feedback.
2025-09-03 11:56:57 +08:00
kangfenmao
76ac1bd8f7 fix: enhance provider selection logic in AssistantService
- Updated getProviderByModel function to improve provider selection.
- Added fallback logic to return a default or cherryin provider if the specified model provider is not found.
- Ensured that the first provider is returned as a last resort, enhancing robustness in provider retrieval.
2025-09-03 11:43:42 +08:00
beyondkmp
362658339a feat: integrate file selection and upload functionality in KnowledgeFiles component (#9815)
* feat: integrate file selection and upload functionality in KnowledgeFiles component

- Added useFiles hook to manage file selection.
- Updated handleAddFile to utilize the new file selection logic, allowing multiple file uploads.
- Improved user experience by handling file uploads asynchronously and logging the results.

* feat: enhance file upload interaction in KnowledgeFiles component

- Wrapped Dragger component in a div to allow for custom click handling.
- Prevented default click behavior to improve user experience when adding files.
- Maintained existing file upload functionality while enhancing the UI interaction.

* refactor(KnowledgeFiles): 提取文件处理逻辑到独立函数

将重复的文件上传和处理逻辑提取到独立的processFiles函数中,提高代码复用性和可维护性

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-09-02 23:34:08 +08:00
one
925d7e2a25 fix: draggable list id type (#9809)
* refactor(dnd): rename idKey to itemKey for clarity

* refactor: key and id type for draggable lists

* chore: update yarn lock

* fix: type error

* refactor: improve getId fallbacks
2025-09-02 23:28:29 +08:00
one
089477eb1e refactor(CodeViewer): improve props, aligned to CodeEditor (#9786)
* refactor(CodeViewer): improve props, aligned to CodeEditor

* refactor: simplify internal variables

* refactor: remove default lineNumbers

* fix: shiki theme container style

* revert: use ReactMarkdown for prompt editing
2025-09-02 21:52:14 +08:00
Phantom
f153f77a7e chore: update yarn.lock (#9808)
chore: 更新 yarn.lock
2025-09-02 20:03:27 +08:00
Teo
a34141c912 chore(migrate): update migration logic for version 145 and enforce showMessageOutline default (#9805) 2025-09-02 20:03:19 +08:00
Konv Suu
94374e7de2 fix: tabs 高度不足导致 border 样式不能占满父元素 (#9780)
* fix: tabs 高度不足导致 border 样式不能占满父元素

* Revert
2025-09-02 19:09:11 +08:00
yyhhyyyyyy
bdf6748956 fix: auto-enable image generation button for Gemini 2.5 Flash Image model (#9787) 2025-09-02 18:41:39 +08:00
beyondkmp
d6dcb471f9 chore: update TypeScript configuration and scripts (#9792)
- Added support for TypeScript incremental builds by enabling the `incremental` option and specifying the `tsBuildInfoFile` in both `tsconfig.node.json` and `tsconfig.web.json`.
- Updated the `typecheck` script in `package.json` to use `concurrently` for running node and web type checks in parallel.
- Added `.tsbuildinfo` to `.gitignore` to prevent build info files from being tracked.
2025-09-02 16:21:09 +08:00
beyondkmp
2c0391da81 chore: update vite down to 7.1.5 (#9794)
chore: update electron.vite.config.ts and yarn.lock for dependency management

- Added additional external dependencies in electron.vite.config.ts to improve build configuration.
- Updated multiple package versions in yarn.lock, including @napi-rs/wasm-runtime, @oxc-project/runtime, and @rolldown packages to their latest beta versions for better compatibility and performance.
- Adjusted fdir and picomatch versions to ensure alignment with the latest features and fixes.
2025-09-02 15:51:23 +08:00
Konv Suu
77c2255da4 feat: 解析链接的 og 数据并添加到 preview 内容中 (#9752)
* feat: 解析链接的 og 数据并添加到 preview 内容中

* update test cases

* refactor(useMetaDataParser): 移除冗余的isLoaded状态并优化加载逻辑

* feat(hyperlink): 重构超链接组件,提取OG卡片为独立组件

将超链接预览功能中的OG卡片逻辑提取为独立的OGCard组件
简化Hyperlink组件逻辑,移除重复代码

* refactor(OGCard): 简化加载状态并改进骨架屏样式

移除多余的SkeletonContainer包装,直接在加载状态返回CardSkeleton
重构骨架屏组件,添加图片和文本骨架样式
调整容器布局为flex列布局并添加间距

* test(Hyperlink): 添加OGCard组件模拟并更新快照

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-09-02 14:46:41 +08:00
beyondkmp
5ce7261678 refactor: Improve linux build for system-ocr (#9775)
* refactor(ocr): streamline OCR service registration and improve image preprocessing

- Simplified the registration of the system OCR service by removing the conditional check for Linux.
- Updated SystemOcrService to directly import necessary modules, enhancing clarity.
- Refactored image preprocessing to use a static import of the 'sharp' library for better performance.

* add patch for system-ocr

* add patch

* add patch again

* add patch

* delete setting

* delete i18n

* lint error

* add isLinux

* Revert "delete i18n"

This reverts commit 173e65bbd0.

* Revert "delete setting"

This reverts commit de39c76f83.

* fix: add system check for error message

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-09-02 12:59:14 +08:00
co63oc
001253b32d fix typo grap gap (#9777) 2025-09-02 12:36:45 +08:00
2480822690 Remove loading state blocks input, Add "Retry failed messages" button (#9513)
* feat(chat/input): allow sending during streaming; remove loading guard

- Inputbar: drop loading check; keep Send clickable; right-click to pause
- Message/MessageMenubar: render menubar even while streaming
- Minor cleanup of unused imports and lints

* feat(chat/menubar): one-click regenerate (remove confirm); add empty-context fallback

- MessageMenubar: remove Popconfirm; click to regenerate
- ApiService: ensure last user message is included if filters empty (avoid messages=[])
- Minor cleanup

* feat(chat/ui): decouple generation from sending; enable actions during streaming

- MessageGroupMenuBar: add “Retry all” (ReloadOutlined) to retry errored/empty/no-block replies; tooltip via i18n
- Context fallback: always include last user message in both messageThunk and ApiService
- i18n: add message.group.retry_failed (zh-CN, en-US, ja-JP, ru-RU, zh-TW)
- Cleanup: remove unused imports; fix lints

* feat(chat/settings): Add confirmation settings for message actions

- Add "Confirm before deleting message" and "Confirm before regenerating message" options to the settings page, allowing users to customize action confirmations.
- Update internationalization files to support multi-language prompt messages.
- Modify the message menu bar to integrate the confirmation logic, enhancing the user experience.

* fix(chat/ui): Apply regeneration confirmation to user messages

Previously, the "Regenerate" action on user messages would trigger immediately, bypassing the `confirmRegenerateMessage` setting. This behavior was inconsistent with the regeneration logic for assistant messages, which correctly showed a confirmation dialog.

This commit wraps the user message's regenerate button in a `Popconfirm` component, conditioned on the `confirmRegenerateMessage` setting. This aligns its behavior with the existing logic for assistant messages.

Now, all regeneration actions are uniformly governed by the user's confirmation preference, creating a more consistent and predictable user experience.

* fix(ui): Only show 'Retry failed' button when errors exist

fix(ui): Conditionally render the 'Retry failed' button

The 'Retry failed messages' button in the message group menu bar was previously always visible, even when no messages had failed.

- The 'Retry failed' button is now conditionally rendered and will only appear if one or more messages in the group meet the failure criteria.

* feat(chat/ui): Add dedicated button to pause message generation

Replaced the undiscoverable right-click-to-pause functionality on the send button with a dedicated, visible "Pause" button. This new button only appears during message generation, making the action intuitive and accessible.

- Removed `onPause` and context menu logic from `SendMessageButton`.
- Added a conditional `CirclePause` button to the `Inputbar` when loading.

* feat(settings/migrate): initialize confirm message flags for legacy users

- Add migration 138 to default confirmDeleteMessage/confirmRegenerateMessage
- No behavior change for fresh installs (uses initialState)

fix(format): correct indentation in MessageMenubar

fix(settings/migrate): fix persistedReducer verison

* fix(ui): resolve React Hook dependency warnings

- Remove unnecessary `topic.prompt` dependencies from Message components
- Remove `loading` dependency from Inputbar useCallback

Resolves ESLint exhaustive-deps warnings and conflicts

---------

Co-authored-by: n2yt584v2t4nh7y <117180266+n2yt584v2t4nh7y@users.noreply.github.com>
2025-09-02 11:24:20 +08:00
RieN 7z
16b9f49cc8 feat: Add animation for sidebar (#9768) 2025-09-02 09:26:56 +08:00
Xiangxi Meng
1295d37ff6 Add the missing quotation mark (#9772)
Signed-off-by: Xiangxi Meng <mengxiangxi@pku.edu.cn>
2025-09-02 07:27:16 +08:00
kangfenmao
7119c8155a chore: bump version to 1.5.9 2025-09-02 04:43:58 +08:00
kangfenmao
cef8791c82 refactor(RichEditPopup): update minHeight to be dynamic based on window size
- Changed minHeight from a fixed value to a dynamic calculation using window.innerHeight for improved responsiveness.
2025-09-02 04:40:02 +08:00
kangfenmao
e417b57123 refactor(AihubmixPage): update mode options for aihubmix to include specific action values
- Changed mode option values to be more descriptive by prefixing with 'aihubmix_' for better clarity in the image generation context.
2025-09-02 04:40:02 +08:00
kangfenmao
c827aeaab2 feat(code): add 'modelscope' to CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS and define its API base URL
- Updated CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS to include 'modelscope'.
- Added API base URL configuration for 'modelscope' under the anthropic provider settings.
2025-09-02 04:40:02 +08:00
kangfenmao
f271cf737c fix: modelscope deepseek v3.1 think mode 2025-09-02 04:11:56 +08:00
kangfenmao
1f9a8276b1 refactor(settings): remove showTokens setting and related logic
- Eliminated the showTokens state and its associated actions from the settings management.
- Updated MessageTokens component to directly display token usage without relying on showTokens.
- Adjusted migration logic to remove default showTokens setting during state initialization.
2025-09-02 03:53:28 +08:00
kangfenmao
06b17128fd refactor(models): streamline model handling and update image generation logic
- Consolidated vision and function calling model definitions into a more structured format.
- Introduced dedicated image generation model checks in the Inputbar component.
- Removed deprecated model checks and updated related logic in ApiService.
- Added new TEXT_TO_IMAGES_MODELS configuration for better image model management.
- Enhanced overall model type handling for improved clarity and maintainability.
2025-09-02 03:44:46 +08:00
kangfenmao
6fb878d3b6 Revert "fix: complete PoeLogo rendering support across provider UI components (#9756)"
This reverts commit df7fd26907.
2025-09-02 02:48:43 +08:00
kangfenmao
80f49aecd7 feat(ocr): enhance OCR service for Linux compatibility and update image processing
- Added conditional registration of the system OCR service for non-Linux platforms.
- Updated SystemOcrService to handle Linux by returning an empty text response.
- Modified image preprocessing to dynamically require the 'sharp' library, improving compatibility and performance.
- Included additional files in the electron-builder configuration for packaging.
2025-09-02 02:29:24 +08:00
kangfenmao
5ab98c9d05 feat(i18n): add "supported_providers" key to multiple locale files and enhance CodeToolsPage with provider information
- Added the "supported_providers" translation key to English, Japanese, Russian, Simplified Chinese, and Traditional Chinese locale files.
- Updated CodeToolsPage to display a popover with supported providers when the Claude Code model is selected, improving user experience and accessibility of provider information.
- Introduced a new styled component for provider logos to enhance visual representation.
2025-09-02 01:07:38 +08:00
Yuhang
df7fd26907 fix: complete PoeLogo rendering support across provider UI components (#9756)
* fix:  PoeLogo in ProviderLogoPicker

* fix: PoeLogo in AddProviderPopup

该文件缺少足够参数匹配‘poe’,鉴于目前只有poe用的‘svg’,简便起见所以只匹配‘svg’而不匹配‘poe’,以后可能需要重构代码

* fix: PoeLogo in ProviderList

* refactor: provider logo rendering logic

1.三个地方渲染头像统一命名成getProviderLogo
2.鉴于目前PROVIDER_LOGO_MAP中只有poe的是‘svg’,故对于判断简化处理,以后有其它提供商改成‘svg’时再重构代码
3.优化内置头像选择器组件部分代码

* Update index.tsx

* refactor(provider-logo): 将获取provider logo的逻辑统一到useProviderLogo钩子中

将原本分散在各处的获取provider logo的逻辑集中到新创建的useProviderLogo钩子中
移除config/providers.ts中的getProviderLogo函数
修改所有使用getProviderLogo的地方改为使用新钩子

* refactor(useProvider): 移除未使用的getProviderLogo函数

* refactor(ProviderAvatar): 重构提供者头像组件为更灵活的API设计

将原有的getProviderAvatar函数重构为ProviderAvatar组件,提供更灵活的属性和样式控制
统一所有使用提供者头像的地方调用新组件,移除重复的样式代码

* refactor(ImageStorage): 使用put替代add方法更新数据库

将db.settings.add方法替换为db.settings.put,以提高数据更新的准确性和一致性

* feat(llm): 添加logos状态管理及相关操作

添加logos状态字段及setLogos、setLogo、removeLogo操作方法,用于管理不同LLM提供商的logo

* refactor(hooks): 使用 useCallback 优化 useTimer 的性能

将 setTimeoutTimer 和 setIntervalTimer 函数用 useCallback 包裹以避免不必要的重新创建

* refactor(provider-logo): 重构提供商logo管理逻辑,使用redux存储logo状态

- 将logo管理逻辑从组件中抽离到useProviderLogo hook
- 使用redux存储和更新logo状态
- 优化logo的加载和保存流程
- 统一处理系统提供商和自定义提供商的logo显示
- 移除冗余的ImageStorage直接调用

* test(api): 修复ApiService测试中的mock数据缺失

* fix(store): 更新持久化存储版本至144并添加迁移逻辑

添加版本144的迁移逻辑,初始化llm.logos状态以修复潜在问题

---------

Co-authored-by: icarus <eurfelux@gmail.com>
2025-09-02 00:50:54 +08:00
beyondkmp
86d8e10dcb refactor: fix asar integration (#9753)
* refactor: replace afterPack script with beforePack and remove after-pack.js file

- Updated electron-builder configuration to use beforePack instead of afterPack.
- Removed the after-pack.js script as its functionality is no longer needed.

* refactor: streamline filter logic for architecture-specific packages in before-pack script

- Consolidated platform-specific filter conditions for arm64 and x64 architectures.
- Improved readability and maintainability by using arrays to manage filters for different architectures.
- Ensured that the correct filters are applied based on the architecture during the packaging process.

* chore: remove npm build commands from CI workflows

- Eliminated unnecessary `yarn build:npm` commands from the nightly build and release workflows for Linux, macOS, and Windows.
- Streamlined the build process by focusing on platform-specific build commands.

* delete build npm

* refactor: enhance architecture-specific package management in before-pack script

- Updated the before-pack script to include additional architecture-specific packages for arm64 and x64.
- Improved the organization of package filters and streamlined the logic for handling different architectures during the packaging process.
- Ensured that the correct filters are applied based on the architecture, enhancing the build process for various platforms.

* docs: clarify comment on prebuild binaries in before-pack script

* format code

* chore: add afterPack script to electron-builder configuration

- Included afterPack script in electron-builder.yml to enhance the packaging process.
- This addition allows for post-packaging tasks to be executed, improving build automation.

* chore: update macOS entitlements to disable library validation

- Added the key `com.apple.security.cs.disable-library-validation` to the entitlements file to enhance security settings for macOS builds.

* chore: remove unused package for win32 arm64 architecture

- Deleted the `@strongtz/win32-arm64-msvc` package from `package.json` and `yarn.lock` as it is no longer needed.
- Updated the `before-pack.js` script to improve architecture-specific package management by refining filter logic and ensuring correct package downloads based on architecture.
- Enhanced the `downloadNpmPackage` function to use Node.js streams for downloading and extracting packages, improving efficiency and error handling.
2025-09-01 19:48:24 +08:00
kangfenmao
d258d9474a fix(ApiService): change default knowledgeRecognition setting from 'on' to 'off' 2025-09-01 19:32:30 +08:00
kangfenmao
15043ba46c chore: bump version to 1.5.8 2025-09-01 17:15:18 +08:00
SuYao
f085f6c7bc feat: add html-tags and htmlparser2 dependencies; enhance CodeViewer and RichEditor components (#9757)
* feat: add html-tags and htmlparser2 dependencies; enhance CodeViewer and RichEditor components

* fix(NotesPage): prevent unnecessary state clearing when notesTree is empty

* feat(NotesPage): enhance note saving functionality to include file path management

* style: refine button and border styles across components for improved aesthetics

- Updated ToolbarButton styles to simplify background and hover effects.
- Adjusted border styles in NotesEditor, NotesSidebar, and NotesSidebarHeader for a more consistent look.
- Enhanced overall UI by reducing border thickness in various components.

* style: add bottom spacer to richtext component for improved viewport padding

* style: ensure drag handles and plus buttons are interactive in richtext component

* feat(RichEditor): add conditional focus behavior based on text length in rich editor

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
2025-09-01 17:13:31 +08:00
沿途风浪
197bae6065 feat(Miniapp): add longcat.chat mini app (#9736)
* feat(Miniapp): add longcat.chat mini app

* feat(Miniapp): add longcat.chat mini app

* feat(Miniapp): add longcat.chat mini app update miniapps.ts

* feat(Miniapp): add longcat.chat mini app update longcat.svg and miniapps.ts
2025-09-01 14:25:53 +08:00
dependabot[bot]
22729b3d71 ci(deps): bump actions/download-artifact from 4 to 5 (#9743)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-01 13:15:12 +08:00
Phantom
33363f0d6a fix(ProviderSettings): click padding won't trigger onClick (#9740)
* fix(ProviderSettings): 修复添加提供商弹窗菜单项样式问题

调整菜单项样式以移除默认内边距,并统一使用 MenuItem 组件封装
修复点击边缘无法触发回调的问题,通过添加 overlayClassName 控制

* refactor(ProviderSettings): 优化添加供应商弹窗的菜单项交互逻辑

移除不必要的ant.scss样式和overlayClassName属性
使用useRef和ItemType优化菜单项点击处理
2025-09-01 13:01:11 +08:00
Phantom
22ca77188b feat(openrouter): support openrouter to generate image (#9750)
* feat(openrouter): 支持OpenRouter的图像生成功能并处理模型名称

修改getLowerBaseModelName函数以处理OpenRouter的:free后缀
在OpenAIApiClient中添加enableGenerateImage参数支持图像生成

* refactor(openai): 重构OpenAI参数类型并优化翻译选项处理

重构OpenAIParamsWithoutReasoningEffort为OpenAIParamsPurified,新增OpenAIModalities和OpenAIExtraBody类型
优化翻译选项处理逻辑,提前验证目标语言有效性
将modalities参数从extra_body分离以提升类型安全性

* test(naming): 修复模型名称处理测试并添加新测试用例

修复getLowerBaseModelName测试中对GPT-4:free的预期结果
添加新测试用例验证去除:free后缀的功能

* test(naming): 移除对包含冒号的模型名称的测试
2025-09-01 12:55:46 +08:00
Konv Suu
fd2d4c723c fix: 快捷助手打开网址应该使用浏览器进行操作 (#9664)
fix: 快捷助手打开网页应该使用浏览器进行操作

update

revert

update
2025-09-01 12:55:33 +08:00
dependabot[bot]
79a9dd15a7 ci(deps): bump actions/checkout from 4 to 5 (#9742)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-01 12:53:40 +08:00
亢奋猫
f599b2c846 fix: improve image block layout and spacing (#9754)
- Adjust image block margin from 10px to 1em for better consistency
- Fix single vs multi-image display logic with proper layout
- Update ImageBlock component to handle single/multi display modes
- Fix image model detection logic for better compatibility
- Improve overall spacing between content blocks

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-01 12:44:24 +08:00
yyhhyyyyyy
de8c7dbc93 fix: resolve mermaid theme switching issue from dark to light mode (#9745) 2025-09-01 11:53:38 +08:00
co63oc
ed22890d67 fix typo secounds (#9751) 2025-09-01 11:45:00 +08:00
Phantom
7303c785aa feat(MCPSettings): add special error boundary & data validation for mcp server (#9633)
* feat(MCPSettings): 为McpServerCard添加错误边界处理

在McpServerCard组件外层添加ErrorBoundary,防止组件内部错误导致整个页面崩溃

* feat(types): 添加 MCP 服务器配置的类型定义和验证函数

添加 MCP 服务器配置的 Zod schema 类型定义,包括服务器类型、配置和验证函数
将 MCPServer 的 type 字段更新为复用 McpServerType

* feat(MCPSettings): 添加ErrorBoundary包装路由组件以捕获错误

* feat(types): 为McpServerConfigSchema添加url、headers和tags字段

添加服务器配置的URL地址、请求头配置和标签字段,以支持更灵活的服务器配置选项

* refactor(MCPSettings): 重构JSON解析逻辑为独立函数并使用zod验证

将原有的parseAndExtractServer函数重构为getServerFromJson,使用zod进行配置验证
移除重复的解析逻辑,简化代码结构

* feat(设置): 添加MCP服务器错误处理和详情展示功能

在MCP服务器卡片中添加错误边界处理,当服务器无效时显示错误提示和详情按钮
新增GeneralPopup组件用于展示错误详情
更新i18n翻译文件添加相关文本

* fix(MCPSettings): 修复导入服务器配置时的类型检查和错误处理

修正 getServerFromJson 返回类型定义,明确区分成功和错误状态
修复错误判断逻辑,使用 null 明确检查而非隐式转换
修复服务器名称存在时的错误提示,移除不必要的非空断言

* feat(MCPSettings): 添加临时测试用的无效服务器功能

添加一个临时测试按钮用于模拟添加无效服务器配置,方便测试错误处理流程

* feat(i18n): 添加错误处理页面的多语言翻译

添加"details"和"mcp.invalid"字段的翻译,用于错误处理页面

* fix(MCPSettings): 修复导入MCP服务器配置时的JSON解析和验证逻辑

将JSON解析和验证拆分为两个步骤,分别捕获解析和验证错误并记录日志
修复服务器配置名称赋值逻辑,使用正确的键名

* refactor(MCPSettings): 替换删除图标为DeleteIcon组件

* feat(MCPSettings): 在McpServerCard中添加错误详情点击展示功能

- 提取错误信息格式化逻辑到变量errorDetails
- 添加点击卡片展示完整错误详情的功能
- 统一按钮点击事件处理,阻止事件冒泡
- 优化错误展示样式,增加内边距和文字省略效果

* refactor(utils): 移除错误处理模块中的日志记录

* docs(MCPSettings): 移除AddMcpServerModal中多余的t参数注释

* test(utils): 移除对console.error的冗余断言

* fix(types): 将args字段从必需改为可选并设置默认值

修改McpServerConfigSchema中的args字段,使其从必需字段变为可选字段并设置默认空数组

* fix(types): 将服务器配置的command和args字段改为可选

command字段现在默认为空字符串,args字段默认为空数组,以提供更灵活的配置方式

* feat(types): 扩展 MCP 服务器配置类型,新增 baseUrl 等字段

添加 baseUrl、description、registryUrl 和 provider 字段以增强服务器配置能力

* fix(MCPSettings): 修复导入MCP服务器时未设置名称的问题

当导入MCP服务器配置时,仅在名称未设置时使用key作为默认名称

* refactor(types): 重构 MCP 相关类型定义并添加更多配置字段

将 MCPConfigSample 从接口改为 zod 推断类型
为 McpServerConfigSchema 添加更多可选配置字段
重新组织 MCPServer 接口字段并添加内部使用注释

* refactor(types): 将 MCP 相关类型定义提取到独立文件

将 MCP 服务器配置相关的 Zod schema 和类型定义从 index.ts 移动到新的 mcp.ts 文件
保持原有功能不变,提高代码组织性和可维护性

* docs(types): 更新MCP服务器内部字段的注释说明

添加关于JSON数据格式暴露的额外警告信息

* feat(types): 添加 strip 工具函数用于移除对象属性

添加一个通用的 strip 工具函数,用于从对象中移除指定的属性并返回新对象

* refactor(types): 调整MCPServer接口和strip函数参数格式

将MCPServer接口中的disabledTools和disabledAutoApproveTools字段移动到文档注释下方
修改strip函数参数从可变参数改为数组形式
更新McpServerConfigSchema字段的默认值和描述

* feat(mcp): 改进 MCP 配置验证并添加 Zod 错误格式化功能

添加 formatZodError 工具函数用于格式化 Zod 验证错误
修改 MCP 配置验证逻辑,使用 safeValidateMcpConfig 替代直接验证
允许 inMemory 类型服务器并添加额外校验规则
更新相关组件使用新的验证方式和错误处理

* refactor(MCPSettings): 移除临时测试代码和无效server添加按钮

* fix(MCPSettings): 修复EditMcpJsonPopup中json错误显示样式问题
2025-09-01 10:20:02 +08:00
beyondkmp
9df7ac0ac2 feat: add support for downloading and retaining @napi-rs/system-ocr packages (#9741)
- Implemented downloading of architecture-specific versions of the @napi-rs/system-ocr packages for macOS and Windows platforms in build-npm.js.
- Updated after-pack.js to retain these packages during the packaging process, ensuring compatibility across different architectures.
2025-09-01 09:59:53 +08:00
kangfenmao
a0fa536926 chore: bump version to 1.5.8-rc.2
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 23:03:19 +08:00
SuYao
ce4cad67a6 Fix/newNode (#9727)
* feat: enhance note saving functionality with immediate cache invalidation

* fix: improve file name handling and localization updates

* feat: implement multi-level note sorting and enhance state management

- Introduced sorting options for notes by name, update time, and creation time, allowing users to sort notes in ascending and descending order.
- Updated NotesPage and NotesSidebar components to handle sorting functionality.
- Enhanced Redux store to manage the sorting type, improving state management for note organization.
- Refactored related services to support recursive sorting logic, ensuring a consistent user experience.

* feat(i18n): add new file upload messages for multiple languages

* fix(styles): adjust padding in richtext.scss to accommodate scrollbar

* style(NotesSidebar): add border-top-left-radius to enhance sidebar aesthetics

* feat(RichEditPopup): add isFullWidth prop to enhance popup layout

* feat(RichEditPopup): disable keyboard interaction for improved user experience

* feat(NotesPage): integrate sorting after node deletion and movement

- Added sorting functionality to be triggered after deleting or moving nodes, ensuring notes are organized immediately.
- Updated dependencies in useCallback hooks to include sortType for consistent behavior across operations.

* feat(NotesService): update initWorkSpace and sortAllLevels to accept sortType

- Modified initWorkSpace to include sortType for improved note organization during initialization.
- Enhanced sortAllLevels to optionally accept a tree parameter, allowing for more flexible sorting operations.
- Updated NotesPage to utilize the new parameters, ensuring consistent sorting behavior across various actions.

* feat(NotesSidebar): implement in-place editing for note renaming

- Introduced a new hook, useInPlaceEdit, to manage in-place editing of note names, enhancing user experience during renaming.
- Updated the NotesSidebar component to utilize this hook, streamlining the editing process and improving state management.
- Removed redundant state variables related to editing, simplifying the component's logic.

* refactor(NotesPage): remove commented code for clarity

- Removed a comment regarding folder selection behavior to streamline the code and improve readability.
- This change does not affect functionality but enhances the overall code quality.

* feat(NotesSettings): update initWorkSpace to include default sort type

- Modified initWorkSpace calls in NotesSettings to accept a default sort type of 'sort_a2z', ensuring consistent note organization during path updates and resets.
- This change enhances the initialization process by applying a predefined sorting method.
2025-08-31 21:53:53 +08:00
Pleasure1234
bf23c5b209 fix: initialize notes path on app startup to resolve missing default directory (#9728)
* fix: initialize notes path on app startup to resolve missing default directory

- Add notes path initialization in store persistStore callback after rehydration
- Update NotesSettings to sync tempPath when notesPath changes from initialization
- Ensure notes directory is set even when user doesn't visit NotesPage first
- Fixes issue where notes path was empty after data reset until NotesPage visit

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update NotesSettings.tsx

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-31 21:46:39 +08:00
Phantom
57d9b79e77 fix: enableWebSearch logic error (#9730)
fix: 修复enableWebSearch条件判断逻辑错误
2025-08-31 20:52:29 +08:00
Phantom
16973fc034 fix(ipc): check mainWindow in ipc handler (#9712)
fix(ipc): 添加主窗口检查并修复窗口重置逻辑

在Windows_ResetMinimumSize处理中添加主窗口存在性检查
移除不必要的可选链操作符,确保窗口操作安全
2025-08-31 20:41:32 +08:00
Phantom
2e5ffb8324 fix(poe): poe cannot process multiple text part (#9711)
fix(poe): 修复poe provider不支持array content的问题

临时解决方案是强制poe使用string content,同时将reasoning_effort参数拼接逻辑优化为使用suffix变量
2025-08-31 20:41:18 +08:00
one
4dbe5c8055 refactor(CodeEditor): support file extensions explicitly (#9707) 2025-08-31 18:31:28 +08:00
Phantom
1ee57f1385 feat(window): check fullscreen state when useFullScreen mounted (#9719)
feat(全屏): 添加检查窗口是否全屏的功能

实现通过IPC通道检查主窗口全屏状态的功能,并在渲染进程首次加载时获取当前状态
2025-08-31 18:17:16 +08:00
SuYao
10d6256ce1 feat: enhance note saving functionality with immediate cache invalidation (#9725) 2025-08-31 18:15:34 +08:00
beyondkmp
03ebc4a794 feat: enhance package management for image processing libraries (#9721)
- Added support for downloading and retaining architecture-specific versions of the `@img/sharp` and `@img/sharp-libvips` packages for macOS, Linux, and Windows platforms.
- Removed the deprecated function for deleting macOS-only packages, streamlining the package management process.
- Updated the `after-pack.js` and `build-npm.js` scripts to ensure proper handling of image processing dependencies across different architectures.
2025-08-31 16:16:52 +08:00
vectorstone
54a92bf2c6 fix: support rendering and display of <details/> and <summary/> tags (#9699)
* 修复issue https://github.com/CherryHQ/cherry-studio/issues/9635,支持<details/>和<summary/>标签渲染展示,后期可以考虑做成配置化,允许用户自定义标签展示逻辑,或者扩展rehypeRaw插件标签识别逻辑,支持任意标签的渲染展示

* 修复issue https://github.com/CherryHQ/cherry-studio/issues/9635,支持<details/>和<summary/>标签渲染展示,后期可以考虑做成配置化,允许用户自定义标签展示逻辑,或者扩展rehypeRaw插件标签识别逻辑,支持任意标签的渲染展示
2025-08-31 12:28:50 +08:00
one
9e567ace4e refactor: simplify select model popup (#9630)
* refactor: simplify SelectModelPopup

* refactor: extract useModelTagFilter

* refactor: improve filter naming

* refactor: extract TagFilterSection, improve tag style

* test: add tests for filters

* refactor: focus on tag selection

* refactor: suppress react key warning

* refactor: add log to TagFilterSection

* refactor: add initialTagSelection

* refactor: use objectEntries

* test: add tests for TagFilterSection

* refactor: improve group action icon style

* refactor: ease CustomTag opacity change

* fix: GroupItem alignment
2025-08-31 12:26:59 +08:00
kangfenmao
a1f5c12a96 chore: release v1.5.8-rc.1
- Update version to 1.5.8-rc.1
- Update release notes with latest features and fixes

🤖 Generated with Claude Code
https://claude.ai/code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:53:19 +08:00
亢奋猫
96d8ac7250 docs: update trendshift badge 2025-08-31 09:30:46 +08:00
Pleasure1234
fef6dccfd7 fix: missing note sidebar control button when the navigation bar is on the left (#9698) 2025-08-31 00:50:56 +08:00
Phantom
0b7543a59b fix: support DeepSeek v3.1 for ppio & openrouter free (#9697)
* fix: 修复deepseek-chat-v3.1模型判断逻辑

使用includes替代严格相等判断,以兼容更多可能的provider模型id格式

* feat: 添加对PPIO提供商的支持并优化DeepSeek思考令牌逻辑

为DeepSeek V3.1添加PPIO提供商支持,同时统一硅和PPIO提供商的思考令牌配置
将未知提供商的默认行为改为启用思考令牌,并更新警告日志信息

* fix(openrouter): 处理总是思考模型的特殊情况

当模型为总是思考类型且不支持思考标记时,返回空对象以避免隐藏思考内容
2025-08-31 00:48:20 +08:00
SuYao
dfb3322b28 feat: add notes module (#8871)
* feat: integrate rich text editing

- Replaced the TextEditPopup with RichEditPopup for adding and editing notes, enhancing the editing experience with rich text capabilities.
- Updated note previews to display HTML content appropriately, improving usability and visual representation.
- Added a styled component for note previews to enhance user interaction.

* feat(RichEditor): enhance rich text editing capabilities

- Added new command system for rich text editing, allowing users to execute commands like headings, lists, and formatting.
- Integrated drag handle functionality for better content manipulation within the editor.
- Updated toolbar to include additional formatting options such as strikethrough and code blocks.
- Improved markdown and HTML content handling, enabling seamless conversion and previewing.
- Introduced new utility functions for markdown conversion and sanitization.
- Added tests for command list popover and rich editor functionalities to ensure reliability.

* refactor(RichEditor): remove debug log from command suggestion

* feat(RichEditor): add link and unlink functionality

- Introduced link and unlink commands in the RichEditor toolbar, enhancing text formatting capabilities.
- Updated placeholder text for the RichEditor to provide clearer user guidance.
- Refactored styles and removed unused code to streamline the RichEditor component.
- Added internationalization support for new toolbar items and placeholder text in both English and Chinese.

* wip: custom codeblock

* feat: add new dependencies for markdown processing

- Introduced `he` for HTML entity decoding and `striptags` for stripping HTML tags in markdown conversion.
- Updated `package.json` and `yarn.lock` to include new type definitions and library versions.

* feat(RichEditor): enhance image and math input capabilities

- Added ImageUploader component for embedding images with URL support and drag-and-drop functionality.
- Introduced MathInputDialog for entering LaTeX formulas, allowing real-time updates and integration with the editor.
- Enhanced RichEditor toolbar with new commands for image and math insertion.
- Updated styles for better user experience and accessibility.
- Added internationalization support for new features in multiple languages.

* refactor(CodeBlockView): change export to local variable

- Changed the export of CodeHeader to a local variable within CodeBlockView.
- Removed unused export from code-block-shiki index file.

* feat(RichEditor): enhance command management and toolbar functionality

- Added support for disabling specific commands in the RichEditPopup.
- Implemented dynamic command registration and management in the RichEditor, allowing for initial commands to be registered on mount.
- Updated toolbar to dynamically generate items based on command groups, improving organization and accessibility.
- Introduced new command definitions for text formatting, including bold, italic, underline, and strikethrough, with toolbar visibility options.
- Enhanced command handling capabilities, including the ability to unregister commands and set their availability based on editor context.

* refactor(RichEditPopup): remove translation functionality and related components

- Eliminated translation handling logic, including the translate button and associated state management.
- Cleaned up imports and unused variables to streamline the RichEditPopup component.
- Simplified the content change handling by focusing solely on rich content management.

* feat(ImageUploader): enhance image upload functionality and styling

- Added custom image upload button with improved styling and theme support.
- Refactored image display logic to use a more flexible layout.
- Updated file acceptance criteria to restrict uploads to PNG and JPEG formats.
- Simplified the upload process by preventing default behavior and customizing request handling.
- Improved overall component structure and styling for better user experience.

* feat(AssistantPromptSettings): implement throttled update functionality and enhance UI

- Introduced throttling for the update function to improve performance and reduce unnecessary updates.
- Added a save button to the UI for manual saving of changes, enhancing user experience.
- Refactored the component to streamline the handling of emoji selection and markdown changes.
- Updated layout with Flex component for better alignment of buttons in the settings interface.

* feat(RichEditor): integrate internationalization for placeholder text

- Updated the placeholder property to utilize the i18next translation function, enhancing support for multiple languages.
- Improved user experience by providing localized placeholder text in the RichEditor component.

* fix(styles): update list styles for ordered and unordered lists in richtext.scss

- Removed default list style for ordered lists and added decimal style.
- Added disc style for unordered lists to enhance visual consistency.

* fix(styles): improve table cell background handling in richtext.scss

- Added !important to header background color to ensure consistency.
- Set table cell backgrounds to transparent to prevent inheritance issues during drag operations.
- Updated ProseMirror widget styles to maintain transparency for table cells.
- Enhanced overall table styling to improve user experience.

* fix(styles): update padding and overflow handling in RichEditor

- Increased padding in the tiptap class for improved spacing.
- Modified overflow-x property in EditorContent to allow horizontal scrolling, preventing the drag handle from being cut off.
- Ensured proper positioning and visibility of the drag handle with updated styles.
- Adjusted ProseMirror editor content to maintain drag handle positioning.

* refactor(CodeBlockNodeView, shikijsPlugin): improve language handling for code blocks

- Updated language options to ensure 'text' is always available.
- Introduced a set of languages to skip syntax highlighting, enhancing performance and user experience.
- Simplified logic for checking loaded languages, avoiding unnecessary fallbacks for unsupported languages.

* fix(RichEditor): improve link handling and selection behavior

- Enhanced link insertion logic to ensure the entire paragraph is selected when creating a link.
- Added error handling to toggle link state if selection fails.
- Cleaned up code by moving paragraph text retrieval to the appropriate location for better readability.

* fix(styles): update inline code background and text colors in color.scss

- Changed inline code background color to a solid value for better visibility.
- Updated inline code text color to use RGB format for consistency.

* refactor(RichEditor): simplify editable state management and improve UI interactions

- Removed the disabled prop from RichEditor, simplifying the editable state logic.
- Updated the useRichEditor hook to directly manage the editable state based on the editable prop.
- Enhanced the AssistantPromptSettings component by streamlining the RichEditor rendering logic and improving the save button functionality.

* chore(tests): move useRichEditor test suite

* refactor(RichEditor): enhance command handling and UI responsiveness

- Removed the 'unlink' command from the command list and toolbar for a cleaner interface.
- Improved command filtering logic by removing the maxResults limit.
- Updated command positioning to use fixed strategy with enhanced middleware for better responsiveness.
- Integrated a dynamic virtual list for command suggestions, improving performance and user experience.
- Added internationalization support for 'undo' and 'redo' commands in multiple languages.

* fix(styles): adjust strong tag styling in richtext.scss

- Updated the strong tag styling to apply font-weight to all child elements, ensuring consistent text formatting within rich text content.

* fix(RichEditor): prevent codeBlock nodes from being skipped during drag operations

- Updated the placeholder extension to check for drag operations, ensuring that codeBlock nodes are not skipped when dragging is in progress. This improves the user experience by maintaining expected behavior during content manipulation.

* feat(markdown): integrate turndown-plugin-gfm for enhanced markdown support

- Added turndown-plugin-gfm to enable support for tables and additional markdown features.
- Updated the markdown converter to include new rules for underlining and table elements.
- Enhanced HTML sanitization to allow table-related attributes, improving markdown conversion accuracy.

* feat(markdown): add task list support and enhance markdown conversion

- Integrated @rxliuli/markdown-it-task-lists for task list functionality in markdown.
- Updated markdown converter to handle task list syntax, converting it to appropriate HTML structure.
- Enhanced styles for task lists in richtext.scss to improve visual representation.
- Modified useRichEditor to include task list extensions, ensuring proper functionality within the editor.

* fix(styles): update table header styling in richtext.scss

- Modified table header styling to apply background color and font weight to all child elements, ensuring consistent formatting within tables.

* fix(styles): enhance strong tag styling in richtext.scss

- Added styling for the strong tag to ensure consistent font-weight application across all child elements, improving text formatting in rich text content.

* refactor(markdown): remove @rxliuli/markdown-it-task-lists and implement custom task list plugin

- Removed dependency on @rxliuli/markdown-it-task-lists and integrated a custom task list plugin for markdown-it.
- Enhanced markdown conversion to support task lists with improved HTML structure and sanitization.
- Updated tests to validate task list functionality and ensure proper conversion between markdown and HTML.

* refactor(tests): remove redundant task item label test from markdownConverter tests

- Deleted the test case that checked for the absence of label wrapping around task items, as it is no longer relevant with the updated markdown conversion logic.
- Ensured that existing tests continue to validate the preservation of labels in sanitized HTML for task lists.

* feat(extension-table-plus): add new table extension for Tiptap

- Introduced the @cherrystudio/extension-table-plus package, providing a comprehensive table extension for Tiptap.
- Implemented core functionalities including table, table cell, header, and row management.
- Enhanced the editor with a TableKit for easier table manipulation and integration.
- Updated styles for improved table presentation and interaction within the rich text editor.
- Modified useRichEditor to utilize the new TableKit, ensuring seamless integration with existing features.

* chore(package): remove @tiptap/extension-table dependency

- Deleted the @tiptap/extension-table from package.json and yarn.lock as it is no longer needed.
- Updated dependency management to streamline the project and reduce unnecessary packages.

* chore(package): update package.json for @cherrystudio/extension-table-plus

- Changed the description to reflect the forked nature of the extension.
- Downgraded the version to 3.0.10 to align with the new release strategy.
- Updated the homepage URL to point to the new project site.
- Modified the repository URL to reflect the new GitHub location and directory structure.

* chore(package): update @cherrystudio/extension-table-plus version in package.json

- Changed the version of @cherrystudio/extension-table-plus from workspace:* to ^3.0.10 to align with the new release strategy.

* chore(yarn): update @cherrystudio/extension-table-plus version in yarn.lock

- Changed the version of @cherrystudio/extension-table-plus from workspace:* to npm:^3.0.10 to align with the updated package management strategy.

* chore(useRichEditor): clean up comments and improve code clarity

* chore(package): update @cherrystudio/extension-table-plus version to workspace:^ in package.json and yarn.lock

- Changed the version of @cherrystudio/extension-table-plus from ^3.0.10 to workspace:^ to align with the updated package management strategy.

* chore(tsconfig): add path mapping for @cherrystudio/extension-table-plus in tsconfig.web.json

- Updated tsconfig.web.json to include path mapping for the @cherrystudio/extension-table-plus package, enhancing module resolution for TypeScript.

* chore(dependencies): update ESLint and Prettier configurations

- Added ESLint and Prettier as development dependencies in package.json and yarn.lock.
- Updated lint script to format code and fix issues automatically.
- Enhanced type safety by specifying Node type in TableKit extension.

* fix(deleteTableWhenAllCellsSelected): ensure function returns true after cell count check

- Updated the deleteTableWhenAllCellsSelected function to return true after counting selected table cells, improving the logic for table deletion when all cells are selected.

* chore(electron.config): add path mapping for @cherrystudio/extension-table-plus

- Updated electron.vite.config.ts to include path mapping for the @cherrystudio/extension-table-plus package, improving module resolution for Electron builds.

* refactor(table-cell): rename allowNestedTables to allowNestedNodes and update content type

- Changed the TableCell option from allowNestedTables to allowNestedNodes for clarity on nested node support.
- Updated content type in TableCell and TableHeader from 'block+' to 'paragraph+' to better reflect intended structure.
- Adjusted logic in Table to disallow inserting tables inside nested nodes based on the new option.

* fix: math block bug

* feat(richEditor): add inline and block math commands with updated toolbar support

- Introduced 'inlineMath' and 'blockMath' commands for inserting inline and block mathematical formulas.
- Updated the toolbar to include new commands and their respective tooltips.
- Enhanced the math input dialog to handle both inline and block math types.
- Adjusted markdown conversion to support new math syntax for inline and block math.
- Updated localization files to include translations for new commands.

* feat(table-cell): add cell selection styling and decorations

- Implemented a new plugin for cell selection styling in table cells.
- Added logic to create decorations for selected cells, enhancing visual feedback.
- Updated CSS to style selected cells with borders based on selection edges.

* feat(table): enhance table action handling with new row/column action triggers

- Added optional callbacks for row and column action triggers in TableOptions.
- Implemented row and column action buttons in TableView, allowing for dynamic actions on selected rows and columns.
- Introduced utility functions for calculating cell selection bounds and element border widths.
- Updated styles to accommodate new action buttons and ensure proper positioning.
- Integrated action menu in RichEditor for managing table actions, enhancing user interaction.

* feat(table): enhance table action menu and localization support

- Updated TableOptions to include optional position parameters for row and column action callbacks.
- Refactored TableView to utilize new action callbacks for row and column actions, improving interaction.
- Integrated ActionMenu in RichEditor for better management of table actions, replacing the previous event-based approach.
- Added localization strings for new table action commands in multiple languages, enhancing user accessibility.

* feat(richEditor): update table action icons for improved clarity

- Replaced icons for row insertion actions in the table action menu, using ArrowUp for inserting a row before and ArrowDown for inserting a row after.
- Enhanced visual representation of table actions to better align with user expectations.

* chore(package): bump version to 3.0.11 for @cherrystudio/extension-table-plus

* feat(richtext): enhance table cell styling and resize handle functionality

- Added styles for text overflow handling in table cells to improve readability.
- Introduced a column resize handle with specific positioning and visibility rules.
- Updated the RichEditor to support resizable tables, enhancing user interaction with table elements.

* fix: auto scroll to incomplete command list

* fix: cli

* feat: add MdiDragHandle icon and update RichEditor to use it

- Introduced a new MdiDragHandle SVG icon in the SVGIcon component.
- Replaced the MdiLightbulbOn icon with MdiDragHandle in the RichEditor component for improved functionality.

* feat(RichEditor): add onPaste callback for handling paste events

- Introduced an onPaste callback in both RichEditorProps and UseRichEditorOptions interfaces to allow custom handling of paste events.
- Implemented paste event handling in the useRichEditor hook, converting pasted text to HTML and dispatching it to the editor.

* feat(markdownConverter): extend allowed attributes for HTML sanitization

- Added 'width', 'height', and 'loading' to the list of allowed attributes in the sanitizeHtml function to enhance HTML sanitization capabilities.

* refactor(richtext): update paragraph and heading styles for improved layout

- Removed default margins from paragraphs and adjusted margins for headings to enhance spacing.
- Updated font sizes for headings to improve hierarchy and readability.
- Enhanced blockquote styling with a new border color and italic font style.
- Added specific margin rules for the first and last paragraphs to ensure consistent spacing.

* style(richtext): adjust margins for headings and paragraphs

- Updated heading margins from 'em' to 'rem' for consistency.
- Modified paragraph margins to improve spacing and readability.
- Removed redundant margin rules for first and last paragraphs.

* feat(AssistantPromptSettings): implement draft prompt handling for improved token estimation

- Introduced a draftPrompt ref to manage prompt changes before committing.
- Updated token count estimation to use the draft prompt instead of the current prompt.
- Enhanced the onUpdate function to commit the draft prompt when saving changes.
- Modified handleMarkdownChange to update the draft prompt directly.

* refactor(RichEditor): optimize command handling with useCallback

- Refactored the handleCommand function to use useCallback for improved performance.
- Cleaned up the command handling logic for better readability and maintainability.
- Ensured consistent behavior for link handling and other formatting commands.

* style(richtext): reorganize list styles and enhance task item appearance

- Moved list styles for unordered and ordered lists to a new section for better organization.
- Ensured consistent padding and margin for list items.
- Updated task item styles to improve visual clarity, including checked checkbox appearance.
- Adjusted paragraph margins within list items for improved readability.

* feat(richtext): add table of contents support in RichEditor

- Introduced a new table of contents extension to enhance document navigation.
- Updated RichEditor component to conditionally render the table of contents based on the new `showTableOfContents` prop.
- Integrated table of contents functionality within the useRichEditor hook, allowing for dynamic updates based on document structure.
- Styled the table of contents for improved visibility and usability.
- Updated package.json and yarn.lock to include the new @tiptap/extension-table-of-contents dependency.

* feat(richtext): enhance RichEditor with content search functionality

- Added `enableContentSearch` prop to RichEditor for in-editor content search.
- Integrated ContentSearch component, allowing users to search within the editor.
- Introduced `showUserToggle` and `positionMode` props for ContentSearch customization.
- Updated styling for Container and SearchBarContainer to support new positioning options.
- Adjusted RichEditor settings in AssistantPromptSettings to reflect new content search feature.

* fix: renderer

* fix: styles

* fix: code styles

* fix: table save

* styles: a link

* feat: link editor

* perf: don't show when editable equals to false

* chore: remove some log

* feat: link remove

* style: reduce space for nested list

* fix/link

* feat: add PlusButton to RichEditor and adjust padding in richtext styles

* style: increase font size in richtext styles

* feat: add task list functionality to RichEditor with toolbar integration and localization support

* feat: enhance math dialog positioning and toolbar integration in RichEditor

* feat: enhance Table of Contents functionality with dynamic item display and scroll behavior

* feat: enhance markdown rendering by properly escaping HTML entities in code blocks and inline code

* feat: update link handling in RichEditor to use enhancedLink functionality and auto-update href based on text content

* feat: improve link hover functionality in RichEditor by calculating position based on full link range

* refactor: remove unused MdiDragHandle component from SVGIcon

* fix: update markdown conversion tests to ensure proper HTML output for line breaks and code blocks

* feat: enhance RichEditor functionality by adding code block handling for paste events and keyboard shortcuts for indentation

* feat: enhance code block language options in RichEditor by dynamically loading available languages from Shiki

* feat: update math syntax handling in RichEditor and markdown converter to use $$ for block and inline math

* feat: allow mathPlaceholder node to accept block content in EnhancedMath extension

* feat: improve paste handling in RichEditor by conditionally cleaning HTML based on cursor position and paragraph state

* fix: correct HTML cleaning logic in RichEditor to remove only outer paragraph tags during content insertion

* feat: enhance markdown conversion to support LaTeX in table cells and improve escaping logic

* fix: enhance link hover positioning in RichEditor to account for document boundaries and improve accuracy near the end of the document

* feat: add note book feature (#8234)

* feat: add notes feature with sidebar integration

Introduces a new Notes page and integrates it into the sidebar and routing. Updates sidebar icon types, default icons, and migration logic to support the new 'notes' icon. Adds initial types for notes and folders, and provides a basic NotesPage component. Also updates Chinese locale for notes.

* feat: add notes feature with sidebar, editor, and storage

Introduces a full notes management feature, including a sidebar for folders and notes, a markdown editor using Vditor, and persistent storage of the notes tree. Adds new components (NotesNavbar, NotesSidebar), a NotesService utility for CRUD operations, and updates settings and migration logic to support workspace visibility. Also updates Chinese i18n for notes, and refines the notes type definition.

* feat: enhance notes functionality with auto-save and file name synchronization

* feat: add export to Notes feature

Introduced the ability to export messages and topics to the Notes workspace. Updated UI components, i18n strings, settings, migration logic, and export utilities to support the new export option.

* fix: merge main branch error

* fix: build check error

* Update src/renderer/src/utils/export.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/utils/export.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/App.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/pages/home/Tabs/TopicsTab.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/pages/notes/NotesPage.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert "Update src/renderer/src/pages/notes/NotesPage.tsx"

This reverts commit a1b9c5a5b0.

* Merge branch 'feat/richeditor' into feat-note

* wip: read markdown

* wip: markdown content save

* fix: content save

* feat: add context menu to notes sidebar and loading state

Implemented a right-click context menu for notes in the sidebar, including options to rename, star, export to knowledge base, and delete notes. Added a loading spinner to NotesPage when loading note content. Updated i18n labels and Chinese translations for new features.

* Enable exporting notes to knowledge base

Added support for exporting individual notes to the knowledge base via a popup. Updated SaveToKnowledgePopup to handle notes, adjusted UI logic for note export, and added relevant i18n strings for export actions and feedback in all supported languages. NotesSidebar now provides an export option in the note context menu.

* Add favorite notes feature to notes sidebar

Introduces the ability to mark notes as favorites and view only starred notes in the sidebar. Updates i18n translations for related labels in all supported languages. Implements UI controls for toggling favorite status and switching between all notes and starred notes view.

* Refactor notes export and update UI labels

Moved NotesService to a shared services directory and updated all imports. Replaced the notes export menu option with a direct 'Save to Notes' action in message and topic menus. Updated i18n labels for 'Save to Notes' in multiple languages and removed the notes export toggle from settings. Cleaned up related migration logic and improved code organization.

* Add drag-and-drop Markdown note upload

Implemented drag-and-drop file upload in NotesSidebar, allowing users to upload Markdown (.md) files directly to notes. Added internationalized messages for upload success, failure, and file type restrictions. Updated NotesService with uploadNote method to handle file storage and tree updates.

* fix: editor init

* Implement drag-and-drop sorting for notes tree

Replaces node moving with a more flexible drag-and-drop sorting mechanism in the notes sidebar. Adds visual indicators for drop positions and updates NotesService with a sortNodes method to handle before, after, and inside placement of nodes. Improves user experience and tree manipulation capabilities.

* fix: some bugs

* fix: remove NotesService class

* Migrate notes tree storage to IndexedDB

Replaced localStorage usage with IndexedDB for storing and retrieving the notes tree structure. Updated NotesService methods and related logic to use the new notes_tree table in Dexie, including making buildNodePath asynchronous. Adjusted translations in NotesSidebar for consistency.

* fix: some bugs

* Merge branch 'feat/richeditor' into feat-note

* feat: enhance RichEditor with table of contents and content search features

Added 'showTableOfContents' and 'enableContentSearch' props to the RichEditor component in NotesPage, improving navigation and search capabilities within notes.

* Add multi-level note sorting functionality

Introduced sorting options for notes by name, update time, and creation time in ascending and descending order. Updated UI and i18n files to support new sorting features, refactored NotesSidebar and NotesPage to handle sorting, and extended NotesService with recursive sorting logic. Added NotesSortType to note types for better type safety.

* perf: reduce rerender

* Add search functionality to notes sidebar

Introduces a search view in the notes sidebar, allowing users to filter notes by keyword. Adds UI elements for toggling search mode and inputting search terms, and updates filtering logic to support both starred and search views.

* Update NotesPage.tsx

* Add header navbar to notes page

Introduced a new HeaderNavbar component for the notes page and integrated it into NotesPage. Adjusted layout styles and reduced NotesSidebar width from 280px to 250px for improved UI consistency.

* Refactor notes state management and add character count i18n

Moved activeNodeId state to Redux store for better state management in NotesPage. Added 'characters' translation key to all supported locales and updated NotesPage to use it. Cleaned exported note content in exportMessageToNotes to remove assistant header.

* Add breadcrumb navigation to notes header

Introduces breadcrumb navigation in the notes header by passing notesTree to HeaderNavbar and implementing path calculation using getNodePathArray. Also exports findNodeInTree and refactors layout for improved UI structure.

* fix: style

* fix: update sorting labels and title capitalization in localization files for multiple languages

* style: adjust margin and padding in TableOfContentsWrapper and ToCDock components

* feat: implement content update handling in RichEditor and enhance mode switching in NotesPage

* refactor: remove redundant content update handling in RichEditor

* Update NotesPage.tsx

* Update NotesPage.tsx

* feat: enhance Table of Contents functionality with dynamic item display and scroll behavior

* fix: update markdown conversion tests to ensure proper HTML output for line breaks and code blocks

* feat: add support for saving pasted images in RichEditor with compression handling and IPC integration

* feat: enhance markdown conversion to support file:// protocol images as HTML img tags

* fix: update RichEditor styles for overflow handling and adjust markdown conversion to preserve <br> tags

* fix: refine RichEditor styles to improve text wrapping and ensure proper display of paragraphs

* Update NotesPage.tsx

* fix: update content structure in TableCell and EnhancedImage to allow for multiple block types

* feat: add methods to set selection to the last row and last column in TableView for improved user experience

* fix: adjust table layout and minimum width in RichEditor styles for better responsiveness and display

* fix: update table layout and scrollbar styles in RichEditor for improved responsiveness and user experience

* fix: improve style

* fix: update content structure in TableCell to support images alongside paragraphs

* fix: enhance layout and styling in NotesPage for improved responsiveness and user experience

* fix: unsaved mention

* Update NotesPage.tsx

* Update NotesPage.tsx

* fix: refine styling in RichEditor and NotesPage for improved layout and responsiveness

* fix: remove extraneous text from Navbar component rendering

* fix: adjust layout and styling in HeaderNavbar for better alignment and responsiveness

* fix: update Scrollbar styling in RichEditor for improved layout

* feat: implement enhanced math command in RichEditor for improved math placeholder insertion

* chore: update @tiptap dependencies to version 3.2.0 and enhance drag handling in RichEditor

* refactor: remove italic font style from rich text and clean up button styles in RichEditor

* refactor: streamline drag handle behavior and adjust styling in RichEditor for improved layout

* style: add code block styling to rich text for consistent font properties

* feat: add tooltips for plus button and drag handle in RichEditor for enhanced user guidance

* fix: update @tiptap/extension-drag-handle to use a patch version for improved functionality in RichEditor

* feat: enhance RichEditor with full width and font family options, and update settings localization

* feat: add drop hint for importing markdown files in NotesSidebar and update localization for multiple languages

* refactor: remove EditorContainer and simplify JSX structure in NotesPage for cleaner layout

* feat: add copy content functionality to HeaderNavbar and update localization for multiple languages

* refactor: simplify NotesPage by integrating NotesEditor component and optimizing state management with useCallback

* wip: open external folder

* feat: enable full width option in AssistantPromptSettings for improved layout

* wip: open external folder

* wip: open external folder

* wip: open external folder

* fix: move node

* wip: fix file rename

* refactor: notebook feature

* fix: improve file and directory deletion and renaming logic

Enhanced error handling and logging for external file and directory deletion in FileStorage. Updated file renaming to consistently append '.md' extension. Improved NotesService and NotesTreeService to use externalPath for renaming and fixed path updates for renamed nodes. Breadcrumbs in HeaderNavbar now reset when no active node is present. File scanning now strips file extension from node names.

* Refactor notes directory handling and state management

Replaces 'folderPath' with 'notesPath' throughout the codebase for clarity and consistency. Adds getNotesDir utility and updates IPC, FileStorage, Redux store, hooks, and UI components to use the new notes directory logic. Improves initialization and workspace setup for notes, ensuring correct directory creation and state synchronization.

* fix

* wip: ensure unique names for notes and folders

Introduces logic to prevent name collisions when creating or uploading notes and folders by checking for existing paths and appending a counter if necessary. Updates related services and UI to handle only one file upload at a time and provide user feedback for invalid actions.

* feat: add file watcher functionality and validate notes directory

Introduces a file watcher using chokidar to monitor changes in the notes directory. Adds IPC channels for starting and stopping the watcher, and validates the selected notes directory to ensure it meets specific criteria. Updates relevant components and services to integrate the new functionality, enhancing the application's responsiveness to file changes.

* fix: add file name validation and uniqueness checks

Introduces file name legality and uniqueness checks to prevent duplicate or invalid file/folder names. Updates IPC, backend, and renderer logic to use these checks for creating, renaming, and uploading notes and folders. Removes legacy unique name logic and refactors related code for consistency.

* fix: file name guard

* fix: rename

* Update NotesSettings.tsx

* Update NotesSettings.tsx

* feat: enhance notes settings and editor functionality

Refactors the notes settings to introduce new display and editor configurations, including options for default view modes and content compression. Updates the Redux store to manage these new settings and modifies relevant components to reflect the changes. Ensures a more intuitive user experience by allowing users to customize their editing environment and display preferences.

* feat: enhance file change handling and directory scanning

Introduces a new FileChangeEvent type to manage file system events more effectively. Updates the FileStorage service to handle directory operations with immediate synchronization and implements a debounce mechanism for file changes. Enhances the scanDir function to support recursive directory scanning with a configurable depth, improving the overall file management capabilities. Additionally, updates the Redux store to track active file paths instead of node IDs, streamlining the note management process.

* feat: enhance directory scanning and note management

Updates the scanDir function to include an optional basePath parameter for improved relative path handling. Modifies the NotesPage and NotesSidebar components to manage selected folder states, allowing for more intuitive folder and note creation. Refactors the createFolder and createNote services to ensure proper tree structure updates based on the selected folder. Additionally, introduces a new utility function to find nodes by external path, enhancing the overall note management experience.

* feat: improve code block highlighting and note saving functionality

Enhances the ShikiPlugin to load themes and languages only when necessary, preventing redundant operations. Introduces error handling for loading processes. In the NotesPage component, implements a debounced save mechanism to optimize note saving, ensuring that changes are saved only after a pause in typing. Additionally, adds logic to compare file content changes before triggering updates, improving the efficiency of file watching and content management.

* Update file.ts

* fix: improve file name validation and sanitization

Refactored file name validation logic to support platform-specific rules and provide detailed error messages. Added a sanitizeFilename utility to clean file names by replacing invalid characters and handling reserved names. Updated getName and checkName functions to use the new validation and sanitization methods.

* fix: remove unused languageMap from useRichEditor hook

Eliminated the languageMap variable from the useRichEditor hook as it was not being utilized, streamlining the code and improving performance. Updated dependencies in the effect hook accordingly.

* refactor: streamline theme and language loading in ShikiPlugin

Removed unnecessary snapshot logic for loaded themes and languages in the ShikiPlugin. Simplified the loading check by introducing a flag to track if any themes or languages were loaded, enhancing performance and reducing complexity.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>

* feat: add isTextFile method to API for file type checking

Introduced a new method `isTextFile` in the preload API to determine if a given file path corresponds to a text file. This enhancement improves file handling capabilities within the application.

* refactor: remove useRichEditor test file

* fix: add i18n

* Update fr-fr.json

* fix: merge main branch build error

* fix the missing navbar when the navigation bar is on the left

* fix: version

* feat: update NotesEditor and NotesService functionality

- Added a temporary view mode state in NotesEditor for improved UI handling.
- Enhanced sortAllLevels function in NotesService to persist the notes tree after sorting.
- Changed default edit mode in note state from 'realtime' to 'preview' for better user experience.

* fix: prevent expanded CodeEditor in NotesEditor

* feat: implement initial sorting for notes tree

- Added a new ref to track if initial sorting has been applied.
- Introduced a useEffect to apply alphabetical sorting to the notes tree on initial load, ensuring a consistent order for users.
- Included error handling for the sorting process to log any issues encountered.

* refactor: comment out file content comparison logic in NotesPage

- Temporarily disabled the file content comparison logic in the NotesPage component to streamline the file reading process.
- This change is intended for further review and potential optimization of the file handling functionality.

* style: remove overflow-y property from richtext.scss

- Eliminated the overflow-y property to improve the styling of the rich text editor, allowing for better content display and user experience.

---------

Co-authored-by: Pleasure1234 <3196812536@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: one <wangan.cs@gmail.com>
2025-08-30 23:09:13 +08:00
亢奋猫
ebe2806467 feat: add cherryin provider (#9681)
* feat: add Zhipu logo and update related images

- Introduced a new Zhipu logo component in SVG format.
- Updated existing image assets for chatglm, zhipu, and zhipu_dark.
- Added a new zhipu image for search functionality.

* feat: integrate Cherryin signature generation

- Added a new integration for Cherryin, including a signature generation feature.
- Updated IPC channels to handle Cherryin requests.
- Introduced a new JavaScript file for Cherryin integration.
- Modified configuration files to include Cherryin client secrets and paths.
- Enhanced the ESLint and TypeScript configurations to accommodate the new integration.

* feat: add Zhipu search provider and logo integration

- Implemented a new ZhipuProvider for web search functionality.
- Added Zhipu logo to the WebSearchButton component.
- Updated WebSearchProviderFactory to include Zhipu as a search option.
- Enhanced error handling and logging for Zhipu search requests.

* feat: add cherryin provider

* fix: correct import path for CherryinAPIClient and update paintings state structure in migration

* chore: update version number to 1.5.8 in package.json

* feat: enhance model filtering in SelectModelPopup

- Added support for identifying free trial models in the model filtering logic.
- Updated the condition for determining free models to include both free and free trial models.

* refactor: update navigation to use query parameters for provider settings

- Modified navigation logic in FreeTrialModelTag and related utilities to use query parameters instead of state for provider identification.
- Removed unused useLocation hook in ProviderList component to streamline state management.

* fix: remove provider ID from search parameters on selection change in ProviderList

* refactor: remove free trial model references and update related logic

- Eliminated the FreeTrialTag component and its associated logic from ModelTagsWithLabel and SelectModelPopup.
- Updated model filtering to only consider free models without the free trial distinction.
- Removed translations and utility functions related to free trial models across multiple locales.

* fix: prevent mutation of read-only properties in web search provider

- Updated the addWebSearchProvider function to clone the provider object before pushing it to the state, preventing mutation of read-only properties.
- Enhanced the migration logic to update the apiKey for the zhipu web search provider if it exists.

* refactor: streamline provider selection and navigation logic

- Updated FreeTrialModelTag to directly navigate to provider settings using query parameters, removing unnecessary provider fetching.
- Simplified ProviderList by eliminating the EventEmitter for provider selection and ensuring search parameters are updated correctly.
2025-08-30 20:09:35 +08:00
beyondkmp
e1b6e46b2f chore: update electron to 37.4.0 (#9692)
* update electron to 37.4.0

* change setBackgroundMaterial to auto

* update
2025-08-30 20:09:02 +08:00
Phantom
c5e746b6c6 fix: filter inline base64 image in messages summary (#9687)
* feat(markdown): 添加清理base64图片链接的功能

添加purifyMarkdownImages函数用于将Markdown中的base64图片链接替换为普通链接格式

* fix(utils): 清理markdown中的base64图片链接并应用到消息摘要

在ApiService中调用purifyMarkdownImages清理消息摘要中的base64图片链接
2025-08-30 18:24:44 +08:00
RieN 7z
e5327aba78 fix: cloudflare turnstile protection error (#9663) 2025-08-30 16:56:04 +08:00
one
d4e024f42d refactor(CodeEditor): improve code editor props (#9653)
* refactor(CodeEditor): improve props for clarity

* refactor: update CodeEditor usage

* refactor: change unwrapped to wrapped

* fix: CodeViewer unwrap

* refactor: simplify code viewer border radius, add comments
2025-08-30 15:11:29 +08:00
Phantom
4f620aed8d fix: code editor style (#9667)
* fix(markdown): 修复cm-announced导致滚动条出现大量空白区域的问题

* fix(CodeBlockView): 修复自动补全被隐藏的问题

* revert: preserve code viewer border radius

---------

Co-authored-by: one <wangan.cs@gmail.com>
2025-08-30 12:47:03 +08:00
defi-failure
8f5e89d69a fix: replace hardcoded window size on first start (#9669)
* fix: replace hardcoded window size on first start

Signed-off-by: dev <verc20.dev@proton.me>

* fix: change MIN_WINDOW_WIDTH from 1080 to 960

---------

Signed-off-by: dev <verc20.dev@proton.me>
2025-08-30 09:38:39 +08:00
one
86635eef49 refactor(SvgPreview): add tag use and animate (#9660)
* refactor(SvgPreview): add tag use

* refactor(Svg): add tag animate
2025-08-29 18:29:52 +08:00
one
25c94dc2f0 fix: mcp tags overflow (#9662) 2025-08-29 17:29:27 +08:00
Konv Suu
c376426cdf fix: useAssistant hook 导致快捷助手渲染问题 (#9657) 2025-08-29 17:19:22 +08:00
one
2f5cd78f7f chore(deps): bump mermaid, shiki, tanstack, etc. (#9658)
* chore(deps): bump mermaid, shiki, tanstack, etc.

* refactor: update code languages, fix lint warnings
2025-08-29 16:17:55 +08:00
one
ffbbec879b refactor: provider list and urlSchema popup (#9626)
* refactor: separate ProviderList from index

* refactor: add UrlSchemaInfoPopup

* refactor: improve popup style
2025-08-29 15:51:33 +08:00
Yongfu
e5416827cb fix: missing model icons (#9650)
* Update models.ts

修复图标的缺失

* Update models.ts

解决CI
2025-08-29 14:17:11 +08:00
Pleasure1234
279ab8f808 feat: add batch delete functionality for files page (#9636)
* feat: add batch delete functionality for files page

- Add batch selection with checkboxes for individual files
- Implement batch delete operation with confirmation dialog
- Add select all/none functionality with indeterminate state
- Include safety check to prevent deleting files used in paintings
- Support multiple languages for batch operation UI text
- Exclude image files from batch operations per design requirements

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* style: make batch delete popconfirm icon color consistent

- Add red color styling to batch delete popconfirm icon to match individual delete
- Update i18n translations for batch warning message across all locales

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: improve batch delete UX and i18n translations

- Change "batch_operation" label to "Select All" across all languages
- Remove unused batch_warning translation for paintings
- Simplify delete confirmation messages to use single form
- Optimize batch delete to use Promise.all for better performance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-29 00:57:24 +08:00
one
144012b980 test: link and hyperlink (#9638)
* test: add tests for Link

* test: add tests for HyperLink

* refactor: use zod for citation data

* refactor: update import
2025-08-29 00:37:14 +08:00
one
95ff67e99c fix(ImageViewer): prevent double menu, improve icons (#9637)
* fix(ImageViewer): prevent double menu, improve icons

* refactor: default icon size, standard error messages
2025-08-29 00:25:58 +08:00
one
649a2a645c feat: capture iframe as image (#9607)
* refactor: update capture function signatures

* feat: capture html as png

* refactor: rename the ipc channel

* fix: stop propagate double clicks

* fix: improve conversion from title to filename

* refactor: improve capture, add more capture options

* fix: button icons

* refactor: add success message
2025-08-28 21:22:56 +08:00
beyondkmp
46e731dee0 chore: update electron-builder.yml to exclude unnecessary Tesseract.js core files from build (#9631) 2025-08-28 21:05:58 +08:00
Phantom
626a5ed4f1 fix: qwen-mt translate (#9627)
* feat(translate): 优化翻译助手类型定义和服务逻辑

重构翻译助手类型定义,将内容字段和模型校验逻辑分离
修改翻译服务以使用助手中的模型而非全局配置
为QwenMT模型添加特殊处理逻辑

* docs(i18n): 添加Qwen MT模型在对话中不可用的错误提示

* refactor(openai): 优化翻译选项处理逻辑

移除不必要的的TranslateAssistant类型断言并重构翻译选项的生成方式

* fix(types): 修复isTranslateAssistant类型检查逻辑

确保assistant.content为字符串类型时才返回true
2025-08-28 17:06:38 +08:00
Hualet Wang
8240493685 fix: not using system default terminal on deepin (#9527)
* fix: not using system default terminal on deepin

deepin-terminal is not in the `linuxTerminals` list, that will
cause xterm picked as the terminal to run code tool, which has
compatibility with Chinese charaters.

see: https://bbs.deepin.org.cn/post/290948

Signed-off-by: Hualet Wang <mr.asianwang@gmail.com>

* feat(codetool): add support for deepin-terminal

---------

Signed-off-by: Hualet Wang <mr.asianwang@gmail.com>
Co-authored-by: GeorgeDong32 <GeorgeDong32@qq.com>
2025-08-28 16:52:06 +08:00
Phantom
f95b9cef77 feat: System (MacOS & Windows) OCR (#9572)
* build: 添加 macOS 系统 OCR 作为可选依赖

* refactor: 移动TesseractService

* feat(ocr): 添加MacOS Vision OCR支持并优化类型定义

添加对MacOS Vision OCR的支持,同时重构OCR相关类型定义以提升可维护性。新增PDF文件元数据类型为后续功能做准备。

* refactor(types): 重命名 isImageFile 为 isImageFileMetadata 以更准确描述功能

* refactor(ocr): 更新导入

* feat(ocr): 实现MacOS Vision OCR服务并重构OCR基础结构

添加MacOcrService以支持MacOS Vision OCR功能
创建OcrBaseService作为OCR服务的基类
清理MacOS OCR配置中的冗余字段

* fix(store): 更新持久化存储版本至138并添加MAC OCR提供者

添加内置OCR提供者支持并清空翻译输入框

* chore: 更新 @cherrystudio/mac-system-ocr 依赖至 0.2.4 版本

* feat(ocr): 添加 macOS 原生 OCR 服务支持

添加 macOS 原生 OCR 服务作为内置 OCR 提供商
在设置页面显示不可配置提示
添加相关 logo 和翻译文本

* build: 将 @cherrystudio/mac-system-ocr 从可选依赖移至常规依赖

* fix(ocr): 临时使用any类型替代平台特定依赖的类型定义

为了避免在Linux上运行类型检查CI时抛出错误,暂时将MacOCR属性的类型从平台特定依赖的类型定义改为any类型

* refactor(build): 将mac-system-ocr移至optionalDependencies并更新vite配置

将@cherrystudio/mac-system-ocr从dependencies移至optionalDependencies
更新electron.vite.config.ts中的external配置以包含该依赖

* feat(OCR设置): 根据平台过滤OCR提供商选项

添加平台检测逻辑,在非Mac平台隐藏Mac内置OCR提供商选项

* feat(OCR): 添加非MacOS系统的错误提示

在OCR图片设置中添加对非MacOS系统的错误提示,当用户尝试在非Mac系统上使用OCR功能时显示错误标签

* feat(i18n): 添加 OCR 相关多语言翻译

为 OCR 功能添加错误提示和配置项的多语言翻译,包括非 MacOS 系统提示和无配置项提示

* fix(MacOcrService): 忽略macOS专属模块的类型检查错误

添加@ts-ignore注释以避免在非macOS平台上的类型检查错误,该模块仅在macOS上可用

* build: 添加 @napi-rs/system-ocr 依赖以支持OCR功能

* chore: 移除未使用的mac-system-ocr依赖

* refactor(ocr): 将 MacOS OCR 重构为跨平台的系统 OCR

重构 OCR 服务,将原本仅支持 MacOS 的 OCR 功能扩展为支持 Windows 和 MacOS 的系统 OCR
更新相关类型定义、配置和界面适配

* feat(hooks): 添加设置图片OCR提供商的功能

* refactor(ocr): 重构OCR提供者相关逻辑,优化代码结构

- 将OCR提供者相关工具函数和hook合并到useOcrProvider中
- 替换mac提供者为system提供者
- 优化OCR设置界面的错误处理和UI展示
- 删除不再使用的ocr.ts工具文件

* refactor(OCR设置): 移除多余的SettingGroup包装并优化provider设置逻辑

移除OcrSettings中多余的SettingGroup包装,将主题样式直接应用于OcrProviderSettings组件
优化OcrProviderSettings逻辑,对于system provider直接返回null

* fix(i18n): 移除OCR服务中不可配置项的翻译并更新系统OCR支持提示

* fix(ocr): 根据系统平台设置默认OCR提供商

在Windows和Mac平台上使用系统OCR作为默认提供商,其他平台继续使用Tesseract

* build: 从外部依赖中移除 @cherrystudio/mac-system-ocr

* fix(i18n): 更新多语言OCR相关翻译

* fix(store): 在迁移配置中移除翻译输入的清空操作

* refactor(hooks): 将 getOcrProviderLogo 重命名为 OcrProviderLogo 并改为组件形式

将 useOcrProviders 中的 getOcrProviderLogo 函数重构为 OcrProviderLogo 组件
更新 OcrProviderSettings 中对应的调用方式

* support jpg

* refactor(ocr): 重构OCR服务基础结构并支持多语言配置

重构OCR基础服务类,提取公共接口为抽象类
为系统OCR和Tesseract服务添加多语言配置支持

* refactor(ocr): 重构OCR类型定义以提高可维护性

将OcrProviderConfig拆分为基础配置和具体实现配置类型
优化类型结构以更清晰地区分不同OCR提供者的配置

* feat(组件): 新增错误标签组件 ErrorTag

* refactor(ocr): 替换自定义标签组件为ErrorTag组件以简化代码

* fix(ocr): 在macOS下忽略语言参数

* feat(组件): 添加警告标签组件用于显示警告信息

* feat(ocr): 添加系统OCR支持并优化语言配置

- 新增系统OCR设置组件,支持Windows和MacOS平台
- 为系统OCR添加语言选择功能,Windows需配置语言包
- 创建SuccessTag组件用于显示配置状态
- 统一OCR语言设置相关翻译键名
- 修复系统OCR在非Windows/Mac平台下的显示问题

* feat(i18n): 添加 OCR 设置页面的多语言支持

为 OCR 设置页面添加了新的多语言翻译,包括支持的语言列表和系统 OCR 的相关提示信息

* feat(ocr): 支持自定义 Tesseract OCR 语言选择

添加 Tesseract OCR 语言映射配置和动态语言选择功能
在设置界面实现多语言选择器,支持用户自定义 OCR 语言
更新相关类型定义和工具提示信息

* docs(i18n): 为Tesseract OCR添加自定义语言支持提示文本

* fix(i18n): 移除OCR服务中临时语言支持提示

* fix(ocr): 修复OCR服务未传递provider配置的问题

* fix(ocr): 修复OCR服务未传递provider配置的问题

* fix(TesseractService): 修复worker没有显式dispose的问题

* feat(拖拽): 在useDrag钩子中暴露setIsDragging方法

允许外部组件直接控制拖拽状态,用于在TranslatePage中处理文件拖放时重置拖拽状态

* feat(i18n): 更新输入框占位文本以支持OCR功能

* fix(ocr): 添加错误处理并记录日志以改进Tesseract服务

在TesseractService中添加错误处理回调函数,捕获并抛出worker创建过程中的错误
同时增加调试日志以跟踪语言数组和worker创建过程

* refactor(ocr): 重构OCR状态管理,使用ID引用图像提供者并添加选择器

将imageProvider字段改为imageProviderId以简化状态管理
添加getImageProvider选择器方便获取当前图像提供者

* update cn data

* refactor(ocr): 重构OCR提供者管理逻辑,使用自定义hook统一处理

- 将OCR提供者状态管理从Redux迁移到自定义hook useOcrProviders
- 修复默认OCR提供者初始化问题
- 优化OCR图片识别逻辑,使用useCallback提升性能

* fix(ocr): 修复Tesseract worker初始化错误处理逻辑

重构worker初始化流程,使用Promise处理错误而非全局变量
修正非CN地区语言包下载URL为空的问题

* fix(ocr): 修复url

* feat(OCR设置): 在Tesseract语言选择器中添加自定义标签渲染

添加CustomTag组件以禁用默认的关闭操作

* refactor(translate): 优化拖拽上传文件的hooks调用顺序

将useDrag hooks的声明移到使用位置附近,提高代码可读性

* perf(ocr): 移除不必要的await提升图像预处理性能

* feat(translate): 添加文本文件类型检查并优化文件处理逻辑

在翻译页面中增加对文本文件类型的检查,避免处理非文本文件。同时优化文件处理流程,包括错误处理和加载状态管理。

* feat(i18n): 添加文件类型检查错误的多语言翻译

* docs(i18n): 更新输入框占位符文本以更清晰描述支持的功能

---------

Co-authored-by: beyondkmp <beyondkmp@gmail.com>
2025-08-28 15:28:27 +08:00
one
168cc36410 fix(i18n): backup/restore progress (#9622) 2025-08-28 14:26:45 +08:00
Teo
2dbe9c1e0e feat(Link): add hyperlink tooltips (#9620) 2025-08-28 11:54:50 +08:00
Phantom
e222ba5459 fix: missing dependency (#9615)
fix: 修复依赖缺失
2025-08-28 11:52:10 +08:00
beyondkmp
69252f6177 fix(CodeToolsService): open new terminal tab for command execution (#9610)
Updated the terminal command execution logic to open a new tab in the Terminal application for running commands. This change enhances user experience by keeping the original tab available while executing commands in a separate tab.
2025-08-28 09:24:15 +08:00
Phantom
7407bb335d feat: more abort control (#9267)
* feat(abort): 添加 abortKey 参数支持以自定义中止控制

支持通过参数传入 abortKey 来替代 messageId,提供更灵活的中止控制方式

* fix(aiCore): 修复checkApi时未正确abort的问题

* feat(翻译): 添加翻译中止功能

支持在翻译过程中中止操作,包括添加中止键状态管理、中止错误处理和界面停止按钮
添加相关国际化文案

* feat(i18n): 添加翻译中止相关文本和多语言支持

* style(translate): 调整停止按钮图标大小以保持视觉一致性

* fix(TranslateService): 改进翻译错误处理逻辑

正确处理翻译中止和失败的情况,统一错误信息格式化方式

* fix(aiCore): 去除不必要的类型断言

* style(TabContainer): 移除多余的空格并保持代码整洁

* fix(translate): 添加翻译前校验并修复文件处理异步问题

在翻译前添加couldTranslate校验,防止无效操作
将processFile改为异步调用以正确处理文件处理流程
2025-08-27 23:56:11 +08:00
Phantom
aaa0eb7140 feat: user filter models (#8953)
* feat(组件): 添加模型标签组件并重构相关引用

添加新的模型标签组件(EmbeddingTag, ReasoningTag等)并集中导出
重构ModelTagsWithLabel组件使用新的标签组件
移除旧的ModelCapabilities引用

* refactor(Tags): 移除FreeTag组件中未使用的showLabel属性

* feat(模型选择弹窗): 添加按标签筛选模型功能

在模型选择弹窗中新增标签筛选功能,支持按视觉、网页搜索、推理、工具调用、免费、嵌入和重排等标签类型筛选模型

* feat(i18n): 添加按标签筛选的翻译文本

* feat(SelectModelPopup): 优化模型筛选逻辑并添加类型安全的objectKeys工具函数

重构模型筛选逻辑,简化条件判断并支持外部传入的filterTypes控制显示。添加类型安全的objectKeys工具函数用于获取对象键名。调整筛选标签的显示逻辑,仅在相关filterTypes存在时显示对应标签。

* refactor(SelectModelPopup): 移除已注释的冗余代码

* fix(SelectModelPopup): 修复布局偏移问题并调整内边距

为弹出容器添加动态高度以避免布局偏移
调整过滤容器的内边距

* refactor(model): 将isFreeModel函数移动到单独的文件并添加模型标签功能

重构模型相关工具函数,将isFreeModel从utils/index.ts移动到utils/model.ts
新增getModelTags函数用于获取模型标签状态
更新相关导入路径以保持一致性

* refactor(SelectModelPopup): 重构扁平列表项类型定义以提高类型安全性

将 FlatListItem 拆分为 FlatListGroup 和 FlatListModel 两种具体类型
确保模型项必须包含 model 属性而分组项不包含
更新相关组件代码以适配新的类型定义

* docs(utils/model): 添加getModelTags函数的注释说明

* refactor(SelectModelPopup): 重构模型筛选逻辑,优化标签管理

- 将模型筛选逻辑拆分为用户筛选和搜索筛选两部分
- 去除内置筛选
- 使用getModelTags获取模型标签
- 优化筛选条件判断逻辑,提高可读性

* refactor(SelectModelButton): 重构模型选择按钮的过滤逻辑

使用模型类型判断函数替代硬编码的过滤类型数组

* fix(MessageMenubar): 修复模型提及过滤逻辑,排除嵌入和重排模型

默认过滤条件现在会排除嵌入和重排模型,确保视觉模型检查时也应用此过滤

* feat(AssistantModelSettings): 添加模型过滤功能以排除嵌入和重排模型

* perf(SelectModelPopup): 使用useMemo优化模型列表计算性能

避免在每次渲染时重新计算模型列表,仅在modelFilter或providers变化时重新计算

* test(model): 将 isFreeModel 测试迁移到 model.test.ts 并添加 getModelTags 测试

* feat(types): 添加类型安全的对象键值对转换函数

* feat(模型选择弹窗): 优化标签筛选功能并添加已选标签显示

重构标签筛选逻辑,使用更简洁的预测函数配置方式
添加已选标签显示区域,提升用户操作体验
更新国际化文件添加"已选标签"翻译

* feat(i18n): 添加多语言翻译的"selected tags"字段和"code"模块

为过滤功能添加"selected tags"翻译字段
新增"code"模块的多语言翻译内容

* refactor(SelectModelPopup): 优化标签筛选逻辑和样式布局

移除重复的标签选择逻辑,合并为单一标签组件
调整筛选区域的布局和样式,简化界面结构
将PAGE_SIZE从11改为12以适应布局需求
2025-08-27 22:55:14 +08:00
Phantom
6376bbb9a7 fix: mcp-auto-install cannot start (#9015)
* refactor(types): 将内置MCPServer类型从MCPServer分离并添加类型守卫

将内置MCPServer相关逻辑从通用MCPServer类型中分离,新增BuiltinMCPServer类型和类型守卫函数

* refactor(MCPService): 使用isBuiltinMCPServer检查内置服务器类型

修改传输层创建逻辑,通过isBuiltinMCPServer函数判断是否为内置服务器,并排除特定服务器

* refactor(types): 统一内置MCP服务器名称的键值格式并优化类型定义

将BuiltinMCPServers对象的键改为与值相同的格式,使结构更一致
同时简化BuiltinMCPServerName类型定义和isBuiltinMCPServerName检查逻辑

* refactor(types): 为内置MCP服务器名称添加类型定义

为`MCP_AUTO_INSTALL_SERVER_NAME`和`builtInMcpDescriptionKeyMap`添加`BuiltinMCPServerName`类型定义,提高类型安全性

* refactor(types): 重命名BuiltinMCPServers为BuiltinMCPServerNames以更准确描述用途

* style: 移除 TabContainer 组件中的多余空行

* refactor(mcpServers): 使用类型化的BuiltinMCPServerName替换字符串参数

将createInMemoryMCPServer函数的name参数从string类型改为BuiltinMCPServerName类型,提高类型安全性

* refactor(types): 重构内置MCPServer名称常量及类型定义

将BuiltinMCPServerNames的键名改为驼峰命名,并添加BuiltinMCPServerNamesArray常量
修改isBuiltinMCPServerName函数使用数组进行判断

* refactor(mcp): 使用枚举替换硬编码的服务器名称字符串
2025-08-27 22:46:42 +08:00
Pleasure1234
c01642ef22 fix: decode Bing redirect URLs in LocalBingProvider (#9593)
Added logic to extract and decode the actual target URL from Bing redirect links in LocalBingProvider. Also introduced debug logging in SearchService to log search URLs.
2025-08-27 21:34:01 +08:00
SuYao
72f4584b0f fix(aihubmix): gemini image generation (#9601) 2025-08-27 16:26:46 +08:00
Phantom
941f86008b fix: restrict using gemini native tools and mcp tools simultaneously (#9361)
* feat(providers): 添加对支持URL上下文的提供者类型的检查

新增 `isSupportUrlContextProvider` 函数用于检查提供者是否支持URL上下文功能

* fix(InputbarTools): 修复URL上下文按钮显示条件判断

添加对模型提供者是否支持URL上下文的检查

* fix(gemini): 修复原生工具与函数调用同时启用时的冲突

当同时启用web搜索和URL上下文工具时,如果已存在函数调用工具,则添加警告日志提示当前不支持同时使用

* feat(i18n): 限制 Gemini 同时使用网页上下文与 MCP 工具

添加多语言翻译文案和功能实现,当用户尝试同时启用网页上下文和 MCP 工具时,显示警告提示并自动禁用网页上下文

* perf(WebSearchButton): 使用定时器优化更新性能避免卡顿

移除startTransition并使用useTimer的setTimeoutTimer来延迟更新操作,解决updateAssistant导致的快捷面板关闭卡顿问题

* feat(i18n): 限制 Gemini 原生搜索工具与函数调用的同时使用

添加对 Gemini 原生搜索工具与函数调用同时使用时的冲突检测
更新相关国际化文案和功能实现

* fix(GeminiAPIClient): 修复工具使用模式判断逻辑

当工具使用模式为'prompt'时应该允许使用native tool

* reafactor: 简化 Gemini 模型下工具使用模式的 URL 上下文和网页搜索检查逻辑

* fix(WebSearchButton): 修复条件判断

* refactor(utils): 提取函数工具使用模式判断逻辑到单独函数

* test(assistant): 添加工具使用模式功能的单元测试

* refactor(InputbarTools): 使用isGeminiModel函数替代字符串检查

简化模型类型检查逻辑,提高代码可读性和维护性

* perf(Inputbar): 使用setTimeoutTimer替代startTransition解决性能问题
2025-08-27 14:37:46 +08:00
one
fac8e91d3a refactor(DraggableList): remove antd List component (#9565)
* refactor(DraggableList): remove antd List component

The DraggableList component was unnecessarily wrapped in an antd List component. This change removes the antd List and replaces it with a standard div.

A `className` has been added to the new div for testing purposes. The `listProps` prop is preserved and its type is updated to `React.HTMLAttributes<HTMLDivElement>`.

The tests have been updated to reflect the new DOM structure, using a class selector instead of a data-testid. The antd mock has been removed, and the snapshot has been updated.

* test: fix

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-08-27 14:37:26 +08:00
yyhhyyyyyy
ce14d15ba3 feat: support openrouter gemini 2.5 flash image preview (#9587)
* feat: support openrouter gemini 2.5 flash image preview

* feat: improve image content handling with type safety

* fix: code fmt
2025-08-27 13:03:50 +08:00
Pleasure1234
92ab338640 fix: remove Content-Length header and add duplex option in putFile (#9576)
The putFile method no longer sets the Content-Length header and now includes the duplex: 'half' option in the net.fetch call. This change ensures compatibility with streaming uploads and the requirements of net.fetch.
2025-08-27 12:07:57 +08:00
SuYao
f273621082 fix(models): enhance model type checks for function calling and web s… (#9579)
fix(models): enhance model type checks for function calling and web search models
2025-08-27 00:50:47 +08:00
one
ddc5f46e9b feat: goto provider settings from models popup (#9573)
* feat: goto provider settings from models popup

* refactor: improve paddings

* refactor: update types

* refactor: update types

* doc: update comments

* refactor: more comments

* refactor: scroll to the selected provider on navigation

* test: update mocks
2025-08-27 00:04:52 +08:00
416 changed files with 32710 additions and 4778 deletions

View File

@@ -51,7 +51,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: main
@@ -94,17 +94,18 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:npm linux
yarn build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Mac
if: matrix.os == 'macos-latest'
run: |
yarn build:npm mac
yarn build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
@@ -112,19 +113,24 @@ jobs:
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ vars.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:npm windows
yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
- name: Rename artifacts with nightly format
shell: bash
@@ -220,7 +226,7 @@ jobs:
shell: bash
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
path: all-artifacts
merge-multiple: false

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v4

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -80,12 +80,12 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y rpm
yarn build:npm linux
yarn build:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
@@ -94,7 +94,6 @@ jobs:
if: matrix.os == 'macos-latest'
run: |
sudo -H pip install setuptools
yarn build:npm mac
yarn build:mac
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
@@ -104,6 +103,7 @@ jobs:
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}
@@ -111,11 +111,11 @@ jobs:
- name: Build Windows
if: matrix.os == 'windows-latest'
run: |
yarn build:npm windows
yarn build:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=8192
MAIN_VITE_CHERRYIN_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYIN_CLIENT_SECRET }}
MAIN_VITE_MINERU_API_KEY: ${{ vars.MAIN_VITE_MINERU_API_KEY }}
RENDERER_VITE_AIHUBMIX_SECRET: ${{ vars.RENDERER_VITE_AIHUBMIX_SECRET }}
RENDERER_VITE_PPIO_APP_SECRET: ${{ vars.RENDERER_VITE_PPIO_APP_SECRET }}

3
.gitignore vendored
View File

@@ -60,6 +60,9 @@ coverage
.vitest-cache
vitest.config.*.timestamp-*
# TypeScript incremental build
.tsbuildinfo
# playwright
playwright-report
test-results

View File

@@ -7,3 +7,4 @@ tsconfig.*.json
CHANGELOG*.md
agents.json
src/renderer/src/integration/nutstore/sso/lib
src/main/integration/cherryin/index.js

View File

@@ -0,0 +1,30 @@
diff --git a/index.js b/index.js
index dc071739e79876dff88e1be06a9168e294222d13..b9df7525c62bdf777e89e732e1b0c81f84d872f2 100644
--- a/index.js
+++ b/index.js
@@ -380,7 +380,7 @@ if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
}
}
-if (!nativeBinding) {
+if (!nativeBinding && process.platform !== 'linux') {
if (loadErrors.length > 0) {
throw new Error(
`Cannot find native binding. ` +
@@ -392,6 +392,13 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
-module.exports = nativeBinding
-module.exports.OcrAccuracy = nativeBinding.OcrAccuracy
-module.exports.recognize = nativeBinding.recognize
+if (process.platform === 'linux') {
+ module.exports = {OcrAccuracy: {
+ Fast: 0,
+ Accurate: 1
+ }, recognize: () => Promise.resolve({text: '', confidence: 1.0})}
+}else{
+ module.exports = nativeBinding
+ module.exports.OcrAccuracy = nativeBinding.OcrAccuracy
+ module.exports.recognize = nativeBinding.recognize
+}

View File

@@ -0,0 +1,48 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 8e560a4406c5cc616c11bb9fd5455ac0dcf47fa3..c7cd0d65ddc971bff71e89f610de82cfdaa5a8c7 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -413,6 +413,19 @@ var DragHandlePlugin = ({
}
return false;
},
+ scroll(view) {
+ if (!element || locked) {
+ return false;
+ }
+ if (view.hasFocus()) {
+ hideHandle();
+ currentNode = null;
+ currentNodePos = -1;
+ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 });
+ return false;
+ }
+ return false;
+ },
mouseleave(_view, e) {
if (locked) {
return false;
diff --git a/dist/index.js b/dist/index.js
index 39e4c3ef9986cd25544d9d3994cf6a9ada74b145..378d9130abbfdd0e1e4f743b5b537743c9ab07d0 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -387,6 +387,19 @@ var DragHandlePlugin = ({
}
return false;
},
+ scroll(view) {
+ if (!element || locked) {
+ return false;
+ }
+ if (view.hasFocus()) {
+ hideHandle();
+ currentNode = null;
+ currentNodePos = -1;
+ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 });
+ return false;
+ }
+ return false;
+ },
mouseleave(_view, e) {
if (locked) {
return false;

View File

@@ -57,7 +57,7 @@
<div align="center">
<a href="https://hellogithub.com/repository/1605492e1e2a4df3be07abfa4578dd37" target="_blank" style="text-decoration: none"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=1605492e1e2a4df3be07abfa4578dd37" alt="FeaturedHelloGitHub" width="220" height="55" /></a>
<a href="https://trendshift.io/repositories/11772" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/11772" alt="kangfenmao%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
<a href="https://trendshift.io/repositories/14318" target="_blank" style="text-decoration: none"><img src="https://trendshift.io/api/badge/repositories/14318" alt="CherryHQ%2Fcherry-studio | Trendshift" width="220" height="55" /></a>
<a href="https://www.producthunt.com/posts/cherry-studio?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cherry&#0045;studio" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=496640&theme=light" alt="Cherry&#0032;Studio - AI&#0032;Chatbots&#0044;&#0032;AI&#0032;Desktop&#0032;Client | Product Hunt" width="220" height="55" /></a>
</div>

View File

@@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -55,10 +55,14 @@ files:
- '!node_modules/selection-hook/prebuilds/**/*' # we rebuild .node, don't use prebuilds
- '!node_modules/selection-hook/node_modules' # we don't need what in the node_modules dir
- '!node_modules/selection-hook/src' # we don't need source files
- '!node_modules/tesseract.js-core/{tesseract-core.js,tesseract-core.wasm,tesseract-core.wasm.js}' # we don't need source files
- '!node_modules/tesseract.js-core/{tesseract-core-lstm.js,tesseract-core-lstm.wasm,tesseract-core-lstm.wasm.js}' # we don't need source files
- '!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}' # we don't need source files
- '!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}' # filter .node build files
asarUnpack:
- resources/**
- '**/*.{metal,exp,lib}'
- 'node_modules/@img/sharp-libvips-*/**'
win:
executableName: Cherry Studio
artifactName: ${productName}-${version}-${arch}-setup.${ext}
@@ -106,21 +110,21 @@ linux:
StartupWMClass: CherryStudio
mimeTypes:
- x-scheme-handler/cherrystudio
rpm:
# Workaround for electron build issue on rpm package:
# https://github.com/electron/forge/issues/3594
fpm: ['--rpm-rpmbuild-define=_build_id_links none']
publish:
provider: generic
url: https://releases.cherry-ai.com
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
beforePack: scripts/before-pack.js
afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js
artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
输入框快捷菜单增加清除按钮
侧边栏增加代码工具入口,代码工具增加环境变量设置
小程序增加多语言显示
优化 MCP 服务器列表
新增 Web 搜索图标
优化 SVG 预览,优化 HTML 内容样式
修复知识库文档预处理失败问题
稳定性改进和错误修复
Top navigation bar mode will display the mini-program using tabs
Fixed the issue with Google mini-program login
Notes support drag and drop sorting

View File

@@ -4,6 +4,8 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
import pkg from './package.json' assert { type: 'json' }
const visualizerPlugin = (type: 'renderer' | 'main') => {
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
}
@@ -26,7 +28,7 @@ export default defineConfig({
},
build: {
rollupOptions: {
external: ['@libsql/client', 'bufferutil', 'utf-8-validate'],
external: ['bufferutil', 'utf-8-validate', 'electron', ...Object.keys(pkg.dependencies)],
output: {
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
@@ -81,7 +83,8 @@ export default defineConfig({
'@shared': resolve('packages/shared'),
'@logger': resolve('src/renderer/src/services/LoggerService'),
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web')
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
}
},
optimizeDeps: {

View File

@@ -122,7 +122,8 @@ export default defineConfig([
'.yarn/**',
'.gitignore',
'scripts/cloudflare-worker.js',
'src/main/integration/nutstore/sso/lib/**'
'src/main/integration/nutstore/sso/lib/**',
'src/main/integration/cherryin/index.js'
]
}
])

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "1.5.7-rc.2",
"version": "1.5.11",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -19,7 +19,8 @@
"packages/database",
"packages/mcp-trace/trace-core",
"packages/mcp-trace/trace-node",
"packages/mcp-trace/trace-web"
"packages/mcp-trace/trace-web",
"packages/extension-table-plus"
]
}
},
@@ -39,7 +40,6 @@
"build:linux": "dotenv npm run build && electron-builder --linux --x64 --arm64",
"build:linux:arm64": "dotenv npm run build && electron-builder --linux --arm64",
"build:linux:x64": "dotenv npm run build && electron-builder --linux --x64",
"build:npm": "node scripts/build-npm.js",
"release": "node scripts/version.js",
"publish": "yarn build:check && yarn release patch push",
"pulish:artifacts": "cd packages/artifacts && npm publish && cd -",
@@ -47,7 +47,7 @@
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true yarn build",
"analyze:main": "VISUALIZER_MAIN=true yarn build",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "tsx scripts/check-i18n.ts",
@@ -72,6 +72,7 @@
"dependencies": {
"@libsql/client": "0.14.0",
"@libsql/win32-x64-msvc": "^0.4.7",
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"graceful-fs": "^4.2.11",
"jsdom": "26.1.0",
@@ -105,6 +106,7 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -119,7 +121,7 @@
"@eslint-react/eslint-plugin": "^1.36.1",
"@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@hello-pangea/dnd": "^16.6.0",
"@hello-pangea/dnd": "^18.0.1",
"@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^0.3.36",
"@langchain/ollama": "^0.2.1",
@@ -135,18 +137,35 @@
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@playwright/test": "^1.52.0",
"@reduxjs/toolkit": "^2.2.5",
"@shikijs/markdown-it": "^3.9.1",
"@shikijs/markdown-it": "^3.12.0",
"@swc/plugin-styled-components": "^7.1.5",
"@tanstack/react-query": "^5.27.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tiptap/extension-collaboration": "^3.2.0",
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
"@tiptap/extension-drag-handle-react": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-list": "^3.2.0",
"@tiptap/extension-mathematics": "^3.2.0",
"@tiptap/extension-mention": "^3.2.0",
"@tiptap/extension-node-range": "^3.2.0",
"@tiptap/extension-table-of-contents": "^3.2.0",
"@tiptap/extension-typography": "^3.2.0",
"@tiptap/extension-underline": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/react": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",
"@tiptap/suggestion": "^3.2.0",
"@tiptap/y-tiptap": "^3.0.0",
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
"@types/cli-progress": "^3",
"@types/diff": "^7",
"@types/fs-extra": "^11",
"@types/he": "^1",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
@@ -157,6 +176,7 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-transition-group": "^4.4.12",
"@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1",
@@ -175,17 +195,20 @@
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"chardet": "^2.1.0",
"chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
"code-inspector-plugin": "^0.20.14",
"color": "^5.0.0",
"concurrently": "^9.2.1",
"country-flag-emoji-polyfill": "0.1.8",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"diff": "^7.0.0",
"diff": "^8.0.2",
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.3.1",
"electron": "37.4.0",
"electron-builder": "26.0.15",
"electron-devtools-installer": "^3.2.0",
"electron-store": "^8.2.0",
@@ -205,21 +228,24 @@
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"google-auth-library": "^9.15.1",
"he": "^1.2.0",
"html-tags": "^5.1.0",
"html-to-image": "^1.11.13",
"htmlparser2": "^10.0.0",
"husky": "^9.1.7",
"i18next": "^23.11.5",
"iconv-lite": "^0.6.3",
"isbinaryfile": "5.0.4",
"jaison": "^2.0.2",
"jest-styled-components": "^7.2.0",
"linguist-languages": "^8.0.0",
"linguist-languages": "^8.1.0",
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.525.0",
"macos-release": "^3.4.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.9.0",
"mermaid": "^11.10.1",
"mime": "^4.0.4",
"motion": "^12.10.5",
"notion-helper": "^1.3.22",
@@ -259,14 +285,16 @@
"remove-markdown": "^0.6.2",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.88.0",
"shiki": "^3.9.1",
"shiki": "^3.12.0",
"strict-url-sanitise": "^0.0.1",
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"tsx": "^4.20.3",
"turndown-plugin-gfm": "^1.0.2",
"typescript": "^5.6.2",
"undici": "6.21.2",
"unified": "^11.0.5",
@@ -277,6 +305,8 @@
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"word-extractor": "^1.0.4",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27",
"zipread": "^1.3.3",
"zod": "^3.25.74"
},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-table
[![Version](https://img.shields.io/npm/v/@tiptap/extension-table.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-table.svg)](https://www.npmjs.com/package/@tiptap/extension-table)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,93 @@
{
"name": "@cherrystudio/extension-table-plus",
"description": "table extension for tiptap forked from tiptap/extension-table",
"version": "3.0.11",
"homepage": "https://cherry-ai.com",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./table": {
"types": {
"import": "./dist/table/index.d.ts",
"require": "./dist/table/index.d.cts"
},
"import": "./dist/table/index.js",
"require": "./dist/table/index.cjs"
},
"./cell": {
"types": {
"import": "./dist/cell/index.d.ts",
"require": "./dist/cell/index.d.cts"
},
"import": "./dist/cell/index.js",
"require": "./dist/cell/index.cjs"
},
"./header": {
"types": {
"import": "./dist/header/index.d.ts",
"require": "./dist/header/index.d.cts"
},
"import": "./dist/header/index.js",
"require": "./dist/header/index.cjs"
},
"./kit": {
"types": {
"import": "./dist/kit/index.d.ts",
"require": "./dist/kit/index.d.cts"
},
"import": "./dist/kit/index.js",
"require": "./dist/kit/index.cjs"
},
"./row": {
"types": {
"import": "./dist/row/index.d.ts",
"require": "./dist/row/index.d.cts"
},
"import": "./dist/row/index.js",
"require": "./dist/row/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"devDependencies": {
"@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3",
"tsdown": "^0.13.3"
},
"peerDependencies": {
"@tiptap/core": "^3.0.9",
"@tiptap/pm": "^3.0.9"
},
"repository": {
"type": "git",
"url": "https://github.com/CherryHQ/cherry-studio",
"directory": "packages/extension-table-plus"
},
"scripts": {
"build": "tsdown",
"lint": "prettier ./src/ --write && eslint --fix ./src/"
},
"packageManager": "yarn@4.9.1"
}

View File

@@ -0,0 +1 @@
export * from './table-cell.js'

View File

@@ -0,0 +1,150 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export interface TableCellOptions {
/**
* The HTML attributes for a table cell node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Whether nodes can be nested inside a cell.
* @default false
*/
allowNestedNodes: boolean
}
const cellSelectionPluginKey = new PluginKey('cellSelectionStyling')
function isTableNode(node: ProseMirrorNode): boolean {
const spec = node.type.spec as { tableRole?: string } | undefined
return node.type.name === 'table' || spec?.tableRole === 'table'
}
function createCellSelectionDecorationSet(doc: ProseMirrorNode, selection: Selection): DecorationSet {
if (!(selection instanceof CellSelection)) {
return DecorationSet.empty
}
const $anchor = selection.$anchorCell || selection.$anchor
let tableNode: ProseMirrorNode | null = null
let tablePos = -1
for (let depth = $anchor.depth; depth > 0; depth--) {
const nodeAtDepth = $anchor.node(depth) as ProseMirrorNode
if (isTableNode(nodeAtDepth)) {
tableNode = nodeAtDepth
tablePos = $anchor.before(depth)
break
}
}
if (!tableNode) {
return DecorationSet.empty
}
const map = TableMap.get(tableNode)
const tableStart = tablePos + 1
type Rect = { top: number; bottom: number; left: number; right: number }
type Item = { pos: number; node: ProseMirrorNode; rect: Rect }
const items: Item[] = []
let minRow = Number.POSITIVE_INFINITY
let maxRow = Number.NEGATIVE_INFINITY
let minCol = Number.POSITIVE_INFINITY
let maxCol = Number.NEGATIVE_INFINITY
selection.forEachCell((cell, pos) => {
const rect = map.findCell(pos - tableStart)
items.push({ pos, node: cell, rect })
minRow = Math.min(minRow, rect.top)
maxRow = Math.max(maxRow, rect.bottom - 1)
minCol = Math.min(minCol, rect.left)
maxCol = Math.max(maxCol, rect.right - 1)
})
const decorations: Decoration[] = []
for (const { pos, node, rect } of items) {
const classes: string[] = ['selectedCell']
if (rect.top === minRow) classes.push('selection-top')
if (rect.bottom - 1 === maxRow) classes.push('selection-bottom')
if (rect.left === minCol) classes.push('selection-left')
if (rect.right - 1 === maxCol) classes.push('selection-right')
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' ')
})
)
}
return DecorationSet.create(doc, decorations)
}
/**
* This extension allows you to create table cells.
* @see https://www.tiptap.dev/api/nodes/table-cell
*/
export const TableCell = Node.create<TableCellOptions>({
name: 'tableCell',
addOptions() {
return {
HTMLAttributes: {},
allowNestedNodes: false
}
},
content: '(paragraph | image)+',
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth')
const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null
return value
}
}
}
},
tableRole: 'cell',
isolating: true,
parseHTML() {
return [{ tag: 'td' }]
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addProseMirrorPlugins() {
return [
new Plugin({
key: cellSelectionPluginKey,
props: {
decorations: ({ doc, selection }) => createCellSelectionDecorationSet(doc as ProseMirrorNode, selection)
}
})
]
}
})

View File

@@ -0,0 +1 @@
export * from './table-header.js'

View File

@@ -0,0 +1,60 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
export interface TableHeaderOptions {
/**
* The HTML attributes for a table header node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table headers.
* @see https://www.tiptap.dev/api/nodes/table-header
*/
export const TableHeader = Node.create<TableHeaderOptions>({
name: 'tableHeader',
addOptions() {
return {
HTMLAttributes: {}
}
},
content: 'paragraph+',
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth')
const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null
return value
}
}
}
},
tableRole: 'header_cell',
isolating: true,
parseHTML() {
return [{ tag: 'th' }]
},
renderHTML({ HTMLAttributes }) {
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
})

View File

@@ -0,0 +1,6 @@
export * from './cell/index.js'
export * from './header/index.js'
export * from './kit/index.js'
export * from './row/index.js'
export * from './table/index.js'
export * from './table/TableView.js'

View File

@@ -0,0 +1,64 @@
import { Extension, Node } from '@tiptap/core'
import type { TableCellOptions } from '../cell/index.js'
import { TableCell } from '../cell/index.js'
import type { TableHeaderOptions } from '../header/index.js'
import { TableHeader } from '../header/index.js'
import type { TableRowOptions } from '../row/index.js'
import { TableRow } from '../row/index.js'
import type { TableOptions } from '../table/index.js'
import { Table } from '../table/index.js'
export interface TableKitOptions {
/**
* If set to false, the table extension will not be registered
* @example table: false
*/
table: Partial<TableOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableCell: false
*/
tableCell: Partial<TableCellOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableHeader: false
*/
tableHeader: Partial<TableHeaderOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableRow: false
*/
tableRow: Partial<TableRowOptions> | false
}
/**
* The table kit is a collection of table editor extensions.
*
* Its a good starting point for building your own table in Tiptap.
*/
export const TableKit = Extension.create<TableKitOptions>({
name: 'tableKit',
addExtensions() {
const extensions: Node[] = []
if (this.options.table !== false) {
extensions.push(Table.configure(this.options.table))
}
if (this.options.tableCell !== false) {
extensions.push(TableCell.configure(this.options.tableCell))
}
if (this.options.tableHeader !== false) {
extensions.push(TableHeader.configure(this.options.tableHeader))
}
if (this.options.tableRow !== false) {
extensions.push(TableRow.configure(this.options.tableRow))
}
return extensions
}
})

View File

@@ -0,0 +1 @@
export * from './table-row.js'

View File

@@ -0,0 +1,38 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
export interface TableRowOptions {
/**
* The HTML attributes for a table row node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table rows.
* @see https://www.tiptap.dev/api/nodes/table-row
*/
export const TableRow = Node.create<TableRowOptions>({
name: 'tableRow',
addOptions() {
return {
HTMLAttributes: {}
}
},
content: '(tableCell | tableHeader)*',
tableRole: 'row',
parseHTML() {
return [{ tag: 'tr' }]
},
renderHTML({ HTMLAttributes }) {
return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
})

View File

@@ -0,0 +1,558 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import { addColumnAfter, addRowAfter, CellSelection, TableMap } from '@tiptap/pm/tables'
import type { EditorView, NodeView, ViewMutationRecord } from '@tiptap/pm/view'
import { getColStyleDeclaration } from './utilities/colStyle.js'
import { getElementBorderWidth } from './utilities/getBorderWidth.js'
import { isCellSelection } from './utilities/isCellSelection.js'
import { getCellSelectionBounds } from './utilities/selectionBounds.js'
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLTableColElement, // <colgroup> has the same prototype as <col>
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number
) {
let totalWidth = 0
let fixedWidth = true
let nextDOM = colgroup.firstChild
const row = node.firstChild
if (row !== null) {
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth = overrideCol === col ? overrideValue : ((colwidth && colwidth[j]) as number | undefined)
const cssWidth = hasWidth ? `${hasWidth}px` : ''
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
if (!nextDOM) {
const colElement = document.createElement('col')
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
colElement.style.setProperty(propertyKey, propertyValue)
colgroup.appendChild(colElement)
} else {
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
;(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue)
}
nextDOM = nextDOM.nextSibling
}
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling
nextDOM.parentNode?.removeChild(nextDOM)
nextDOM = after
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`
table.style.minWidth = ''
} else {
table.style.width = ''
table.style.minWidth = `${totalWidth}px`
}
}
// Callbacks are now handled by a decorations plugin; keep type removed here
type ButtonPosition = { x: number; y: number }
type RowActionCallback = (args: { rowIndex: number; view: EditorView; position?: ButtonPosition }) => void
type ColumnActionCallback = (args: { colIndex: number; view: EditorView; position?: ButtonPosition }) => void
export class TableView implements NodeView {
node: ProseMirrorNode
cellMinWidth: number
dom: HTMLDivElement
table: HTMLTableElement
colgroup: HTMLTableColElement
contentDOM: HTMLTableSectionElement
view: EditorView
addRowButton: HTMLButtonElement
addColumnButton: HTMLButtonElement
tableContainer: HTMLDivElement
// Hover add buttons are kept; overlay endpoints absolute on wrapper
private selectionChangeDisposer?: () => void
private rowEndpoint?: HTMLButtonElement
private colEndpoint?: HTMLButtonElement
private overlayUpdateRafId: number | null = null
private actionCallbacks?: {
onRowActionClick?: RowActionCallback
onColumnActionClick?: ColumnActionCallback
}
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
view: EditorView,
actionCallbacks?: { onRowActionClick?: RowActionCallback; onColumnActionClick?: ColumnActionCallback }
) {
this.node = node
this.cellMinWidth = cellMinWidth
this.view = view
this.actionCallbacks = actionCallbacks
// selection triggers handled by decorations plugin
// Create the wrapper with grid layout
this.dom = document.createElement('div')
this.dom.className = 'tableWrapper'
// Create table container
this.tableContainer = document.createElement('div')
this.tableContainer.className = 'table-container'
this.table = this.tableContainer.appendChild(document.createElement('table'))
this.colgroup = this.table.appendChild(document.createElement('colgroup'))
updateColumns(node, this.colgroup, this.table, cellMinWidth)
this.contentDOM = this.table.appendChild(document.createElement('tbody'))
this.addRowButton = document.createElement('button')
this.addColumnButton = document.createElement('button')
this.createHoverButtons()
this.dom.appendChild(this.tableContainer)
this.dom.appendChild(this.addColumnButton)
this.dom.appendChild(this.addRowButton)
this.syncEditableState()
this.setupEventListeners()
// create overlay endpoints
this.rowEndpoint = document.createElement('button')
this.rowEndpoint.className = 'row-action-trigger'
this.rowEndpoint.type = 'button'
this.rowEndpoint.setAttribute('contenteditable', 'false')
this.rowEndpoint.style.position = 'absolute'
this.rowEndpoint.style.display = 'none'
this.rowEndpoint.tabIndex = -1
this.colEndpoint = document.createElement('button')
this.colEndpoint.className = 'column-action-trigger'
this.colEndpoint.type = 'button'
this.colEndpoint.setAttribute('contenteditable', 'false')
this.colEndpoint.style.position = 'absolute'
this.colEndpoint.style.display = 'none'
this.colEndpoint.tabIndex = -1
this.dom.appendChild(this.rowEndpoint)
this.dom.appendChild(this.colEndpoint)
this.bindOverlayHandlers()
this.startSelectionWatcher()
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) {
return false
}
this.node = node
updateColumns(node, this.colgroup, this.table, this.cellMinWidth)
// Keep buttons' disabled state in sync during updates
this.syncEditableState()
// Recalculate overlay positions after node/table mutations so triggers follow the updated layout
this.scheduleOverlayUpdate()
return true
}
ignoreMutation(mutation: ViewMutationRecord) {
return (
(mutation.type === 'attributes' && (mutation.target === this.table || this.colgroup.contains(mutation.target))) ||
// Ignore mutations on our action buttons
(mutation.target as Element)?.classList?.contains('row-action-trigger') ||
(mutation.target as Element)?.classList?.contains('column-action-trigger')
)
}
private isEditable(): boolean {
// Rely on DOM attribute to avoid depending on EditorView internals
return this.view.dom.getAttribute('contenteditable') !== 'false'
}
private syncEditableState() {
const editable = this.isEditable()
this.addRowButton.toggleAttribute('disabled', !editable)
this.addColumnButton.toggleAttribute('disabled', !editable)
this.addRowButton.style.display = editable ? '' : 'none'
this.addColumnButton.style.display = editable ? '' : 'none'
this.dom.classList.toggle('is-readonly', !editable)
}
createHoverButtons() {
this.addRowButton.className = 'add-row-button'
this.addRowButton.type = 'button'
this.addRowButton.setAttribute('contenteditable', 'false')
this.addColumnButton.className = 'add-column-button'
this.addColumnButton.type = 'button'
this.addColumnButton.setAttribute('contenteditable', 'false')
}
private addTableRowOrColumn(isRow: boolean) {
if (!this.isEditable()) return
this.view.focus()
// Save current selection info and calculate position in table
const { state } = this.view
const originalSelection = state.selection
// Find which cell we're currently in and the relative position within that cell
let tablePos = -1
let currentCellRow = -1
let currentCellCol = -1
let relativeOffsetInCell = 0
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
const map = TableMap.get(this.node)
// Find which cell contains our selection
const selectionPos = originalSelection.from
for (let row = 0; row < map.height; row++) {
for (let col = 0; col < map.width; col++) {
const cellIndex = row * map.width + col
const cellStart = pos + 1 + map.map[cellIndex]
const cellNode = state.doc.nodeAt(cellStart)
if (cellNode) {
const cellEnd = cellStart + cellNode.nodeSize
if (selectionPos >= cellStart && selectionPos < cellEnd) {
currentCellRow = row
currentCellCol = col
relativeOffsetInCell = selectionPos - cellStart
return false
}
}
}
}
return false
}
return true
})
// Set selection to appropriate position for adding
if (isRow) {
this.setSelectionToLastRow()
} else {
this.setSelectionToLastColumn()
}
setTimeout(() => {
const { state, dispatch } = this.view
const addFunction = isRow ? addRowAfter : addColumnAfter
if (addFunction(state, dispatch)) {
setTimeout(() => {
const newState = this.view.state
// Calculate new position for the same logical cell with same relative offset
if (tablePos >= 0 && currentCellRow >= 0 && currentCellCol >= 0) {
newState.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && pos === tablePos) {
const newMap = TableMap.get(node)
const newCellIndex = currentCellRow * newMap.width + currentCellCol
const newCellStart = pos + 1 + newMap.map[newCellIndex]
const newCellNode = newState.doc.nodeAt(newCellStart)
if (newCellNode) {
// Try to maintain the same relative position within the cell
const newCellEnd = newCellStart + newCellNode.nodeSize
const targetPos = Math.min(newCellStart + relativeOffsetInCell, newCellEnd - 1)
const newSelection = TextSelection.create(newState.doc, targetPos)
const newTr = newState.tr.setSelection(newSelection)
this.view.dispatch(newTr)
}
return false
}
return true
})
}
}, 10)
}
}, 10)
}
setupEventListeners() {
// Add row button click handler
this.addRowButton.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
this.addTableRowOrColumn(true)
})
// Add column button click handler
this.addColumnButton.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
this.addTableRowOrColumn(false)
})
}
private bindOverlayHandlers() {
if (!this.rowEndpoint || !this.colEndpoint) return
this.rowEndpoint.addEventListener('mousedown', (e) => e.preventDefault())
this.colEndpoint.addEventListener('mousedown', (e) => e.preventDefault())
this.rowEndpoint.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const bounds = getCellSelectionBounds(this.view, this.node)
if (!bounds) return
this.selectRow(bounds.maxRow)
const rect = this.rowEndpoint!.getBoundingClientRect()
const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
this.actionCallbacks?.onRowActionClick?.({ rowIndex: bounds.maxRow, view: this.view, position })
this.scheduleOverlayUpdate()
})
this.colEndpoint.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const bounds = getCellSelectionBounds(this.view, this.node)
if (!bounds) return
this.selectColumn(bounds.maxCol)
const rect = this.colEndpoint!.getBoundingClientRect()
const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
this.actionCallbacks?.onColumnActionClick?.({ colIndex: bounds.maxCol, view: this.view, position })
this.scheduleOverlayUpdate()
})
}
private startSelectionWatcher() {
const owner = this.view.dom.ownerDocument || document
const handler = () => this.scheduleOverlayUpdate()
owner.addEventListener('selectionchange', handler)
this.selectionChangeDisposer = () => owner.removeEventListener('selectionchange', handler)
this.scheduleOverlayUpdate()
}
private scheduleOverlayUpdate() {
if (this.overlayUpdateRafId !== null) {
cancelAnimationFrame(this.overlayUpdateRafId)
}
this.overlayUpdateRafId = requestAnimationFrame(() => {
this.overlayUpdateRafId = null
this.updateOverlayPositions()
})
}
private updateOverlayPositions() {
if (!this.rowEndpoint || !this.colEndpoint) return
const bounds = getCellSelectionBounds(this.view, this.node)
if (!bounds) {
this.rowEndpoint.style.display = 'none'
this.colEndpoint.style.display = 'none'
return
}
const { map, tableStart, maxRow, maxCol } = bounds
const getCellDomAndRect = (row: number, col: number) => {
const cellIndex = row * map.width + col
const cellPos = tableStart + map.map[cellIndex]
const cellDom = this.view.nodeDOM(cellPos) as HTMLElement | null
return {
dom: cellDom,
rect: cellDom?.getBoundingClientRect()
}
}
// Position row endpoint (left side)
const bottomLeft = getCellDomAndRect(maxRow, 0)
const topLeft = getCellDomAndRect(0, 0)
if (bottomLeft.dom && bottomLeft.rect && topLeft.rect) {
const midY = (bottomLeft.rect.top + bottomLeft.rect.bottom) / 2
this.rowEndpoint.style.display = 'flex'
const borderWidth = getElementBorderWidth(this.rowEndpoint)
this.rowEndpoint.style.left = `${bottomLeft.rect.left - topLeft.rect.left - this.rowEndpoint.getBoundingClientRect().width / 2 + borderWidth.left / 2}px`
this.rowEndpoint.style.top = `${midY - topLeft.rect.top - this.rowEndpoint.getBoundingClientRect().height / 2}px`
} else {
this.rowEndpoint.style.display = 'none'
}
// Position column endpoint (top side)
const topRight = getCellDomAndRect(0, maxCol)
const topLeftForCol = getCellDomAndRect(0, 0)
if (topRight.dom && topRight.rect && topLeftForCol.rect) {
const midX = topRight.rect.left + topRight.rect.width / 2
const borderWidth = getElementBorderWidth(this.colEndpoint)
this.colEndpoint.style.display = 'flex'
this.colEndpoint.style.left = `${midX - topLeftForCol.rect.left - this.colEndpoint.getBoundingClientRect().width / 2}px`
this.colEndpoint.style.top = `${topRight.rect.top - topLeftForCol.rect.top - this.colEndpoint.getBoundingClientRect().height / 2 + borderWidth.top / 2}px`
} else {
this.colEndpoint.style.display = 'none'
}
}
setSelectionToTable() {
const { state } = this.view
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const firstCellPos = tablePos + 3
const selection = TextSelection.create(state.doc, firstCellPos)
const tr = state.tr.setSelection(selection)
this.view.dispatch(tr)
}
}
setSelectionToLastRow() {
const { state } = this.view
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const lastRowIndex = map.height - 1
const lastRowFirstCell = map.map[lastRowIndex * map.width]
const lastRowFirstCellPos = tablePos + 1 + lastRowFirstCell
const selection = TextSelection.create(state.doc, lastRowFirstCellPos)
const tr = state.tr.setSelection(selection)
this.view.dispatch(tr)
}
}
setSelectionToLastColumn() {
const { state } = this.view
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const lastColumnIndex = map.width - 1
const lastColumnFirstCell = map.map[lastColumnIndex]
const lastColumnFirstCellPos = tablePos + 1 + lastColumnFirstCell
const selection = TextSelection.create(state.doc, lastColumnFirstCellPos)
const tr = state.tr.setSelection(selection)
this.view.dispatch(tr)
}
}
// selection triggers moved to decorations plugin
hasTableCellSelection(): boolean {
const selection = this.view.state.selection
return isCellSelection(selection)
}
selectRow(rowIndex: number) {
const { state, dispatch } = this.view
// Find the table position
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const firstCellInRow = map.map[rowIndex * map.width]
const lastCellInRow = map.map[rowIndex * map.width + map.width - 1]
const firstCellPos = tablePos + 1 + firstCellInRow
const lastCellPos = tablePos + 1 + lastCellInRow
const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos)
const tr = state.tr.setSelection(selection)
dispatch(tr)
}
}
selectColumn(colIndex: number) {
const { state, dispatch } = this.view
// Find the table position
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const firstCellInCol = map.map[colIndex]
const lastCellInCol = map.map[(map.height - 1) * map.width + colIndex]
const firstCellPos = tablePos + 1 + firstCellInCol
const lastCellPos = tablePos + 1 + lastCellInCol
const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos)
const tr = state.tr.setSelection(selection)
dispatch(tr)
}
}
destroy() {
this.addRowButton?.remove()
this.addColumnButton?.remove()
if (this.rowEndpoint) this.rowEndpoint.remove()
if (this.colEndpoint) this.colEndpoint.remove()
if (this.selectionChangeDisposer) this.selectionChangeDisposer()
if (this.overlayUpdateRafId !== null) cancelAnimationFrame(this.overlayUpdateRafId)
}
}

View File

@@ -0,0 +1,3 @@
export * from './table.js'
export * from './utilities/createColGroup.js'
export * from './utilities/createTable.js'

View File

@@ -0,0 +1,486 @@
import '../types.js'
import { callOrReturn, getExtensionField, mergeAttributes, Node } from '@tiptap/core'
import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from '@tiptap/pm/tables'
import { type EditorView, type NodeView } from '@tiptap/pm/view'
import { TableView } from './TableView.js'
import { createColGroup } from './utilities/createColGroup.js'
import { createTable } from './utilities/createTable.js'
import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js'
export interface TableOptions {
/**
* HTML attributes for the table element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Enables the resizing of tables.
* @default false
* @example true
*/
resizable: boolean
/**
* The width of the resize handle.
* @default 5
* @example 10
*/
handleWidth: number
/**
* The minimum width of a cell.
* @default 25
* @example 50
*/
cellMinWidth: number
/**
* The node view to render the table.
* @default TableView
*/
View: (new (node: ProseMirrorNode, cellMinWidth: number, view: EditorView) => NodeView) | null
/**
* Enables the resizing of the last column.
* @default true
* @example false
*/
lastColumnResizable: boolean
/**
* Allow table node selection.
* @default false
* @example true
*/
allowTableNodeSelection: boolean
/**
* Optional callbacks for row/column action triggers
*/
onRowActionClick?: (args: { rowIndex: number; view: EditorView; position?: { x: number; y: number } }) => void
onColumnActionClick?: (args: { colIndex: number; view: EditorView; position?: { x: number; y: number } }) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
table: {
/**
* Insert a table
* @param options The table attributes
* @returns True if the command was successful, otherwise false
* @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
*/
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType
/**
* Add a column before the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.addColumnBefore()
*/
addColumnBefore: () => ReturnType
/**
* Add a column after the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.addColumnAfter()
*/
addColumnAfter: () => ReturnType
/**
* Delete the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteColumn()
*/
deleteColumn: () => ReturnType
/**
* Add a row before the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.addRowBefore()
*/
addRowBefore: () => ReturnType
/**
* Add a row after the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.addRowAfter()
*/
addRowAfter: () => ReturnType
/**
* Delete the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteRow()
*/
deleteRow: () => ReturnType
/**
* Delete the current table
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteTable()
*/
deleteTable: () => ReturnType
/**
* Merge the currently selected cells
* @returns True if the command was successful, otherwise false
* @example editor.commands.mergeCells()
*/
mergeCells: () => ReturnType
/**
* Split the currently selected cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.splitCell()
*/
splitCell: () => ReturnType
/**
* Toggle the header column
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderColumn()
*/
toggleHeaderColumn: () => ReturnType
/**
* Toggle the header row
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderRow()
*/
toggleHeaderRow: () => ReturnType
/**
* Toggle the header cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderCell()
*/
toggleHeaderCell: () => ReturnType
/**
* Merge or split the currently selected cells
* @returns True if the command was successful, otherwise false
* @example editor.commands.mergeOrSplit()
*/
mergeOrSplit: () => ReturnType
/**
* Set a cell attribute
* @param name The attribute name
* @param value The attribute value
* @returns True if the command was successful, otherwise false
* @example editor.commands.setCellAttribute('align', 'right')
*/
setCellAttribute: (name: string, value: any) => ReturnType
/**
* Moves the selection to the next cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.goToNextCell()
*/
goToNextCell: () => ReturnType
/**
* Moves the selection to the previous cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.goToPreviousCell()
*/
goToPreviousCell: () => ReturnType
/**
* Try to fix the table structure if necessary
* @returns True if the command was successful, otherwise false
* @example editor.commands.fixTables()
*/
fixTables: () => ReturnType
/**
* Set a cell selection inside the current table
* @param position The cell position
* @returns True if the command was successful, otherwise false
* @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 })
*/
setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType
}
}
}
/**
* This extension allows you to create tables.
* @see https://www.tiptap.dev/api/nodes/table
*/
export const Table = Node.create<TableOptions>({
name: 'table',
// @ts-ignore - TODO: fix
addOptions() {
return {
HTMLAttributes: {},
resizable: false,
handleWidth: 5,
cellMinWidth: 25,
// TODO: fix
View: TableView,
lastColumnResizable: true,
allowTableNodeSelection: false
}
},
content: 'tableRow+',
tableRole: 'table',
isolating: true,
group: 'block',
parseHTML() {
return [{ tag: 'table' }]
},
renderHTML({ node, HTMLAttributes }) {
const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth)
const table: DOMOutputSpec = [
'table',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`
}),
colgroup,
['tbody', 0]
]
return table
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ tr, dispatch, editor }) => {
// Disallow inserting table inside nested nodes when TableCell option allowNestedNodes is false
const tableCellExtension = this.editor.extensionManager.extensions.find((ext) => ext.name === 'tableCell')
const allowNestedNodes: boolean = tableCellExtension
? Boolean((tableCellExtension.options as { allowNestedNodes?: boolean }).allowNestedNodes)
: false
if (!allowNestedNodes) {
const { $from } = tr.selection
// Only allow table insertion at top-level (depth <= 1),
// disallow when selection is inside any nested node (list, blockquote, table, etc.)
if ($from.depth > 1) {
return false
}
}
const node = createTable(editor.schema, rows, cols, withHeaderRow)
if (dispatch) {
const offset = tr.selection.from + 1
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)))
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => {
return addColumnBefore(state, dispatch)
},
addColumnAfter:
() =>
({ state, dispatch }) => {
return addColumnAfter(state, dispatch)
},
deleteColumn:
() =>
({ state, dispatch }) => {
return deleteColumn(state, dispatch)
},
addRowBefore:
() =>
({ state, dispatch }) => {
return addRowBefore(state, dispatch)
},
addRowAfter:
() =>
({ state, dispatch }) => {
return addRowAfter(state, dispatch)
},
deleteRow:
() =>
({ state, dispatch }) => {
return deleteRow(state, dispatch)
},
deleteTable:
() =>
({ state, dispatch }) => {
return deleteTable(state, dispatch)
},
mergeCells:
() =>
({ state, dispatch }) => {
return mergeCells(state, dispatch)
},
splitCell:
() =>
({ state, dispatch }) => {
return splitCell(state, dispatch)
},
toggleHeaderColumn:
() =>
({ state, dispatch }) => {
return toggleHeader('column')(state, dispatch)
},
toggleHeaderRow:
() =>
({ state, dispatch }) => {
return toggleHeader('row')(state, dispatch)
},
toggleHeaderCell:
() =>
({ state, dispatch }) => {
return toggleHeaderCell(state, dispatch)
},
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => {
return setCellAttr(name, value)(state, dispatch)
},
goToNextCell:
() =>
({ state, dispatch }) => {
return goToNextCell(1)(state, dispatch)
},
goToPreviousCell:
() =>
({ state, dispatch }) => {
return goToNextCell(-1)(state, dispatch)
},
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell)
// @ts-ignore - TODO: fix
tr.setSelection(selection)
}
return true
}
}
},
addNodeView() {
return (props) => {
const { node, view } = props
const ViewClass = this.options.View || TableView
if (ViewClass === TableView) {
return new TableView(node, this.options.cellMinWidth, view, {
onRowActionClick: this.options.onRowActionClick,
onColumnActionClick: this.options.onColumnActionClick
})
}
return new ViewClass(node, this.options.cellMinWidth, view)
}
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
'Mod-Backspace': deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
'Mod-Delete': deleteTableWhenAllCellsSelected
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
return [
...(isResizable
? [
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
defaultCellMinWidth: this.options.cellMinWidth,
View: this.options.View,
lastColumnResizable: this.options.lastColumnResizable
})
]
: []),
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
})
]
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
}
return {
tableRole: callOrReturn(getExtensionField(extension, 'tableRole', context))
}
}
})

View File

@@ -0,0 +1,9 @@
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
if (width) {
// apply the stored width unless it is below the configured minimum cell width
return ['width', `${Math.max(width, minWidth)}px`]
}
// set the minimum with on the column if it has no stored width
return ['min-width', `${minWidth}px`]
}

View File

@@ -0,0 +1,12 @@
import type { Fragment, Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model'
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
return cellType.createAndFill()
}

View File

@@ -0,0 +1,68 @@
import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { getColStyleDeclaration } from './colStyle.js'
export type ColGroup =
| {
colgroup: DOMOutputSpec
tableWidth: string
tableMinWidth: string
}
| Record<string, never>
/**
* Creates a colgroup element for a table node in ProseMirror.
*
* @param node - The ProseMirror node representing the table.
* @param cellMinWidth - The minimum width of a cell in the table.
* @param overrideCol - (Optional) The index of the column to override the width of.
* @param overrideValue - (Optional) The width value to use for the overridden column.
* @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
*/
export function createColGroup(node: ProseMirrorNode, cellMinWidth: number): ColGroup
export function createColGroup(
node: ProseMirrorNode,
cellMinWidth: number,
overrideCol: number,
overrideValue: number
): ColGroup
export function createColGroup(
node: ProseMirrorNode,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number
): ColGroup {
let totalWidth = 0
let fixedWidth = true
const cols: DOMOutputSpec[] = []
const row = node.firstChild
if (!row) {
return {}
}
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth = overrideCol === col ? overrideValue : colwidth && (colwidth[j] as number | undefined)
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth)
cols.push(['col', { style: `${property}: ${value}` }])
}
}
const tableWidth = fixedWidth ? `${totalWidth}px` : ''
const tableMinWidth = fixedWidth ? '' : `${totalWidth}px`
const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols]
return { colgroup, tableWidth, tableMinWidth }
}

View File

@@ -0,0 +1,40 @@
import type { Fragment, Node as ProsemirrorNode, Schema } from '@tiptap/pm/model'
import { createCell } from './createCell.js'
import { getTableNodeTypes } from './getTableNodeTypes.js'
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells))
}
return types.table.createChecked(null, rows)
}

View File

@@ -0,0 +1,38 @@
import type { KeyboardShortcutCommand } from '@tiptap/core'
import { findParentNodeClosestToPos } from '@tiptap/core'
import { isCellSelection } from './isCellSelection.js'
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
const { selection } = editor.state
if (!isCellSelection(selection)) {
return false
}
let cellCount = 0
const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => {
return node.type.name === 'table'
})
table?.node.descendants((node) => {
if (node.type.name === 'table') {
return false
}
if (['tableCell', 'tableHeader'].includes(node.type.name)) {
cellCount += 1
}
return true
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}

View File

@@ -0,0 +1,14 @@
export function getElementBorderWidth(element: HTMLElement): {
top: number
right: number
bottom: number
left: number
} {
const style = window.getComputedStyle(element)
return {
top: parseFloat(style.borderTopWidth),
right: parseFloat(style.borderRightWidth),
bottom: parseFloat(style.borderBottomWidth),
left: parseFloat(style.borderLeftWidth)
}
}

View File

@@ -0,0 +1,21 @@
import type { NodeType, Schema } from '@tiptap/pm/model'
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
}
const roles: { [key: string]: NodeType } = {}
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
}

View File

@@ -0,0 +1,5 @@
import { CellSelection } from '@tiptap/pm/tables'
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}

View File

@@ -0,0 +1,68 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import type { EditorView } from '@tiptap/pm/view'
export interface SelectionBounds {
tablePos: number
tableStart: number
map: ReturnType<typeof TableMap.get>
minRow: number
maxRow: number
minCol: number
maxCol: number
topLeftPos: number
topRightPos: number
}
/**
* Compute logical bounds for current CellSelection inside the provided table node.
* Returns null if current selection is not a CellSelection or not within the table node.
*/
export function getCellSelectionBounds(view: EditorView, tableNode: ProseMirrorNode): SelectionBounds | null {
const selection = view.state.selection
if (!(selection instanceof CellSelection)) return null
const $anchor = selection.$anchorCell || selection.$anchor
let tablePos = -1
let currentTable: ProseMirrorNode | null = null
for (let d = $anchor.depth; d > 0; d--) {
const n = $anchor.node(d)
const role = (n.type.spec as { tableRole?: string } | undefined)?.tableRole
if (n.type.name === 'table' || role === 'table') {
tablePos = $anchor.before(d)
currentTable = n
break
}
}
if (tablePos < 0 || currentTable !== tableNode) return null
const map = TableMap.get(tableNode)
const tableStart = tablePos + 1
let minRow = Number.POSITIVE_INFINITY
let maxRow = Number.NEGATIVE_INFINITY
let minCol = Number.POSITIVE_INFINITY
let maxCol = Number.NEGATIVE_INFINITY
let topLeftPos: number | null = null
let topRightPos: number | null = null
selection.forEachCell((_cell, pos) => {
const rect = map.findCell(pos - tableStart)
if (rect.top < minRow) minRow = rect.top
if (rect.left < minCol) minCol = rect.left
if (rect.bottom - 1 > maxRow) maxRow = rect.bottom - 1
if (rect.right - 1 > maxCol) maxCol = rect.right - 1
if (rect.top === minRow && rect.left === minCol) {
if (topLeftPos === null || pos < topLeftPos) topLeftPos = pos
}
if (rect.top === minRow && rect.right - 1 === maxCol) {
if (topRightPos === null || pos < topRightPos) topRightPos = pos
}
})
if (!isFinite(minRow) || !isFinite(minCol) || topLeftPos == null) return null
if (topRightPos == null) topRightPos = topLeftPos
return { tablePos, tableStart, map, minRow, maxRow, minCol, maxCol, topLeftPos, topRightPos }
}

View File

@@ -0,0 +1,19 @@
import type { ParentConfig } from '@tiptap/core'
declare module '@tiptap/core' {
interface NodeConfig<Options, Storage> {
/**
* A string or function to determine the role of the table.
* @default 'table'
* @example () => 'table'
*/
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>['tableRole']
}) => string)
}
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'tsdown'
export default defineConfig(
[
'src/table/index.ts',
'src/cell/index.ts',
'src/header/index.ts',
'src/kit/index.ts',
'src/row/index.ts',
'src/index.ts'
].map((entry) => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: true,
sourcemap: true,
format: ['esm', 'cjs'],
external: [/^[^./]/]
}))
)

View File

@@ -36,6 +36,7 @@ export enum IpcChannel {
App_LogToMain = 'app:log-to-main',
App_SaveData = 'app:save-data',
App_SetFullScreen = 'app:set-full-screen',
App_IsFullScreen = 'app:is-full-screen',
App_MacIsProcessTrusted = 'app:mac-is-process-trusted',
App_MacRequestProcessTrust = 'app:mac-request-process-trust',
@@ -140,16 +141,25 @@ export enum IpcChannel {
File_Upload = 'file:upload',
File_Clear = 'file:clear',
File_Read = 'file:read',
File_ReadExternal = 'file:readExternal',
File_Delete = 'file:delete',
File_DeleteDir = 'file:deleteDir',
File_DeleteExternalFile = 'file:deleteExternalFile',
File_DeleteExternalDir = 'file:deleteExternalDir',
File_Move = 'file:move',
File_MoveDir = 'file:moveDir',
File_Rename = 'file:rename',
File_RenameDir = 'file:renameDir',
File_Get = 'file:get',
File_SelectFolder = 'file:selectFolder',
File_CreateTempFile = 'file:createTempFile',
File_Mkdir = 'file:mkdir',
File_Write = 'file:write',
File_WriteWithId = 'file:writeWithId',
File_SaveImage = 'file:saveImage',
File_Base64Image = 'file:base64Image',
File_SaveBase64Image = 'file:saveBase64Image',
File_SavePastedImage = 'file:savePastedImage',
File_Download = 'file:download',
File_Copy = 'file:copy',
File_BinaryImage = 'file:binaryImage',
@@ -159,6 +169,11 @@ export enum IpcChannel {
Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath',
File_IsTextFile = 'file:isTextFile',
File_GetDirectoryStructure = 'file:getDirectoryStructure',
File_CheckFileName = 'file:checkFileName',
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher',
// file service
FileService_Upload = 'file-service:upload',
@@ -285,5 +300,8 @@ export enum IpcChannel {
CodeTools_Run = 'code-tools:run',
// OCR
OCR_ocr = 'ocr:ocr'
OCR_ocr = 'ocr:ocr',
// Cherryin
Cherryin_GetSignature = 'cherryin:get-signature'
}

View File

@@ -207,7 +207,7 @@ export const defaultTimeout = 10 * 1000 * 60
export const occupiedDirs = ['logs', 'Network', 'Partitions/webview/Network']
export const MIN_WINDOW_WIDTH = 1080
export const MIN_WINDOW_WIDTH = 960
export const SECOND_MIN_WINDOW_WIDTH = 520
export const MIN_WINDOW_HEIGHT = 600
export const defaultByPassRules = 'localhost,127.0.0.1,::1'

View File

@@ -2020,6 +2020,10 @@ export const languages: Record<string, LanguageData> = {
extensions: ['.nginx', '.nginxconf', '.vhost'],
aliases: ['nginx configuration file']
},
Nickel: {
type: 'programming',
extensions: ['.ncl']
},
Nim: {
type: 'programming',
extensions: ['.nim', '.nim.cfg', '.nimble', '.nimrod', '.nims']
@@ -3061,7 +3065,7 @@ export const languages: Record<string, LanguageData> = {
},
SWIG: {
type: 'programming',
extensions: ['.i']
extensions: ['.i', '.swg', '.swig']
},
SystemVerilog: {
type: 'programming',

View File

@@ -9,3 +9,11 @@ export type LoaderReturn = {
message?: string
messageSource?: 'preprocess' | 'embedding'
}
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
export type FileChangeEvent = {
eventType: FileChangeEventType
filePath: string
watchPath: string
}

View File

@@ -2089,7 +2089,7 @@
"Design",
"Education"
],
"prompt": "I want you to act as a Graphviz DOT generator, an expert to create meaningful diagrams. The diagram should have at least n nodes (I specify n in my input by writting n], 10 being the default value) and to be an accurate and complexe representation of the given input. Each node is indexed by a number to reduce the size of the output, should not include any styling, and with layout=neato, overlap=false, node shape=rectangle] as parameters. The code should be valid, bugless and returned on a single line, without any explanation. Provide a clear and organized diagram, the relationships between the nodes have to make sense for an expert of that input. My first diagram is: \"The water cycle 8]\".\n\n",
"prompt": "I want you to act as a Graphviz DOT generator, an expert to create meaningful diagrams. The diagram should have at least n nodes (I specify n in my input by writing n], 10 being the default value) and to be an accurate and complex representation of the given input. Each node is indexed by a number to reduce the size of the output, should not include any styling, and with layout=neato, overlap=false, node shape=rectangle] as parameters. The code should be valid, bugless and returned on a single line, without any explanation. Provide a clear and organized diagram, the relationships between the nodes have to make sense for an expert of that input. My first diagram is: \"The water cycle 8]\".\n\n",
"description": "Generate meaningful charts."
},
{
@@ -2148,7 +2148,7 @@
"Career",
"Business"
],
"prompt": "Please acknowledge my following request. Please respond to me as a product manager. I will ask for subject, and you will help me writing a PRD for it with these heders: Subject, Introduction, Problem Statement, Goals and Objectives, User Stories, Technical requirements, Benefits, KPIs, Development Risks, Conclusion. Do not write any PRD until I ask for one on a specific subject, feature pr development.\n\n",
"prompt": "Please acknowledge my following request. Please respond to me as a product manager. I will ask for subject, and you will help me writing a PRD for it with these headers: Subject, Introduction, Problem Statement, Goals and Objectives, User Stories, Technical requirements, Benefits, KPIs, Development Risks, Conclusion. Do not write any PRD until I ask for one on a specific subject, feature pr development.\n\n",
"description": "Help draft the Product Requirements Document."
},
{
@@ -2159,7 +2159,7 @@
"Entertainment",
"General"
],
"prompt": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkeness I mentionned. Do not write explanations on replies. My first sentence is \"how are you?",
"prompt": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkenness I mentioned. Do not write explanations on replies. My first sentence is \"how are you?",
"description": "Mimic the speech pattern of a drunk person."
},
{
@@ -3517,7 +3517,7 @@
"Tools",
"Copywriting"
],
"prompt": "I want you to act as a scientific manuscript matcher. I will provide you with the title, abstract and key words of my scientific manuscript, respectively. Your task is analyzing my title, abstract and key words synthetically to find the most related, reputable journals for potential publication of my research based on an analysis of tens of millions of citation connections in database, such as Web of Science, Pubmed, Scopus, ScienceDirect and so on. You only need to provide me with the 15 most suitable journals. Your reply should include the name of journal, the cooresponding match score (The full score is ten). I want you to reply in text-based excel sheet and sort by matching scores in reverse order.\nMy title is \"XXX\" My abstract is \"XXX\" My key words are \"XXX\"\n\n",
"prompt": "I want you to act as a scientific manuscript matcher. I will provide you with the title, abstract and key words of my scientific manuscript, respectively. Your task is analyzing my title, abstract and key words synthetically to find the most related, reputable journals for potential publication of my research based on an analysis of tens of millions of citation connections in database, such as Web of Science, Pubmed, Scopus, ScienceDirect and so on. You only need to provide me with the 15 most suitable journals. Your reply should include the name of journal, the corresponding match score (The full score is ten). I want you to reply in text-based excel sheet and sort by matching scores in reverse order.\nMy title is \"XXX\" My abstract is \"XXX\" My key words are \"XXX\"\n\n",
"description": ""
},
{

View File

@@ -1,89 +1,10 @@
const { Arch } = require('electron-builder')
const fs = require('fs')
const path = require('path')
exports.default = async function (context) {
const platform = context.packager.platform.name
const arch = context.arch
if (platform === 'mac') {
const node_modules_path = path.join(
context.appOutDir,
'Cherry Studio.app',
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules'
)
keepPackageNodeFiles(node_modules_path, '@libsql', arch === Arch.arm64 ? ['darwin-arm64'] : ['darwin-x64'])
}
if (platform === 'linux') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
const _arch = arch === Arch.arm64 ? ['linux-arm64-gnu', 'linux-arm64-musl'] : ['linux-x64-gnu', 'linux-x64-musl']
keepPackageNodeFiles(node_modules_path, '@libsql', _arch)
// 删除 macOS 专用的 OCR 包
removeMacOnlyPackages(node_modules_path)
}
if (platform === 'windows') {
const node_modules_path = path.join(context.appOutDir, 'resources', 'app.asar.unpacked', 'node_modules')
if (arch === Arch.arm64) {
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-arm64-msvc'])
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-arm64-msvc'])
}
if (arch === Arch.x64) {
keepPackageNodeFiles(node_modules_path, '@strongtz', ['win32-x64-msvc'])
keepPackageNodeFiles(node_modules_path, '@libsql', ['win32-x64-msvc'])
}
removeMacOnlyPackages(node_modules_path)
}
if (platform === 'windows') {
fs.rmSync(path.join(context.appOutDir, 'LICENSE.electron.txt'), { force: true })
fs.rmSync(path.join(context.appOutDir, 'LICENSES.chromium.html'), { force: true })
}
}
/**
* 删除 macOS 专用的包
* @param {string} nodeModulesPath
*/
function removeMacOnlyPackages(nodeModulesPath) {
const macOnlyPackages = []
macOnlyPackages.forEach((packageName) => {
const packagePath = path.join(nodeModulesPath, packageName)
if (fs.existsSync(packagePath)) {
fs.rmSync(packagePath, { recursive: true, force: true })
console.log(`[After Pack] Removed macOS-only package: ${packageName}`)
}
})
}
/**
* 使用指定架构的 node_modules 文件
* @param {*} nodeModulesPath
* @param {*} packageName
* @param {*} arch
* @returns
*/
function keepPackageNodeFiles(nodeModulesPath, packageName, arch) {
const modulePath = path.join(nodeModulesPath, packageName)
if (!fs.existsSync(modulePath)) {
console.log(`[After Pack] Directory does not exist: ${modulePath}`)
return
}
const dirs = fs.readdirSync(modulePath)
dirs
.filter((dir) => !arch.includes(dir))
.forEach((dir) => {
fs.rmSync(path.join(modulePath, dir), { recursive: true, force: true })
console.log(`[After Pack] Removed dir: ${dir}`, arch)
})
}

91
scripts/before-pack.js Normal file
View File

@@ -0,0 +1,91 @@
const { Arch } = require('electron-builder')
const { downloadNpmPackage } = require('./utils')
// if you want to add new prebuild binaries packages with different architectures, you can add them here
// please add to allX64 and allArm64 from yarn.lock
const allArm64 = {
'@img/sharp-darwin-arm64': '0.34.3',
'@img/sharp-win32-arm64': '0.34.3',
'@img/sharp-linux-arm64': '0.34.3',
'@img/sharp-libvips-darwin-arm64': '1.2.0',
'@img/sharp-libvips-linux-arm64': '1.2.0',
'@libsql/darwin-arm64': '0.4.7',
'@libsql/linux-arm64-gnu': '0.4.7',
'@strongtz/win32-arm64-msvc': '0.4.7',
'@napi-rs/system-ocr-darwin-arm64': '1.0.2',
'@napi-rs/system-ocr-win32-arm64-msvc': '1.0.2'
}
const allX64 = {
'@img/sharp-darwin-x64': '0.34.3',
'@img/sharp-linux-x64': '0.34.3',
'@img/sharp-win32-x64': '0.34.3',
'@img/sharp-libvips-darwin-x64': '1.2.0',
'@img/sharp-libvips-linux-x64': '1.2.0',
'@libsql/darwin-x64': '0.4.7',
'@libsql/linux-x64-gnu': '0.4.7',
'@libsql/win32-x64-msvc': '0.4.7',
'@napi-rs/system-ocr-darwin-x64': '1.0.2',
'@napi-rs/system-ocr-win32-x64-msvc': '1.0.2'
}
const platformToArch = {
mac: 'darwin',
windows: 'win32',
linux: 'linux'
}
exports.default = async function (context) {
const arch = context.arch
const archType = arch === Arch.arm64 ? 'arm64' : 'x64'
const platform = context.packager.platform.name
const arm64Filters = Object.keys(allArm64).map((f) => '!node_modules/' + f + '/**')
const x64Filters = Object.keys(allX64).map((f) => '!node_modules/' + f + '/*')
const downloadPackages = async (packages) => {
console.log('downloading packages ......')
const downloadPromises = []
for (const name of Object.keys(packages)) {
if (name.includes(`${platformToArch[platform]}`) && name.includes(`-${archType}`)) {
downloadPromises.push(
downloadNpmPackage(
name,
`https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${packages[name]}.tgz`
)
)
}
}
await Promise.all(downloadPromises)
}
const changeFilters = async (packages, filtersToExclude, filtersToInclude) => {
await downloadPackages(packages)
// remove filters for the target architecture (allow inclusion)
let filters = context.packager.config.files[0].filter
filters = filters.filter((filter) => !filtersToInclude.includes(filter))
// add filters for other architectures (exclude them)
filters.push(...filtersToExclude)
context.packager.config.files[0].filter = filters
}
if (arch === Arch.arm64) {
await changeFilters(allArm64, x64Filters, arm64Filters)
return
}
if (arch === Arch.x64) {
await changeFilters(allX64, arm64Filters, x64Filters)
return
}
}

View File

@@ -1,44 +0,0 @@
const { downloadNpmPackage } = require('./utils')
async function downloadNpm(platform) {
if (!platform || platform === 'mac') {
downloadNpmPackage(
'@libsql/darwin-arm64',
'https://registry.npmjs.org/@libsql/darwin-arm64/-/darwin-arm64-0.4.7.tgz'
)
downloadNpmPackage('@libsql/darwin-x64', 'https://registry.npmjs.org/@libsql/darwin-x64/-/darwin-x64-0.4.7.tgz')
}
if (!platform || platform === 'linux') {
downloadNpmPackage(
'@libsql/linux-arm64-gnu',
'https://registry.npmjs.org/@libsql/linux-arm64-gnu/-/linux-arm64-gnu-0.4.7.tgz'
)
downloadNpmPackage(
'@libsql/linux-arm64-musl',
'https://registry.npmjs.org/@libsql/linux-arm64-musl/-/linux-arm64-musl-0.4.7.tgz'
)
downloadNpmPackage(
'@libsql/linux-x64-gnu',
'https://registry.npmjs.org/@libsql/linux-x64-gnu/-/linux-x64-gnu-0.4.7.tgz'
)
downloadNpmPackage(
'@libsql/linux-x64-musl',
'https://registry.npmjs.org/@libsql/linux-x64-musl/-/linux-x64-musl-0.4.7.tgz'
)
}
if (!platform || platform === 'windows') {
downloadNpmPackage(
'@libsql/win32-x64-msvc',
'https://registry.npmjs.org/@libsql/win32-x64-msvc/-/win32-x64-msvc-0.4.7.tgz'
)
downloadNpmPackage(
'@strongtz/win32-arm64-msvc',
'https://registry.npmjs.org/@strongtz/win32-arm64-msvc/-/win32-arm64-msvc-0.4.7.tgz'
)
}
}
const platformArg = process.argv[2]
downloadNpm(platformArg)

View File

@@ -66,7 +66,7 @@ ${JSON.stringify({
confirm: '确定要备份数据吗?',
select_model: '选择模型',
title: '文件',
deeply_thought: '已深度思考(用时 {{secounds}} 秒)'
deeply_thought: '已深度思考(用时 {{seconds}} 秒)'
})}
######################################################
MAKE SURE TO OUTPUT IN Russian. DO NOT OUTPUT IN UNSPECIFIED LANGUAGE.

View File

@@ -1,12 +1,15 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const zlib = require('zlib')
const tar = require('tar')
const { pipeline } = require('stream/promises')
function downloadNpmPackage(packageName, url) {
async function downloadNpmPackage(packageName, url) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'npm-download-'))
const targetDir = path.join('./node_modules/', packageName)
const filename = packageName.replace('/', '-') + '.tgz'
const filename = path.join(tempDir, packageName.replace('/', '-') + '.tgz')
const extractDir = path.join(tempDir, 'extract')
// Skip if directory already exists
if (fs.existsSync(targetDir)) {
@@ -16,23 +19,44 @@ function downloadNpmPackage(packageName, url) {
try {
console.log(`Downloading ${packageName}...`, url)
const { execSync } = require('child_process')
execSync(`curl --fail -o ${filename} ${url}`)
// Download file using fetch API
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const fileStream = fs.createWriteStream(filename)
await pipeline(response.body, fileStream)
console.log(`Extracting ${filename}...`)
execSync(`tar -xvf ${filename}`)
execSync(`rm -rf ${filename}`)
execSync(`mkdir -p ${targetDir}`)
execSync(`mv package/* ${targetDir}/`)
// Create extraction directory
fs.mkdirSync(extractDir, { recursive: true })
// Extract tar.gz file using Node.js streams
await pipeline(fs.createReadStream(filename), zlib.createGunzip(), tar.extract({ cwd: extractDir }))
// Remove the downloaded file
fs.rmSync(filename, { force: true })
// Create target directory
fs.mkdirSync(targetDir, { recursive: true })
// Move extracted package contents to target directory
const packageDir = path.join(extractDir, 'package')
if (fs.existsSync(packageDir)) {
fs.cpSync(packageDir, targetDir, { recursive: true })
}
} catch (error) {
console.error(`Error processing ${packageName}: ${error.message}`)
if (fs.existsSync(filename)) {
fs.unlinkSync(filename)
}
throw error
} finally {
// Clean up temp directory
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
}
fs.rmSync(tempDir, { recursive: true, force: true })
}
module.exports = {

View File

@@ -20,3 +20,5 @@ export const titleBarOverlayLight = {
color: 'rgba(255,255,255,0)',
symbolColor: '#000'
}
global.CHERRYIN_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYIN_CLIENT_SECRET

View File

@@ -0,0 +1 @@
var _0x6gg;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0x6gg='\u006D\u006F\u006C\u006A\u0065\u0065';var _0x111cbe;const CLIENT_ID="oiduts-yrrehc".split("").reverse().join("");_0x111cbe=(977158^977167)+(164595^164594);var _0x6d6adc=(756649^756650)+(497587^497587);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0x6d6adc=233169^233176;const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0049\u004E\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{"method":method,"path":path,"query":query='',"body":body=''}=options;const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(110765^111429))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();var _0xe08cc=(212246^212244)+(773521^773523);let bodyString='';_0xe08cc=(606778^606776)+(962748^962740);if(body){if(typeof body==="\u006F\u0062\u006A\u0065\u0063\u0074"){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];var _0x5693g=(936664^936668)+(685268^685277);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x5693g=(266582^266576)+(337322^337315);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0x5fba=(354480^354481)+(537437^537434);const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("\u0068\u0065\u0078");_0x5fba=(249614^249610)+(915906^915914);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],'X-Timestamp':timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,'\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065':generateSignature};

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryin'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
@@ -57,7 +58,15 @@ import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, isPathInside, untildify } from './utils/file'
import {
getCacheDir,
getConfigDir,
getFilesDir,
getNotesDir,
hasWritePermission,
isPathInside,
untildify
} from './utils/file'
import { updateAppDataConfig } from './utils/init'
import { compress, decompress } from './utils/zip'
@@ -77,11 +86,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
const checkMainWindow = () => {
if (!mainWindow || mainWindow.isDestroyed()) {
throw new Error('Main window does not exist or has been destroyed')
}
}
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
notesPath: getNotesDir(),
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
@@ -196,6 +212,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
mainWindow.setFullScreen(value)
})
ipcMain.handle(IpcChannel.App_IsFullScreen, (): boolean => {
return mainWindow.isFullScreen()
})
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
@@ -429,16 +449,25 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear.bind(fileManager))
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_ReadExternal, fileManager.readExternalFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile.bind(fileManager))
ipcMain.handle('file:deleteDir', fileManager.deleteDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_DeleteDir, fileManager.deleteDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_DeleteExternalFile, fileManager.deleteExternalFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_DeleteExternalDir, fileManager.deleteExternalDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Move, fileManager.moveFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_MoveDir, fileManager.moveDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Rename, fileManager.renameFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_RenameDir, fileManager.renameDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder.bind(fileManager))
ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Mkdir, fileManager.mkdir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId.bind(fileManager))
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image.bind(fileManager))
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image.bind(fileManager))
ipcMain.handle(IpcChannel.File_SavePastedImage, fileManager.savePastedImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File.bind(fileManager))
ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount.bind(fileManager))
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile.bind(fileManager))
@@ -446,6 +475,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -534,19 +568,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// window
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
checkMainWindow()
mainWindow.setMinimumSize(width, height)
})
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
mainWindow?.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
checkMainWindow()
mainWindow.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
if (width < MIN_WINDOW_WIDTH) {
mainWindow?.setSize(MIN_WINDOW_WIDTH, height)
mainWindow.setSize(MIN_WINDOW_WIDTH, height)
}
})
ipcMain.handle(IpcChannel.Windows_GetSize, () => {
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
checkMainWindow()
const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
return [width, height]
})
@@ -714,4 +752,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
// CherryIN
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
}

View File

@@ -201,20 +201,14 @@ export default class Doc2xPreprocessProvider extends BasePreprocessProvider {
*/
private async putFile(filePath: string, url: string): Promise<void> {
try {
// 获取文件大小用于设置 Content-Length
const stats = await fs.promises.stat(filePath)
const fileSize = stats.size
// 创建可读流
const fileStream = fs.createReadStream(filePath)
const response = await net.fetch(url, {
method: 'PUT',
body: fileStream as any, // TypeScript 类型转换net.fetch 支持 ReadableStream
headers: {
'Content-Length': fileSize.toString()
}
})
duplex: 'half'
} as any) // TypeScript 类型转换net.fetch 需要 duplex 选项
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)

View File

@@ -1,5 +1,6 @@
import { loggerService } from '@logger'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { BuiltinMCPServerName, BuiltinMCPServerNames } from '@types'
import BraveSearchServer from './brave-search'
import DifyKnowledgeServer from './dify-knowledge'
@@ -11,30 +12,34 @@ import ThinkingServer from './sequentialthinking'
const logger = loggerService.withContext('MCPFactory')
export function createInMemoryMCPServer(name: string, args: string[] = [], envs: Record<string, string> = {}): Server {
export function createInMemoryMCPServer(
name: BuiltinMCPServerName,
args: string[] = [],
envs: Record<string, string> = {}
): Server {
logger.debug(`[MCP] Creating in-memory MCP server: ${name} with args: ${args} and envs: ${JSON.stringify(envs)}`)
switch (name) {
case '@cherry/memory': {
case BuiltinMCPServerNames.memory: {
const envPath = envs.MEMORY_FILE_PATH
return new MemoryServer(envPath).server
}
case '@cherry/sequentialthinking': {
case BuiltinMCPServerNames.sequentialThinking: {
return new ThinkingServer().server
}
case '@cherry/brave-search': {
case BuiltinMCPServerNames.braveSearch: {
return new BraveSearchServer(envs.BRAVE_API_KEY).server
}
case '@cherry/fetch': {
case BuiltinMCPServerNames.fetch: {
return new FetchServer().server
}
case '@cherry/filesystem': {
case BuiltinMCPServerNames.filesystem: {
return new FileSystemServer(args).server
}
case '@cherry/dify-knowledge': {
case BuiltinMCPServerNames.difyKnowledge: {
const difyKey = envs.DIFY_KEY
return new DifyKnowledgeServer(difyKey, args).server
}
case '@cherry/python': {
case BuiltinMCPServerNames.python: {
return new PythonServer().server
}
default:

View File

@@ -30,7 +30,8 @@ export default class AppUpdater {
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
'User-Agent': generateUserAgent()
'User-Agent': generateUserAgent(),
'X-Client-Id': configManager.getClientId()
}
autoUpdater.on('error', (error) => {

View File

@@ -323,7 +323,7 @@ class CodeToolsService {
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
}
@@ -332,13 +332,15 @@ class CodeToolsService {
// macOS - Use osascript to launch terminal and execute command directly, without showing startup command
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
// Combine directory change with the main command to ensure they execute in the same shell session
const fullCommand = `cd '${directory.replace(/'/g, "\\'")}' && clear && ${command}`
terminalCommand = 'osascript'
terminalArgs = [
'-e',
`tell application "Terminal"
do script "${fullCommand.replace(/"/g, '\\"')}"
activate
do script "cd '${directory.replace(/'/g, "\\'")}' && clear && ${command.replace(/"/g, '\\"')}"
end tell`
]
break
@@ -420,7 +422,7 @@ end tell`
const envPrefix = buildEnvPrefix(false)
const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand
const linuxTerminals = ['gnome-terminal', 'konsole', 'xterm', 'x-terminal-emulator']
const linuxTerminals = ['gnome-terminal', 'konsole', 'deepin-terminal', 'xterm', 'x-terminal-emulator']
let foundTerminal = 'xterm' // Default to xterm
for (const terminal of linuxTerminals) {
@@ -447,6 +449,9 @@ end tell`
} else if (foundTerminal === 'konsole') {
terminalCommand = 'konsole'
terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
} else if (foundTerminal === 'deepin-terminal') {
terminalCommand = 'deepin-terminal'
terminalArgs = ['-w', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
} else {
// Default to xterm
terminalCommand = 'xterm'

View File

@@ -2,6 +2,7 @@ import { defaultLanguage, UpgradeChannel, ZOOM_SHORTCUTS } from '@shared/config/
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { v4 as uuidv4 } from 'uuid'
import { locales } from '../utils/locales'
@@ -27,7 +28,8 @@ export enum ConfigKeys {
SelectionAssistantFilterList = 'selectionAssistantFilterList',
DisableHardwareAcceleration = 'disableHardwareAcceleration',
Proxy = 'proxy',
EnableDeveloperMode = 'enableDeveloperMode'
EnableDeveloperMode = 'enableDeveloperMode',
ClientId = 'clientId'
}
export class ConfigManager {
@@ -241,6 +243,17 @@ export class ConfigManager {
this.set(ConfigKeys.EnableDeveloperMode, value)
}
getClientId(): string {
let clientId = this.get<string>(ConfigKeys.ClientId)
if (!clientId) {
clientId = uuidv4()
this.set(ConfigKeys.ClientId, clientId)
}
return clientId
}
set(key: string, value: unknown, isNotify: boolean = false) {
this.store.set(key, value)
isNotify && this.notifySubscribers(key, value)

View File

@@ -1,8 +1,18 @@
import { loggerService } from '@logger'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import {
checkName,
getFilesDir,
getFileType,
getName,
getNotesDir,
getTempDir,
readTextFileWithAutoEncoding,
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import { FileMetadata, NotesTreeNode } from '@types'
import chardet from 'chardet'
import chokidar, { FSWatcher } from 'chokidar'
import * as crypto from 'crypto'
import {
dialog,
@@ -26,9 +36,39 @@ import WordExtractor from 'word-extractor'
const logger = loggerService.withContext('FileStorage')
interface FileWatcherConfig {
watchExtensions?: string[]
ignoredPatterns?: (string | RegExp)[]
debounceMs?: number
maxDepth?: number
usePolling?: boolean
retryOnError?: boolean
retryDelayMs?: number
stabilityThreshold?: number
eventChannel?: string
}
const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
watchExtensions: ['.md', '.markdown', '.txt'],
ignoredPatterns: [/(^|[/\\])\../, '**/node_modules/**', '**/.git/**', '**/*.tmp', '**/*.temp', '**/.DS_Store'],
debounceMs: 1000,
maxDepth: 10,
usePolling: false,
retryOnError: true,
retryDelayMs: 5000,
stabilityThreshold: 500,
eventChannel: 'file-change'
}
class FileStorage {
private storageDir = getFilesDir()
private notesDir = getNotesDir()
private tempDir = getTempDir()
private watcher?: FSWatcher
private watcherSender?: Electron.WebContents
private currentWatchPath?: string
private debounceTimer?: NodeJS.Timeout
private watcherConfig: Required<FileWatcherConfig> = DEFAULT_WATCHER_CONFIG
constructor() {
this.initStorageDir()
@@ -39,6 +79,9 @@ class FileStorage {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.notesDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
@@ -209,7 +252,7 @@ class FileStorage {
const ext = path.extname(filePath)
const fileType = getFileType(ext)
const fileInfo: FileMetadata = {
return {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
@@ -220,8 +263,6 @@ class FileStorage {
type: fileType,
count: 1
}
return fileInfo
}
// @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' })
@@ -239,6 +280,122 @@ class FileStorage {
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public deleteExternalFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
return
}
await fs.promises.rm(filePath, { force: true })
logger.debug(`External file deleted successfully: ${filePath}`)
} catch (error) {
logger.error('Failed to delete external file:', error as Error)
throw error
}
}
public deleteExternalDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
return
}
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.debug(`External directory deleted successfully: ${dirPath}`)
} catch (error) {
logger.error('Failed to delete external directory:', error as Error)
throw error
}
}
public moveFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newPath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
// 确保目标目录存在
const destDir = path.dirname(newPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 移动文件
await fs.promises.rename(filePath, newPath)
logger.debug(`File moved successfully: ${filePath} to ${newPath}`)
} catch (error) {
logger.error('Move file failed:', error as Error)
throw error
}
}
public moveDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newDirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
// 确保目标父目录存在
const parentDir = path.dirname(newDirPath)
if (!fs.existsSync(parentDir)) {
await fs.promises.mkdir(parentDir, { recursive: true })
}
// 移动目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Move directory failed:', error as Error)
throw error
}
}
public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
const dirPath = path.dirname(filePath)
const newFilePath = path.join(dirPath, newName + '.md')
// 如果目标文件已存在,抛出错误
if (fs.existsSync(newFilePath)) {
throw new Error(`Target file already exists: ${newFilePath}`)
}
// 重命名文件
await fs.promises.rename(filePath, newFilePath)
logger.debug(`File renamed successfully: ${filePath} to ${newFilePath}`)
} catch (error) {
logger.error('Rename file failed:', error as Error)
throw error
}
}
public renameDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
const parentDir = path.dirname(dirPath)
const newDirPath = path.join(parentDir, newName)
// 如果目标目录已存在,抛出错误
if (fs.existsSync(newDirPath)) {
throw new Error(`Target directory already exists: ${newDirPath}`)
}
// 重命名目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory renamed successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Rename directory failed:', error as Error)
throw error
}
}
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
@@ -282,6 +439,51 @@ class FileStorage {
}
}
public readExternalFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string,
detectEncoding: boolean = false
): Promise<string> => {
if (!fs.existsSync(filePath)) {
throw new Error(`File does not exist: ${filePath}`)
}
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
} catch (error) {
chdir(originalCwd)
logger.error('Failed to read file:', error as Error)
throw error
}
}
try {
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error('Failed to read file:', error as Error)
throw new Error(`Failed to read file: ${filePath}.`)
}
}
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
@@ -298,6 +500,32 @@ class FileStorage {
await fs.promises.writeFile(filePath, data)
}
public fileNameGuard = async (
_: Electron.IpcMainInvokeEvent,
dirPath: string,
fileName: string,
isFile: boolean
): Promise<{ safeName: string; exists: boolean }> => {
const safeName = checkName(fileName)
const finalName = getName(dirPath, safeName, isFile)
const fullPath = path.join(dirPath, finalName + (isFile ? '.md' : ''))
const exists = fs.existsSync(fullPath)
logger.debug(`File name guard: ${fileName} -> ${finalName}, exists: ${exists}`)
return { safeName: finalName, exists }
}
public mkdir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<string> => {
try {
logger.debug(`Attempting to create directory: ${dirPath}`)
await fs.promises.mkdir(dirPath, { recursive: true })
return dirPath
} catch (error) {
logger.error('Failed to create directory:', error as Error)
throw new Error(`Failed to create directory: ${dirPath}. Error: ${(error as Error).message}`)
}
}
public base64Image = async (
_: Electron.IpcMainInvokeEvent,
id: string
@@ -340,7 +568,7 @@ class FileStorage {
await fs.promises.writeFile(destPath, buffer)
const fileMetadata: FileMetadata = {
return {
id: uuid,
origin_name: uuid + ext,
name: uuid + ext,
@@ -351,14 +579,84 @@ class FileStorage {
type: getFileType(ext),
count: 1
}
return fileMetadata
} catch (error) {
logger.error('Failed to save base64 image:', error as Error)
throw error
}
}
public savePastedImage = async (
_: Electron.IpcMainInvokeEvent,
imageData: Uint8Array | Buffer,
extension?: string
): Promise<FileMetadata> => {
try {
const uuid = uuidv4()
const ext = extension || '.png'
const destPath = path.join(this.storageDir, uuid + ext)
logger.debug('Saving pasted image:', {
storageDir: this.storageDir,
destPath,
bufferSize: imageData.length
})
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
// 确保 imageData 是 Buffer
const buffer = Buffer.isBuffer(imageData) ? imageData : Buffer.from(imageData)
// 如果图片大于1MB进行压缩处理
if (buffer.length > MB) {
await this.compressImageBuffer(buffer, destPath, ext)
} else {
await fs.promises.writeFile(destPath, buffer)
}
const stats = await fs.promises.stat(destPath)
return {
id: uuid,
origin_name: `pasted_image_${uuid}${ext}`,
name: uuid + ext,
path: destPath,
created_at: new Date().toISOString(),
size: stats.size,
ext: ext.slice(1),
type: getFileType(ext),
count: 1
}
} catch (error) {
logger.error('Failed to save pasted image:', error as Error)
throw error
}
}
private async compressImageBuffer(imageBuffer: Buffer, destPath: string, ext: string): Promise<void> {
try {
// 创建临时文件
const tempPath = path.join(this.tempDir, `temp_${uuidv4()}${ext}`)
await fs.promises.writeFile(tempPath, imageBuffer)
// 使用现有的压缩方法
await this.compressImage(tempPath, destPath)
// 清理临时文件
try {
await fs.promises.unlink(tempPath)
} catch (error) {
logger.warn('Failed to cleanup temp file:', error as Error)
}
} catch (error) {
logger.error('Image buffer compression failed, saving original:', error as Error)
// 压缩失败时保存原始文件
await fs.promises.writeFile(destPath, imageBuffer)
}
}
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)
@@ -384,7 +682,7 @@ class FileStorage {
public clear = async (): Promise<void> => {
await fs.promises.rm(this.storageDir, { recursive: true })
await this.initStorageDir()
this.initStorageDir()
}
public clearTemp = async (): Promise<void> => {
@@ -432,6 +730,7 @@ class FileStorage {
/**
* 通过相对路径打开文件,跨设备时使用
* @param _
* @param file
*/
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
@@ -443,6 +742,79 @@ class FileStorage {
}
}
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
try {
return await scanDir(dirPath)
} catch (error) {
logger.error('Failed to get directory structure:', error as Error)
throw error
}
}
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
try {
if (!dirPath || typeof dirPath !== 'string') {
return false
}
// Normalize path
const normalizedPath = path.resolve(dirPath)
// Check if directory exists
if (!fs.existsSync(normalizedPath)) {
return false
}
// Check if it's actually a directory
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
return false
}
// Get app paths to prevent selection of restricted directories
const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config'))
const filesDir = path.resolve(getFilesDir())
const currentNotesDir = path.resolve(getNotesDir())
// Prevent selecting app data directories
if (
normalizedPath.startsWith(filesDir) ||
normalizedPath.startsWith(appDataPath) ||
normalizedPath === currentNotesDir
) {
logger.warn(`Invalid directory selection: ${normalizedPath} (app data directory)`)
return false
}
// Prevent selecting system root directories
const isSystemRoot =
process.platform === 'win32'
? /^[a-zA-Z]:[\\/]?$/.test(normalizedPath)
: normalizedPath === '/' ||
normalizedPath === '/usr' ||
normalizedPath === '/etc' ||
normalizedPath === '/System'
if (isSystemRoot) {
logger.warn(`Invalid directory selection: ${normalizedPath} (system root directory)`)
return false
}
// Check write permissions
try {
fs.accessSync(normalizedPath, fs.constants.W_OK)
} catch (error) {
logger.warn(`Directory not writable: ${normalizedPath}`)
return false
}
return true
} catch (error) {
logger.error('Failed to validate notes directory:', error as Error)
return false
}
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,
@@ -461,7 +833,7 @@ class FileStorage {
}
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
@@ -552,7 +924,7 @@ class FileStorage {
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileMetadata = {
return {
id: uuid,
origin_name: filename,
name: uuid + ext,
@@ -563,8 +935,6 @@ class FileStorage {
type: fileType,
count: 1
}
return fileMetadata
} catch (error) {
logger.error('Download file error:', error as Error)
throw error
@@ -629,6 +999,205 @@ class FileStorage {
}
}
public startFileWatcher = async (
event: Electron.IpcMainInvokeEvent,
dirPath: string,
config?: FileWatcherConfig
): Promise<void> => {
try {
this.watcherConfig = { ...DEFAULT_WATCHER_CONFIG, ...config }
if (!dirPath?.trim()) {
throw new Error('Directory path is required')
}
const normalizedPath = path.resolve(dirPath.trim())
if (!fs.existsSync(normalizedPath)) {
throw new Error(`Directory does not exist: ${normalizedPath}`)
}
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${normalizedPath}`)
}
if (this.currentWatchPath === normalizedPath && this.watcher) {
this.watcherSender = event.sender
logger.debug('Already watching directory, updated sender', { path: normalizedPath })
return
}
await this.stopFileWatcher()
logger.info('Starting file watcher', {
path: normalizedPath,
config: {
extensions: this.watcherConfig.watchExtensions,
debounceMs: this.watcherConfig.debounceMs,
maxDepth: this.watcherConfig.maxDepth
}
})
this.currentWatchPath = normalizedPath
this.watcherSender = event.sender
const watchOptions = {
ignored: this.watcherConfig.ignoredPatterns,
persistent: true,
ignoreInitial: true,
depth: this.watcherConfig.maxDepth,
usePolling: this.watcherConfig.usePolling,
awaitWriteFinish: {
stabilityThreshold: this.watcherConfig.stabilityThreshold,
pollInterval: 100
},
alwaysStat: false,
atomic: true
}
this.watcher = chokidar.watch(normalizedPath, watchOptions)
const handleChange = this.createChangeHandler()
this.watcher
.on('add', (filePath: string) => handleChange('add', filePath))
.on('unlink', (filePath: string) => handleChange('unlink', filePath))
.on('addDir', (dirPath: string) => handleChange('addDir', dirPath))
.on('unlinkDir', (dirPath: string) => handleChange('unlinkDir', dirPath))
.on('error', (error: unknown) => {
logger.error('File watcher error', { error: error as Error, path: normalizedPath })
if (this.watcherConfig.retryOnError) {
this.handleWatcherError(error as Error)
}
})
.on('ready', () => {
logger.debug('File watcher ready', { path: normalizedPath })
})
logger.info('File watcher started successfully')
} catch (error) {
logger.error('Failed to start file watcher', error as Error)
this.cleanup()
throw error
}
}
private createChangeHandler() {
return (eventType: string, filePath: string) => {
if (!this.shouldWatchFile(filePath, eventType)) {
return
}
logger.debug('File change detected', { eventType, filePath, path: this.currentWatchPath })
// 对于目录操作,立即触发同步,不使用防抖
if (eventType === 'addDir' || eventType === 'unlinkDir') {
logger.debug('Directory operation detected, triggering immediate sync', { eventType, filePath })
this.notifyChange(eventType, filePath)
return
}
// 对于文件操作,使用防抖机制
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.notifyChange(eventType, filePath)
this.debounceTimer = undefined
}, this.watcherConfig.debounceMs)
}
}
private shouldWatchFile(filePath: string, eventType: string): boolean {
if (eventType.includes('Dir')) {
return true
}
const ext = path.extname(filePath).toLowerCase()
return this.watcherConfig.watchExtensions.includes(ext)
}
private notifyChange(eventType: string, filePath: string) {
try {
if (!this.watcherSender || this.watcherSender.isDestroyed()) {
logger.warn('Sender destroyed, stopping watcher')
this.stopFileWatcher()
return
}
logger.debug('Sending file change event', {
eventType,
filePath,
channel: this.watcherConfig.eventChannel,
senderExists: !!this.watcherSender,
senderDestroyed: this.watcherSender.isDestroyed()
})
this.watcherSender.send(this.watcherConfig.eventChannel, {
eventType,
filePath,
watchPath: this.currentWatchPath
})
logger.debug('File change event sent successfully')
} catch (error) {
logger.error('Failed to send notification', error as Error)
}
}
private handleWatcherError(error: Error) {
const retryableErrors = ['EMFILE', 'ENFILE', 'ENOSPC']
const isRetryable = retryableErrors.some((code) => error.message.includes(code))
if (isRetryable && this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
logger.warn('Attempting restart due to recoverable error', { error: error.message })
setTimeout(async () => {
try {
if (this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
const mockEvent = { sender: this.watcherSender } as Electron.IpcMainInvokeEvent
await this.startFileWatcher(mockEvent, this.currentWatchPath, this.watcherConfig)
}
} catch (retryError) {
logger.error('Restart failed', retryError as Error)
}
}, this.watcherConfig.retryDelayMs)
}
}
private cleanup() {
this.currentWatchPath = undefined
this.watcherSender = undefined
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = undefined
}
}
public stopFileWatcher = async (): Promise<void> => {
try {
if (this.watcher) {
logger.info('Stopping file watcher', { path: this.currentWatchPath })
await this.watcher.close()
this.watcher = undefined
logger.debug('File watcher stopped')
}
this.cleanup()
} catch (error) {
logger.error('Failed to stop file watcher', error as Error)
this.watcher = undefined
this.cleanup()
}
}
public getWatcherStatus(): { isActive: boolean; watchPath?: string; hasValidSender: boolean } {
return {
isActive: !!this.watcher,
watchPath: this.currentWatchPath,
hasValidSender: !!this.watcherSender && !this.watcherSender.isDestroyed()
}
}
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}

View File

@@ -27,7 +27,16 @@ import {
ToolListChangedNotificationSchema
} from '@modelcontextprotocol/sdk/types.js'
import { nanoid } from '@reduxjs/toolkit'
import type { GetResourceResponse, MCPCallToolResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types'
import {
BuiltinMCPServerNames,
type GetResourceResponse,
isBuiltinMCPServer,
type MCPCallToolResponse,
type MCPPrompt,
type MCPResource,
type MCPServer,
type MCPTool
} from '@types'
import { app, net } from 'electron'
import { EventEmitter } from 'events'
import { memoize } from 'lodash'
@@ -47,6 +56,45 @@ type CallToolArgs = { server: MCPServer; name: string; args: any; callId?: strin
const logger = loggerService.withContext('MCPService')
// Redact potentially sensitive fields in objects (headers, tokens, api keys)
function redactSensitive(input: any): any {
const SENSITIVE_KEYS = ['authorization', 'Authorization', 'apiKey', 'api_key', 'apikey', 'token', 'access_token']
const MAX_STRING = 300
const redact = (val: any): any => {
if (val == null) return val
if (typeof val === 'string') {
return val.length > MAX_STRING ? `${val.slice(0, MAX_STRING)}…<${val.length - MAX_STRING} more>` : val
}
if (Array.isArray(val)) return val.map((v) => redact(v))
if (typeof val === 'object') {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(val)) {
if (SENSITIVE_KEYS.includes(k)) {
out[k] = '<redacted>'
} else {
out[k] = redact(v)
}
}
return out
}
return val
}
return redact(input)
}
// Create a context-aware logger for a server
function getServerLogger(server: MCPServer, extra?: Record<string, any>) {
const base = {
serverName: server?.name,
serverId: server?.id,
baseUrl: server?.baseUrl,
type: server?.type || (server?.command ? 'stdio' : server?.baseUrl ? 'http' : 'inmemory')
}
return loggerService.withContext('MCPService', { ...base, ...(extra || {}) })
}
/**
* Higher-order function to add caching capability to any async function
* @param fn The original function to be wrapped with caching
@@ -65,15 +113,17 @@ function withCache<T extends unknown[], R>(
const cacheKey = getCacheKey(...args)
if (CacheService.has(cacheKey)) {
logger.debug(`${logPrefix} loaded from cache`)
logger.debug(`${logPrefix} loaded from cache`, { cacheKey })
const cachedData = CacheService.get<R>(cacheKey)
if (cachedData) {
return cachedData
}
}
const start = Date.now()
const result = await fn(...args)
CacheService.set(cacheKey, result, ttl)
logger.debug(`${logPrefix} cached`, { cacheKey, ttlMs: ttl, durationMs: Date.now() - start })
return result
}
}
@@ -119,6 +169,7 @@ class McpService {
// If there's a pending initialization, wait for it
const pendingClient = this.pendingClients.get(serverKey)
if (pendingClient) {
getServerLogger(server).silly(`Waiting for pending client initialization`)
return pendingClient
}
@@ -127,8 +178,11 @@ class McpService {
if (existingClient) {
try {
// Check if the existing client is still connected
const pingResult = await existingClient.ping()
logger.debug(`Ping result for ${server.name}:`, pingResult)
const pingResult = await existingClient.ping({
// add short timeout to prevent hanging
timeout: 1000
})
getServerLogger(server).debug(`Ping result`, { ok: !!pingResult })
// If the ping fails, remove the client from the cache
// and create a new one
if (!pingResult) {
@@ -137,7 +191,7 @@ class McpService {
return existingClient
}
} catch (error: any) {
logger.error(`Error pinging server ${server.name}:`, error?.message)
getServerLogger(server).error(`Error pinging server`, error as Error)
this.clients.delete(serverKey)
}
}
@@ -162,16 +216,16 @@ class McpService {
StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
> => {
// Create appropriate transport based on configuration
if (server.type === 'inMemory') {
logger.debug(`Using in-memory transport for server: ${server.name}`)
if (isBuiltinMCPServer(server) && server.name !== BuiltinMCPServerNames.mcpAutoInstall) {
getServerLogger(server).debug(`Using in-memory transport`)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
// start the in-memory server with the given name and environment variables
const inMemoryServer = createInMemoryMCPServer(server.name, args, server.env || {})
try {
await inMemoryServer.connect(serverTransport)
logger.debug(`In-memory server started: ${server.name}`)
getServerLogger(server).debug(`In-memory server started`)
} catch (error: Error | any) {
logger.error(`Error starting in-memory server: ${error}`)
getServerLogger(server).error(`Error starting in-memory server`, error as Error)
throw new Error(`Failed to start in-memory server: ${error.message}`)
}
// set the client transport to the client
@@ -184,7 +238,10 @@ class McpService {
},
authProvider
}
logger.debug(`StreamableHTTPClientTransport options:`, options)
// redact headers before logging
getServerLogger(server).debug(`StreamableHTTPClientTransport options`, {
options: redactSensitive(options)
})
return new StreamableHTTPClientTransport(new URL(server.baseUrl!), options)
} else if (server.type === 'sse') {
const options: SSEClientTransportOptions = {
@@ -200,7 +257,7 @@ class McpService {
headers['Authorization'] = `Bearer ${tokens.access_token}`
}
} catch (error) {
logger.error('Failed to fetch tokens:', error as Error)
getServerLogger(server).error('Failed to fetch tokens:', error as Error)
}
}
@@ -230,15 +287,18 @@ class McpService {
...server.env,
...resolvedConfig.env
}
logger.debug(`Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`)
getServerLogger(server).debug(`Using resolved DXT config`, {
command: cmd,
args
})
} else {
logger.warn(`Failed to resolve DXT config for ${server.name}, falling back to manifest values`)
getServerLogger(server).warn(`Failed to resolve DXT config, falling back to manifest values`)
}
}
if (server.command === 'npx') {
cmd = await getBinaryPath('bun')
logger.debug(`Using command: ${cmd}`)
getServerLogger(server).debug(`Using command`, { command: cmd })
// add -x to args if args exist
if (args && args.length > 0) {
@@ -273,7 +333,7 @@ class McpService {
}
}
logger.debug(`Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
// Logger.info(`[MCP] Environment variables for server:`, server.env)
const loginShellEnv = await this.getLoginShellEnv()
@@ -295,12 +355,14 @@ class McpService {
// For DXT servers, set the working directory to the extracted path
if (server.dxtPath) {
transportOptions.cwd = server.dxtPath
logger.debug(`Setting working directory for DXT server: ${server.dxtPath}`)
getServerLogger(server).debug(`Setting working directory for DXT server`, {
cwd: server.dxtPath
})
}
const stdioTransport = new StdioClientTransport(transportOptions)
stdioTransport.stderr?.on('data', (data) =>
logger.debug(`Stdio stderr for server: ${server.name}` + data.toString())
getServerLogger(server).debug(`Stdio stderr`, { data: data.toString() })
)
return stdioTransport
} else {
@@ -309,7 +371,7 @@ class McpService {
}
const handleAuth = async (client: Client, transport: SSEClientTransport | StreamableHTTPClientTransport) => {
logger.debug(`Starting OAuth flow for server: ${server.name}`)
getServerLogger(server).debug(`Starting OAuth flow`)
// Create an event emitter for the OAuth callback
const events = new EventEmitter()
@@ -322,27 +384,27 @@ class McpService {
// Set a timeout to close the callback server
const timeoutId = setTimeout(() => {
logger.warn(`OAuth flow timed out for server: ${server.name}`)
getServerLogger(server).warn(`OAuth flow timed out`)
callbackServer.close()
}, 300000) // 5 minutes timeout
try {
// Wait for the authorization code
const authCode = await callbackServer.waitForAuthCode()
logger.debug(`Received auth code: ${authCode}`)
getServerLogger(server).debug(`Received auth code`)
// Complete the OAuth flow
await transport.finishAuth(authCode)
logger.debug(`OAuth flow completed for server: ${server.name}`)
getServerLogger(server).debug(`OAuth flow completed`)
const newTransport = await initTransport()
// Try to connect again
await client.connect(newTransport)
logger.debug(`Successfully authenticated with server: ${server.name}`)
getServerLogger(server).debug(`Successfully authenticated`)
} catch (oauthError) {
logger.error(`OAuth authentication failed for server ${server.name}:`, oauthError as Error)
getServerLogger(server).error(`OAuth authentication failed`, oauthError as Error)
throw new Error(
`OAuth authentication failed: ${oauthError instanceof Error ? oauthError.message : String(oauthError)}`
)
@@ -381,7 +443,7 @@ class McpService {
logger.debug(`Activated server: ${server.name}`)
return client
} catch (error: any) {
logger.error(`Error activating server ${server.name}:`, error?.message)
getServerLogger(server).error(`Error activating server`, error as Error)
throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`)
}
} finally {
@@ -441,9 +503,9 @@ class McpService {
logger.debug(`Message from server ${server.name}:`, notification.params)
})
logger.debug(`Set up notification handlers for server: ${server.name}`)
getServerLogger(server).debug(`Set up notification handlers`)
} catch (error) {
logger.error(`Failed to set up notification handlers for server ${server.name}:`, error as Error)
getServerLogger(server).error(`Failed to set up notification handlers`, error as Error)
}
}
@@ -461,7 +523,7 @@ class McpService {
CacheService.remove(`mcp:list_tool:${serverKey}`)
CacheService.remove(`mcp:list_prompts:${serverKey}`)
CacheService.remove(`mcp:list_resources:${serverKey}`)
logger.debug(`Cleared all caches for server: ${serverKey}`)
logger.debug(`Cleared all caches for server`, { serverKey })
}
async closeClient(serverKey: string) {
@@ -469,18 +531,18 @@ class McpService {
if (client) {
// Remove the client from the cache
await client.close()
logger.debug(`Closed server: ${serverKey}`)
logger.debug(`Closed server`, { serverKey })
this.clients.delete(serverKey)
// Clear all caches for this server
this.clearServerCache(serverKey)
} else {
logger.warn(`No client found for server: ${serverKey}`)
logger.warn(`No client found for server`, { serverKey })
}
}
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const serverKey = this.getServerKey(server)
logger.debug(`Stopping server: ${server.name}`)
getServerLogger(server).debug(`Stopping server`)
await this.closeClient(serverKey)
}
@@ -496,16 +558,16 @@ class McpService {
try {
const cleaned = this.dxtService.cleanupDxtServer(server.name)
if (cleaned) {
logger.debug(`Cleaned up DXT server directory for: ${server.name}`)
getServerLogger(server).debug(`Cleaned up DXT server directory`)
}
} catch (error) {
logger.error(`Failed to cleanup DXT server: ${server.name}`, error as Error)
getServerLogger(server).error(`Failed to cleanup DXT server`, error as Error)
}
}
}
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
logger.debug(`Restarting server: ${server.name}`)
getServerLogger(server).debug(`Restarting server`)
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
// Clear cache before restarting to ensure fresh data
@@ -518,7 +580,7 @@ class McpService {
try {
await this.closeClient(key)
} catch (error: any) {
logger.error(`Failed to close client: ${error?.message}`)
logger.error(`Failed to close client`, error as Error)
}
}
}
@@ -527,9 +589,9 @@ class McpService {
* Check connectivity for an MCP server
*/
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
logger.debug(`Checking connectivity for server: ${server.name}`)
getServerLogger(server).debug(`Checking connectivity`)
try {
logger.debug(`About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient })
getServerLogger(server).debug(`About to call initClient`, { hasInitClient: !!this.initClient })
if (!this.initClient) {
throw new Error('initClient method is not available')
@@ -538,10 +600,10 @@ class McpService {
const client = await this.initClient(server)
// Attempt to list tools as a way to check connectivity
await client.listTools()
logger.debug(`Connectivity check successful for server: ${server.name}`)
getServerLogger(server).debug(`Connectivity check successful`)
return true
} catch (error) {
logger.error(`Connectivity check failed for server: ${server.name}`, error as Error)
getServerLogger(server).error(`Connectivity check failed`, error as Error)
// Close the client if connectivity check fails to ensure a clean state for the next attempt
const serverKey = this.getServerKey(server)
await this.closeClient(serverKey)
@@ -550,9 +612,8 @@ class McpService {
}
private async listToolsImpl(server: MCPServer): Promise<MCPTool[]> {
logger.debug(`Listing tools for server: ${server.name}`)
getServerLogger(server).debug(`Listing tools`)
const client = await this.initClient(server)
logger.debug(`Client for server: ${server.name}`, client)
try {
const { tools } = await client.listTools()
const serverTools: MCPTool[] = []
@@ -567,7 +628,7 @@ class McpService {
})
return serverTools
} catch (error: any) {
logger.error(`Failed to list tools for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list tools`, error as Error)
return []
}
}
@@ -604,12 +665,16 @@ class McpService {
const callToolFunc = async ({ server, name, args }: CallToolArgs) => {
try {
logger.debug(`Calling: ${server.name} ${name} ${JSON.stringify(args)} callId: ${toolCallId}`, server)
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Calling tool`, {
args: redactSensitive(args)
})
if (typeof args === 'string') {
try {
args = JSON.parse(args)
} catch (e) {
logger.error('args parse error', args)
getServerLogger(server, { tool: name, callId: toolCallId }).error('args parse error', e as Error, {
args
})
}
if (args === '') {
args = {}
@@ -618,8 +683,9 @@ class McpService {
const client = await this.initClient(server)
const result = await client.callTool({ name, arguments: args }, undefined, {
onprogress: (process) => {
logger.debug(`Progress: ${process.progress / (process.total || 1)}`)
logger.debug(`Progress notification received for server: ${server.name}`, process)
getServerLogger(server, { tool: name, callId: toolCallId }).debug(`Progress`, {
ratio: process.progress / (process.total || 1)
})
const mainWindow = windowService.getMainWindow()
if (mainWindow) {
mainWindow.webContents.send('mcp-progress', process.progress / (process.total || 1))
@@ -634,7 +700,7 @@ class McpService {
})
return result as MCPCallToolResponse
} catch (error) {
logger.error(`Error calling tool ${name} on ${server.name}:`, error as Error)
getServerLogger(server, { tool: name, callId: toolCallId }).error(`Error calling tool`, error as Error)
throw error
} finally {
this.activeToolCalls.delete(toolCallId)
@@ -658,7 +724,7 @@ class McpService {
*/
private async listPromptsImpl(server: MCPServer): Promise<MCPPrompt[]> {
const client = await this.initClient(server)
logger.debug(`Listing prompts for server: ${server.name}`)
getServerLogger(server).debug(`Listing prompts`)
try {
const { prompts } = await client.listPrompts()
return prompts.map((prompt: any) => ({
@@ -670,7 +736,7 @@ class McpService {
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
logger.error(`Failed to list prompts for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list prompts`, error as Error)
}
return []
}
@@ -739,7 +805,7 @@ class McpService {
} catch (error: any) {
// -32601 is the code for the method not found
if (error?.code !== -32601) {
logger.error(`Failed to list resources for server: ${server.name}`, error?.message)
getServerLogger(server).error(`Failed to list resources`, error as Error)
}
return []
}
@@ -765,7 +831,7 @@ class McpService {
* Get a specific resource from an MCP server (implementation)
*/
private async getResourceImpl(server: MCPServer, uri: string): Promise<GetResourceResponse> {
logger.debug(`Getting resource ${uri} from server: ${server.name}`)
getServerLogger(server, { uri }).debug(`Getting resource`)
const client = await this.initClient(server)
try {
const result = await client.readResource({ uri: uri })
@@ -783,7 +849,7 @@ class McpService {
contents: contents
}
} catch (error: Error | any) {
logger.error(`Failed to get resource ${uri} from server: ${server.name}`, error.message)
getServerLogger(server, { uri }).error(`Failed to get resource`, error as Error)
throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`)
}
}
@@ -828,10 +894,10 @@ class McpService {
if (activeToolCall) {
activeToolCall.abort()
this.activeToolCalls.delete(callId)
logger.debug(`Aborted tool call: ${callId}`)
logger.debug(`Aborted tool call`, { callId })
return true
} else {
logger.warn(`No active tool call found for callId: ${callId}`)
logger.warn(`No active tool call found for callId`, { callId })
return false
}
}
@@ -841,22 +907,22 @@ class McpService {
*/
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
try {
logger.debug(`Getting server version for: ${server.name}`)
getServerLogger(server).debug(`Getting server version`)
const client = await this.initClient(server)
// Try to get server information which may include version
const serverInfo = client.getServerVersion()
logger.debug(`Server info for ${server.name}:`, serverInfo)
getServerLogger(server).debug(`Server info`, redactSensitive(serverInfo))
if (serverInfo && serverInfo.version) {
logger.debug(`Server version for ${server.name}: ${serverInfo.version}`)
getServerLogger(server).debug(`Server version`, { version: serverInfo.version })
return serverInfo.version
}
logger.warn(`No version information available for server: ${server.name}`)
getServerLogger(server).warn(`No version information available`)
return null
} catch (error: any) {
logger.error(`Failed to get server version for ${server.name}:`, error?.message)
getServerLogger(server).error(`Failed to get server version`, error as Error)
return null
}
}

View File

@@ -32,7 +32,8 @@ class ObsidianVaultService {
)
} else {
// Linux
this.obsidianConfigPath = path.join(app.getPath('home'), '.config', 'obsidian', 'obsidian.json')
this.obsidianConfigPath = this.resolveLinuxObsidianConfigPath()
logger.debug(`Resolved Obsidian config path (linux): ${this.obsidianConfigPath}`)
}
}
@@ -164,6 +165,57 @@ class ObsidianVaultService {
return []
}
}
/**
* 在 Linux 下解析 Obsidian 配置文件路径,兼容多种安装方式。
* 优先返回第一个存在的路径;若均不存在,则返回 XDG 默认路径。
*/
private resolveLinuxObsidianConfigPath(): string {
const home = app.getPath('home')
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(home, '.config')
// 常见目录名与文件名大小写差异做兼容
const configDirs = ['obsidian', 'Obsidian']
const fileNames = ['obsidian.json', 'Obsidian.json']
const candidates: string[] = []
// 1) AppImage/DEBXDG 标准路径)
for (const dir of configDirs) {
for (const file of fileNames) {
candidates.push(path.join(xdgConfigHome, dir, file))
}
}
// 2) Snap 安装:
// - 常见:~/snap/obsidian/current/.config/obsidian/obsidian.json
// - 兼容:~/snap/obsidian/common/.config/obsidian/obsidian.json
for (const dir of configDirs) {
for (const file of fileNames) {
candidates.push(path.join(home, 'snap', 'obsidian', 'current', '.config', dir, file))
candidates.push(path.join(home, 'snap', 'obsidian', 'common', '.config', dir, file))
}
}
// 3) Flatpak 安装:~/.var/app/md.obsidian.Obsidian/config/obsidian/obsidian.json
for (const dir of configDirs) {
for (const file of fileNames) {
candidates.push(path.join(home, '.var', 'app', 'md.obsidian.Obsidian', 'config', dir, file))
}
}
const existing = candidates.find((p) => {
try {
return fs.existsSync(p)
} catch {
return false
}
})
if (existing) return existing
return path.join(xdgConfigHome, 'obsidian', 'obsidian.json')
}
}
export default ObsidianVaultService

View File

@@ -1,6 +1,9 @@
import { is } from '@electron-toolkit/utils'
import { loggerService } from '@logger'
import { BrowserWindow } from 'electron'
const logger = loggerService.withContext('SearchService')
export class SearchService {
private static instance: SearchService | null = null
private searchWindows: Record<string, BrowserWindow> = {}
@@ -55,6 +58,7 @@ export class SearchService {
public async openUrlInSearchWindow(uid: string, url: string): Promise<any> {
let window = this.searchWindows[uid]
logger.debug(`Searching with URL: ${url}`)
if (window) {
await window.loadURL(url)
} else {

View File

@@ -416,7 +416,6 @@ export class SelectionService {
hasShadow: false,
thickFrame: false,
roundedCorners: true,
backgroundMaterial: 'none',
// Platform specific settings
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together

View File

@@ -10,6 +10,13 @@ export function initSessionUserAgent() {
const newUA = originUA.replace(/CherryStudio\/\S+\s/, '').replace(/Electron\/\S+\s/, '')
wvSession.setUserAgent(newUA)
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
const headers = {
...details.requestHeaders,
'User-Agent': details.url.includes('google.com') ? originUA : newUA
}
cb({ requestHeaders: headers })
})
}
/**

View File

@@ -5,6 +5,7 @@ import { is } from '@electron-toolkit/utils'
import { loggerService } from '@logger'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, nativeTheme, screen, shell } from 'electron'
import windowStateKeeper from 'electron-window-state'
@@ -47,8 +48,8 @@ export class WindowService {
}
const mainWindowState = windowStateKeeper({
defaultWidth: 960,
defaultHeight: 600,
defaultWidth: MIN_WINDOW_WIDTH,
defaultHeight: MIN_WINDOW_HEIGHT,
fullScreen: false,
maximize: false
})
@@ -58,8 +59,8 @@ export class WindowService {
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 960,
minHeight: 600,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
autoHideMenuBar: true,
transparent: false,
@@ -498,6 +499,8 @@ export class WindowService {
}
})
this.setupWebContentsHandlers(this.miniWindow)
miniWindowState.manage(this.miniWindow)
//miniWindow should show in current desktop

View File

@@ -1,7 +1,9 @@
import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { tesseractService } from './tesseract/TesseractService'
import { systemOcrService } from './builtin/SystemOcrService'
import { tesseractService } from './builtin/TesseractService'
const logger = loggerService.withContext('OcrService')
@@ -24,7 +26,7 @@ export class OcrService {
if (!handler) {
throw new Error(`Provider ${provider.id} is not registered`)
}
return handler(file)
return handler(file, provider.config)
}
}
@@ -32,3 +34,5 @@ export const ocrService = new OcrService()
// Register built-in providers
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))

View File

@@ -0,0 +1,5 @@
import { OcrHandler } from '@types'
export abstract class OcrBaseService {
abstract ocr: OcrHandler
}

View File

@@ -0,0 +1,39 @@
import { isLinux, isWin } from '@main/constant'
import { loadOcrImage } from '@main/utils/ocr'
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
import {
ImageFileMetadata,
isImageFileMetadata as isImageFileMetadata,
OcrResult,
OcrSystemConfig,
SupportedOcrFile
} from '@types'
import { OcrBaseService } from './OcrBaseService'
// const logger = loggerService.withContext('SystemOcrService')
export class SystemOcrService extends OcrBaseService {
constructor() {
super()
}
private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise<OcrResult> {
if (isLinux) {
return { text: '' }
}
const buffer = await loadOcrImage(file)
const langs = isWin ? options?.langs : undefined
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
return { text: result.text }
}
public ocr = async (file: SupportedOcrFile, options?: OcrSystemConfig): Promise<OcrResult> => {
if (isImageFileMetadata(file)) {
return this.ocrImage(file, options)
} else {
throw new Error('Unsupported file type, currently only image files are supported')
}
}
}
export const systemOcrService = new SystemOcrService()

View File

@@ -0,0 +1,115 @@
import { loggerService } from '@logger'
import { getIpCountry } from '@main/utils/ipService'
import { loadOcrImage } from '@main/utils/ocr'
import { MB } from '@shared/config/constant'
import { ImageFileMetadata, isImageFileMetadata, OcrResult, OcrTesseractConfig, SupportedOcrFile } from '@types'
import { app } from 'electron'
import fs from 'fs'
import { isEqual } from 'lodash'
import path from 'path'
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
import { OcrBaseService } from './OcrBaseService'
const logger = loggerService.withContext('TesseractService')
// config
const MB_SIZE_THRESHOLD = 50
const defaultLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
enum TesseractLangsDownloadUrl {
CN = 'https://gitcode.com/beyondkmp/tessdata-best/releases/download/1.0.0/'
}
export class TesseractService extends OcrBaseService {
private worker: Tesseract.Worker | null = null
private previousLangs: OcrTesseractConfig['langs']
constructor() {
super()
this.previousLangs = {}
}
async getWorker(options?: OcrTesseractConfig): Promise<Tesseract.Worker> {
let langsArray: LanguageCode[]
if (options?.langs) {
// TODO: use type safe objectKeys
langsArray = Object.keys(options.langs) as LanguageCode[]
if (langsArray.length === 0) {
logger.warn('Empty langs option. Fallback to defaultLangs.')
langsArray = defaultLangs
}
} else {
langsArray = defaultLangs
}
logger.debug('langsArray', langsArray)
if (!this.worker || !isEqual(this.previousLangs, langsArray)) {
if (this.worker) {
await this.dispose()
}
logger.debug('use langsArray to create worker', langsArray)
const langPath = await this._getLangPath()
const cachePath = await this._getCacheDir()
const promise = new Promise<Tesseract.Worker>((resolve, reject) => {
createWorker(langsArray, undefined, {
langPath,
cachePath,
logger: (m) => logger.debug('From worker', m),
errorHandler: (e) => {
logger.error('Worker Error', e)
reject(e)
}
})
.then(resolve)
.catch(reject)
})
this.worker = await promise
}
return this.worker
}
private async imageOcr(file: ImageFileMetadata, options?: OcrTesseractConfig): Promise<OcrResult> {
const worker = await this.getWorker(options)
const stat = await fs.promises.stat(file.path)
if (stat.size > MB_SIZE_THRESHOLD * MB) {
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
}
const buffer = await loadOcrImage(file)
const result = await worker.recognize(buffer)
return { text: result.data.text }
}
public ocr = async (file: SupportedOcrFile, options?: OcrTesseractConfig): Promise<OcrResult> => {
if (!isImageFileMetadata(file)) {
throw new Error('Only image files are supported currently')
}
return this.imageOcr(file, options)
}
private async _getLangPath(): Promise<string> {
const country = await getIpCountry()
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : ''
}
private async _getCacheDir(): Promise<string> {
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
// use access to check if the directory exists
if (
!(await fs.promises
.access(cacheDir, fs.constants.F_OK)
.then(() => true)
.catch(() => false))
) {
await fs.promises.mkdir(cacheDir, { recursive: true })
}
return cacheDir
}
async dispose(): Promise<void> {
if (this.worker) {
await this.worker.terminate()
this.worker = null
}
}
}
export const tesseractService = new TesseractService()

View File

@@ -1,82 +0,0 @@
import { loggerService } from '@logger'
import { getIpCountry } from '@main/utils/ipService'
import { loadOcrImage } from '@main/utils/ocr'
import { MB } from '@shared/config/constant'
import { ImageFileMetadata, isImageFile, OcrResult, SupportedOcrFile } from '@types'
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import Tesseract, { createWorker, LanguageCode } from 'tesseract.js'
const logger = loggerService.withContext('TesseractService')
// config
const MB_SIZE_THRESHOLD = 50
const tesseractLangs = ['chi_sim', 'chi_tra', 'eng'] satisfies LanguageCode[]
enum TesseractLangsDownloadUrl {
CN = 'https://gitcode.com/beyondkmp/tessdata/releases/download/4.1.0/',
GLOBAL = 'https://github.com/tesseract-ocr/tessdata/raw/main/'
}
export class TesseractService {
private worker: Tesseract.Worker | null = null
async getWorker(): Promise<Tesseract.Worker> {
if (!this.worker) {
// for now, only support limited languages
this.worker = await createWorker(tesseractLangs, undefined, {
langPath: await this._getLangPath(),
cachePath: await this._getCacheDir(),
gzip: false,
logger: (m) => logger.debug('From worker', m)
})
}
return this.worker
}
async imageOcr(file: ImageFileMetadata): Promise<OcrResult> {
const worker = await this.getWorker()
const stat = await fs.promises.stat(file.path)
if (stat.size > MB_SIZE_THRESHOLD * MB) {
throw new Error(`This image is too large (max ${MB_SIZE_THRESHOLD}MB)`)
}
const buffer = await loadOcrImage(file)
const result = await worker.recognize(buffer)
return { text: result.data.text }
}
async ocr(file: SupportedOcrFile): Promise<OcrResult> {
if (!isImageFile(file)) {
throw new Error('Only image files are supported currently')
}
return this.imageOcr(file)
}
private async _getLangPath(): Promise<string> {
const country = await getIpCountry()
return country.toLowerCase() === 'cn' ? TesseractLangsDownloadUrl.CN : TesseractLangsDownloadUrl.GLOBAL
}
private async _getCacheDir(): Promise<string> {
const cacheDir = path.join(app.getPath('userData'), 'tesseract')
// use access to check if the directory exists
if (
!(await fs.promises
.access(cacheDir, fs.constants.F_OK)
.then(() => true)
.catch(() => false))
) {
await fs.promises.mkdir(cacheDir, { recursive: true })
}
return cacheDir
}
async dispose(): Promise<void> {
if (this.worker) {
await this.worker.terminate()
this.worker = null
}
}
}
export const tesseractService = new TesseractService()

View File

@@ -5,7 +5,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { FileMetadata, FileTypes } from '@types'
import { FileMetadata, FileTypes, NotesTreeNode } from '@types'
import chardet from 'chardet'
import { app } from 'electron'
import iconv from 'iconv-lite'
@@ -148,6 +148,15 @@ export function getFilesDir() {
return path.join(app.getPath('userData'), 'Data', 'Files')
}
export function getNotesDir() {
const notesDir = path.join(app.getPath('userData'), 'Data', 'Notes')
if (!fs.existsSync(notesDir)) {
fs.mkdirSync(notesDir, { recursive: true })
logger.info(`Notes directory created at: ${notesDir}`)
}
return notesDir
}
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}
@@ -195,3 +204,215 @@ export async function readTextFileWithAutoEncoding(filePath: string): Promise<st
logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
return iconv.decode(data, 'UTF-8')
}
/**
* 递归扫描目录,获取符合条件的文件和目录结构
* @param dirPath 当前要扫描的路径
* @param depth 当前深度
* @param basePath
* @returns 文件元数据数组
*/
export async function scanDir(dirPath: string, depth = 0, basePath?: string): Promise<NotesTreeNode[]> {
const options = {
includeFiles: true,
includeDirectories: true,
fileExtensions: ['.md'],
ignoreHiddenFiles: true,
recursive: true,
maxDepth: 10
}
// 如果是第一次调用设置basePath为当前目录
if (!basePath) {
basePath = dirPath
}
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return []
}
if (!fs.existsSync(dirPath)) {
loggerService.withContext('Utils:File').warn(`Dir not exist: ${dirPath}`)
return []
}
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
const result: NotesTreeNode[] = []
for (const entry of entries) {
if (options.ignoreHiddenFiles && entry.name.startsWith('.')) {
continue
}
const entryPath = path.join(dirPath, entry.name)
const relativePath = path.relative(basePath, entryPath)
const treePath = '/' + relativePath.replace(/\\/g, '/')
if (entry.isDirectory() && options.includeDirectories) {
const stats = await fs.promises.stat(entryPath)
const dirTreeNode: NotesTreeNode = {
id: uuidv4(),
name: entry.name,
treePath: treePath,
externalPath: entryPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'folder',
children: [] // 添加 children 属性
}
// 如果启用了递归扫描,则递归调用 scanDir
if (options.recursive) {
dirTreeNode.children = await scanDir(entryPath, depth + 1, basePath)
}
result.push(dirTreeNode)
} else if (entry.isFile() && options.includeFiles) {
const ext = path.extname(entry.name).toLowerCase()
if (options.fileExtensions.length > 0 && !options.fileExtensions.includes(ext)) {
continue
}
const stats = await fs.promises.stat(entryPath)
const name = entry.name.endsWith(options.fileExtensions[0])
? entry.name.slice(0, -options.fileExtensions[0].length)
: entry.name
// 对于文件treePath应该使用不带扩展名的路径
const nameWithoutExt = path.basename(entryPath, path.extname(entryPath))
const dirRelativePath = path.relative(basePath, path.dirname(entryPath))
const fileTreePath = dirRelativePath
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
: `/${nameWithoutExt}`
const fileTreeNode: NotesTreeNode = {
id: uuidv4(),
name: name,
treePath: fileTreePath,
externalPath: entryPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'file'
}
result.push(fileTreeNode)
}
}
return result
}
/**
* 文件名唯一性约束
* @param baseDir 基础目录
* @param fileName 文件名
* @param isFile 是否为文件
* @returns 唯一的文件名
*/
export function getName(baseDir: string, fileName: string, isFile: boolean): string {
// 首先清理文件名
const baseName = sanitizeFilename(fileName)
let candidate = isFile ? baseName + '.md' : baseName
let counter = 1
while (fs.existsSync(path.join(baseDir, candidate))) {
candidate = isFile ? `${baseName}${counter}.md` : `${baseName}${counter}`
counter++
}
return isFile ? candidate.slice(0, -3) : candidate
}
/**
* 文件名合法性校验
* @param fileName 文件名
* @param platform 平台,默认为当前运行平台
* @returns 验证结果
*/
export function validateFileName(fileName: string, platform = process.platform): { valid: boolean; error?: string } {
if (!fileName) {
return { valid: false, error: 'File name cannot be empty' }
}
// 通用检查
if (fileName.length === 0 || fileName.length > 255) {
return { valid: false, error: 'File name length must be between 1 and 255 characters' }
}
// 检查 null 字符(所有系统都不允许)
if (fileName.includes('\0')) {
return { valid: false, error: 'File name cannot contain null characters.' }
}
// Windows 特殊限制
if (platform === 'win32') {
const winInvalidChars = /[<>:"/\\|?*]/
if (winInvalidChars.test(fileName)) {
return { valid: false, error: 'File name contains characters not supported by Windows: < > : " / \\ | ? *' }
}
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i
if (reservedNames.test(fileName)) {
return { valid: false, error: 'File name is a Windows reserved name.' }
}
if (fileName.endsWith('.') || fileName.endsWith(' ')) {
return { valid: false, error: 'File name cannot end with a dot or a space' }
}
}
// Unix/Linux/macOS 限制
if (platform !== 'win32') {
if (fileName.includes('/')) {
return { valid: false, error: 'File name cannot contain slashes /' }
}
}
// macOS 额外限制
if (platform === 'darwin') {
if (fileName.includes(':')) {
return { valid: false, error: 'macOS filenames cannot contain a colon :' }
}
}
return { valid: true }
}
/**
* 文件名合法性检查
* @param fileName 文件名
* @throws 如果文件名不合法则抛出异常
* @returns 合法的文件名
*/
export function checkName(fileName: string): string {
const validation = validateFileName(fileName)
if (!validation.valid) {
throw new Error(`Invalid file name: ${fileName}. ${validation.error}`)
}
return fileName
}
/**
* 清理文件名,替换不合法字符
* @param fileName 原始文件名
* @param replacement 替换字符,默认为下划线
* @returns 清理后的文件名
*/
export function sanitizeFilename(fileName: string, replacement = '_'): string {
if (!fileName) return ''
// 移除或替换非法字符
let sanitized = fileName
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符
.replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名
.replace(/[\s.]+$/, '') // 移除末尾的空格和点
.substring(0, 255) // 限制长度
// 确保不为空
if (!sanitized) {
sanitized = 'untitled'
}
return sanitized
}

View File

@@ -2,11 +2,12 @@ import { ImageFileMetadata } from '@types'
import { readFile } from 'fs/promises'
import sharp from 'sharp'
const preprocessImage = async (buffer: Buffer) => {
return await sharp(buffer)
const preprocessImage = async (buffer: Buffer): Promise<Buffer> => {
return sharp(buffer)
.grayscale() // 转为灰度
.normalize()
.sharpen()
.png({ quality: 100 })
.toBuffer()
}
@@ -23,5 +24,5 @@ const preprocessImage = async (buffer: Buffer) => {
*/
export const loadOcrImage = async (file: ImageFileMetadata): Promise<Buffer> => {
const buffer = await readFile(file.path)
return await preprocessImage(buffer)
return preprocessImage(buffer)
}

View File

@@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import {
AddMemoryOptions,
@@ -80,6 +81,7 @@ const api = {
logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) =>
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
mac: {
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
@@ -141,33 +143,33 @@ const api = {
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
deleteExternalFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalFile, filePath),
deleteExternalDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalDir, dirPath),
move: (path: string, newPath: string) => ipcRenderer.invoke(IpcChannel.File_Move, path, newPath),
moveDir: (dirPath: string, newDirPath: string) => ipcRenderer.invoke(IpcChannel.File_MoveDir, dirPath, newDirPath),
rename: (path: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_Rename, path, newName),
renameDir: (dirPath: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_RenameDir, dirPath, newName),
read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
readExternal: (filePath: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_ReadExternal, filePath, detectEncoding),
clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext),
get: (filePath: string): Promise<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
* 创建一个空的临时文件
* @param fileName 文件名
* @returns 临时文件路径
*/
createTempFile: (fileName: string): Promise<string> => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName),
/**
* 写入文件
* @param filePath 文件路径
* @param data 数据
*/
mkdir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_Mkdir, dirPath),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) =>
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, spanContext),
selectFolder: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, options),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
savePastedImage: (imageData: Uint8Array, extension?: string) =>
ipcRenderer.invoke(IpcChannel.File_SavePastedImage, imageData, extension),
download: (url: string, isUseContentType?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
@@ -175,7 +177,23 @@ const api = {
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath),
checkFileName: (dirPath: string, fileName: string, isFile: boolean) =>
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),
startFileWatcher: (dirPath: string, config?: any) =>
ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config),
stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher),
onFileChange: (callback: (data: FileChangeEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
if (data && typeof data === 'object') {
callback(data)
}
}
ipcRenderer.on('file-change', listener)
return () => ipcRenderer.off('file-change', listener)
}
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
@@ -415,6 +433,10 @@ const api = {
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
},
cherryin: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
}
}

View File

@@ -2,6 +2,7 @@ import '@renderer/databases'
import { loggerService } from '@logger'
import store, { persistor } from '@renderer/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
@@ -15,26 +16,38 @@ import Router from './Router'
const logger = loggerService.withContext('App.tsx')
// 创建 React Query 客户端
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false
}
}
})
function App(): React.ReactElement {
logger.info('App initialized')
return (
<Provider store={store}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<QueryClientProvider client={queryClient}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
</QueryClientProvider>
</Provider>
)
}

View File

@@ -14,7 +14,9 @@ import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppPage from './pages/minapps/MinAppPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
@@ -31,7 +33,9 @@ const Router: FC = () => {
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps/:appId" element={<MinAppPage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />

View File

@@ -1,9 +1,9 @@
import { AihubmixAPIClient } from '@renderer/aiCore/clients/AihubmixAPIClient'
import { AihubmixAPIClient } from '@renderer/aiCore/clients/aihubmix/AihubmixAPIClient'
import { AnthropicAPIClient } from '@renderer/aiCore/clients/anthropic/AnthropicAPIClient'
import { ApiClientFactory } from '@renderer/aiCore/clients/ApiClientFactory'
import { GeminiAPIClient } from '@renderer/aiCore/clients/gemini/GeminiAPIClient'
import { VertexAPIClient } from '@renderer/aiCore/clients/gemini/VertexAPIClient'
import { NewAPIClient } from '@renderer/aiCore/clients/NewAPIClient'
import { NewAPIClient } from '@renderer/aiCore/clients/newapi/NewAPIClient'
import { OpenAIAPIClient } from '@renderer/aiCore/clients/openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '@renderer/aiCore/clients/openai/OpenAIResponseAPIClient'
import { EndpointType, Model, Provider } from '@renderer/types'
@@ -16,6 +16,7 @@ vi.mock('@renderer/config/models', () => ({
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-4', name: 'GPT-4' }
],
zhipu: [],
silicon: [],
openai: [],
anthropic: [],
@@ -32,7 +33,13 @@ vi.mock('@renderer/config/models', () => ({
isWebSearchModel: vi.fn().mockReturnValue(false),
findTokenLimit: vi.fn().mockReturnValue(4096),
isFunctionCallingModel: vi.fn().mockReturnValue(false),
DEFAULT_MAX_TOKENS: 4096
DEFAULT_MAX_TOKENS: 4096,
glm45FlashModel: {
id: 'glm-4.5-flash',
name: 'GLM-4.5-Flash',
provider: 'cherryin',
group: 'GLM-4.5'
}
}))
vi.mock('@renderer/services/AssistantService', () => ({

View File

@@ -1,16 +1,18 @@
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { AihubmixAPIClient } from './AihubmixAPIClient'
import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { CherryinAPIClient } from './cherryin/CherryinAPIClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { VertexAPIClient } from './gemini/VertexAPIClient'
import { NewAPIClient } from './NewAPIClient'
import { NewAPIClient } from './newapi/NewAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
const logger = loggerService.withContext('ApiClientFactory')
@@ -31,24 +33,36 @@ export class ApiClientFactory {
let instance: BaseApiClient
// 首先检查特殊的provider id
// 首先检查特殊的 Provider ID
if (provider.id === 'cherryin') {
instance = new CherryinAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'aihubmix') {
logger.debug(`Creating AihubmixAPIClient for provider: ${provider.id}`)
instance = new AihubmixAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'new-api') {
logger.debug(`Creating NewAPIClient for provider: ${provider.id}`)
instance = new NewAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'ppio') {
logger.debug(`Creating PPIOAPIClient for provider: ${provider.id}`)
instance = new PPIOAPIClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的provider type
if (provider.id === 'zhipu') {
instance = new ZhipuAPIClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的 Provider Type
switch (provider.type) {
case 'openai':
instance = new OpenAIAPIClient(provider) as BaseApiClient
@@ -78,8 +92,3 @@ export class ApiClientFactory {
return instance
}
}
// 移除这个函数,它已经移动到 utils/index.ts
// export function isOpenAIProvider(provider: Provider) {
// return !['anthropic', 'gemini'].includes(provider.type)
// }

View File

@@ -2,13 +2,13 @@ import { Provider } from '@renderer/types'
import { isOpenAIProvider } from '@renderer/utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../AihubmixAPIClient'
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { ApiClientFactory } from '../ApiClientFactory'
import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { VertexAPIClient } from '../gemini/VertexAPIClient'
import { NewAPIClient } from '../NewAPIClient'
import { NewAPIClient } from '../newapi/NewAPIClient'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
import { PPIOAPIClient } from '../ppio/PPIOAPIClient'
@@ -26,7 +26,7 @@ const createTestProvider = (id: string, type: string): Provider => ({
})
// Mock 所有客户端模块
vi.mock('../AihubmixAPIClient', () => ({
vi.mock('../aihubmix/AihubmixAPIClient', () => ({
AihubmixAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../anthropic/AnthropicAPIClient', () => ({
@@ -41,7 +41,7 @@ vi.mock('../gemini/GeminiAPIClient', () => ({
vi.mock('../gemini/VertexAPIClient', () => ({
VertexAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../NewAPIClient', () => ({
vi.mock('../newapi/NewAPIClient', () => ({
NewAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../openai/OpenAIApiClient', () => ({

View File

@@ -1,12 +1,12 @@
import { isOpenAILLMModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from './MixedBaseApiClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { BaseApiClient } from '../BaseApiClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from '../MixedBaseApiClient'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
/**
* AihubmixAPIClient - ApiClient

View File

@@ -0,0 +1,51 @@
import { Provider } from '@renderer/types'
import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
export class CherryinAPIClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override async createCompletions(
payload: OpenAISdkParams,
options?: OpenAI.RequestOptions
): Promise<OpenAISdkRawOutput> {
const sdk = await this.getSdkInstance()
options = options || {}
options.headers = options.headers || {}
const signature = await window.api.cherryin.generateSignature({
method: 'POST',
path: '/chat/completions',
query: '',
body: payload
})
options.headers = {
...options.headers,
...signature
}
// @ts-ignore - SDK参数可能有额外的字段
return await sdk.chat.completions.create(payload, options)
}
override getClientCompatibilityType(): string[] {
return ['CherryinAPIClient']
}
public async listModels(): Promise<OpenAI.Models.Model[]> {
const models = ['glm-4.5-flash', 'Qwen/Qwen3-8B']
const created = Date.now()
return models.map((id) => ({
id,
owned_by: 'cherryin',
object: 'model' as const,
created
}))
}
}

View File

@@ -52,6 +52,7 @@ import {
GeminiSdkRawOutput,
GeminiSdkToolCall
} from '@renderer/types/sdk'
import { isToolUseModeFunction } from '@renderer/utils/assistant'
import {
geminiFunctionCallToMcpTool,
isEnabledToolUse,
@@ -428,8 +429,7 @@ export class GeminiAPIClient extends BaseApiClient<
private getGenerateImageParameter(): Partial<GenerateContentConfig> {
return {
systemInstruction: undefined,
responseModalities: [Modality.TEXT, Modality.IMAGE],
responseMimeType: 'text/plain'
responseModalities: [Modality.TEXT, Modality.IMAGE]
}
}
@@ -476,16 +476,20 @@ export class GeminiAPIClient extends BaseApiClient<
}
}
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (tools.length === 0 || !isToolUseModeFunction(assistant)) {
if (enableWebSearch) {
tools.push({
googleSearch: {}
})
}
if (enableUrlContext) {
tools.push({
urlContext: {}
})
if (enableUrlContext) {
tools.push({
urlContext: {}
})
}
} else if (enableWebSearch || enableUrlContext) {
logger.warn('Native tools cannot be used with function calling for now.')
}
if (isGemmaModel(model) && assistant.prompt) {

View File

@@ -3,4 +3,6 @@ export * from './BaseApiClient'
export * from './types'
// Export specific clients from subdirectories
export * from './anthropic/AnthropicAPIClient'
export * from './openai/OpenAIApiClient'
export * from './openai/OpenAIResponseAPIClient'

View File

@@ -3,12 +3,12 @@ import { isSupportedModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import { NewApiModel } from '@renderer/types/sdk'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from './MixedBaseApiClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { BaseApiClient } from '../BaseApiClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from '../MixedBaseApiClient'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
const logger = loggerService.withContext('NewAPIClient')

View File

@@ -46,6 +46,7 @@ import {
EFFORT_RATIO,
FileTypes,
isSystemProvider,
isTranslateAssistant,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
@@ -54,12 +55,13 @@ import {
Provider,
SystemProviderIds,
ToolCallResponse,
TranslateAssistant,
WebSearchSource
} from '@renderer/types'
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
OpenAIExtraBody,
OpenAIModality,
OpenAISdkMessageParam,
OpenAISdkParams,
OpenAISdkRawChunk,
@@ -122,13 +124,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (!isReasoningModel(model)) {
return {}
}
const reasoningEffort = assistant?.settings?.reasoning_effort
if (isSupportedThinkingTokenZhipuModel(model)) {
if (!reasoningEffort) {
return { thinking: { type: 'disabled' } }
}
return { thinking: { type: 'enabled' } }
return { thinking: { type: reasoningEffort ? 'enabled' : 'disabled' } }
}
if (!reasoningEffort) {
@@ -139,6 +139,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// }
// openrouter: use reasoning
// openrouter 如果关闭思考,会隐藏思考内容,所以对于总是思考的模型需要特别处理
if (model.provider === SystemProviderIds.openrouter) {
// Don't disable reasoning for Gemini models that support thinking tokens
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
@@ -148,6 +149,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
return {}
}
if (isReasoningModel(model) && !isSupportedThinkingTokenModel(model)) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
}
@@ -205,10 +209,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
enable_thinking: true,
incremental_output: true
}
case SystemProviderIds.silicon:
return {
enable_thinking: true
}
case SystemProviderIds.doubao:
return {
thinking: {
@@ -227,10 +227,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
thinking: true
}
}
case SystemProviderIds.silicon:
case SystemProviderIds.ppio:
return {
enable_thinking: true
}
default:
logger.warn(
`Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown`
`Use enable_thinking option as fallback for provider ${this.provider.name} since DeepSeek v3.1 thinking control method is unknown`
)
return {
enable_thinking: true
}
}
}
}
@@ -558,7 +566,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
messages: OpenAISdkMessageParam[]
metadata: Record<string, any>
}> => {
const { messages, mcpTools, maxTokens, enableWebSearch } = coreRequest
const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest
let { streamOutput } = coreRequest
// Qwen3商业版思考模式、Qwen3开源版、QwQ、QVQ只支持流式输出。
@@ -566,16 +574,21 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
streamOutput = true
}
const extra_body: Record<string, any> = {}
const extra_body: OpenAIExtraBody = {}
if (isQwenMTModel(model)) {
const targetLanguage = (assistant as TranslateAssistant).targetLanguage
extra_body.translation_options = {
source_lang: 'auto',
target_lang: mapLanguageToQwenMTModel(targetLanguage!)
}
if (!extra_body.translation_options.target_lang) {
throw new Error(t('translate.error.not_supported', { language: targetLanguage?.value }))
if (isTranslateAssistant(assistant)) {
const targetLanguage = mapLanguageToQwenMTModel(assistant.targetLanguage)
if (!targetLanguage) {
throw new Error(t('translate.error.not_supported', { language: assistant.targetLanguage.value }))
}
const translationOptions = {
source_lang: 'auto',
target_lang: targetLanguage
} as const
extra_body.translation_options = translationOptions
} else {
throw new Error(t('translate.error.chat_qwen_mt'))
}
}
@@ -628,12 +641,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
if (this.provider.id === SystemProviderIds.poe) {
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
let suffix = ''
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
suffix = ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
}
// FIXME: poe 不支持多个text part上传文本文件的时候用的不是file part而是text part因此会出问题
// 临时解决方案是强制poe用string content但是其实poe部分支持array
if (typeof lastUserMsg.content === 'string') {
lastUserMsg.content += suffix
}
}
}
@@ -667,6 +686,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
reasoningEffort.reasoning_effort = 'low'
}
const modalities: {
modalities?: OpenAIModality[]
} = {}
// for openrouter generate image
// https://openrouter.ai/docs/features/multimodal/image-generation
if (enableGenerateImage && this.provider.id === SystemProviderIds.openrouter) {
modalities.modalities = ['image', 'text']
}
const commonParams: OpenAISdkParams = {
model: model.id,
messages:
@@ -679,6 +707,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
tools: tools.length > 0 ? tools : undefined,
stream: streamOutput,
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}),
...modalities,
// groq 有不同的 service tier 配置,不符合 openai 接口类型
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
...this.getProviderSpecificParameters(assistant, model),
@@ -686,7 +715,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
...getOpenAIWebSearchParams(model, enableWebSearch),
// OpenRouter usage tracking
...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}),
...(isQwenMTModel(model) ? extra_body : {}),
...extra_body,
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
// 注意:用户自定义参数总是应该覆盖其他参数
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
@@ -881,7 +910,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
(typeof choice.delta.content === 'string' && choice.delta.content !== '') ||
(typeof (choice.delta as any).reasoning_content === 'string' &&
(choice.delta as any).reasoning_content !== '') ||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== ''))
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== '') ||
((choice.delta as OpenAISdkRawContentSource).images &&
Array.isArray((choice.delta as OpenAISdkRawContentSource).images)))
) {
contentSource = choice.delta
} else if ('message' in choice) {
@@ -979,6 +1010,20 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
accumulatingText = false
}
// 处理图片内容 (e.g. from OpenRouter Gemini image generation models)
if (contentSource.images && Array.isArray(contentSource.images)) {
controller.enqueue({
type: ChunkType.IMAGE_CREATED
})
controller.enqueue({
type: ChunkType.IMAGE_COMPLETE,
image: {
type: 'base64',
images: contentSource.images.map((image) => image.image_url?.url || '')
}
})
}
// 处理工具调用
if (contentSource.tool_calls) {
for (const toolCall of contentSource.tool_calls) {

View File

@@ -0,0 +1,100 @@
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { GenerateImageParams } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
const logger = loggerService.withContext('ZhipuAPIClient')
export class ZhipuAPIClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override getClientCompatibilityType(): string[] {
return ['ZhipuAPIClient']
}
override async generateImage({
model,
prompt,
negativePrompt,
imageSize,
batchSize,
signal,
quality
}: GenerateImageParams): Promise<string[]> {
const sdk = await this.getSdkInstance()
// 智谱AI使用不同的参数格式
const body: any = {
model,
prompt
}
// 智谱AI特有的参数格式
body.size = imageSize
body.n = batchSize
if (negativePrompt) {
body.negative_prompt = negativePrompt
}
// 只有cogview-4-250304模型支持quality和style参数
if (model === 'cogview-4-250304') {
if (quality) {
body.quality = quality
}
body.style = 'vivid'
}
try {
logger.debug('Calling Zhipu image generation API with params:', body)
const response = await sdk.images.generate(body, { signal })
if (response.data && response.data.length > 0) {
return response.data.map((image: any) => image.url).filter(Boolean)
}
return []
} catch (error) {
logger.error('Zhipu image generation failed:', error as Error)
throw error
}
}
public async listModels(): Promise<OpenAI.Models.Model[]> {
const models = [
'glm-4.5',
'glm-4.5-x',
'glm-4.5-air',
'glm-4.5-airx',
'glm-4.5-flash',
'glm-4.5v',
'glm-z1-air',
'glm-z1-airx',
'cogview-3-flash',
'cogview-4-250304',
'glm-4-long',
'glm-4-plus',
'glm-4-air-250414',
'glm-4-airx',
'glm-4-flashx',
'glm-4v',
'glm-4v-flash',
'glm-4v-plus-0111',
'glm-4.1v-thinking-flash',
'glm-4-alltools',
'embedding-3'
]
const created = Date.now()
return models.map((id) => ({
id,
owned_by: 'zhipu',
object: 'model' as const,
created
}))
}
}

View File

@@ -9,9 +9,9 @@ import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import { isEnabledToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/AihubmixAPIClient'
import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient'
import { VertexAPIClient } from './clients/gemini/VertexAPIClient'
import { NewAPIClient } from './clients/NewAPIClient'
import { NewAPIClient } from './clients/newapi/NewAPIClient'
import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient'
import { CompletionsMiddlewareBuilder } from './middleware/builder'
import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware'
@@ -112,7 +112,7 @@ export default class AiProvider {
builder.remove(ToolUseExtractionMiddlewareName)
logger.silly('ToolUseExtractionMiddleware is removed')
}
if (params.callType !== 'chat') {
if (params.callType !== 'chat' && params.callType !== 'check' && params.callType !== 'translate') {
logger.silly('AbortHandlerMiddleware is removed')
builder.remove(AbortHandlerMiddlewareName)
}

View File

@@ -21,32 +21,38 @@ export const AbortHandlerMiddleware: CompletionsMiddleware =
return result
}
// 获取当前消息的ID用于abort管理
// 优先使用处理过的消息,如果没有则使用原始消息
let messageId: string | undefined
if (typeof params.messages === 'string') {
messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
} else {
const processedMessages = params.messages
const lastUserMessage = processedMessages.findLast((m) => m.role === 'user')
messageId = lastUserMessage?.id
}
if (!messageId) {
logger.warn(`No messageId found, abort functionality will not be available.`)
return next(ctx, params)
}
const abortController = new AbortController()
const abortFn = (): void => abortController.abort()
addAbortController(messageId, abortFn)
let abortSignal: AbortSignal | null = abortController.signal
let abortKey: string
// 如果参数中传入了abortKey则优先使用
if (params.abortKey) {
abortKey = params.abortKey
} else {
// 获取当前消息的ID用于abort管理
// 优先使用处理过的消息,如果没有则使用原始消息
let messageId: string | undefined
if (typeof params.messages === 'string') {
messageId = `message-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
} else {
const processedMessages = params.messages
const lastUserMessage = processedMessages.findLast((m) => m.role === 'user')
messageId = lastUserMessage?.id
}
if (!messageId) {
logger.warn(`No messageId found, abort functionality will not be available.`)
return next(ctx, params)
}
abortKey = messageId
}
addAbortController(abortKey, abortFn)
const cleanup = (): void => {
removeAbortController(messageId as string, abortFn)
removeAbortController(abortKey, abortFn)
if (ctx._internal?.flowControl) {
ctx._internal.flowControl.abortController = undefined
ctx._internal.flowControl.abortSignal = undefined

View File

@@ -1,7 +1,9 @@
import { loggerService } from '@logger'
import { isZhipuModel } from '@renderer/config/models'
import store from '@renderer/store'
import { Chunk } from '@renderer/types/chunk'
import { CompletionsResult } from '../schemas'
import { CompletionsParams, CompletionsResult } from '../schemas'
import { CompletionsContext } from '../types'
import { createErrorChunk } from '../utils'
@@ -28,17 +30,22 @@ export const ErrorHandlerMiddleware =
// 尝试执行下一个中间件
return await next(ctx, params)
} catch (error: any) {
logger.error('ErrorHandlerMiddleware_error', error)
logger.error(error)
let processedError = error
processedError = handleError(error, params)
// 1. 使用通用的工具函数将错误解析为标准格式
const errorChunk = createErrorChunk(error)
const errorChunk = createErrorChunk(processedError)
// 2. 调用从外部传入的 onError 回调
if (params.onError) {
params.onError(error)
params.onError(processedError)
}
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
if (shouldThrow) {
throw error
throw processedError
}
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
@@ -57,3 +64,70 @@ export const ErrorHandlerMiddleware =
}
}
}
function handleError(error: any, params: CompletionsParams): any {
if (isZhipuModel(params.assistant.model) && error.status && !params.enableGenerateImage) {
return handleZhipuError(error)
}
if (error.status === 401 || error.message.includes('401')) {
return {
...error,
i18nKey: 'chat.no_api_key',
providerId: params.assistant?.model?.provider
}
}
return error
}
/**
* 处理智谱特定错误
* 1. 只有对话功能enableGenerateImage为false才使用自定义错误处理
* 2. 绘画功能enableGenerateImage为true使用通用错误处理
*/
function handleZhipuError(error: any): any {
const provider = store.getState().llm.providers.find((p) => p.id === 'zhipu')
const logger = loggerService.withContext('handleZhipuError')
// 定义错误模式映射
const errorPatterns = [
{
condition: () => error.status === 401 || /令牌已过期|AuthenticationError|Unauthorized/i.test(error.message),
i18nKey: 'chat.no_api_key',
providerId: provider?.id
},
{
condition: () => error.error?.code === '1304' || /限额|免费配额|free quota|rate limit/i.test(error.message),
i18nKey: 'chat.quota_exceeded',
providerId: provider?.id
},
{
condition: () =>
(error.status === 429 && error.error?.code === '1113') || /余额不足|insufficient balance/i.test(error.message),
i18nKey: 'chat.insufficient_balance',
providerId: provider?.id
},
{
condition: () => !provider?.apiKey?.trim(),
i18nKey: 'chat.no_api_key',
providerId: provider?.id
}
]
// 遍历错误模式,返回第一个匹配的错误
for (const pattern of errorPatterns) {
if (pattern.condition()) {
return {
...error,
providerId: pattern.providerId,
i18nKey: pattern.i18nKey
}
}
}
// 如果不是智谱特定错误,返回原始错误
logger.debug('🔧 不是智谱特定错误,返回原始错误')
return error
}

View File

@@ -59,6 +59,9 @@ export interface CompletionsParams {
contextCount?: number
topicId?: string // 主题ID用于关联上下文
// abort 控制
abortKey?: string
_internal?: ProcessingState
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="40" height="40" viewBox="0 0 40 40"><g><g><rect x="0" y="0" width="40" height="40" rx="10" fill="#FFFFFF" fill-opacity="1"/></g><g><g><path d="M3.7180590000000002,31.9163C3.24403,31.9163,2.9002264,31.4649,3.0262264,31.0079L9.07884,9.056280000000001C9.337579999999999,8.117891,10.43555,7.703758,11.24956,8.237532L19.2136,13.459869999999999C19.6909,13.77281,20.3081,13.77329,20.7859,13.46111L28.7837,8.234667C29.5984,7.702255,30.6956,8.11792,30.953,9.056519999999999L36.974,31.0089C37.0993,31.4656,36.7556,31.9163,36.2819,31.9163L28.5948,31.9163C29.9941,30.2961,30.7641,28.2266,30.7641,26.0857L30.7641,25.8358C30.7641,23.7417,30.0023,21.719,28.6209,20.1451L27.6344,15.19322C27.5764,14.90227,27.321,14.69275,27.0243,14.69275C26.8898,14.69275,26.7588,14.7364,26.6511,14.81715L22.9316,17.60681C22.6667,17.80548,22.3241,17.868740000000003,22.0057,17.77777C20.6944,17.403100000000002,19.3043,17.403100000000002,17.9929,17.77777C17.674599999999998,17.868740000000003,17.332,17.80548,17.0671,17.60681L13.3459,14.815909999999999C13.2393,14.73596,13.1096,14.69275,12.97639,14.69275C12.67948,14.69275,12.42483,14.90461,12.37083,15.196570000000001L11.41369,20.371000000000002C10.01719,21.7911,9.2346,23.703,9.2346,25.6947L9.2346,26.168C9.2346,28.2566,9.98171,30.2762,11.3409,31.8619L11.38755,31.9163L3.7180590000000002,31.9163Z" fill-rule="evenodd" fill="#29E154" fill-opacity="1"/></g><g><path d="M16.05224895477295,27.610614743041992L18.20519895477295,27.610614743041992L18.20519895477295,22.587064743041992L16.37845295477295,22.587064743041992L16.05224895477295,27.610614743041992ZM23.94638895477295,27.610614743041992L21.79344895477295,27.610614743041992L21.79344895477295,22.587064743041992L23.62018895477295,22.587064743041992L23.94638895477295,27.610614743041992Z" fill="#000000" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,59 @@
.command-list-popover {
// Base styles are handled inline for theme support
// Arrow styles based on placement
&[data-placement^='bottom'] {
transform-origin: top center;
animation: slideDownAndFadeIn 0.15s ease-out;
}
&[data-placement^='top'] {
transform-origin: bottom center;
animation: slideUpAndFadeIn 0.15s ease-out;
}
&[data-placement*='start'] {
transform-origin: left center;
}
&[data-placement*='end'] {
transform-origin: right center;
}
}
@keyframes slideDownAndFadeIn {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes slideUpAndFadeIn {
0% {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Ensure smooth scrolling in virtual list
.command-list-popover .dynamic-virtual-list {
scroll-behavior: smooth;
}
// Better focus indicators
.command-list-popover [data-index] {
position: relative;
&:focus-visible {
outline: 2px solid var(--color-primary, #1677ff);
outline-offset: -2px;
}
}

View File

@@ -35,6 +35,8 @@
--color-error: #ff4d50;
--color-link: #338cff;
--color-code-background: #323232;
--color-inline-code-background: #323232;
--color-inline-code-text: rgb(218, 97, 92);
--color-hover: rgba(40, 40, 40, 1);
--color-active: rgba(55, 55, 55, 1);
--color-frame-border: #333;
@@ -115,6 +117,8 @@
--color-error: #ff4d50;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
--color-inline-code-background: rgba(0, 0, 0, 0.06);
--color-inline-code-text: rgba(235, 87, 87);
--color-hover: var(--color-white-mute);
--color-active: var(--color-white-soft);
--color-frame-border: #ddd;

View File

@@ -5,6 +5,7 @@
@use './scrollbar.scss';
@use './container.scss';
@use './animation.scss';
@use './richtext.scss';
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
@import '../fonts/country-flag-fonts/flag.css';
@@ -164,9 +165,6 @@ ul {
}
.markdown {
display: flow-root;
*:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -202,7 +202,7 @@
img {
max-width: 100%;
height: auto;
margin: 10px 0;
margin: 1em 0;
}
a,
@@ -321,6 +321,10 @@ emoji-picker {
--border-size: 0;
}
.block-wrapper + .block-wrapper {
margin-top: 1em;
}
.katex,
mjx-container {
display: inline-block;
@@ -367,4 +371,9 @@ mjx-container {
white-space: pre-wrap;
}
}
.cm-announced {
position: absolute;
display: none;
}
}

View File

@@ -0,0 +1,508 @@
.tiptap {
// 预留5px给scrollbar
padding: 12px 55px 12px 60px;
outline: none;
min-height: 120px;
overflow-wrap: break-word;
word-break: break-word;
&:focus {
outline: none;
}
:first-child {
margin-top: 0;
}
h1:first-child,
h2:first-child,
h3:first-child,
h4:first-child,
h5:first-child,
h6:first-child {
margin-top: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1.5rem 0 1rem 0;
line-height: 1.1;
text-wrap: pretty;
font-weight: 600;
code {
font-size: inherit;
font-weight: inherit;
}
}
h1 {
margin-top: 0;
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.2rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
p {
margin: 1.1rem 0 0.5rem 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
width: 100%;
line-height: 1.6;
hyphens: auto;
&:has(+ ul) {
margin-bottom: 0;
}
}
a {
color: var(--color-link);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
blockquote {
border-left: 4px solid var(--color-primary);
margin: 1.5rem 0;
padding-left: 1rem;
}
code {
background-color: var(--color-inline-code-background);
border-radius: 0.4rem;
color: var(--color-inline-code-text);
font-size: 0.85rem;
padding: 0.25em 0.3em;
font-family: var(--code-font-family);
}
pre {
background: var(--color-code-background);
border-radius: 0.5rem;
color: var(--color-text);
font-family: var(--code-font-family);
margin: 1.5rem 0;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border-soft);
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
border: none;
}
}
hr {
border: none;
border-top: 1px solid var(--color-gray-2);
margin: 2rem 0;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
strong,
strong * {
font-weight: 600;
}
.placeholder {
position: relative;
&:before {
content: attr(data-placeholder);
position: absolute;
color: var(--color-text-secondary);
opacity: 0.6;
pointer-events: none;
font-style: italic;
left: 0;
right: 0;
}
/* Ensure drag handles and plus buttons remain interactive */
.drag-handle,
.plus-button {
pointer-events: auto;
}
}
/* Show placeholder only when focused or when it's the only empty node */
.placeholder.has-focus:before {
opacity: 0.8;
}
img {
max-width: 800px;
width: 100%;
height: auto;
}
table {
border-collapse: collapse;
margin: 0;
/* Allow action endpoints (rendered as decorations) to slightly overflow table edges */
overflow: visible;
table-layout: fixed;
width: 100%;
td,
th {
border: 1px solid var(--color-border-soft);
box-sizing: border-box;
display: table-cell;
min-width: 120px;
padding: 6px 8px;
position: relative;
vertical-align: top;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
> * {
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
th,
th * {
background-color: var(--color-gray-3);
font-weight: bold;
text-align: left;
}
.selectedCell {
position: relative; // 确保伪元素定位
}
.selectedCell::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border: 0 solid var(--color-primary);
border-radius: 0;
}
.selectedCell.selection-top::after {
border-top-width: 2px;
}
.selectedCell.selection-bottom::after {
border-bottom-width: 2px;
}
.selectedCell.selection-left::after {
border-left-width: 2px;
}
.selectedCell.selection-right::after {
border-right-width: 2px;
}
.column-resize-handle {
background-color: var(--color-primary);
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
}
&:has(.selectedCell) {
caret-color: transparent !important;
user-select: none !important;
*::selection {
background: transparent !important;
}
.column-resize-handle {
display: none;
}
}
// Position row action buttons relative to first column cells
tbody tr td:first-child,
tbody tr th:first-child {
position: relative;
}
// Position column action buttons relative to first row cells
tbody tr:first-child td,
tbody tr:first-child th {
position: relative;
}
}
.tableWrapper {
position: relative;
margin: 1rem 0;
display: grid;
grid-template-columns: 1fr 25px;
grid-template-rows: 1fr 25px;
grid-template-areas:
'table column-btn'
'row-btn corner';
gap: 5px;
.table-container {
grid-area: table;
overflow-x: auto;
overflow-y: visible;
&::-webkit-scrollbar {
cursor: default;
}
&::-webkit-scrollbar:horizontal {
cursor: default;
}
&::-webkit-scrollbar-thumb {
cursor: default;
}
&::-webkit-scrollbar-track {
cursor: default;
}
table {
width: max-content;
min-width: 100%;
}
}
.add-row-button,
.add-column-button {
border: 1px solid var(--color-border);
background: var(--color-bg-base);
border-radius: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
color: var(--color-text);
z-index: 20;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
pointer-events: auto;
&:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.98);
}
&::before {
content: '+';
font-weight: bold;
}
}
.add-row-button {
grid-area: row-btn;
}
.add-column-button {
grid-area: column-btn;
}
&:hover,
&:has(.add-row-button:hover),
&:has(.add-column-button:hover) {
.add-row-button,
.add-column-button {
display: flex;
}
}
/* Do not show in readonly even on hover */
&.is-readonly,
&.is-readonly:hover {
.add-row-button,
.add-column-button {
display: none !important;
}
}
.add-row-button:hover,
.add-column-button:hover {
display: flex !important;
}
/* Row/Column action triggers (visible on cell selection) */
.row-action-trigger,
.column-action-trigger {
position: absolute;
height: 20px;
border-radius: 8px;
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
display: none;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
z-index: 30;
pointer-events: auto;
}
.row-action-trigger::before,
.column-action-trigger::before {
content: '•••';
}
}
&.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
// Reduce spacing for nested lists
ul,
ol {
margin: 0.5rem 0.5rem 0.5rem 0.2rem;
}
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
align-items: center;
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
display: flex;
align-items: center;
}
> div {
flex: 1 1 auto;
p {
margin: 0;
}
}
}
/* Checked task item appearance */
li[data-checked='true'] {
> div {
color: var(--color-text-2);
text-decoration: line-through;
}
}
input[type='checkbox'] {
cursor: pointer;
}
/* Use primary color for checked checkbox */
input[type='checkbox']:checked {
accent-color: var(--color-primary);
background-color: var(--color-primary);
border-color: var(--color-primary);
}
ul[data-type='taskList'] {
margin: 0;
}
}
/* Math block */
.block-math-inner {
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2rem;
}
/* Bottom spacer to create viewport padding */
&::after {
content: '';
display: block;
height: 50px;
pointer-events: none;
}
}
// Code block wrapper and header styles
.code-block-wrapper {
position: relative;
.code-block-header {
display: flex;
align-items: center;
gap: 6px;
position: absolute;
top: 4px;
right: 6px;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .code-block-header {
opacity: 1;
}
}

View File

@@ -2,7 +2,7 @@ import { CodeOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { extractTitle } from '@renderer/utils/formats'
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
import { FC, useState } from 'react'
@@ -28,7 +28,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({
const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'HTML Artifacts'
const title = extractHtmlTitle(html) || 'HTML Artifacts'
const [isPopupOpen, setIsPopupOpen] = useState(false)
const { theme } = useTheme()
@@ -48,7 +48,7 @@ const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) =>
}
const handleDownload = async () => {
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
const fileName = `${getFileNameFromHtmlTitle(title) || 'html-artifact'}.html`
await window.api.file.save(fileName, htmlContent)
window.message.success({ content: t('message.download.success'), key: 'download' })
}

Some files were not shown because too many files have changed in this diff Show More