Compare commits
27 Commits
feat/custo
...
v1.3.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d72f14dc | ||
|
|
f36735f6db | ||
|
|
1b0b08c4c4 | ||
|
|
13d440b0b6 | ||
|
|
2dc81ab8c8 | ||
|
|
b2b0fe9072 | ||
|
|
da30b52334 | ||
|
|
e854ef8757 | ||
|
|
d90ac44945 | ||
|
|
55852cb0a1 | ||
|
|
c28afebdfd | ||
|
|
07407f751f | ||
|
|
4726673508 | ||
|
|
5dc48580a0 | ||
|
|
676c1cbe83 | ||
|
|
6d61bcd605 | ||
|
|
ee78dbd27e | ||
|
|
d88d78e143 | ||
|
|
458f017517 | ||
|
|
f462b7f94e | ||
|
|
94792c9bb1 | ||
|
|
adef817e86 | ||
|
|
2f312d68a0 | ||
|
|
a7520169e6 | ||
|
|
59e3082642 | ||
|
|
795d12c91e | ||
|
|
8eb0be7562 |
48
README.md
48
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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)に参加して、アイデアやフィードバックを共有してください!
|
||||
|
||||
# 🌈 テーマ
|
||||
|
||||
|
||||
@@ -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) 分享您的想法和反馈!
|
||||
|
||||
# 🌈 主题
|
||||
|
||||
|
||||
@@ -37,6 +37,14 @@ yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
yarn debug
|
||||
```
|
||||
|
||||
Then input chrome://inspect in browser
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
|
||||
@@ -95,13 +95,9 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
⚠️ 注意:升级前请备份数据,否则将无法降级
|
||||
增加 TokenFlux 服务商
|
||||
增加 Claude 4 模型支持
|
||||
Grok 模型增加联网能力
|
||||
小程序支持前进和后退
|
||||
修复 Windows 用户 MCP 无法启动问题
|
||||
修复无法搜索历史消息问题
|
||||
修复 MCP 代理问题
|
||||
修复精简备份恢复覆盖文件问题
|
||||
修复@模型回复插入位置错误问题
|
||||
修复搜索小程序崩溃问题
|
||||
文生图新增服务商 DMXAPI(限时免费)
|
||||
输入框按钮支持拖拽排序
|
||||
修复知识库搜索结果 100% 问题
|
||||
修复拖拽多选消息相关问题
|
||||
修复翻译回复内容导致内存异常问题
|
||||
常规错误修复和优化
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
BIN
src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp
Normal file
BIN
src/renderer/src/assets/images/providers/dmxapi-logo-dark.webp
Normal file
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 |
@@ -4,3 +4,9 @@
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
.context-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ mjx-container {
|
||||
|
||||
.cm-lineWrapping * {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')};
|
||||
`
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
26
src/renderer/src/components/CodeToolbar/hook.ts
Normal file
26
src/renderer/src/components/CodeToolbar/hook.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './constants'
|
||||
export * from './context'
|
||||
export * from './hook'
|
||||
export * from './toolbar'
|
||||
export * from './types'
|
||||
export * from './usePreviewTools'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
// 根据提供的功能有选择性地注册工具
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "カスタム",
|
||||
|
||||
@@ -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": "Модель автоматически определяет количество токенов для размышления",
|
||||
|
||||
@@ -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": "清除上下文",
|
||||
|
||||
@@ -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 數",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
639
src/renderer/src/pages/home/Inputbar/InputbarTools.tsx
Normal file
639
src/renderer/src/pages/home/Inputbar/InputbarTools.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -94,6 +94,7 @@ const NutstoreSettings: FC = () => {
|
||||
if (confirmedLogout) {
|
||||
dispatch(setNutstoreToken(''))
|
||||
dispatch(setNutstorePath(''))
|
||||
dispatch(setNutstoreAutoSync(false))
|
||||
setNutstoreUsername('')
|
||||
setStoragePath(undefined)
|
||||
}
|
||||
|
||||
@@ -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 // 確保是直接的伺服器物件
|
||||
) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) &&
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
51
src/renderer/src/store/inputTools.ts
Normal file
51
src/renderer/src/store/inputTools.ts
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ export interface KnowledgeBase {
|
||||
export type KnowledgeBaseParams = {
|
||||
id: string
|
||||
model: string
|
||||
dimensions: number
|
||||
dimensions?: number
|
||||
apiKey: string
|
||||
apiVersion?: string
|
||||
baseURL: string
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user