Compare commits

...

27 Commits

Author SHA1 Message Date
kangfenmao
11d72f14dc chore(version): 1.3.12 2025-05-26 15:15:24 +08:00
kangfenmao
f36735f6db refactor: streamline provider menu logic in settings
- Consolidated edit and delete menu items for providers into separate constants for improved readability and maintainability.
- Enhanced the logic for displaying menus based on provider status, ensuring correct options are presented for system providers and initial providers.
2025-05-26 15:14:14 +08:00
kangfenmao
1b0b08c4c4 chore: remove gitee provider 2025-05-26 15:08:20 +08:00
kangfenmao
13d440b0b6 chore: update release notes and fix various issues
- Updated release notes to include new DMXAPI service and fixed knowledge base search results issue.
- Enhanced drag-and-drop functionality for message selection and resolved memory exceptions in translation replies.
- Added styling adjustments for context menu and improved layout in CodeBlockView and MessageGroup components.
2025-05-26 15:07:53 +08:00
Teo
2dc81ab8c8 Feat: Supports sorting of textarea function buttons by dragging (#6268)
* feat(inputbar): add collapsible tools and localization for tool actions

* refactor(inputbar): simplify tool rendering logic in InputbarTools

* refactor(inputbar): enhance tool visibility logic and improve rendering structure in InputbarTools

* fix(inputbar): correct tooltip text for collapse/expand action in InputbarTools

* refactor(Inputbar): simplify Toolbar structure and improve styling
2025-05-26 14:23:27 +08:00
SuYao
b2b0fe9072 chore: update electron configuration and add debug script (#6361)
* chore: update electron configuration and add debug script

- Added sourcemap generation for development builds in electron.vite.config.ts.
- Introduced a new debug script in package.json for easier debugging with remote inspection.

* docs: add debug section to development documentation

- Introduced a new section for debugging instructions, including the command to run the debug script and how to access the Chrome inspect tool.
2025-05-26 11:48:19 +08:00
kangfenmao
da30b52334 feat: add dark mode support for DMXAPI logo in settings
- Introduced a new dark mode logo for DMXAPI and updated the logo rendering logic in the DMXAPISettings component to switch between light and dark logos based on the current theme.
2025-05-26 11:46:56 +08:00
Roland
e854ef8757 fix: 修复Nutstore设置中的自动同步状态和错误消息内容 (#6452)
- 在NutstoreSettings组件中,添加了设置Nutstore自动同步状态的逻辑。
- 更新NutstoreService中的错误消息内容,确保使用正确的国际化键。
2025-05-26 11:46:26 +08:00
kangfenmao
d90ac44945 docs: update README files to enhance feature listings and organization
- Renumbered feature sections for clarity and consistency across English, Japanese, and Chinese README files.
- Improved formatting by removing unnecessary bullet points for a cleaner presentation of core features, knowledge management, platform support, and advanced features.
2025-05-26 11:30:34 +08:00
kangfenmao
55852cb0a1 fix: change display style of .shiki class to flex for improved layout in CodePreview component 2025-05-26 10:59:35 +08:00
Pleasurecruise
c28afebdfd fix: update popup content to improve user interaction in MessageGroup component 2025-05-26 10:35:30 +08:00
kangfenmao
07407f751f docs: update README files to reflect new roadmap and feature enhancements
- Revised the TODO section to a comprehensive roadmap outlining core features, knowledge management, platform support, and advanced features.
- Added links to the project board and GitHub Discussions for community engagement and feedback.
2025-05-26 10:27:02 +08:00
kanweiwei
4726673508 fix: return value from appUpdater.checkForUpdates in IPC handler 2025-05-26 10:11:24 +08:00
Konv Suu
5dc48580a0 fix: improve header styling in CustomCollapse component (#6449) 2025-05-26 10:10:21 +08:00
fullex
676c1cbe83 chore: remove postinstall script from package.json 2025-05-26 10:07:20 +08:00
iola1999
6d61bcd605 fix: Chinese input issue in AddProviderPopup (#6445) 2025-05-26 09:46:31 +08:00
MyPrototypeWhat
ee78dbd27e feat: throttle updateTranslationBlock dispatch for improved performance (#6442)
- Introduced throttling to the updateTranslationBlock dispatch function to limit the frequency of updates, enhancing performance during message operations.
- Utilized lodash's throttle function to ensure efficient handling of accumulated text updates.
2025-05-25 23:37:59 +08:00
Pleasurecruise
d88d78e143 fix: escape special characters in search pattern for improved filtering 2025-05-25 21:20:26 +08:00
SuYao
458f017517 fix: enhance web search recognization in AI providers (#6423) 2025-05-25 21:13:29 +08:00
SuYao
f462b7f94e fix: enhance ExportService to support nested bold and italic formatting (#6420)
* fix: enhance ExportService to support nested bold and italic formatting

- Added tracking for nested bold and italic tags in the ExportService.
- Updated text rendering logic to apply bold and italic styles based on the nesting level of the tags.

* fix: remove unused citation variable in messageToMarkdown function
2025-05-25 21:08:47 +08:00
SuYao
94792c9bb1 feat: enhance citation handling in message export functionality (#6422)
* feat: enhance citation handling in message export functionality

- Refactored message export functions to include citation content in markdown output.
- Introduced a new utility function to extract and format citations from messages.
- Updated related imports and adjusted existing markdown generation logic for improved clarity and maintainability.

* feat: enhance message export tests to include citation and reasoning content

- Added tests to verify inclusion of citation content in markdown output when citation blocks exist.
- Ensured proper formatting with double newlines between sections in exported markdown.
- Updated existing tests to handle cases with reasoning content and no main text block gracefully.

* fix: update citation mapping in export tests for consistency

- Modified the citation mapping in export tests to use the index parameter directly, improving clarity and consistency in the generated markdown output.
2025-05-25 21:08:06 +08:00
w
adef817e86 修复DMXAPI文生成画bug 2025-05-25 21:05:15 +08:00
one
2f312d68a0 refactor(CodeTool): use hook for codeblock tools rather than context (#6273)
* refactor(CodeTool): use hook for codeblock tools rather than context

* fix: codeblock overflow behaviour

* fix: CodePreview scrollbar

* refactor: move margin to CodeHeader

* refactor: add min-width to codeblock
2025-05-25 18:01:27 +08:00
one
a7520169e6 fix: MessageMenubar copy uses latest content (#6435)
* Fix: MessageMenubar copy uses latest content

The 'Copy' button in MessageMenubar was previously using memoized content
derived from component props. This could lead to copying stale content if
you edited a message (e.g., a code block), saved it, and then
immediately clicked 'Copy', because the asynchronous Redux store update
might not have completed and propagated to the props yet.

This commit modifies the onCopy function in MessageMenubar.tsx to
fetch the latest version of the message directly from the Redux store
(store.getState().messages.entities[message.id]) at the moment the
copy action is performed. This ensures that the most up-to-date content
is always copied, resolving the stale content issue.

* Chore: Remove unnecessary comments from MessageMenubar

Removes a few explanatory comments from the onCopy function in
MessageMenubar.tsx that were deemed unnecessary, to keep the code
cleaner.

The comments originated from an example provided in a previous description. The core logic of the function remains unchanged.

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-25 15:57:54 +08:00
SuYao
59e3082642 fix: update dimensions handling in KnowledgeBaseParams (#6417)
fix: update dimensions handling in KnowledgeBaseParams and add supported dimension providers
2025-05-25 13:07:21 +08:00
自由的世界人
795d12c91e fix: ensure args are an array in AddMcpServerModal and MCPService com… (#6413)
fix: ensure args are an array in AddMcpServerModal and MCPService components
2025-05-24 23:17:15 +08:00
SuYao
8eb0be7562 fix: handle optional usage properties in AnthropicProvider (#6418) 2025-05-24 23:12:57 +08:00
69 changed files with 1394 additions and 932 deletions

View File

@@ -67,20 +67,42 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
- 📝 Complete Markdown Rendering - 📝 Complete Markdown Rendering
- 🤲 Easy Content Sharing - 🤲 Easy Content Sharing
# 📝 TODO # 📝 Roadmap
- [x] Quick popup (read clipboard, quick question, explain, translate, summarize) We're actively working on the following features and improvements:
- [x] Comparison of multi-model answers
- [x] Support login using SSO provided by service providers 1. 🎯 **Core Features**
- [x] All models support networking
- [x] Launch of the first official version - Selection Assistant - Smart content selection enhancement
- [x] Bug fixes and improvements (In progress...) - Deep Research - Advanced research capabilities
- [ ] Plugin functionality (JavaScript) - Memory System - Global context awareness
- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base) - Document Preprocessing - Improved document handling
- [ ] iOS & Android client - MCP Marketplace - Model Context Protocol ecosystem
- [ ] AI notes
- [ ] Voice input and output (AI call) 2. 🗂 **Knowledge Management**
- [ ] Data backup supports custom backup content
- Notes and Collections
- Dynamic Canvas visualization
- OCR capabilities
- TTS (Text-to-Speech) support
3. 📱 **Platform Support**
- HarmonyOS Edition (PC)
- Android App (Phase 1)
- iOS App (Phase 1)
- Multi-Window support
- Window Pinning functionality
4. 🔌 **Advanced Features**
- Plugin System
- ASR (Automatic Speech Recognition)
- Assistant and Topic Interaction Refactoring
Track our progress and contribute on our [project board](https://github.com/orgs/CherryHQ/projects/7).
Want to influence our roadmap? Join our [GitHub Discussions](https://github.com/CherryHQ/cherry-studio/discussions) to share your ideas and feedback!
# 🌈 Theme # 🌈 Theme

View File

@@ -70,20 +70,42 @@ https://docs.cherry-ai.com
- 📝 完全な Markdown レンダリング - 📝 完全な Markdown レンダリング
- 🤲 簡単な共有機能 - 🤲 簡単な共有機能
# 📝 TODO # 📝 開発計画
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約) 以下の機能と改善に積極的に取り組んでいます:
- [x] 複数モデルの回答の比較
- [x] サービスプロバイダーが提供する SSO を使用したログイン対応 1. 🎯 **コア機能**
- [x] すべてのモデルのネットワーク対応
- [x] 最初の公式バージョンのリリース - 選択アシスタント - スマートな内容選択の強化
- [x] バグ修正と改善(進行中... - ディープリサーチ - 高度な研究能力
- [ ] プラグイン機能JavaScript - メモリーシステム - グローバルコンテキスト認識
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加) - ドキュメント前処理 - 文書処理の改善
- [ ] iOS & Android クライアント - MCP マーケットプレイス - モデルコンテキストプロトコルエコシステム
- [ ] AI ノート
- [ ] 音声入出力AI コール) 2. 🗂 **ナレッジ管理**
- [ ] データバックアップのカスタマイズ対応
- ノートとコレクション
- ダイナミックキャンバス可視化
- OCR 機能
- TTSテキスト読み上げサポート
3. 📱 **プラットフォーム対応**
- HarmonyOS エディション
- Android アプリフェーズ1
- iOS アプリフェーズ1
- マルチウィンドウ対応
- ウィンドウピン留め機能
4. 🔌 **高度な機能**
- プラグインシステム
- ASR音声認識
- アシスタントとトピックの対話機能リファクタリング
[プロジェクトボード](https://github.com/orgs/CherryHQ/projects/7)で進捗を確認し、貢献することができます。
開発計画に影響を与えたいですか?[GitHub ディスカッション](https://github.com/CherryHQ/cherry-studio/discussions)に参加して、アイデアやフィードバックを共有してください!
# 🌈 テーマ # 🌈 テーマ

View File

@@ -77,20 +77,42 @@ https://docs.cherry-ai.com
- 📝 完整的 Markdown 渲染 - 📝 完整的 Markdown 渲染
- 🤲 便捷的内容分享功能 - 🤲 便捷的内容分享功能
# 📝 待办事项 # 📝 开发计划
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结) 我们正在积极开发以下功能和改进:
- [x] 多模型回答对比
- [x] 支持使用服务供应商提供的 SSO 进行登录 1. 🎯 **核心功能**
- [x] 所有模型支持联网
- [x] 推出第一个正式版 - 选择助手 - 智能内容选择增强
- [x] 错误修复和改进(开发中... - 深度研究 - 高级研究能力
- [ ] 插件功能JavaScript - 全局记忆 - 全局上下文感知
- [ ] 浏览器插件(划词翻译、总结、新增至知识库) - 文档预处理 - 改进文档处理能力
- [ ] iOS & Android 客户端 - MCP 市场 - 模型上下文协议生态系统
- [ ] AI 笔记
- [ ] 语音输入输出AI 通话) 2. 🗂 **知识管理**
- [ ] 数据备份支持自定义备份内容
- 笔记与收藏功能
- 动态画布可视化
- OCR 光学字符识别
- TTS 文本转语音支持
3. 📱 **平台支持**
- 鸿蒙版本 (PC)
- Android 应用(第一期)
- iOS 应用(第一期)
- 多窗口支持
- 窗口置顶功能
4. 🔌 **高级特性**
- 插件系统
- ASR 语音识别
- 助手与话题交互重构
在我们的[项目面板](https://github.com/orgs/CherryHQ/projects/7)上跟踪进展并参与贡献。
想要影响开发计划?欢迎加入我们的 [GitHub 讨论区](https://github.com/CherryHQ/cherry-studio/discussions) 分享您的想法和反馈!
# 🌈 主题 # 🌈 主题

View File

@@ -37,6 +37,14 @@ yarn install
yarn dev yarn dev
``` ```
### Debug
```bash
yarn debug
```
Then input chrome://inspect in browser
### Test ### Test
```bash ```bash

View File

@@ -95,13 +95,9 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
⚠️ 注意:升级前请备份数据,否则将无法降级 ⚠️ 注意:升级前请备份数据,否则将无法降级
增加 TokenFlux 服务商 文生图新增服务商 DMXAPI限时免费
增加 Claude 4 模型支持 输入框按钮支持拖拽排序
Grok 模型增加联网能力 修复知识库搜索结果 100% 问题
小程序支持前进和后退 修复拖拽多选消息相关问题
修复 Windows 用户 MCP 无法启动问题 修复翻译回复内容导致内存异常问题
修复无法搜索历史消息问题 常规错误修复和优化
修复 MCP 代理问题
修复精简备份恢复覆盖文件问题
修复@模型回复插入位置错误问题
修复搜索小程序崩溃问题

View File

@@ -38,7 +38,11 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
external: ['@libsql/client'] external: ['@libsql/client']
} },
sourcemap: process.env.NODE_ENV === 'development'
},
optimizeDeps: {
noDiscovery: process.env.NODE_ENV === 'development'
} }
}, },
preload: { preload: {
@@ -47,6 +51,9 @@ export default defineConfig({
alias: { alias: {
'@shared': resolve('packages/shared') '@shared': resolve('packages/shared')
} }
},
build: {
sourcemap: process.env.NODE_ENV === 'development'
} }
}, },
renderer: { renderer: {

View File

@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.3.11", "version": "1.3.12",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -20,6 +20,7 @@
"scripts": { "scripts": {
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222",
"build": "npm run typecheck && electron-vite build", "build": "npm run typecheck && electron-vite build",
"build:check": "yarn test && yarn typecheck && yarn check:i18n", "build:check": "yarn test && yarn typecheck && yarn check:i18n",
"build:unpack": "dotenv npm run build && electron-builder --dir", "build:unpack": "dotenv npm run build && electron-builder --dir",
@@ -53,7 +54,6 @@
"test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"postinstall": "electron-builder install-app-deps",
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {

View File

@@ -23,14 +23,14 @@ export default class EmbeddingsFactory {
azureOpenAIApiVersion: apiVersion, azureOpenAIApiVersion: apiVersion,
azureOpenAIApiDeploymentName: model, azureOpenAIApiDeploymentName: model,
azureOpenAIApiInstanceName: getInstanceName(baseURL), azureOpenAIApiInstanceName: getInstanceName(baseURL),
// dimensions, dimensions,
batchSize batchSize
}) })
} }
return new OpenAiEmbeddings({ return new OpenAiEmbeddings({
model, model,
apiKey, apiKey,
// dimensions, dimensions,
batchSize, batchSize,
configuration: { baseURL } configuration: { baseURL }
}) })

View File

@@ -200,7 +200,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// check for update // check for update
ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => { ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => {
await appUpdater.checkForUpdates() return await appUpdater.checkForUpdates()
}) })
// notification // notification

View File

@@ -47,6 +47,8 @@ export class ExportService {
let linkText = '' let linkText = ''
let linkUrl = '' let linkUrl = ''
let insideLink = false let insideLink = false
let boldStack = 0 // 跟踪嵌套的粗体标记
let italicStack = 0 // 跟踪嵌套的斜体标记
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
const token = tokens[i] const token = tokens[i]
@@ -82,17 +84,37 @@ export class ExportService {
insideLink = false insideLink = false
} }
break break
case 'strong_open':
boldStack++
break
case 'strong_close':
boldStack--
break
case 'em_open':
italicStack++
break
case 'em_close':
italicStack--
break
case 'text': case 'text':
runs.push(new TextRun({ text: token.content, bold: isHeaderRow })) runs.push(
break new TextRun({
case 'strong': text: token.content,
runs.push(new TextRun({ text: token.content, bold: true })) bold: isHeaderRow || boldStack > 0,
break italics: italicStack > 0
case 'em': })
runs.push(new TextRun({ text: token.content, italics: true })) )
break break
case 'code_inline': case 'code_inline':
runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 })) runs.push(
new TextRun({
text: token.content,
font: 'Consolas',
size: 20,
bold: isHeaderRow || boldStack > 0,
italics: italicStack > 0
})
)
break break
} }
} }

View File

@@ -91,7 +91,7 @@ class McpService {
return JSON.stringify({ return JSON.stringify({
baseUrl: server.baseUrl, baseUrl: server.baseUrl,
command: server.command, command: server.command,
args: server.args, args: Array.isArray(server.args) ? server.args : [],
registryUrl: server.registryUrl, registryUrl: server.registryUrl,
env: server.env, env: server.env,
id: server.id id: server.id
@@ -245,7 +245,7 @@ class McpService {
const loginShellEnv = await this.getLoginShellEnv() const loginShellEnv = await this.getLoginShellEnv()
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812 // Bun not support proxy https://github.com/oven-sh/bun/issues/16812
if (cmd.endsWith('bun')) { if (cmd.includes('bun')) {
this.removeProxyEnv(loginShellEnv) this.removeProxyEnv(loginShellEnv)
} }
@@ -567,12 +567,11 @@ class McpService {
try { try {
const result = await client.listResources() const result = await client.listResources()
const resources = result.resources || [] const resources = result.resources || []
const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({ return (Array.isArray(resources) ? resources : []).map((resource: any) => ({
...resource, ...resource,
serverId: server.id, serverId: server.id,
serverName: server.name serverName: server.name
})) }))
return serverResources
} catch (error: any) { } catch (error: any) {
// -32601 is the code for the method not found // -32601 is the code for the method not found
if (error?.code !== -32601) { if (error?.code !== -32601) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -4,3 +4,9 @@
border-top-left-radius: 10px; border-top-left-radius: 10px;
border-left: 0.5px solid var(--color-border); border-left: 0.5px solid var(--color-border);
} }
.group-container {
.context-menu-container {
width: 100%;
}
}

View File

@@ -321,6 +321,7 @@ mjx-container {
.cm-lineWrapping * { .cm-lineWrapping * {
word-wrap: break-word; word-wrap: break-word;
white-space: pre-wrap;
} }
} }
} }

View File

@@ -18,7 +18,8 @@ body[theme-mode='light'] {
height: 6px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
@@ -30,7 +31,7 @@ body[theme-mode='light'] {
} }
} }
pre::-webkit-scrollbar-thumb { pre:not(.shiki)::-webkit-scrollbar-thumb {
border-radius: 0; border-radius: 0;
background: rgba(0, 0, 0, 0.08); background: rgba(0, 0, 0, 0.08);
&:hover { &:hover {

View File

@@ -1,4 +1,4 @@
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils' import { uuid } from '@renderer/utils'
@@ -12,6 +12,7 @@ import styled from 'styled-components'
interface CodePreviewProps { interface CodePreviewProps {
children: string children: string
language: string language: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
} }
/** /**
@@ -20,7 +21,7 @@ interface CodePreviewProps {
* - 通过 shiki tokenizer 处理流式响应 * - 通过 shiki tokenizer 处理流式响应
* - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer不能跳过 * - 为了正确执行语法高亮,必须保证流式响应都依次到达 tokenizer不能跳过
*/ */
const CodePreview = ({ children, language }: CodePreviewProps) => { const CodePreview = ({ children, language, setTools }: CodePreviewProps) => {
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle() const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible) const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
@@ -35,7 +36,7 @@ const CodePreview = ({ children, language }: CodePreviewProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar() const { registerTool, removeTool } = useCodeTool(setTools)
// 展开/折叠工具 // 展开/折叠工具
useEffect(() => { useEffect(() => {
@@ -171,14 +172,13 @@ const CodePreview = ({ children, language }: CodePreviewProps) => {
ref={codeContentRef} ref={codeContentRef}
$lineNumbers={codeShowLineNumbers} $lineNumbers={codeShowLineNumbers}
$wrap={codeWrappable && !isUnwrapped} $wrap={codeWrappable && !isUnwrapped}
$fadeIn={hasHighlightedCode}
style={{ style={{
fontSize: fontSize - 1, fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none' maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none'
}}> }}>
{hasHighlightedCode ? ( {hasHighlightedCode ? (
<div className="fade-in-effect">
<ShikiTokensRenderer language={language} tokenLines={tokenLines} /> <ShikiTokensRenderer language={language} tokenLines={tokenLines} />
</div>
) : ( ) : (
<CodePlaceholder>{children}</CodePlaceholder> <CodePlaceholder>{children}</CodePlaceholder>
)} )}
@@ -229,26 +229,22 @@ const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[
const ContentContainer = styled.div<{ const ContentContainer = styled.div<{
$lineNumbers: boolean $lineNumbers: boolean
$wrap: boolean $wrap: boolean
$fadeIn: boolean
}>` }>`
display: block;
position: relative; position: relative;
overflow: auto; overflow: auto;
display: flex;
flex-direction: column;
border: 0.5px solid transparent; border: 0.5px solid transparent;
border-radius: 5px; border-radius: 5px;
margin-top: 0; margin-top: 0;
::-webkit-scrollbar-thumb {
border-radius: 10px;
}
.shiki { .shiki {
display: flex;
min-width: 100%;
padding: 1em; padding: 1em;
code { code {
display: flex; display: block;
flex-direction: column;
width: 100%;
.line { .line {
display: block; display: block;
@@ -256,7 +252,7 @@ const ContentContainer = styled.div<{
padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')}; padding-left: ${(props) => (props.$lineNumbers ? '2rem' : '0')};
* { * {
word-wrap: ${(props) => (props.$wrap ? 'break-word' : undefined)}; overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')}; white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
} }
} }
@@ -292,18 +288,15 @@ const ContentContainer = styled.div<{
} }
} }
.fade-in-effect { animation: ${(props) => (props.$fadeIn ? 'contentFadeIn 0.3s ease-in-out forwards' : 'none')};
animation: contentFadeIn 0.3s ease-in-out forwards;
}
` `
const CodePlaceholder = styled.div` const CodePlaceholder = styled.div`
display: block;
opacity: 0.1; opacity: 0.1;
flex-direction: column;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-all;
overflow-x: hidden; overflow-x: hidden;
display: block;
min-height: 1.3rem; min-height: 1.3rem;
` `

View File

@@ -1,5 +1,5 @@
import { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { useMermaid } from '@renderer/hooks/useMermaid' import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex } from 'antd' import { Flex } from 'antd'
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react' import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
@@ -7,9 +7,10 @@ import styled from 'styled-components'
interface Props { interface Props {
children: string children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
} }
const MermaidPreview: React.FC<Props> = ({ children }) => { const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
const { mermaid, isLoading, error: mermaidError } = useMermaid() const { mermaid, isLoading, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null) const mermaidRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -25,6 +26,7 @@ const MermaidPreview: React.FC<Props> = ({ children }) => {
// 使用工具栏 // 使用工具栏
usePreviewTools({ usePreviewTools({
setTools,
handleZoom, handleZoom,
handleCopyImage, handleCopyImage,
handleDownload handleDownload

View File

@@ -1,5 +1,5 @@
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { Spin } from 'antd' import { Spin } from 'antd'
import pako from 'pako' import pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react' import React, { memo, useCallback, useRef, useState } from 'react'
@@ -134,9 +134,10 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
interface PlantUMLProps { interface PlantUMLProps {
children: string children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
} }
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => { const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
const { t } = useTranslation() const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -165,6 +166,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
// 使用工具栏 // 使用工具栏
usePreviewTools({ usePreviewTools({
setTools,
handleZoom, handleZoom,
handleCopyImage, handleCopyImage,
handleDownload: customDownload handleDownload: customDownload

View File

@@ -1,12 +1,13 @@
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar' import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useRef } from 'react' import { memo, useRef } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
children: string children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
} }
const SvgPreview: React.FC<Props> = ({ children }) => { const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
const svgContainerRef = useRef<HTMLDivElement>(null) const svgContainerRef = useRef<HTMLDivElement>(null)
// 使用通用图像工具 // 使用通用图像工具
@@ -17,6 +18,7 @@ const SvgPreview: React.FC<Props> = ({ children }) => {
// 使用工具栏 // 使用工具栏
usePreviewTools({ usePreviewTools({
setTools,
handleCopyImage, handleCopyImage,
handleDownload handleDownload
}) })

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor' import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' import { CodeTool, CodeToolbar, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService' import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats' import { extractTitle } from '@renderer/utils/formats'
@@ -49,6 +49,9 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
const [isRunning, setIsRunning] = useState(false) const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('') const [output, setOutput] = useState('')
const [tools, setTools] = useState<CodeTool[]>([])
const { registerTool, removeTool } = useCodeTool(setTools)
const isExecutable = useMemo(() => { const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python' return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language]) }, [codeExecution.enabled, language])
@@ -59,33 +62,17 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return hasSpecialView && viewMode === 'special' return hasSpecialView && viewMode === 'special'
}, [hasSpecialView, viewMode]) }, [hasSpecialView, viewMode])
const { updateContext, registerTool, removeTool } = useCodeToolbar() const handleCopySource = useCallback(() => {
navigator.clipboard.writeText(children)
useEffect(() => {
updateContext({
code: children,
language
})
}, [children, language, updateContext])
const handleCopySource = useCallback(
(ctx?: CodeToolContext) => {
if (!ctx) return
navigator.clipboard.writeText(ctx.code)
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' }) window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
}, }, [children, t])
[t]
)
const handleDownloadSource = useCallback((ctx?: CodeToolContext) => { const handleDownloadSource = useCallback(() => {
if (!ctx) return
const { code, language } = ctx
let fileName = '' let fileName = ''
// 尝试提取标题 // 尝试提取标题
if (language === 'html' && code.includes('</html>')) { if (language === 'html' && children.includes('</html>')) {
const title = extractTitle(code) const title = extractTitle(children)
if (title) { if (title) {
fileName = `${title}.html` fileName = `${title}.html`
} }
@@ -96,18 +83,15 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
} }
window.api.file.save(fileName, code) window.api.file.save(fileName, children)
}, []) }, [children, language])
const handleRunScript = useCallback(
(ctx?: CodeToolContext) => {
if (!ctx) return
const handleRunScript = useCallback(() => {
setIsRunning(true) setIsRunning(true)
setOutput('') setOutput('')
pyodideService pyodideService
.runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000) .runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((formattedOutput) => { .then((formattedOutput) => {
setOutput(formattedOutput) setOutput(formattedOutput)
}) })
@@ -118,9 +102,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
.finally(() => { .finally(() => {
setIsRunning(false) setIsRunning(false)
}) })
}, }, [children, codeExecution.timeoutMinutes])
[codeExecution.timeoutMinutes]
)
useEffect(() => { useEffect(() => {
// 复制按钮 // 复制按钮
@@ -191,7 +173,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
...TOOL_SPECS.run, ...TOOL_SPECS.run,
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />, icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
tooltip: t('code_block.run'), tooltip: t('code_block.run'),
onClick: (ctx) => !isRunning && handleRunScript(ctx) onClick: () => !isRunning && handleRunScript()
}) })
return () => isExecutable && removeTool(TOOL_SPECS.run.id) return () => isExecutable && removeTool(TOOL_SPECS.run.id)
@@ -200,20 +182,32 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 源代码视图组件 // 源代码视图组件
const sourceView = useMemo(() => { const sourceView = useMemo(() => {
if (codeEditor.enabled) { if (codeEditor.enabled) {
return <CodeEditor value={children} language={language} onSave={onSave} options={{ stream: true }} /> return (
<CodeEditor
value={children}
language={language}
onSave={onSave}
options={{ stream: true }}
setTools={setTools}
/>
)
} else { } else {
return <CodePreview language={language}>{children}</CodePreview> return (
<CodePreview language={language} setTools={setTools}>
{children}
</CodePreview>
)
} }
}, [children, codeEditor.enabled, language, onSave]) }, [children, codeEditor.enabled, language, onSave, setTools])
// 特殊视图组件映射 // 特殊视图组件映射
const specialView = useMemo(() => { const specialView = useMemo(() => {
if (language === 'mermaid') { if (language === 'mermaid') {
return <MermaidPreview>{children}</MermaidPreview> return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) { } else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview>{children}</PlantUmlPreview> return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
} else if (language === 'svg') { } else if (language === 'svg') {
return <SvgPreview>{children}</SvgPreview> return <SvgPreview setTools={setTools}>{children}</SvgPreview>
} }
return null return null
}, [children, language]) }, [children, language])
@@ -246,7 +240,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return ( return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}> <CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader} {renderHeader}
<CodeToolbar /> <CodeToolbar tools={tools} />
{renderContent} {renderContent}
{renderArtifacts} {renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>} {isExecutable && output && <StatusBar>{output}</StatusBar>}
@@ -255,10 +249,10 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
} }
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>` const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
/* FIXME: 在 bubble style 中撑开一些宽度*/
position: relative; position: relative;
.code-toolbar { .code-toolbar {
margin-top: ${(props) => (props.$isInSpecialView ? '20px' : '0')};
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')}; background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')}; border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')};
opacity: 0; opacity: 0;
@@ -279,13 +273,13 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>` const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
color: var(--color-text); color: var(--color-text);
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
padding: 0 10px; padding: 0 10px;
border-top-left-radius: 8px; border-top-left-radius: 8px;
border-top-right-radius: 8px; border-top-right-radius: 8px;
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')}; height: ${(props) => (props.$isInSpecialView ? '16px' : '34px')};
` `

View File

@@ -1,4 +1,4 @@
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar' import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror' import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
@@ -25,6 +25,7 @@ interface Props {
language: string language: string
onSave?: (newContent: string) => void onSave?: (newContent: string) => void
onChange?: (newContent: string) => void onChange?: (newContent: string) => void
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
minHeight?: string minHeight?: string
maxHeight?: string maxHeight?: string
/** 用于覆写编辑器的某些设置 */ /** 用于覆写编辑器的某些设置 */
@@ -52,6 +53,7 @@ const CodeEditor = ({
language, language,
onSave, onSave,
onChange, onChange,
setTools,
minHeight, minHeight,
maxHeight, maxHeight,
options, options,
@@ -88,7 +90,7 @@ const CodeEditor = ({
const langExtensions = useLanguageExtensions(language, options?.lint) const langExtensions = useLanguageExtensions(language, options?.lint)
const { registerTool, removeTool } = useCodeToolbar() const { registerTool, removeTool } = useCodeTool(setTools)
// 展开/折叠工具 // 展开/折叠工具
useEffect(() => { useEffect(() => {

View File

@@ -1,71 +0,0 @@
import React, { createContext, use, useCallback, useMemo, useState } from 'react'
import { CodeTool, CodeToolContext } from './types'
// 定义上下文默认值
const defaultContext: CodeToolContext = {
code: '',
language: ''
}
export interface CodeToolbarContextType {
tools: CodeTool[]
context: CodeToolContext
registerTool: (tool: CodeTool) => void
removeTool: (id: string) => void
updateContext: (newContext: Partial<CodeToolContext>) => void
}
const defaultCodeToolbarContext: CodeToolbarContextType = {
tools: [],
context: defaultContext,
registerTool: () => {},
removeTool: () => {},
updateContext: () => {}
}
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [tools, setTools] = useState<CodeTool[]>([])
const [context, setContext] = useState<CodeToolContext>(defaultContext)
// 注册工具如果已存在同ID工具则替换
const registerTool = useCallback((tool: CodeTool) => {
setTools((prev) => {
const filtered = prev.filter((t) => t.id !== tool.id)
return [...filtered, tool].sort((a, b) => b.order - a.order)
})
}, [])
// 移除工具
const removeTool = useCallback((id: string) => {
setTools((prev) => prev.filter((tool) => tool.id !== id))
}, [])
// 更新上下文
const updateContext = useCallback((newContext: Partial<CodeToolContext>) => {
setContext((prev) => ({ ...prev, ...newContext }))
}, [])
const value: CodeToolbarContextType = useMemo(
() => ({
tools,
context,
registerTool,
removeTool,
updateContext
}),
[tools, context, registerTool, removeTool, updateContext]
)
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
}
export const useCodeToolbar = () => {
const context = use(CodeToolbarContext)
if (!context) {
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
}
return context
}

View File

@@ -0,0 +1,26 @@
import { useCallback } from 'react'
import { CodeTool } from './types'
export const useCodeTool = (setTools?: (value: React.SetStateAction<CodeTool[]>) => void) => {
// 注册工具如果已存在同ID工具则替换
const registerTool = useCallback(
(tool: CodeTool) => {
setTools?.((prev) => {
const filtered = prev.filter((t) => t.id !== tool.id)
return [...filtered, tool].sort((a, b) => b.order - a.order)
})
},
[setTools]
)
// 移除工具
const removeTool = useCallback(
(id: string) => {
setTools?.((prev) => prev.filter((tool) => tool.id !== id))
},
[setTools]
)
return { registerTool, removeTool }
}

View File

@@ -1,5 +1,5 @@
export * from './constants' export * from './constants'
export * from './context' export * from './hook'
export * from './toolbar' export * from './toolbar'
export * from './types' export * from './types'
export * from './usePreviewTools' export * from './usePreviewTools'

View File

@@ -5,7 +5,6 @@ import React, { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { useCodeToolbar } from './context'
import { CodeTool } from './types' import { CodeTool } from './types'
interface CodeToolButtonProps { interface CodeToolButtonProps {
@@ -13,22 +12,19 @@ interface CodeToolButtonProps {
} }
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => { const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
const { context } = useCodeToolbar()
return ( return (
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}> <Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper> <ToolWrapper onClick={() => tool.onClick()}>{tool.icon}</ToolWrapper>
</Tooltip> </Tooltip>
) )
}) })
export const CodeToolbar: React.FC = memo(() => { export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => {
const { tools, context } = useCodeToolbar()
const [showQuickTools, setShowQuickTools] = useState(false) const [showQuickTools, setShowQuickTools] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
// 根据条件显示工具 // 根据条件显示工具
const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context)) const visibleTools = tools.filter((tool) => !tool.visible || tool.visible())
// 按类型分组 // 按类型分组
const coreTools = visibleTools.filter((tool) => tool.type === 'core') const coreTools = visibleTools.filter((tool) => tool.type === 'core')

View File

@@ -20,16 +20,6 @@ export interface CodeToolSpec {
export interface CodeTool extends CodeToolSpec { export interface CodeTool extends CodeToolSpec {
icon: React.ReactNode icon: React.ReactNode
tooltip: string tooltip: string
visible?: (ctx?: CodeToolContext) => boolean visible?: () => boolean
onClick: (ctx?: CodeToolContext) => void onClick: () => void
}
/**
* 工具上下文接口
* @param code 代码内容
* @param language 语言类型
*/
export interface CodeToolContext {
code: string
language: string
} }

View File

@@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next'
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons' import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
import { TOOL_SPECS } from './constants' import { TOOL_SPECS } from './constants'
import { useCodeToolbar } from './context' import { useCodeTool } from './hook'
import { CodeTool } from './types'
// 预编译正则表达式用于查询位置 // 预编译正则表达式用于查询位置
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/ const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
@@ -272,6 +273,7 @@ export const usePreviewToolHandlers = (
} }
export interface PreviewToolsOptions { export interface PreviewToolsOptions {
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
handleZoom?: (delta: number) => void handleZoom?: (delta: number) => void
handleCopyImage?: () => Promise<void> handleCopyImage?: () => Promise<void>
handleDownload?: (format: 'svg' | 'png') => void handleDownload?: (format: 'svg' | 'png') => void
@@ -280,9 +282,9 @@ export interface PreviewToolsOptions {
/** /**
* 提供预览组件通用工具栏功能的自定义Hook * 提供预览组件通用工具栏功能的自定义Hook
*/ */
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => { export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar() const { registerTool, removeTool } = useCodeTool(setTools)
useEffect(() => { useEffect(() => {
// 根据提供的功能有选择性地注册工具 // 根据提供的功能有选择性地注册工具

View File

@@ -74,7 +74,7 @@ const ContextMenu: React.FC<ContextMenuProps> = ({ children, onContextMenu }) =>
] ]
return ( return (
<ContextContainer onContextMenu={handleContextMenu}> <ContextContainer onContextMenu={handleContextMenu} className="context-menu-container">
{contextMenuPosition && ( {contextMenuPosition && (
<Dropdown <Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }} overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}

View File

@@ -1,6 +1,6 @@
import { Collapse } from 'antd' import { Collapse } from 'antd'
import { merge } from 'lodash' import { merge } from 'lodash'
import { FC, memo } from 'react' import { FC, memo, useMemo, useState } from 'react'
interface CustomCollapseProps { interface CustomCollapseProps {
label: React.ReactNode label: React.ReactNode
@@ -28,28 +28,45 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
style, style,
styles styles
}) => { }) => {
const [activeKeys, setActiveKeys] = useState(activeKey || defaultActiveKey)
const defaultCollapseStyle = { const defaultCollapseStyle = {
width: '100%', width: '100%',
background: 'transparent', background: 'transparent',
border: '0.5px solid var(--color-border)' border: '0.5px solid var(--color-border)'
} }
const defaultCollapseItemStyles = { const defaultCollpaseHeaderStyle = {
header: {
padding: '3px 16px', padding: '3px 16px',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
background: 'var(--color-background-soft)', background: 'var(--color-background-soft)'
}
const getHeaderStyle = () => {
return activeKeys && activeKeys.length > 0
? {
...defaultCollpaseHeaderStyle,
borderTopLeftRadius: '8px', borderTopLeftRadius: '8px',
borderTopRightRadius: '8px' borderTopRightRadius: '8px'
}, }
: {
...defaultCollpaseHeaderStyle,
borderRadius: '8px'
}
}
const defaultCollapseItemStyles = {
header: getHeaderStyle(),
body: { body: {
borderTop: 'none' borderTop: 'none'
} }
} }
const collapseStyle = merge({}, defaultCollapseStyle, style) const collapseStyle = merge({}, defaultCollapseStyle, style)
const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles) const collapseItemStyles = useMemo(() => {
return merge({}, defaultCollapseItemStyles, styles)
}, [activeKeys])
return ( return (
<Collapse <Collapse
@@ -59,6 +76,7 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
activeKey={activeKey} activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel} destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible} collapsible={collapsible}
onChange={setActiveKeys}
items={[ items={[
{ {
styles: collapseItemStyles, styles: collapseItemStyles,

View File

@@ -86,7 +86,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
return true return true
} }
const pattern = lowerSearchText.split('').join('.*') const pattern = lowerSearchText
.split('')
.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
})
.join('.*')
if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) {
try { try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()

View File

@@ -773,200 +773,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Claude 3' group: 'Claude 3'
} }
], ],
'gitee-ai': [ 'gitee-ai': [],
{
id: 'Qwen3-30B-A3B',
name: 'Qwen3-30B-A3B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-32B',
name: 'Qwen3-32B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-8B',
name: 'Qwen3-8B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-4B',
name: 'Qwen3-4B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen3-0.6B',
name: 'Qwen3-0.6B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-72B-Instruct',
name: 'Qwen2.5-72B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-14B-Instruct',
name: 'Qwen2.5-14B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2-7B-Instruct',
name: 'Qwen2-7B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-32B-Instruct',
name: 'Qwen2.5-32B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2-72B-Instruct',
name: 'Qwen2-72B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2-VL-72B',
name: 'Qwen2-VL-72B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Qwen2.5-VL-32B-Instruct',
name: 'Qwen2.5-VL-32B-Instruct',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'QwQ-32B',
name: 'QwQ-32B',
provider: 'gitee-ai',
group: 'Qwen'
},
{
id: 'Align-DS-V',
name: 'Align-DS-V',
provider: 'gitee-ai',
group: 'Align'
},
{
id: 'Yi-34B-Chat',
name: 'Yi-34B-Chat',
provider: 'gitee-ai',
group: '01-ai'
},
{
id: 'glm-4-9b-chat',
name: 'glm-4-9b-chat',
provider: 'gitee-ai',
group: 'THUDM'
},
{
id: 'deepseek-coder-33B-instruct',
name: 'deepseek-coder-33B-instruct',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'codegeex4-all-9b',
name: 'codegeex4-all-9b',
provider: 'gitee-ai',
group: 'THUDM'
},
{
id: 'InternVL2-8B',
name: 'InternVL2-8B',
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'InternVL2.5-26B',
name: 'InternVL2.5-26B',
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'InternVL2.5-78B',
name: 'InternVL2.5-78B',
provider: 'gitee-ai',
group: 'OpenGVLab'
},
{
id: 'DeepSeek-R1-Distill-Qwen-32B',
name: 'DeepSeek-R1-Distill-Qwen-32B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-1.5B',
name: 'DeepSeek-R1-Distill-Qwen-1.5B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-14B',
name: 'DeepSeek-R1-Distill-Qwen-14B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1-Distill-Qwen-7B',
name: 'DeepSeek-R1-Distill-Qwen-7B',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-V3',
name: 'DeepSeek-V3',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'DeepSeek-R1',
name: 'DeepSeek-R1',
provider: 'gitee-ai',
group: 'DeepSeek'
},
{
id: 'gemma-3-27b-it',
name: 'gemma-3-27b-it',
provider: 'gitee-ai',
group: 'Gemma'
},
{
id: 'bge-large-zh-v1.5',
name: 'bge-large-zh-v1.5',
provider: 'gitee-ai',
group: 'BAAI'
},
{
id: 'bge-small-zh-v1.5',
name: 'bge-small-zh-v1.5',
provider: 'gitee-ai',
group: 'BAAI'
},
{
id: 'bge-m3',
name: 'bge-m3',
provider: 'gitee-ai',
group: 'BAAI'
},
{
id: 'bce-embedding-base_v1',
name: 'bce-embedding-base_v1',
provider: 'gitee-ai',
group: 'netease-youdao'
}
],
deepseek: [ deepseek: [
{ {
id: 'deepseek-chat', id: 'deepseek-chat',

View File

@@ -103,6 +103,7 @@ export function getProviderLogo(providerId: string) {
// export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai', 'dashscope', 'aihubmix'] // export const SUPPORTED_REANK_PROVIDERS = ['silicon', 'jina', 'voyageai', 'dashscope', 'aihubmix']
export const NOT_SUPPORTED_REANK_PROVIDERS = ['ollama'] export const NOT_SUPPORTED_REANK_PROVIDERS = ['ollama']
export const ONLY_SUPPORTED_DIMENSION_PROVIDERS = ['ollama', 'infini']
export const PROVIDER_CONFIG = { export const PROVIDER_CONFIG = {
openai: { openai: {

View File

@@ -23,6 +23,7 @@ import type { Assistant, Model, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage' import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { abortCompletion } from '@renderer/utils/abortController' import { abortCompletion } from '@renderer/utils/abortController'
import { throttle } from 'lodash'
import { useCallback } from 'react' import { useCallback } from 'react'
const selectMessagesState = (state: RootState) => state.messages const selectMessagesState = (state: RootState) => state.messages
@@ -243,9 +244,13 @@ export function useMessageOperations(topic: Topic) {
return null return null
} }
return (accumulatedText: string, isComplete: boolean = false) => { return throttle(
(accumulatedText: string, isComplete: boolean = false) => {
dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete)) dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete))
} },
200,
{ leading: true, trailing: true }
)
}, },
[dispatch, topic.id] [dispatch, topic.id]
) )

View File

@@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "The current model does not support web search", "input.web_search.builtin.disabled_content": "The current model does not support web search",
"input.web_search.no_web_search": "Disable Web Search", "input.web_search.no_web_search": "Disable Web Search",
"input.web_search.no_web_search.description": "Do not enable web search", "input.web_search.no_web_search.description": "Do not enable web search",
"input.tools.collapse": "Collapse",
"input.tools.expand": "Expand",
"input.tools.collapse_in": "Collapse",
"input.tools.collapse_out": "Remove from collapse",
"input.thinking": "Thinking", "input.thinking": "Thinking",
"input.thinking.mode.default": "Default", "input.thinking.mode.default": "Default",
"input.thinking.mode.default.tip": "The model will automatically determine the number of tokens to think", "input.thinking.mode.default.tip": "The model will automatically determine the number of tokens to think",

View File

@@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "現在のモデルはウェブ検索をサポートしていません", "input.web_search.builtin.disabled_content": "現在のモデルはウェブ検索をサポートしていません",
"input.web_search.no_web_search": "ウェブ検索を無効にする", "input.web_search.no_web_search": "ウェブ検索を無効にする",
"input.web_search.no_web_search.description": "ウェブ検索を無効にする", "input.web_search.no_web_search.description": "ウェブ検索を無効にする",
"input.tools.collapse": "折りたたむ",
"input.tools.expand": "展開",
"input.tools.collapse_in": "折りたたむ",
"input.tools.collapse_out": "展開",
"input.thinking": "思考", "input.thinking": "思考",
"input.thinking.mode.default": "デフォルト", "input.thinking.mode.default": "デフォルト",
"input.thinking.mode.custom": "カスタム", "input.thinking.mode.custom": "カスタム",

View File

@@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "Текущая модель не поддерживает веб-поиск", "input.web_search.builtin.disabled_content": "Текущая модель не поддерживает веб-поиск",
"input.web_search.no_web_search": "Отключить веб-поиск", "input.web_search.no_web_search": "Отключить веб-поиск",
"input.web_search.no_web_search.description": "Отключить веб-поиск", "input.web_search.no_web_search.description": "Отключить веб-поиск",
"input.tools.collapse": "Свернуть",
"input.tools.expand": "Развернуть",
"input.tools.collapse_in": "Свернуть",
"input.tools.collapse_out": "Развернуть",
"input.thinking": "Мыслим", "input.thinking": "Мыслим",
"input.thinking.mode.default": "По умолчанию", "input.thinking.mode.default": "По умолчанию",
"input.thinking.mode.default.tip": "Модель автоматически определяет количество токенов для размышления", "input.thinking.mode.default.tip": "Модель автоматически определяет количество токенов для размышления",

View File

@@ -199,6 +199,10 @@
"input.web_search.builtin.disabled_content": "当前模型不支持网络搜索功能", "input.web_search.builtin.disabled_content": "当前模型不支持网络搜索功能",
"input.web_search.no_web_search": "不使用网络", "input.web_search.no_web_search": "不使用网络",
"input.web_search.no_web_search.description": "不启用网络搜索功能", "input.web_search.no_web_search.description": "不启用网络搜索功能",
"input.tools.collapse": "折叠",
"input.tools.expand": "展开",
"input.tools.collapse_in": "加入折叠",
"input.tools.collapse_out": "移出折叠",
"message.new.branch": "分支", "message.new.branch": "分支",
"message.new.branch.created": "新分支已创建", "message.new.branch.created": "新分支已创建",
"message.new.context": "清除上下文", "message.new.context": "清除上下文",

View File

@@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "當前模型不支持網路搜尋功能", "input.web_search.builtin.disabled_content": "當前模型不支持網路搜尋功能",
"input.web_search.no_web_search": "關閉網路搜尋", "input.web_search.no_web_search": "關閉網路搜尋",
"input.web_search.no_web_search.description": "關閉網路搜尋", "input.web_search.no_web_search.description": "關閉網路搜尋",
"input.tools.collapse": "折疊",
"input.tools.expand": "展開",
"input.tools.collapse_in": "加入折疊",
"input.tools.collapse_out": "移出折疊",
"input.thinking": "思考", "input.thinking": "思考",
"input.thinking.mode.default": "預設", "input.thinking.mode.default": "預設",
"input.thinking.mode.default.tip": "模型會自動確定思考的 token 數", "input.thinking.mode.default.tip": "模型會自動確定思考的 token 數",

View File

@@ -15,10 +15,6 @@ interface Props {
const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => { const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEnableGenerateImage }) => {
const { t } = useTranslation() const { t } = useTranslation()
if (!isGenerateImageModel(model)) {
return null
}
return ( return (
<Tooltip <Tooltip
placement="top" placement="top"

View File

@@ -1,5 +1,5 @@
import { HolderOutlined } from '@ant-design/icons' import { HolderOutlined } from '@ant-design/icons'
import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import { QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel'
import TranslateButton from '@renderer/components/TranslateButton' import TranslateButton from '@renderer/components/TranslateButton'
import Logger from '@renderer/config/logger' import Logger from '@renderer/config/logger'
import { import {
@@ -39,42 +39,18 @@ import { Button, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash' import { debounce, isEmpty } from 'lodash'
import { import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react'
AtSign,
CirclePause,
FileSearch,
FileText,
Globe,
Languages,
LucideSquareTerminal,
Maximize,
MessageSquareDiff,
Minimize,
PaintbrushVertical,
Paperclip,
Upload,
Zap
} from 'lucide-react'
// import { CompletionUsage } from 'openai/resources'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import NarrowLayout from '../Messages/NarrowLayout' import NarrowLayout from '../Messages/NarrowLayout'
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview' import AttachmentPreview from './AttachmentPreview'
import GenerateImageButton from './GenerateImageButton' import InputbarTools, { InputbarToolsRef } from './InputbarTools'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import KnowledgeBaseInput from './KnowledgeBaseInput' import KnowledgeBaseInput from './KnowledgeBaseInput'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput' import MentionModelsInput from './MentionModelsInput'
import NewContextButton from './NewContextButton'
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
import SendMessageButton from './SendMessageButton' import SendMessageButton from './SendMessageButton'
import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
import TokenCount from './TokenCount' import TokenCount from './TokenCount'
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
interface Props { interface Props {
assistant: Assistant assistant: Assistant
@@ -135,13 +111,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [tokenCount, setTokenCount] = useState(0) const [tokenCount, setTokenCount] = useState(0)
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null) const inputbarToolsRef = useRef<InputbarToolsRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedEstimate = useCallback( const debouncedEstimate = useCallback(
@@ -314,7 +284,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
description: '', description: '',
icon: <Upload />, icon: <Upload />,
action: () => { action: () => {
attachmentButtonRef.current?.openQuickPanel() inputbarToolsRef.current?.openQuickPanel()
} }
}, },
...knowledgeBases.map((base) => { ...knowledgeBases.map((base) => {
@@ -333,92 +303,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
], ],
symbol: 'file' symbol: 'file'
}) })
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t]) }, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
const quickPanelMenu = useMemo<QuickPanelListItem[]>(() => {
return [
{
label: t('settings.quickPhrase.title'),
description: '',
icon: <Zap />,
isMenu: true,
action: () => {
quickPhrasesButtonRef.current?.openQuickPanel()
}
},
{
label: t('agents.edit.model.select.title'),
description: '',
icon: <AtSign />,
isMenu: true,
action: () => {
mentionModelsButtonRef.current?.openQuickPanel()
}
},
{
label: t('chat.input.knowledge_base'),
description: '',
icon: <FileSearch />,
isMenu: true,
disabled: files.length > 0,
action: () => {
knowledgeBaseButtonRef.current?.openQuickPanel()
}
},
{
label: t('settings.mcp.title'),
description: t('settings.mcp.not_support'),
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
},
{
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openPromptList()
}
},
{
label: `MCP ${t('settings.mcp.tabs.resources')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openResourcesList()
}
},
{
label: t('chat.input.web_search'),
description: '',
icon: <Globe />,
isMenu: true,
action: () => {
webSearchButtonRef.current?.openQuickPanel()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
action: openSelectFileMenu
},
{
label: t('translate.title'),
description: t('translate.menu.description'),
icon: <Languages />,
action: () => {
if (!text) return
translate()
}
}
]
}, [files.length, model, openSelectFileMenu, t, text, translate])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13 const isEnterPressed = event.keyCode == 13
@@ -566,6 +451,16 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const lastSymbol = newText[cursorPosition - 1] const lastSymbol = newText[cursorPosition - 1]
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
model,
text: newText,
openSelectFileMenu,
translate
}) || []
quickPanel.open({ quickPanel.open({
title: t('settings.quickPanel.title'), title: t('settings.quickPanel.title'),
list: quickPanelMenu, list: quickPanelMenu,
@@ -574,7 +469,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
mentionModelsButtonRef.current?.openQuickPanel() inputbarToolsRef.current?.openMentionModelsPanel()
} }
} }
@@ -936,75 +831,30 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
<HolderOutlined /> <HolderOutlined />
</DragHandle> </DragHandle>
<Toolbar> <Toolbar>
<ToolbarMenu> <InputbarTools
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow> ref={inputbarToolsRef}
<ToolbarButton type="text" onClick={addNewTopic}> assistant={assistant}
<MessageSquareDiff size={19} />
</ToolbarButton>
</Tooltip>
<AttachmentButton
ref={attachmentButtonRef}
model={model} model={model}
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
ToolbarButton={ToolbarButton} showThinkingButton={showThinkingButton}
/> showKnowledgeIcon={showKnowledgeIcon}
{showThinkingButton && ( selectedKnowledgeBases={selectedKnowledgeBases}
<ThinkingButton handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
ref={thinkingButtonRef} setText={setText}
model={model}
assistant={assistant}
ToolbarButton={ToolbarButton}
/>
)}
<WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
{showKnowledgeIcon && (
<KnowledgeBaseButton
ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
)}
<MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea} resizeTextArea={resizeTextArea}
/>
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels} mentionModels={mentionModels}
onMentionModel={onMentionModel} onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton} onEnableGenerateImage={onEnableGenerateImage}
isExpended={isExpended}
onToggleExpended={onToggleExpended}
addNewTopic={addNewTopic}
clearTopic={clearTopic}
onNewContext={onNewContext}
newTopicShortcut={newTopicShortcut}
cleanTopicShortcut={cleanTopicShortcut}
/> />
<QuickPhrasesButton <ToolbarMenu>
ref={quickPhrasesButtonRef}
setInputValue={setText}
resizeTextArea={resizeTextArea}
ToolbarButton={ToolbarButton}
assistantObj={assistant}
/>
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={clearTopic}>
<PaintbrushVertical size={18} />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton>
</Tooltip>
<NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
<TokenCount <TokenCount
estimateTokenCount={estimateTokenCount} estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount} inputTokenCount={inputTokenCount}
@@ -1012,8 +862,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
ToolbarButton={ToolbarButton} ToolbarButton={ToolbarButton}
onClick={onNewContext} onClick={onNewContext}
/> />
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} /> <TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
{loading && ( {loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow> <Tooltip placement="top" title={t('chat.input.pause')} arrow>
@@ -1118,7 +966,8 @@ const Toolbar = styled.div`
padding: 0 8px; padding: 0 8px;
padding-bottom: 0; padding-bottom: 0;
margin-bottom: 4px; margin-bottom: 4px;
height: 36px; height: 30px;
gap: 16px;
` `
const ToolbarMenu = styled.div` const ToolbarMenu = styled.div`

View File

@@ -0,0 +1,639 @@
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'
import { QuickPanelListItem } from '@renderer/components/QuickPanel'
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsCollapsed, setToolOrder } from '@renderer/store/inputTools'
import { Assistant, FileType, KnowledgeBase, Model } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Divider, Dropdown, Tooltip } from 'antd'
import { ItemType } from 'antd/es/menu/interface'
import {
AtSign,
Check,
CircleChevronRight,
FileSearch,
Globe,
Languages,
LucideSquareTerminal,
Maximize,
MessageSquareDiff,
Minimize,
PaintbrushVertical,
Paperclip,
Zap
} from 'lucide-react'
import { Dispatch, ReactNode, SetStateAction, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
import GenerateImageButton from './GenerateImageButton'
import { ToolbarButton } from './Inputbar'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
import NewContextButton from './NewContextButton'
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
export interface InputbarToolsRef {
getQuickPanelMenu: (params: {
t: (key: string, options?: any) => string
files: FileType[]
model: Model
text: string
openSelectFileMenu: () => void
translate: () => void
}) => QuickPanelListItem[]
openMentionModelsPanel: () => void
openQuickPanel: () => void
}
export interface InputbarToolsProps {
assistant: Assistant
model: Model
files: FileType[]
setFiles: (files: FileType[]) => void
showThinkingButton: boolean
showKnowledgeIcon: boolean
selectedKnowledgeBases: KnowledgeBase[]
handleKnowledgeBaseSelect: (bases?: KnowledgeBase[]) => void
setText: Dispatch<SetStateAction<string>>
resizeTextArea: () => void
mentionModels: Model[]
onMentionModel: (model: Model) => void
onEnableGenerateImage: () => void
isExpended: boolean
onToggleExpended: () => void
addNewTopic: () => void
clearTopic: () => void
onNewContext: () => void
newTopicShortcut: string
cleanTopicShortcut: string
}
interface ToolButtonConfig {
key: string
component: ReactNode
condition?: boolean
visible?: boolean
label?: string
icon?: ReactNode
}
const DraggablePortal = ({ children, isDragging }) => {
return isDragging ? createPortal(children, document.body) : children
}
const InputbarTools = ({
ref,
assistant,
model,
files,
setFiles,
showThinkingButton,
showKnowledgeIcon,
selectedKnowledgeBases,
handleKnowledgeBaseSelect,
setText,
resizeTextArea,
mentionModels,
onMentionModel,
onEnableGenerateImage,
isExpended,
onToggleExpended,
addNewTopic,
clearTopic,
onNewContext,
newTopicShortcut,
cleanTopicShortcut
}: InputbarToolsProps & { ref?: React.RefObject<InputbarToolsRef | null> }) => {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const quickPhrasesButtonRef = useRef<QuickPhrasesButtonRef>(null)
const mentionModelsButtonRef = useRef<MentionModelsButtonRef>(null)
const knowledgeBaseButtonRef = useRef<KnowledgeBaseButtonRef>(null)
const mcpToolsButtonRef = useRef<MCPToolsButtonRef>(null)
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
const [targetTool, setTargetTool] = useState<ToolButtonConfig | null>(null)
const toggleToolVisibility = useCallback(
(toolKey: string, isVisible: boolean | undefined) => {
const newToolOrder = {
visible: [...toolOrder.visible],
hidden: [...toolOrder.hidden]
}
if (isVisible === true) {
newToolOrder.visible = newToolOrder.visible.filter((key) => key !== toolKey)
newToolOrder.hidden.push(toolKey)
} else {
newToolOrder.hidden = newToolOrder.hidden.filter((key) => key !== toolKey)
newToolOrder.visible.push(toolKey)
}
dispatch(setToolOrder(newToolOrder))
setTargetTool(null)
},
[dispatch, toolOrder.hidden, toolOrder.visible]
)
const getQuickPanelMenuImpl = (params: {
t: (key: string, options?: any) => string
files: FileType[]
model: Model
text: string
openSelectFileMenu: () => void
translate: () => void
}): QuickPanelListItem[] => {
const { t, files, model, text, openSelectFileMenu, translate } = params
return [
{
label: t('settings.quickPhrase.title'),
description: '',
icon: <Zap />,
isMenu: true,
action: () => {
quickPhrasesButtonRef.current?.openQuickPanel()
}
},
{
label: t('agents.edit.model.select.title'),
description: '',
icon: <AtSign />,
isMenu: true,
action: () => {
mentionModelsButtonRef.current?.openQuickPanel()
}
},
{
label: t('chat.input.knowledge_base'),
description: '',
icon: <FileSearch />,
isMenu: true,
disabled: files.length > 0,
action: () => {
knowledgeBaseButtonRef.current?.openQuickPanel()
}
},
{
label: t('settings.mcp.title'),
description: t('settings.mcp.not_support'),
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openQuickPanel()
}
},
{
label: `MCP ${t('settings.mcp.tabs.prompts')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openPromptList()
}
},
{
label: `MCP ${t('settings.mcp.tabs.resources')}`,
description: '',
icon: <LucideSquareTerminal />,
isMenu: true,
action: () => {
mcpToolsButtonRef.current?.openResourcesList()
}
},
{
label: t('chat.input.web_search'),
description: '',
icon: <Globe />,
isMenu: true,
action: () => {
webSearchButtonRef.current?.openQuickPanel()
}
},
{
label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'),
description: '',
icon: <Paperclip />,
isMenu: true,
action: openSelectFileMenu
},
{
label: t('translate.title'),
description: t('translate.menu.description'),
icon: <Languages />,
action: () => {
if (!text) return
translate()
}
}
]
}
const handleDragEnd = (result: DropResult) => {
const { source, destination } = result
if (!destination) return
const sourceId = source.droppableId
const destinationId = destination.droppableId
const newToolOrder = {
visible: [...toolOrder.visible],
hidden: [...toolOrder.hidden]
}
const sourceArray = sourceId === 'inputbar-tools-visible' ? 'visible' : 'hidden'
const destArray = destinationId === 'inputbar-tools-visible' ? 'visible' : 'hidden'
if (sourceArray === destArray) {
const items = newToolOrder[sourceArray]
const [removed] = items.splice(source.index, 1)
items.splice(destination.index, 0, removed)
} else {
const removed = newToolOrder[sourceArray][source.index]
newToolOrder[sourceArray].splice(source.index, 1)
newToolOrder[destArray].splice(destination.index, 0, removed)
}
dispatch(setToolOrder(newToolOrder))
}
useImperativeHandle(ref, () => ({
getQuickPanelMenu: getQuickPanelMenuImpl,
openMentionModelsPanel: () => mentionModelsButtonRef.current?.openQuickPanel(),
openQuickPanel: () => attachmentButtonRef.current?.openQuickPanel()
}))
const toolButtons = useMemo<ToolButtonConfig[]>(() => {
return [
{
key: 'new_topic',
label: t('chat.input.new_topic', { Command: '' }),
component: (
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<MessageSquareDiff size={19} />
</ToolbarButton>
</Tooltip>
)
},
{
key: 'attachment',
label: t('chat.input.upload'),
component: (
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
)
},
{
key: 'thinking',
label: t('chat.input.thinking'),
component: (
<ThinkingButton ref={thinkingButtonRef} model={model} assistant={assistant} ToolbarButton={ToolbarButton} />
),
condition: showThinkingButton
},
{
key: 'web_search',
label: t('chat.input.web_search'),
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
},
{
key: 'knowledge_base',
label: t('chat.input.knowledge_base'),
component: (
<KnowledgeBaseButton
ref={knowledgeBaseButtonRef}
selectedBases={selectedKnowledgeBases}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
),
condition: showKnowledgeIcon
},
{
key: 'mcp_tools',
label: t('settings.mcp.title'),
component: (
<MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}
/>
)
},
{
key: 'generate_image',
label: t('chat.input.generate_image'),
component: (
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
),
condition: isGenerateImageModel(model)
},
{
key: 'mention_models',
label: t('agents.edit.model.select.title'),
component: (
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
)
},
{
key: 'quick_phrases',
label: t('settings.quickPhrase.title'),
component: (
<QuickPhrasesButton
ref={quickPhrasesButtonRef}
setInputValue={setText}
resizeTextArea={resizeTextArea}
ToolbarButton={ToolbarButton}
assistantObj={assistant}
/>
)
},
{
key: 'clear_topic',
label: t('chat.input.clear', { Command: '' }),
component: (
<Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={clearTopic}>
<PaintbrushVertical size={18} />
</ToolbarButton>
</Tooltip>
)
},
{
key: 'toggle_expand',
label: isExpended ? t('chat.input.collapse') : t('chat.input.expand'),
component: (
<Tooltip placement="top" title={isExpended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{isExpended ? <Minimize size={18} /> : <Maximize size={18} />}
</ToolbarButton>
</Tooltip>
)
},
{
key: 'new_context',
label: t('chat.input.new.context', { Command: '' }),
component: <NewContextButton onNewContext={onNewContext} ToolbarButton={ToolbarButton} />
}
]
}, [
addNewTopic,
assistant,
cleanTopicShortcut,
clearTopic,
files,
handleKnowledgeBaseSelect,
isExpended,
mentionModels,
model,
newTopicShortcut,
onEnableGenerateImage,
onMentionModel,
onNewContext,
onToggleExpended,
resizeTextArea,
selectedKnowledgeBases,
setFiles,
setText,
showKnowledgeIcon,
showThinkingButton,
t
])
const visibleTools = useMemo(() => {
return toolOrder.visible.map((v) => ({
...toolButtons.find((tool) => tool.key === v),
visible: true
})) as ToolButtonConfig[]
}, [toolButtons, toolOrder])
const hiddenTools = useMemo(() => {
return toolOrder.hidden.map((v) => ({
...toolButtons.find((tool) => tool.key === v),
visible: false
})) as ToolButtonConfig[]
}, [toolButtons, toolOrder])
const showDivider = useMemo(() => {
return (
hiddenTools.filter((tool) => tool.condition ?? true).length > 0 &&
visibleTools.filter((tool) => tool.condition ?? true).length !== 0
)
}, [hiddenTools, visibleTools])
const showCollapseButton = useMemo(() => {
return hiddenTools.filter((tool) => tool.condition ?? true).length > 0
}, [hiddenTools])
const getMenuItems = useMemo(() => {
const baseItems: ItemType[] = [...visibleTools, ...hiddenTools].map((tool) => ({
label: tool.label,
key: tool.key,
icon: (
<div style={{ width: 20, height: 20, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{tool.visible ? <Check size={16} /> : undefined}
</div>
),
onClick: () => {
toggleToolVisibility(tool.key, tool.visible)
}
}))
if (targetTool) {
baseItems.push({
type: 'divider'
})
baseItems.push({
label: `${targetTool.visible ? t('chat.input.tools.collapse_in') : t('chat.input.tools.collapse_out')} "${targetTool.label}"`,
key: 'selected_' + targetTool.key,
icon: <div style={{ width: 20, height: 20 }}></div>,
onClick: () => {
toggleToolVisibility(targetTool.key, targetTool.visible)
}
})
}
return baseItems
}, [hiddenTools, t, targetTool, toggleToolVisibility, visibleTools])
return (
<Dropdown menu={{ items: getMenuItems }} trigger={['contextMenu']}>
<ToolsContainer
onContextMenu={(e) => {
const target = e.target as HTMLElement
const isToolButton = target.closest('[data-key]')
if (!isToolButton) {
setTargetTool(null)
}
}}>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="inputbar-tools-visible" direction="horizontal">
{(provided) => (
<VisibleTools ref={provided.innerRef} {...provided.droppableProps}>
{visibleTools.map(
(tool, index) =>
(tool.condition ?? true) && (
<Draggable key={tool.key} draggableId={tool.key} index={index}>
{(provided, snapshot) => (
<DraggablePortal isDragging={snapshot.isDragging}>
<ToolWrapper
data-key={tool.key}
onContextMenu={() => setTargetTool(tool)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style
}}>
{tool.component}
</ToolWrapper>
</DraggablePortal>
)}
</Draggable>
)
)}
{provided.placeholder}
</VisibleTools>
)}
</Droppable>
{showDivider && <Divider type="vertical" style={{ margin: '0 4px' }} />}
<Droppable droppableId="inputbar-tools-hidden" direction="horizontal">
{(provided) => (
<HiddenTools ref={provided.innerRef} {...provided.droppableProps}>
{hiddenTools.map(
(tool, index) =>
(tool.condition ?? true) && (
<Draggable key={tool.key} draggableId={tool.key} index={index}>
{(provided, snapshot) => (
<DraggablePortal isDragging={snapshot.isDragging}>
<ToolWrapper
data-key={tool.key}
className={classNames({
'is-collapsed': isCollapse
})}
onContextMenu={() => setTargetTool(tool)}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
transitionDelay: `${index * 0.02}s`
}}>
{tool.component}
</ToolWrapper>
</DraggablePortal>
)}
</Draggable>
)
)}
{provided.placeholder}
</HiddenTools>
)}
</Droppable>
</DragDropContext>
{showCollapseButton && (
<Tooltip
placement="top"
title={isCollapse ? t('chat.input.tools.expand') : t('chat.input.tools.collapse')}
arrow>
<ToolbarButton type="text" onClick={() => dispatch(setIsCollapsed(!isCollapse))}>
<CircleChevronRight
size={18}
style={{
transform: isCollapse ? 'scaleX(1)' : 'scaleX(-1)'
}}
/>
</ToolbarButton>
</Tooltip>
)}
</ToolsContainer>
</Dropdown>
)
}
const ToolsContainer = styled.div`
min-width: 0;
display: flex;
align-items: center;
position: relative;
`
const VisibleTools = styled.div`
height: 30px;
display: flex;
align-items: center;
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
`
const HiddenTools = styled.div`
height: 30px;
display: flex;
align-items: center;
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
`
const ToolWrapper = styled.div`
width: 30px;
margin-right: 6px;
transition:
width 0.2s,
margin-right 0.2s,
opacity 0.2s;
&.is-collapsed {
width: 0px;
margin-right: 0px;
overflow: hidden;
opacity: 0;
}
`
export default InputbarTools

View File

@@ -3,7 +3,6 @@ import { Tooltip } from 'antd'
import { Eraser } from 'lucide-react' import { Eraser } from 'lucide-react'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props { interface Props {
onNewContext: () => void onNewContext: () => void
@@ -17,20 +16,12 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
useShortcut('toggle_new_context', onNewContext) useShortcut('toggle_new_context', onNewContext)
return ( return (
<Container>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow> <Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}> <ToolbarButton type="text" onClick={onNewContext}>
<Eraser size={18} /> <Eraser size={18} />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
</Container>
) )
} }
const Container = styled.div`
@media (max-width: 800px) {
display: none;
}
`
export default NewContextButton export default NewContextButton

View File

@@ -62,7 +62,6 @@ const Container = styled.div`
z-index: 10; z-index: 10;
padding: 3px 10px; padding: 3px 10px;
user-select: none; user-select: none;
border: 0.5px solid var(--color-text-3);
border-radius: 20px; border-radius: 20px;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,5 +1,4 @@
import CodeBlockView from '@renderer/components/CodeBlockView' import CodeBlockView from '@renderer/components/CodeBlockView'
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import React, { memo, useCallback } from 'react' import React, { memo, useCallback } from 'react'
interface Props { interface Props {
@@ -24,11 +23,9 @@ const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
) )
return match ? ( return match ? (
<CodeToolbarProvider>
<CodeBlockView language={language} onSave={handleSave}> <CodeBlockView language={language} onSave={handleSave}>
{children} {children}
</CodeBlockView> </CodeBlockView>
</CodeToolbarProvider>
) : ( ) : (
<code className={className} style={{ textWrap: 'wrap' }}> <code className={className} style={{ textWrap: 'wrap' }}>
{children} {children}

View File

@@ -190,16 +190,6 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem
</MessageWrapper> </MessageWrapper>
) )
const wrappedMessage = (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
topic={topic}
isClearMessage={message.type === 'clear'}>
{messageContent}
</SelectableMessage>
)
if (isGridGroupMessage) { if (isGridGroupMessage) {
return ( return (
<Popover <Popover
@@ -216,12 +206,20 @@ const MessageGroup = ({ messages, topic, hidePresetMessages, registerMessageElem
trigger={gridPopoverTrigger} trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }} styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}> getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}>
{wrappedMessage} <div style={{ cursor: 'pointer' }}>{messageContent}</div>
</Popover> </Popover>
) )
} }
return wrappedMessage return (
<SelectableMessage
key={`selectable-${message.id}`}
messageId={message.id}
topic={topic}
isClearMessage={message.type === 'clear'}>
{messageContent}
</SelectableMessage>
)
}, },
[ [
isGrid, isGrid,

View File

@@ -8,7 +8,7 @@ import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessag
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageTitle } from '@renderer/services/MessagesService' import { getMessageTitle } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import { RootState } from '@renderer/store' import store, { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import type { Model } from '@renderer/types' import type { Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types'
@@ -90,13 +90,24 @@ const MessageMenubar: FC<Props> = (props) => {
const onCopy = useCallback( const onCopy = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
navigator.clipboard.writeText(removeTrailingDoubleSpaces(mainTextContent.trimStart()))
const currentMessageId = message.id // from props
const latestMessageEntity = store.getState().messages.entities[currentMessageId]
let contentToCopy = ''
if (latestMessageEntity) {
contentToCopy = getMainTextContent(latestMessageEntity as Message)
} else {
contentToCopy = getMainTextContent(message)
}
navigator.clipboard.writeText(removeTrailingDoubleSpaces(contentToCopy.trimStart()))
window.message.success({ content: t('message.copied'), key: 'copy-message' }) window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
}, },
[mainTextContent, t] [message, t] // message is needed for message.id and as a fallback. t is for translation.
) )
const onNewBranch = useCallback(async () => { const onNewBranch = useCallback(async () => {

View File

@@ -3,7 +3,7 @@ import FileManager from '@renderer/services/FileManager'
import { Painting } from '@renderer/types' import { Painting } from '@renderer/types'
import { download } from '@renderer/utils/download' import { download } from '@renderer/utils/download'
import { Button, Dropdown, Spin } from 'antd' import { Button, Dropdown, Spin } from 'antd'
import { FC } from 'react' import React, { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -17,6 +17,7 @@ interface ArtboardProps {
onNextImage: () => void onNextImage: () => void
onCancel: () => void onCancel: () => void
retry?: (painting: Painting) => void retry?: (painting: Painting) => void
imageCover?: React.ReactNode
} }
const Artboard: FC<ArtboardProps> = ({ const Artboard: FC<ArtboardProps> = ({
@@ -26,7 +27,8 @@ const Artboard: FC<ArtboardProps> = ({
onPrevImage, onPrevImage,
onNextImage, onNextImage,
onCancel, onCancel,
retry retry,
imageCover
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -108,7 +110,9 @@ const Artboard: FC<ArtboardProps> = ({
</div> </div>
</div> </div>
) : ( ) : (
<div>{t('paintings.image_placeholder')}</div> imageCover ?
imageCover:
(<div>{t('paintings.image_placeholder')}</div>)
)} )}
</ImagePlaceholder> </ImagePlaceholder>
)} )}

View File

@@ -9,9 +9,7 @@ import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings' import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider' import { useAllProviders } from '@renderer/hooks/useProvider'
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
import type { FileType, PaintingsState } from '@renderer/types' import type { FileType, PaintingsState } from '@renderer/types'
@@ -176,7 +174,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
} }
// API请求函数 // API请求函数
const callApi = async (requestConfig: { endpoint: string; body: any }) => { const callApi = async (requestConfig: { endpoint: string; body: any }, controller: AbortController) => {
const { endpoint, body } = requestConfig const { endpoint, body } = requestConfig
const headers = {} const headers = {}
@@ -191,7 +189,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers, headers,
body body,
signal: controller.signal
}) })
if (!response.ok) { if (!response.ok) {
@@ -239,6 +238,10 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
} }
const onGenerate = async () => { const onGenerate = async () => {
// 如果已经在生成过程中,直接返回
if (isLoading) {
return
}
try { try {
// 获取提示词 // 获取提示词
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || '' const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
@@ -254,26 +257,28 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
centered: true centered: true
}) })
if (!confirmed) return if (!confirmed) return
await FileManager.deleteFiles(painting.files)
} }
setIsLoading(true)
// 设置请求状态 // 设置请求状态
const controller = new AbortController() const controller = new AbortController()
setAbortController(controller) setAbortController(controller)
setIsLoading(true)
dispatch(setGenerating(true)) dispatch(setGenerating(true))
// 准备请求配置 // 准备请求配置
const requestConfig = prepareRequestConfig(prompt, painting) const requestConfig = prepareRequestConfig(prompt, painting)
// 发送API请求 // 发送API请求
const urls = await callApi(requestConfig) const urls = await callApi(requestConfig, controller)
// 下载图像 // 下载图像
if (urls.length > 0) { if (urls.length > 0) {
const downloadedFiles = await downloadImages(urls) const downloadedFiles = await downloadImages(urls)
const validFiles = downloadedFiles.filter((file): file is FileType => file !== null) const validFiles = downloadedFiles.filter((file): file is FileType => file !== null)
// 删除之前的图片
await FileManager.deleteFiles(painting.files)
// 保存文件并更新状态 // 保存文件并更新状态
await FileManager.addFiles(validFiles) await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls }) updatePaintingState({ files: validFiles, urls })
@@ -325,51 +330,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
setCurrentImageIndex(0) setCurrentImageIndex(0)
} }
const { autoTranslateWithSpace } = useSettings()
const [spaceClickCount, setSpaceClickCount] = useState(0)
const [isTranslating, setIsTranslating] = useState(false)
const spaceClickTimer = useRef<NodeJS.Timeout>(null) const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const translate = async () => {
if (isTranslating) {
return
}
if (!painting.prompt) {
return
}
try {
setIsTranslating(true)
const translatedText = await translateText(painting.prompt, 'english')
updatePaintingState({ prompt: translatedText })
} catch (error) {
console.error('Translation failed:', error)
} finally {
setIsTranslating(false)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (autoTranslateWithSpace && event.key === ' ') {
setSpaceClickCount((prev) => prev + 1)
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
spaceClickTimer.current = setTimeout(() => {
setSpaceClickCount(0)
}, 200)
if (spaceClickCount === 2) {
setSpaceClickCount(0)
setIsTranslating(true)
translate().then(() => {})
}
}
}
const handleProviderChange = (providerId: string) => { const handleProviderChange = (providerId: string) => {
const routeName = location.pathname.split('/').pop() const routeName = location.pathname.split('/').pop()
if (providerId !== routeName) { if (providerId !== routeName) {
@@ -481,7 +443,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
</SliderContainer> </SliderContainer>
</LeftContainer> </LeftContainer>
<MainContainer> <MainContainer>
{painting?.urls?.length > 0 || DMXAPIPaintings?.length > 1 ? (
<Artboard <Artboard
painting={painting} painting={painting}
isLoading={isLoading} isLoading={isLoading}
@@ -489,13 +450,14 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
onPrevImage={prevImage} onPrevImage={prevImage}
onNextImage={nextImage} onNextImage={nextImage}
onCancel={onCancel} onCancel={onCancel}
/> imageCover={
) : ( painting?.urls?.length > 0 || DMXAPIPaintings?.length > 1 ? null : (
<EmptyImgBox> <EmptyImgBox>
<EmptyImg></EmptyImg> <EmptyImg></EmptyImg>
</EmptyImgBox> </EmptyImgBox>
)} )
}
/>
<InputContainer> <InputContainer>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
@@ -504,8 +466,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => {
value={painting.prompt} value={painting.prompt}
spellCheck={false} spellCheck={false}
onChange={(e) => updatePaintingState({ prompt: e.target.value })} onChange={(e) => updatePaintingState({ prompt: e.target.value })}
placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder')} placeholder={t('paintings.prompt_placeholder')}
onKeyDown={handleKeyDown}
/> />
<Toolbar> <Toolbar>
<ToolbarMenu> <ToolbarMenu>

View File

@@ -94,6 +94,7 @@ const NutstoreSettings: FC = () => {
if (confirmedLogout) { if (confirmedLogout) {
dispatch(setNutstoreToken('')) dispatch(setNutstoreToken(''))
dispatch(setNutstorePath('')) dispatch(setNutstorePath(''))
dispatch(setNutstoreAutoSync(false))
setNutstoreUsername('') setNutstoreUsername('')
setStoragePath(undefined) setStoragePath(undefined)
} }

View File

@@ -1,6 +1,5 @@
import { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import CodeEditor from '@renderer/components/CodeEditor' import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setMCPServerActive } from '@renderer/store/mcp' import { setMCPServerActive } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
@@ -95,7 +94,7 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
description: serverToAdd!.description ?? '', description: serverToAdd!.description ?? '',
baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '', baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '',
command: serverToAdd!.command ?? '', command: serverToAdd!.command ?? '',
args: serverToAdd!.args || [], args: Array.isArray(serverToAdd!.args) ? serverToAdd!.args : [],
env: serverToAdd!.env || {}, env: serverToAdd!.env || {},
isActive: false, isActive: false,
type: serverToAdd!.type, type: serverToAdd!.type,
@@ -157,7 +156,6 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
name="serverConfig" name="serverConfig"
label={t('settings.mcp.addServer.importFrom.tooltip')} label={t('settings.mcp.addServer.importFrom.tooltip')}
rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}> rules={[{ required: true, message: t('settings.mcp.addServer.importFrom.placeholder') }]}>
<CodeToolbarProvider>
<CodeEditor <CodeEditor
// 如果表單值為空,顯示範例 JSON否則顯示表單值 // 如果表單值為空,顯示範例 JSON否則顯示表單值
value={serverConfigValue} value={serverConfigValue}
@@ -175,7 +173,6 @@ const AddMcpServerModal: FC<AddMcpServerModalProps> = ({ visible, onClose, onSuc
keymap: true keymap: true
}} }}
/> />
</CodeToolbarProvider>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -236,7 +233,6 @@ const parseAndExtractServer = (
} }
} else if ( } else if (
typeof parsedJson === 'object' && typeof parsedJson === 'object' &&
parsedJson !== null &&
!Array.isArray(parsedJson) && !Array.isArray(parsedJson) &&
!parsedJson.mcpServers // 確保是直接的伺服器物件 !parsedJson.mcpServers // 確保是直接的伺服器物件
) { ) {

View File

@@ -1,5 +1,4 @@
import CodeEditor from '@renderer/components/CodeEditor' import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp' import { setMCPServers } from '@renderer/store/mcp'
@@ -121,7 +120,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</div> </div>
{jsonConfig && ( {jsonConfig && (
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<CodeToolbarProvider>
<CodeEditor <CodeEditor
value={jsonConfig} value={jsonConfig}
language="json" language="json"
@@ -137,7 +135,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
keymap: true keymap: true
}} }}
/> />
</CodeToolbarProvider>
</div> </div>
)} )}
<Typography.Text type="secondary">{t('settings.mcp.jsonModeHint')}</Typography.Text> <Typography.Text type="secondary">{t('settings.mcp.jsonModeHint')}</Typography.Text>

View File

@@ -180,7 +180,11 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve }) => {
value={name} value={name}
onChange={(e) => setName(e.target.value.trim())} onChange={(e) => setName(e.target.value.trim())}
placeholder={t('settings.provider.add.name.placeholder')} placeholder={t('settings.provider.add.name.placeholder')}
onKeyDown={(e) => e.key === 'Enter' && onOk()} onKeyDown={(e) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
onOk()
}
}}
maxLength={32} maxLength={32}
/> />
</Form.Item> </Form.Item>

View File

@@ -1,4 +1,6 @@
import DmxapiLogo from '@renderer/assets/images/providers/dmxapi-logo.webp' import DmxapiLogo from '@renderer/assets/images/providers/dmxapi-logo.webp'
import DmxapiLogoDark from '@renderer/assets/images/providers/dmxapi-logo-dark.webp'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { Radio, RadioChangeEvent, Space } from 'antd' import { Radio, RadioChangeEvent, Space } from 'antd'
@@ -40,6 +42,7 @@ const PlatformOptions = [
const DMXAPISettings: FC<DMXAPISettingsProps> = ({ provider: initialProvider }) => { const DMXAPISettings: FC<DMXAPISettingsProps> = ({ provider: initialProvider }) => {
const { provider, updateProvider } = useProvider(initialProvider.id) const { provider, updateProvider } = useProvider(initialProvider.id)
const { theme } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
@@ -71,7 +74,7 @@ const DMXAPISettings: FC<DMXAPISettingsProps> = ({ provider: initialProvider })
<Container> <Container>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
<LogoContainer> <LogoContainer>
<Logo src={DmxapiLogo}></Logo> <Logo src={theme === 'dark' ? DmxapiLogoDark : DmxapiLogo}></Logo>
</LogoContainer> </LogoContainer>
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.dmxapi.select_platform')}</SettingSubtitle> <SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.dmxapi.select_platform')}</SettingSubtitle>

View File

@@ -4,6 +4,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { getProviderLogo } from '@renderer/config/providers' import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage' import ImageStorage from '@renderer/services/ImageStorage'
import { INITIAL_PROVIDERS } from '@renderer/store/llm'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils' import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
@@ -115,8 +116,7 @@ const ProvidersList: FC = () => {
onClick: () => ModelNotesPopup.show({ provider }) onClick: () => ModelNotesPopup.show({ provider })
} }
const menus = [ const editMenu = {
{
label: t('common.edit'), label: t('common.edit'),
key: 'edit', key: 'edit',
icon: <EditOutlined />, icon: <EditOutlined />,
@@ -152,9 +152,9 @@ const ProvidersList: FC = () => {
} }
} }
} }
}, }
noteMenu,
{ const deleteMenu = {
label: t('common.delete'), label: t('common.delete'),
key: 'delete', key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
@@ -187,15 +187,19 @@ const ProvidersList: FC = () => {
}) })
} }
} }
]
const menus = [editMenu, noteMenu, deleteMenu]
if (providers.filter((p) => p.id === provider.id).length > 1) { if (providers.filter((p) => p.id === provider.id).length > 1) {
return menus return menus
} }
if (provider.isSystem) { if (provider.isSystem) {
if (INITIAL_PROVIDERS.find((p) => p.id === provider.id)) {
return [noteMenu] return [noteMenu]
} }
return [noteMenu, deleteMenu]
}
return menus return menus
} }

View File

@@ -254,7 +254,7 @@ export default class AnthropicProvider extends BaseProvider {
} }
} }
const isEnabledBuiltinWebSearch = assistant.enableWebSearch const isEnabledBuiltinWebSearch = assistant.enableWebSearch && isWebSearchModel(model)
if (isEnabledBuiltinWebSearch) { if (isEnabledBuiltinWebSearch) {
const webSearchTool = await this.getWebSearchParams(model) const webSearchTool = await this.getWebSearchParams(model)
@@ -322,7 +322,7 @@ export default class AnthropicProvider extends BaseProvider {
reasoning_content, reasoning_content,
usage: message.usage as any, usage: message.usage as any,
metrics: { metrics: {
completion_tokens: message.usage.output_tokens, completion_tokens: message.usage?.output_tokens || 0,
time_completion_millsec, time_completion_millsec,
time_first_token_millsec: 0 time_first_token_millsec: 0
} }
@@ -464,8 +464,8 @@ export default class AnthropicProvider extends BaseProvider {
} }
} }
finalUsage.prompt_tokens += message.usage.input_tokens finalUsage.prompt_tokens += message.usage?.input_tokens || 0
finalUsage.completion_tokens += message.usage.output_tokens finalUsage.completion_tokens += message.usage?.output_tokens || 0
finalUsage.total_tokens += finalUsage.prompt_tokens + finalUsage.completion_tokens finalUsage.total_tokens += finalUsage.prompt_tokens + finalUsage.completion_tokens
finalMetrics.completion_tokens = finalUsage.completion_tokens finalMetrics.completion_tokens = finalUsage.completion_tokens
finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec

View File

@@ -15,6 +15,7 @@ import {
isSupportedThinkingTokenModel, isSupportedThinkingTokenModel,
isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenQwenModel,
isVisionModel, isVisionModel,
isWebSearchModel,
isZhipuModel isZhipuModel
} from '@renderer/config/models' } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings' import { getStoreSetting } from '@renderer/hooks/useSettings'
@@ -371,7 +372,7 @@ export default class OpenAIProvider extends BaseOpenAIProvider {
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant) const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
const isEnabledBultinWebSearch = assistant.enableWebSearch const isEnabledBultinWebSearch = assistant.enableWebSearch && isWebSearchModel(model)
messages = addImageFileToContents(messages) messages = addImageFileToContents(messages)
const enableReasoning = const enableReasoning =
((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) &&

View File

@@ -5,7 +5,8 @@ import {
isSupportedFlexServiceTier, isSupportedFlexServiceTier,
isSupportedModel, isSupportedModel,
isSupportedReasoningEffortOpenAIModel, isSupportedReasoningEffortOpenAIModel,
isVisionModel isVisionModel,
isWebSearchModel
} from '@renderer/config/models' } from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings' import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
@@ -317,7 +318,7 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant) const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
const isEnabledBuiltinWebSearch = assistant.enableWebSearch const isEnabledBuiltinWebSearch = assistant.enableWebSearch && isWebSearchModel(model)
let tools: OpenAI.Responses.Tool[] = [] let tools: OpenAI.Responses.Tool[] = []
const toolChoices: OpenAI.Responses.ToolChoiceTypes = { const toolChoices: OpenAI.Responses.ToolChoiceTypes = {

View File

@@ -2,6 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant' import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings' import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import Logger from '@renderer/config/logger' import Logger from '@renderer/config/logger'
import { ONLY_SUPPORTED_DIMENSION_PROVIDERS } from '@renderer/config/providers'
import AiProvider from '@renderer/providers/AiProvider' import AiProvider from '@renderer/providers/AiProvider'
import store from '@renderer/store' import store from '@renderer/store'
import { FileType, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types' import { FileType, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types'
@@ -38,7 +39,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
return { return {
id: base.id, id: base.id,
model: base.model.id, model: base.model.id,
dimensions: base.dimensions, dimensions: ONLY_SUPPORTED_DIMENSION_PROVIDERS.includes(base.model.provider) ? base.dimensions : undefined,
apiKey: aiProvider.getApiKey() || 'secret', apiKey: aiProvider.getApiKey() || 'secret',
apiVersion: provider.apiVersion, apiVersion: provider.apiVersion,
baseURL: host, baseURL: host,

View File

@@ -13,7 +13,7 @@ function getNutstoreToken() {
const nutstoreToken = store.getState().nutstore.nutstoreToken const nutstoreToken = store.getState().nutstore.nutstoreToken
if (!nutstoreToken) { if (!nutstoreToken) {
window.message.error({ content: i18n.t('error.invalid.nutstore_token'), key: 'nutstore' }) window.message.error({ content: i18n.t('message.error.invalid.nutstore_token'), key: 'nutstore' })
return null return null
} }
return nutstoreToken return nutstoreToken
@@ -164,8 +164,9 @@ export async function startNutstoreAutoSync() {
} }
const nutstoreToken = getNutstoreToken() const nutstoreToken = getNutstoreToken()
if (!nutstoreToken) { if (!nutstoreToken) {
window.message.error({ content: i18n.t('error.invalid.nutstore_token'), key: 'nutstore' }) Logger.log('[startNutstoreAutoSync] Invalid nutstore token, nutstore auto sync disabled')
return return
} }

View File

@@ -8,6 +8,7 @@ import agents from './agents'
import assistants from './assistants' import assistants from './assistants'
import backup from './backup' import backup from './backup'
import copilot from './copilot' import copilot from './copilot'
import inputToolsReducer from './inputTools'
import knowledge from './knowledge' import knowledge from './knowledge'
import llm from './llm' import llm from './llm'
import mcp from './mcp' import mcp from './mcp'
@@ -39,7 +40,8 @@ const rootReducer = combineReducers({
copilot, copilot,
// messages: messagesReducer, // messages: messagesReducer,
messages: newMessagesReducer, messages: newMessagesReducer,
messageBlocks: messageBlocksReducer messageBlocks: messageBlocksReducer,
inputTools: inputToolsReducer
}) })
const persistedReducer = persistReducer( const persistedReducer = persistReducer(

View File

@@ -0,0 +1,51 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export type ToolOrder = {
visible: string[]
hidden: string[]
}
export const DEFAULT_TOOL_ORDER: ToolOrder = {
visible: [
'new_topic',
'attachment',
'thinking',
'web_search',
'knowledge_base',
'mcp_tools',
'generate_image',
'mention_models',
'quick_phrases',
'clear_topic',
'toggle_expand',
'new_context'
],
hidden: []
}
export type InputToolsState = {
toolOrder: ToolOrder
isCollapsed: boolean
}
const initialState: InputToolsState = {
toolOrder: DEFAULT_TOOL_ORDER,
isCollapsed: false
}
const inputToolsSlice = createSlice({
name: 'inputTools',
initialState,
reducers: {
setToolOrder: (state, action: PayloadAction<ToolOrder>) => {
state.toolOrder = action.payload
},
setIsCollapsed: (state, action: PayloadAction<boolean>) => {
state.isCollapsed = action.payload
}
}
})
export const { setToolOrder, setIsCollapsed } = inputToolsSlice.actions
export default inputToolsSlice.reducer

View File

@@ -408,16 +408,6 @@ export const INITIAL_PROVIDERS: Provider[] = [
isSystem: true, isSystem: true,
enabled: false enabled: false
}, },
{
id: 'gitee-ai',
name: 'gitee ai',
type: 'openai',
apiKey: '',
apiHost: 'https://ai.gitee.com',
models: SYSTEM_MODELS['gitee-ai'],
isSystem: true,
enabled: false
},
{ {
id: 'perplexity', id: 'perplexity',
name: 'Perplexity', name: 'Perplexity',

View File

@@ -81,7 +81,7 @@ const selectBlockEntityById = (state: RootState, blockId: string | undefined) =>
blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined // Use adapter selector blockId ? messageBlocksSelectors.selectById(state, blockId) : undefined // Use adapter selector
// --- Centralized Citation Formatting Logic --- // --- Centralized Citation Formatting Logic ---
const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => { export const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Citation[] => {
if (!block) return [] if (!block) return []
let formattedCitations: Citation[] = [] let formattedCitations: Citation[] = []

View File

@@ -11,6 +11,7 @@ import { isEmpty } from 'lodash'
import { createMigrate } from 'redux-persist' import { createMigrate } from 'redux-persist'
import { RootState } from '.' import { RootState } from '.'
import { DEFAULT_TOOL_ORDER } from './inputTools'
import { INITIAL_PROVIDERS, moveProvider } from './llm' import { INITIAL_PROVIDERS, moveProvider } from './llm'
import { mcpSlice } from './mcp' import { mcpSlice } from './mcp'
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings' import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
@@ -1455,6 +1456,15 @@ const migrateConfig = {
} catch (error) { } catch (error) {
return state return state
} }
},
'108': (state: RootState) => {
try {
state.inputTools.toolOrder = DEFAULT_TOOL_ORDER
state.inputTools.isCollapsed = false
return state
} catch (error) {
return state
}
} }
} }

View File

@@ -393,7 +393,7 @@ export interface KnowledgeBase {
export type KnowledgeBaseParams = { export type KnowledgeBaseParams = {
id: string id: string
model: string model: string
dimensions: number dimensions?: number
apiKey: string apiKey: string
apiVersion?: string apiVersion?: string
baseURL: string baseURL: string

View File

@@ -24,6 +24,15 @@ vi.mock('@renderer/utils/messageUtils/find', () => ({
// Assuming content exists on ThinkingBlock // Assuming content exists on ThinkingBlock
// Need to cast block to access content if not on base type // Need to cast block to access content if not on base type
return (thinkingBlock as any)?.content || '' return (thinkingBlock as any)?.content || ''
}),
getCitationContent: vi.fn((message: Message & { _fullBlocks?: MessageBlock[] }) => {
const citationBlocks = message._fullBlocks?.filter((b) => b.type === MessageBlockType.CITATION) || []
// Return empty string if no citation blocks, otherwise mock citation content
if (citationBlocks.length === 0) return ''
// Mock citation format: [number] [url](title)
return citationBlocks
.map((_, index) => `[${index + 1}] [https://example${index + 1}.com](Example Citation ${index + 1})`)
.join('\n\n')
}) })
})) }))
@@ -198,6 +207,9 @@ describe('export', () => {
const markdown = messageToMarkdown(msg!) const markdown = messageToMarkdown(msg!)
expect(markdown).toContain('### 🧑‍💻 User') expect(markdown).toContain('### 🧑‍💻 User')
expect(markdown).toContain('hello user') expect(markdown).toContain('hello user')
// Should have double newlines between sections
const sections = markdown.split('\n\n')
expect(sections.length).toBeGreaterThanOrEqual(3) // title, content, citation (empty)
}) })
it('should format assistant message using main text block', () => { it('should format assistant message using main text block', () => {
@@ -206,6 +218,9 @@ describe('export', () => {
const markdown = messageToMarkdown(msg!) const markdown = messageToMarkdown(msg!)
expect(markdown).toContain('### 🤖 Assistant') expect(markdown).toContain('### 🤖 Assistant')
expect(markdown).toContain('hi assistant') expect(markdown).toContain('hi assistant')
// Should have double newlines between sections
const sections = markdown.split('\n\n')
expect(sections.length).toBeGreaterThanOrEqual(3) // title, content, citation (empty)
}) })
it('should handle message with no main text block gracefully', () => { it('should handle message with no main text block gracefully', () => {
@@ -213,7 +228,19 @@ describe('export', () => {
mockedMessages.push(msg) mockedMessages.push(msg)
const markdown = messageToMarkdown(msg) const markdown = messageToMarkdown(msg)
expect(markdown).toContain('### 🧑‍💻 User') expect(markdown).toContain('### 🧑‍💻 User')
expect(markdown.trim().endsWith('User')).toBe(true) // Check that it doesn't fail when no content exists
expect(markdown).toBeDefined()
})
it('should include citation content when citation blocks exist', () => {
const msgWithCitation = createMessage({ role: 'assistant', id: 'a_cite' }, [
{ type: MessageBlockType.MAIN_TEXT, content: 'Main content' },
{ type: MessageBlockType.CITATION }
])
const markdown = messageToMarkdown(msgWithCitation)
expect(markdown).toContain('### 🤖 Assistant')
expect(markdown).toContain('Main content')
expect(markdown).toContain('[1] [https://example1.com](Example Citation 1)')
}) })
}) })
@@ -231,7 +258,12 @@ describe('export', () => {
const msgWithoutReasoning = createMessage({ role: 'assistant', id: 'a4' }, [ const msgWithoutReasoning = createMessage({ role: 'assistant', id: 'a4' }, [
{ type: MessageBlockType.MAIN_TEXT, content: 'Simple Answer' } { type: MessageBlockType.MAIN_TEXT, content: 'Simple Answer' }
]) ])
mockedMessages = [msgWithReasoning, msgWithThinkTag, msgWithoutReasoning] const msgWithReasoningAndCitation = createMessage({ role: 'assistant', id: 'a5' }, [
{ type: MessageBlockType.MAIN_TEXT, content: 'Answer with citation' },
{ type: MessageBlockType.THINKING, content: 'Some thinking' },
{ type: MessageBlockType.CITATION }
])
mockedMessages = [msgWithReasoning, msgWithThinkTag, msgWithoutReasoning, msgWithReasoningAndCitation]
}) })
it('should include reasoning content from thinking block in details section', () => { it('should include reasoning content from thinking block in details section', () => {
@@ -243,6 +275,9 @@ describe('export', () => {
expect(markdown).toContain('<details') expect(markdown).toContain('<details')
expect(markdown).toContain('<summary>common.reasoning_content</summary>') expect(markdown).toContain('<summary>common.reasoning_content</summary>')
expect(markdown).toContain('Detailed thought process') expect(markdown).toContain('Detailed thought process')
// Should have double newlines between sections
const sections = markdown.split('\n\n')
expect(sections.length).toBeGreaterThanOrEqual(3)
}) })
it('should handle <think> tag and replace newlines with <br> in reasoning', () => { it('should handle <think> tag and replace newlines with <br> in reasoning', () => {
@@ -263,6 +298,17 @@ describe('export', () => {
expect(markdown).toContain('Simple Answer') expect(markdown).toContain('Simple Answer')
expect(markdown).not.toContain('<details') expect(markdown).not.toContain('<details')
}) })
it('should include both reasoning and citation content', () => {
const msg = mockedMessages.find((m) => m.id === 'a5')
expect(msg).toBeDefined()
const markdown = messageToMarkdownWithReasoning(msg!)
expect(markdown).toContain('### 🤖 Assistant')
expect(markdown).toContain('Answer with citation')
expect(markdown).toContain('<details')
expect(markdown).toContain('Some thinking')
expect(markdown).toContain('[1] [https://example1.com](Example Citation 1)')
})
}) })
describe('messagesToMarkdown', () => { describe('messagesToMarkdown', () => {

View File

@@ -8,7 +8,7 @@ import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage' import type { Message } from '@renderer/types/newMessage'
import { removeSpecialCharactersForFileName } from '@renderer/utils/file' import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
import { convertMathFormula } from '@renderer/utils/markdown' import { convertMathFormula } from '@renderer/utils/markdown'
import { getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find' import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
import { markdownToBlocks } from '@tryfabric/martian' import { markdownToBlocks } from '@tryfabric/martian'
import dayjs from 'dayjs' import dayjs from 'dayjs'
//TODO: 添加对思考内容的支持 //TODO: 添加对思考内容的支持
@@ -43,26 +43,15 @@ export function getTitleFromString(str: string, length: number = 80) {
return title return title
} }
export const messageToMarkdown = (message: Message) => { const createBaseMarkdown = (message: Message, includeReasoning: boolean = false) => {
const { forceDollarMathInMarkdown } = store.getState().settings const { forceDollarMathInMarkdown } = store.getState().settings
const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant' const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant'
const titleSection = `### ${roleText}` const titleSection = `### ${roleText}`
const content = getMainTextContent(message)
const contentSection = forceDollarMathInMarkdown ? convertMathFormula(content) : content
return [titleSection, '', contentSection].join('\n')
}
// 保留接口用于其它导出方法使用
export const messageToMarkdownWithReasoning = (message: Message) => {
const { forceDollarMathInMarkdown } = store.getState().settings
const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant'
const titleSection = `### ${roleText}`
let reasoningContent = getThinkingContent(message)
// 处理思考内容
let reasoningSection = '' let reasoningSection = ''
if (includeReasoning) {
let reasoningContent = getThinkingContent(message)
if (reasoningContent) { if (reasoningContent) {
// 移除开头的<think>标记和换行符,并将所有换行符替换为<br>
if (reasoningContent.startsWith('<think>\n')) { if (reasoningContent.startsWith('<think>\n')) {
reasoningContent = reasoningContent.substring(8) reasoningContent = reasoningContent.substring(8)
} else if (reasoningContent.startsWith('<think>')) { } else if (reasoningContent.startsWith('<think>')) {
@@ -70,21 +59,32 @@ export const messageToMarkdownWithReasoning = (message: Message) => {
} }
reasoningContent = reasoningContent.replace(/\n/g, '<br>') reasoningContent = reasoningContent.replace(/\n/g, '<br>')
// 应用数学公式转换(如果启用)
if (forceDollarMathInMarkdown) { if (forceDollarMathInMarkdown) {
reasoningContent = convertMathFormula(reasoningContent) reasoningContent = convertMathFormula(reasoningContent)
} }
// 添加思考内容的Markdown格式
reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;"> reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;">
<summary>${i18n.t('common.reasoning_content')}</summary><hr> <summary>${i18n.t('common.reasoning_content')}</summary><hr>
${reasoningContent} ${reasoningContent}
</details>` </details>`
} }
const content = getMainTextContent(message) }
const content = getMainTextContent(message)
const citation = getCitationContent(message)
const contentSection = forceDollarMathInMarkdown ? convertMathFormula(content) : content const contentSection = forceDollarMathInMarkdown ? convertMathFormula(content) : content
return [titleSection, '', reasoningSection + contentSection].join('\n') return { titleSection, reasoningSection, contentSection, citation }
}
export const messageToMarkdown = (message: Message) => {
const { titleSection, contentSection, citation } = createBaseMarkdown(message)
return [titleSection, '', contentSection, citation].join('\n\n')
}
// 保留接口用于其它导出方法使用
export const messageToMarkdownWithReasoning = (message: Message) => {
const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown(message, true)
return [titleSection, '', reasoningSection + contentSection, citation].join('\n\n')
} }
export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean) => { export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean) => {

View File

@@ -1,5 +1,5 @@
import store from '@renderer/store' import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { formatCitationsFromBlock, messageBlocksSelectors } from '@renderer/store/messageBlock'
import { FileType } from '@renderer/types' import { FileType } from '@renderer/types'
import type { import type {
CitationMessageBlock, CitationMessageBlock,
@@ -128,6 +128,15 @@ export const getThinkingContent = (message: Message): string => {
return thinkingBlocks.map((block) => block.content).join('\n\n') return thinkingBlocks.map((block) => block.content).join('\n\n')
} }
export const getCitationContent = (message: Message): string => {
const citationBlocks = findCitationBlocks(message)
return citationBlocks
.map((block) => formatCitationsFromBlock(block))
.flat()
.map((citation) => `[${citation.number}] [${citation.url}](${citation.title || citation.url})`)
.join('\n\n')
}
/** /**
* Gets the knowledgeBaseIds array from the *first* MainTextMessageBlock of a message. * Gets the knowledgeBaseIds array from the *first* MainTextMessageBlock of a message.
* Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed. * Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed.