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
- 🤲 Easy Content Sharing
# 📝 TODO
# 📝 Roadmap
- [x] Quick popup (read clipboard, quick question, explain, translate, summarize)
- [x] Comparison of multi-model answers
- [x] Support login using SSO provided by service providers
- [x] All models support networking
- [x] Launch of the first official version
- [x] Bug fixes and improvements (In progress...)
- [ ] Plugin functionality (JavaScript)
- [ ] Browser extension (highlight text to translate, summarize, add to knowledge base)
- [ ] iOS & Android client
- [ ] AI notes
- [ ] Voice input and output (AI call)
- [ ] Data backup supports custom backup content
We're actively working on the following features and improvements:
1. 🎯 **Core Features**
- Selection Assistant - Smart content selection enhancement
- Deep Research - Advanced research capabilities
- Memory System - Global context awareness
- Document Preprocessing - Improved document handling
- MCP Marketplace - Model Context Protocol ecosystem
2. 🗂 **Knowledge Management**
- 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

View File

@@ -70,20 +70,42 @@ https://docs.cherry-ai.com
- 📝 完全な Markdown レンダリング
- 🤲 簡単な共有機能
# 📝 TODO
# 📝 開発計画
- [x] クイックポップアップ(クリップボードの読み取り、簡単な質問、説明、翻訳、要約)
- [x] 複数モデルの回答の比較
- [x] サービスプロバイダーが提供する SSO を使用したログイン対応
- [x] すべてのモデルのネットワーク対応
- [x] 最初の公式バージョンのリリース
- [x] バグ修正と改善(進行中...
- [ ] プラグイン機能JavaScript
- [ ] ブラウザ拡張機能(テキストをハイライトして翻訳、要約、ナレッジベースに追加)
- [ ] iOS & Android クライアント
- [ ] AI ノート
- [ ] 音声入出力AI コール)
- [ ] データバックアップのカスタマイズ対応
以下の機能と改善に積極的に取り組んでいます:
1. 🎯 **コア機能**
- 選択アシスタント - スマートな内容選択の強化
- ディープリサーチ - 高度な研究能力
- メモリーシステム - グローバルコンテキスト認識
- ドキュメント前処理 - 文書処理の改善
- MCP マーケットプレイス - モデルコンテキストプロトコルエコシステム
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 渲染
- 🤲 便捷的内容分享功能
# 📝 待办事项
# 📝 开发计划
- [x] 快捷弹窗(读取剪贴板、快速提问、解释、翻译、总结)
- [x] 多模型回答对比
- [x] 支持使用服务供应商提供的 SSO 进行登录
- [x] 所有模型支持联网
- [x] 推出第一个正式版
- [x] 错误修复和改进(开发中...
- [ ] 插件功能JavaScript
- [ ] 浏览器插件(划词翻译、总结、新增至知识库)
- [ ] iOS & Android 客户端
- [ ] AI 笔记
- [ ] 语音输入输出AI 通话)
- [ ] 数据备份支持自定义备份内容
我们正在积极开发以下功能和改进:
1. 🎯 **核心功能**
- 选择助手 - 智能内容选择增强
- 深度研究 - 高级研究能力
- 全局记忆 - 全局上下文感知
- 文档预处理 - 改进文档处理能力
- MCP 市场 - 模型上下文协议生态系统
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
```
### Debug
```bash
yarn debug
```
Then input chrome://inspect in browser
### Test
```bash

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { Flex } from 'antd'
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
@@ -7,9 +7,10 @@ import styled from 'styled-components'
interface Props {
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 mermaidRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
@@ -25,6 +26,7 @@ const MermaidPreview: React.FC<Props> = ({ children }) => {
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
handleDownload

View File

@@ -1,5 +1,5 @@
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 pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react'
@@ -134,9 +134,10 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
interface PlantUMLProps {
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 containerRef = useRef<HTMLDivElement>(null)
@@ -165,6 +166,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
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 styled from 'styled-components'
interface Props {
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)
// 使用通用图像工具
@@ -17,6 +18,7 @@ const SvgPreview: React.FC<Props> = ({ children }) => {
// 使用工具栏
usePreviewTools({
setTools,
handleCopyImage,
handleDownload
})

View File

@@ -1,6 +1,6 @@
import { LoadingOutlined } from '@ant-design/icons'
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 { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
@@ -49,6 +49,9 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
const [tools, setTools] = useState<CodeTool[]>([])
const { registerTool, removeTool } = useCodeTool(setTools)
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
@@ -59,33 +62,17 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return hasSpecialView && viewMode === 'special'
}, [hasSpecialView, viewMode])
const { updateContext, registerTool, removeTool } = useCodeToolbar()
const handleCopySource = useCallback(() => {
navigator.clipboard.writeText(children)
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
}, [children, t])
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' })
},
[t]
)
const handleDownloadSource = useCallback((ctx?: CodeToolContext) => {
if (!ctx) return
const { code, language } = ctx
const handleDownloadSource = useCallback(() => {
let fileName = ''
// 尝试提取标题
if (language === 'html' && code.includes('</html>')) {
const title = extractTitle(code)
if (language === 'html' && children.includes('</html>')) {
const title = extractTitle(children)
if (title) {
fileName = `${title}.html`
}
@@ -96,31 +83,26 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
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)
setOutput('')
setIsRunning(true)
setOutput('')
pyodideService
.runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000)
.then((formattedOutput) => {
setOutput(formattedOutput)
})
.catch((error) => {
console.error('Unexpected error:', error)
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
})
.finally(() => {
setIsRunning(false)
})
},
[codeExecution.timeoutMinutes]
)
pyodideService
.runScript(children, {}, codeExecution.timeoutMinutes * 60000)
.then((formattedOutput) => {
setOutput(formattedOutput)
})
.catch((error) => {
console.error('Unexpected error:', error)
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
})
.finally(() => {
setIsRunning(false)
})
}, [children, codeExecution.timeoutMinutes])
useEffect(() => {
// 复制按钮
@@ -191,7 +173,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
...TOOL_SPECS.run,
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
tooltip: t('code_block.run'),
onClick: (ctx) => !isRunning && handleRunScript(ctx)
onClick: () => !isRunning && handleRunScript()
})
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
@@ -200,20 +182,32 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 源代码视图组件
const sourceView = useMemo(() => {
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 {
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(() => {
if (language === 'mermaid') {
return <MermaidPreview>{children}</MermaidPreview>
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview>{children}</PlantUmlPreview>
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview>{children}</SvgPreview>
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
}
return null
}, [children, language])
@@ -246,7 +240,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
<CodeToolbar />
<CodeToolbar tools={tools} />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
@@ -255,10 +249,10 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
}
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
/* FIXME: 在 bubble style 中撑开一些宽度*/
position: relative;
.code-toolbar {
margin-top: ${(props) => (props.$isInSpecialView ? '20px' : '0')};
background-color: ${(props) => (props.$isInSpecialView ? 'transparent' : 'var(--color-background-mute)')};
border-radius: ${(props) => (props.$isInSpecialView ? '0' : '4px')};
opacity: 0;
@@ -279,13 +273,13 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-text);
font-size: 14px;
font-weight: bold;
padding: 0 10px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-top: ${(props) => (props.$isInSpecialView ? '6px' : '0')};
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 { useSettings } from '@renderer/hooks/useSettings'
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror'
@@ -25,6 +25,7 @@ interface Props {
language: string
onSave?: (newContent: string) => void
onChange?: (newContent: string) => void
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
minHeight?: string
maxHeight?: string
/** 用于覆写编辑器的某些设置 */
@@ -52,6 +53,7 @@ const CodeEditor = ({
language,
onSave,
onChange,
setTools,
minHeight,
maxHeight,
options,
@@ -88,7 +90,7 @@ const CodeEditor = ({
const langExtensions = useLanguageExtensions(language, options?.lint)
const { registerTool, removeTool } = useCodeToolbar()
const { registerTool, removeTool } = useCodeTool(setTools)
// 展开/折叠工具
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 './context'
export * from './hook'
export * from './toolbar'
export * from './types'
export * from './usePreviewTools'

View File

@@ -5,7 +5,6 @@ import React, { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { useCodeToolbar } from './context'
import { CodeTool } from './types'
interface CodeToolButtonProps {
@@ -13,22 +12,19 @@ interface CodeToolButtonProps {
}
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
const { context } = useCodeToolbar()
return (
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
<Tooltip key={tool.id} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick()}>{tool.icon}</ToolWrapper>
</Tooltip>
)
})
export const CodeToolbar: React.FC = memo(() => {
const { tools, context } = useCodeToolbar()
export const CodeToolbar: React.FC<{ tools: CodeTool[] }> = memo(({ tools }) => {
const [showQuickTools, setShowQuickTools] = useState(false)
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')

View File

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

View File

@@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next'
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
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\)/
@@ -272,6 +273,7 @@ export const usePreviewToolHandlers = (
}
export interface PreviewToolsOptions {
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
handleZoom?: (delta: number) => void
handleCopyImage?: () => Promise<void>
handleDownload?: (format: 'svg' | 'png') => void
@@ -280,9 +282,9 @@ export interface PreviewToolsOptions {
/**
* 提供预览组件通用工具栏功能的自定义Hook
*/
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
export const usePreviewTools = ({ setTools, handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
const { registerTool, removeTool } = useCodeTool(setTools)
useEffect(() => {
// 根据提供的功能有选择性地注册工具

View File

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

View File

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

View File

@@ -86,7 +86,12 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
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)) {
try {
const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase()

View File

@@ -773,200 +773,7 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
group: 'Claude 3'
}
],
'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'
}
],
'gitee-ai': [],
deepseek: [
{
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 NOT_SUPPORTED_REANK_PROVIDERS = ['ollama']
export const ONLY_SUPPORTED_DIMENSION_PROVIDERS = ['ollama', 'infini']
export const PROVIDER_CONFIG = {
openai: {

View File

@@ -23,6 +23,7 @@ import type { Assistant, Model, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { abortCompletion } from '@renderer/utils/abortController'
import { throttle } from 'lodash'
import { useCallback } from 'react'
const selectMessagesState = (state: RootState) => state.messages
@@ -243,9 +244,13 @@ export function useMessageOperations(topic: Topic) {
return null
}
return (accumulatedText: string, isComplete: boolean = false) => {
dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete))
}
return throttle(
(accumulatedText: string, isComplete: boolean = false) => {
dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete))
},
200,
{ leading: true, trailing: true }
)
},
[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.no_web_search": "Disable 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.mode.default": "Default",
"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.no_web_search": "ウェブ検索を無効にする",
"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.mode.default": "デフォルト",
"input.thinking.mode.custom": "カスタム",

View File

@@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "Текущая модель не поддерживает веб-поиск",
"input.web_search.no_web_search": "Отключить веб-поиск",
"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.mode.default": "По умолчанию",
"input.thinking.mode.default.tip": "Модель автоматически определяет количество токенов для размышления",

View File

@@ -199,6 +199,10 @@
"input.web_search.builtin.disabled_content": "当前模型不支持网络搜索功能",
"input.web_search.no_web_search": "不使用网络",
"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.created": "新分支已创建",
"message.new.context": "清除上下文",

View File

@@ -314,6 +314,10 @@
"input.web_search.builtin.disabled_content": "當前模型不支持網路搜尋功能",
"input.web_search.no_web_search": "關閉網路搜尋",
"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.mode.default": "預設",
"input.thinking.mode.default.tip": "模型會自動確定思考的 token 數",

View File

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

View File

@@ -1,5 +1,5 @@
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 Logger from '@renderer/config/logger'
import {
@@ -39,42 +39,18 @@ import { Button, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
import { debounce, isEmpty } from 'lodash'
import {
AtSign,
CirclePause,
FileSearch,
FileText,
Globe,
Languages,
LucideSquareTerminal,
Maximize,
MessageSquareDiff,
Minimize,
PaintbrushVertical,
Paperclip,
Upload,
Zap
} from 'lucide-react'
// import { CompletionUsage } from 'openai/resources'
import { CirclePause, FileSearch, FileText, Upload } from 'lucide-react'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import NarrowLayout from '../Messages/NarrowLayout'
import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview'
import GenerateImageButton from './GenerateImageButton'
import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton'
import InputbarTools, { InputbarToolsRef } from './InputbarTools'
import KnowledgeBaseInput from './KnowledgeBaseInput'
import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton'
import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput'
import NewContextButton from './NewContextButton'
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
import SendMessageButton from './SendMessageButton'
import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
import TokenCount from './TokenCount'
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
interface Props {
assistant: Assistant
@@ -135,13 +111,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [tokenCount, setTokenCount] = useState(0)
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 inputbarToolsRef = useRef<InputbarToolsRef>(null)
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedEstimate = useCallback(
@@ -314,7 +284,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
description: '',
icon: <Upload />,
action: () => {
attachmentButtonRef.current?.openQuickPanel()
inputbarToolsRef.current?.openQuickPanel()
}
},
...knowledgeBases.map((base) => {
@@ -333,92 +303,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
],
symbol: 'file'
})
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t])
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])
}, [knowledgeBases, openKnowledgeFileList, quickPanel, t, inputbarToolsRef])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
@@ -566,6 +451,16 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const lastSymbol = newText[cursorPosition - 1]
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') {
const quickPanelMenu =
inputbarToolsRef.current?.getQuickPanelMenu({
t,
files,
model,
text: newText,
openSelectFileMenu,
translate
}) || []
quickPanel.open({
title: t('settings.quickPanel.title'),
list: quickPanelMenu,
@@ -574,7 +469,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') {
mentionModelsButtonRef.current?.openQuickPanel()
inputbarToolsRef.current?.openMentionModelsPanel()
}
}
@@ -936,75 +831,30 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
<HolderOutlined />
</DragHandle>
<Toolbar>
<InputbarTools
ref={inputbarToolsRef}
assistant={assistant}
model={model}
files={files}
setFiles={setFiles}
showThinkingButton={showThinkingButton}
showKnowledgeIcon={showKnowledgeIcon}
selectedKnowledgeBases={selectedKnowledgeBases}
handleKnowledgeBaseSelect={handleKnowledgeBaseSelect}
setText={setText}
resizeTextArea={resizeTextArea}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
onEnableGenerateImage={onEnableGenerateImage}
isExpended={isExpended}
onToggleExpended={onToggleExpended}
addNewTopic={addNewTopic}
clearTopic={clearTopic}
onNewContext={onNewContext}
newTopicShortcut={newTopicShortcut}
cleanTopicShortcut={cleanTopicShortcut}
/>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<MessageSquareDiff size={19} />
</ToolbarButton>
</Tooltip>
<AttachmentButton
ref={attachmentButtonRef}
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
/>
{showThinkingButton && (
<ThinkingButton
ref={thinkingButtonRef}
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}
/>
<GenerateImageButton
model={model}
assistant={assistant}
onEnableGenerateImage={onEnableGenerateImage}
ToolbarButton={ToolbarButton}
/>
<MentionModelsButton
ref={mentionModelsButtonRef}
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
<QuickPhrasesButton
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
estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount}
@@ -1012,8 +862,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
ToolbarButton={ToolbarButton}
onClick={onNewContext}
/>
</ToolbarMenu>
<ToolbarMenu>
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
{loading && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
@@ -1118,7 +966,8 @@ const Toolbar = styled.div`
padding: 0 8px;
padding-bottom: 0;
margin-bottom: 4px;
height: 36px;
height: 30px;
gap: 16px;
`
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 { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
onNewContext: () => void
@@ -17,20 +16,12 @@ const NewContextButton: FC<Props> = ({ onNewContext, ToolbarButton }) => {
useShortcut('toggle_new_context', onNewContext)
return (
<Container>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<Eraser size={18} />
</ToolbarButton>
</Tooltip>
</Container>
<Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<ToolbarButton type="text" onClick={onNewContext}>
<Eraser size={18} />
</ToolbarButton>
</Tooltip>
)
}
const Container = styled.div`
@media (max-width: 800px) {
display: none;
}
`
export default NewContextButton

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessag
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getMessageTitle } from '@renderer/services/MessagesService'
import { translateText } from '@renderer/services/TranslateService'
import { RootState } from '@renderer/store'
import store, { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import type { Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types'
@@ -90,13 +90,24 @@ const MessageMenubar: FC<Props> = (props) => {
const onCopy = useCallback(
(e: React.MouseEvent) => {
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' })
setCopied(true)
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 () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
@@ -121,23 +120,21 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</div>
{jsonConfig && (
<div style={{ marginBottom: '16px' }}>
<CodeToolbarProvider>
<CodeEditor
value={jsonConfig}
language="json"
onChange={(value) => setJsonConfig(value)}
maxHeight="60vh"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
keymap: true
}}
/>
</CodeToolbarProvider>
<CodeEditor
value={jsonConfig}
language="json"
onChange={(value) => setJsonConfig(value)}
maxHeight="60vh"
options={{
lint: true,
collapsible: true,
wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
keymap: true
}}
/>
</div>
)}
<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}
onChange={(e) => setName(e.target.value.trim())}
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}
/>
</Form.Item>

View File

@@ -1,4 +1,6 @@
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 { Provider } from '@renderer/types'
import { Radio, RadioChangeEvent, Space } from 'antd'
@@ -40,6 +42,7 @@ const PlatformOptions = [
const DMXAPISettings: FC<DMXAPISettingsProps> = ({ provider: initialProvider }) => {
const { provider, updateProvider } = useProvider(initialProvider.id)
const { theme } = useTheme()
const { t } = useTranslation()
@@ -71,7 +74,7 @@ const DMXAPISettings: FC<DMXAPISettingsProps> = ({ provider: initialProvider })
<Container>
<Space direction="vertical" style={{ width: '100%' }}>
<LogoContainer>
<Logo src={DmxapiLogo}></Logo>
<Logo src={theme === 'dark' ? DmxapiLogoDark : DmxapiLogo}></Logo>
</LogoContainer>
<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 { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
import ImageStorage from '@renderer/services/ImageStorage'
import { INITIAL_PROVIDERS } from '@renderer/store/llm'
import { Provider } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
@@ -115,86 +116,89 @@ const ProvidersList: FC = () => {
onClick: () => ModelNotesPopup.show({ provider })
}
const menus = [
{
label: t('common.edit'),
key: 'edit',
icon: <EditOutlined />,
async onClick() {
const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
const editMenu = {
label: t('common.edit'),
key: 'edit',
icon: <EditOutlined />,
async onClick() {
const { name, type, logoFile, logo } = await AddProviderPopup.show(provider)
if (name) {
updateProvider({ ...provider, name, type })
if (provider.id) {
if (logoFile && logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
setProviderLogos((prev) => ({
...prev,
[provider.id]: logo
}))
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('更新Provider Logo失败')
}
} else if (logo === undefined && logoFile === undefined) {
try {
await ImageStorage.set(`provider-${provider.id}`, '')
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to reset logo', error)
}
if (name) {
updateProvider({ ...provider, name, type })
if (provider.id) {
if (logoFile && logo) {
try {
await ImageStorage.set(`provider-${provider.id}`, logo)
setProviderLogos((prev) => ({
...prev,
[provider.id]: logo
}))
} catch (error) {
console.error('Failed to save logo', error)
window.message.error('更新Provider Logo失败')
}
} else if (logo === undefined && logoFile === undefined) {
try {
await ImageStorage.set(`provider-${provider.id}`, '')
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to reset logo', error)
}
}
}
}
},
noteMenu,
{
label: t('common.delete'),
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
async onClick() {
window.modal.confirm({
title: t('settings.provider.delete.title'),
content: t('settings.provider.delete.content'),
okButtonProps: { danger: true },
okText: t('common.delete'),
centered: true,
onOk: async () => {
// 删除provider前先清理其logo
if (provider.id) {
try {
await ImageStorage.remove(`provider-${provider.id}`)
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to delete logo', error)
}
}
setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider)
}
})
}
}
]
}
const deleteMenu = {
label: t('common.delete'),
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
async onClick() {
window.modal.confirm({
title: t('settings.provider.delete.title'),
content: t('settings.provider.delete.content'),
okButtonProps: { danger: true },
okText: t('common.delete'),
centered: true,
onOk: async () => {
// 删除provider前先清理其logo
if (provider.id) {
try {
await ImageStorage.remove(`provider-${provider.id}`)
setProviderLogos((prev) => {
const newLogos = { ...prev }
delete newLogos[provider.id]
return newLogos
})
} catch (error) {
console.error('Failed to delete logo', error)
}
}
setSelectedProvider(providers.filter((p) => p.isSystem)[0])
removeProvider(provider)
}
})
}
}
const menus = [editMenu, noteMenu, deleteMenu]
if (providers.filter((p) => p.id === provider.id).length > 1) {
return menus
}
if (provider.isSystem) {
return [noteMenu]
if (INITIAL_PROVIDERS.find((p) => p.id === provider.id)) {
return [noteMenu]
}
return [noteMenu, deleteMenu]
}
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) {
const webSearchTool = await this.getWebSearchParams(model)
@@ -322,7 +322,7 @@ export default class AnthropicProvider extends BaseProvider {
reasoning_content,
usage: message.usage as any,
metrics: {
completion_tokens: message.usage.output_tokens,
completion_tokens: message.usage?.output_tokens || 0,
time_completion_millsec,
time_first_token_millsec: 0
}
@@ -464,8 +464,8 @@ export default class AnthropicProvider extends BaseProvider {
}
}
finalUsage.prompt_tokens += message.usage.input_tokens
finalUsage.completion_tokens += message.usage.output_tokens
finalUsage.prompt_tokens += message.usage?.input_tokens || 0
finalUsage.completion_tokens += message.usage?.output_tokens || 0
finalUsage.total_tokens += finalUsage.prompt_tokens + finalUsage.completion_tokens
finalMetrics.completion_tokens = finalUsage.completion_tokens
finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec

View File

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

View File

@@ -5,7 +5,8 @@ import {
isSupportedFlexServiceTier,
isSupportedModel,
isSupportedReasoningEffortOpenAIModel,
isVisionModel
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
@@ -317,7 +318,7 @@ export abstract class BaseOpenAIProvider extends BaseProvider {
const model = assistant.model || defaultModel
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
const isEnabledBuiltinWebSearch = assistant.enableWebSearch
const isEnabledBuiltinWebSearch = assistant.enableWebSearch && isWebSearchModel(model)
let tools: OpenAI.Responses.Tool[] = []
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 { getEmbeddingMaxContext } from '@renderer/config/embedings'
import Logger from '@renderer/config/logger'
import { ONLY_SUPPORTED_DIMENSION_PROVIDERS } from '@renderer/config/providers'
import AiProvider from '@renderer/providers/AiProvider'
import store from '@renderer/store'
import { FileType, KnowledgeBase, KnowledgeBaseParams, KnowledgeReference } from '@renderer/types'
@@ -38,7 +39,7 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
return {
id: base.id,
model: base.model.id,
dimensions: base.dimensions,
dimensions: ONLY_SUPPORTED_DIMENSION_PROVIDERS.includes(base.model.provider) ? base.dimensions : undefined,
apiKey: aiProvider.getApiKey() || 'secret',
apiVersion: provider.apiVersion,
baseURL: host,

View File

@@ -13,7 +13,7 @@ function getNutstoreToken() {
const nutstoreToken = store.getState().nutstore.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 nutstoreToken
@@ -164,8 +164,9 @@ export async function startNutstoreAutoSync() {
}
const nutstoreToken = getNutstoreToken()
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
}

View File

@@ -8,6 +8,7 @@ import agents from './agents'
import assistants from './assistants'
import backup from './backup'
import copilot from './copilot'
import inputToolsReducer from './inputTools'
import knowledge from './knowledge'
import llm from './llm'
import mcp from './mcp'
@@ -39,7 +40,8 @@ const rootReducer = combineReducers({
copilot,
// messages: messagesReducer,
messages: newMessagesReducer,
messageBlocks: messageBlocksReducer
messageBlocks: messageBlocksReducer,
inputTools: inputToolsReducer
})
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,
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',
name: 'Perplexity',

View File

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

View File

@@ -11,6 +11,7 @@ import { isEmpty } from 'lodash'
import { createMigrate } from 'redux-persist'
import { RootState } from '.'
import { DEFAULT_TOOL_ORDER } from './inputTools'
import { INITIAL_PROVIDERS, moveProvider } from './llm'
import { mcpSlice } from './mcp'
import { DEFAULT_SIDEBAR_ICONS, initialState as settingsInitialState } from './settings'
@@ -1455,6 +1456,15 @@ const migrateConfig = {
} catch (error) {
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 = {
id: string
model: string
dimensions: number
dimensions?: number
apiKey: string
apiVersion?: string
baseURL: string

View File

@@ -24,6 +24,15 @@ vi.mock('@renderer/utils/messageUtils/find', () => ({
// Assuming content exists on ThinkingBlock
// Need to cast block to access content if not on base type
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!)
expect(markdown).toContain('### 🧑‍💻 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', () => {
@@ -206,6 +218,9 @@ describe('export', () => {
const markdown = messageToMarkdown(msg!)
expect(markdown).toContain('### 🤖 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', () => {
@@ -213,7 +228,19 @@ describe('export', () => {
mockedMessages.push(msg)
const markdown = messageToMarkdown(msg)
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' }, [
{ 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', () => {
@@ -243,6 +275,9 @@ describe('export', () => {
expect(markdown).toContain('<details')
expect(markdown).toContain('<summary>common.reasoning_content</summary>')
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', () => {
@@ -263,6 +298,17 @@ describe('export', () => {
expect(markdown).toContain('Simple Answer')
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', () => {

View File

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

View File

@@ -1,5 +1,5 @@
import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { formatCitationsFromBlock, messageBlocksSelectors } from '@renderer/store/messageBlock'
import { FileType } from '@renderer/types'
import type {
CitationMessageBlock,
@@ -128,6 +128,15 @@ export const getThinkingContent = (message: Message): string => {
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.
* Note: Assumes knowledgeBaseIds are only relevant on the first text block, adjust if needed.